Регистрация | Войти
Lisp — программируемый язык программирования
Предыдущая Оглавление Следующая

16. Переходим к объектам: Обобщенные функции

Поскольку Lisp был создан за пару десятилетий до того момента, когда объектно-ориентированное программирование (ООП) стало популярным1), начинающие Lisp-программисты иногда удивляются, открывая для себя, насколько полноценным объектно-ориентированным языком является Common Lisp. Непосредственные его предшественники разрабатывались в то время, когда объектно-ориентированное программирование было волнующе-новой парадигмой и проводилось много экспериментов на тему включения его идей (особенно из языка Smalltalk) в Lisp. Как часть процесса стандартизации Common Lisp, объединение идей нескольких этих экспериментов было представлено под названием Common Lisp Object System (Объектная Система Common Lisp) или CLOS2). Стандарт ANSI включил CLOS в язык, так что сейчас нет смысла говорить о CLOS как об отдельной сущности.

Возможности CLOS внесенные в Common Lisp варьируются от тех, без которых крайне тяжело обойтись, до относительно эзотеричных проявлений философии Lisp как язык-инструмента для построения языков. Полный обзор всех возможностей лежит за пределами данной книги, но в этой и следующей главе я опишу базовые возможности и дам обзор подхода Common Lisp к объектам.

В начале вы должны были заметить, что объектная система Common Lisp предлагает достаточно отличающуюся от других языков реализацию принципов ООП. Если вы имеете глубокое понимание фундаментальных идей, заложенных в основу ООП, то вы, вероятно, оцените способ, который использовался в Common Lisp для их реализации. С другой стороны, если у вас есть опыт использования ООП только на одном языке, то подход Common Lisp может показаться вам несколько чуждым; вы должны избегать предположений, что для языка существует только один способ реализации принципов ООП3). Если у вас имеется небольшой опыт объектно-ориентированного программирования, то вы не должны испытывать проблем с пониманием изложенных здесь объяснений, хотя, в этом случае, возможно, будет лучше проигнорировать сравнения с подходами других языков.

Обобщенные функции и классы

Мощной и фундаментальной особенностью ООП является способ организации программ путем определения типов данных и связывании операций с ними. В частности, вам может понадобиться возможность исполнять операцию, получая поведение, определенное типом объекта (или объектов) для которых эта операция выполняется. Классическим примером, представленным во всех введениях в ООП, является операция рисования, применимая к объектам, представляющим различные геометрические фигуры. Для рисования окружностей, треугольников или квадратов могут быть реализованы разные методы draw, которые будут отображать окружность, треугольник или квадрат, в зависимости от объекта, к которому применяется операция рисования. Эти реализации вводятся раздельно, и новые версии для рисования других фигур могут быть определены, не затрагивая ни кода базового класса, ни других draw. Эта возможность ООП имеет красивое греческое имя "полиморфизм (polymorphism)", переводимое как "множество форм", поскольку одна концептуальная операция, такая как рисование объекта, может иметь множество различных конкретных форм.

Common Lisp, подобно другим современным объектно-ориентированным языкам, основан на классах; все объекты являются экземплярами определенного класса4). Класс объекта определяет его представление – встроенные классы, такие как NUMBER и STRING имеют скрытое представление, доступное только через стандартные функции для работы с этими типами, в то время как экземпляры классов, определенных пользователем, состоят из именованных частей, называемых слотами (вы увидите это в следующей главе).

Классы образуют иерархию/классификацию всех объектов. Класс может быть определен как подкласс других классов, называемых базовыми (или суперклассами). Класс наследует от суперклассов часть своего определения, а экземпляры класса также считаются и экземплярами суперклассов. В Common Lisp иерархия классов имеет один корень – класс T, который является прямым (или косвенным) суперклассом для всех остальных классов. Таким образом, в Common Lisp все данные являются экземплярами класса T5). Common Lisp также поддерживает множественное наследование – один класс может иметь несколько прямых суперклассов.

Вне семейства языков Lisp, почти все объектно-ориентированные языки следуют базовому дизайну, заданному языком Simula, когда поведение, связанное с классом, реализуется в виде методов или функций-членов, которые относятся к определенному классу. В этих языках метод, вызываемый для определенного объекта, и класс, к которому этот объект относится, определяют, какой код будет запущен. Такая модель называется (в терминологии Smalltalk) передачей сообщений (message-passing). Концептуально, вызов методов начинается с отправки сообщения, содержащего имя запускаемого метода и необходимые аргументы, экземпляру объекта, метод которого вызывается. Объект затем использует свой класс для поиска метода, связанного с именем, указанным в сообщении, и вызывает его. Поскольку каждый класс может иметь собственный метод для заданного имени, то одно и то же сообщение, посланное разным объектам, может вызывать разные методы.

Ранние реализации объектной системы Lisp работали аналогичным образом, предоставляя специальную функцию SEND, которая могла быть использована для отправки сообщения определенному объекту. Однако, это было не совсем удобно, поскольку это делало вызов метода отличным от обычного вызова функции. Синтаксически вызов метода записывался как:

(send object 'foo)

вместо:

(foo object)

Более важным являлось то, что поскольку методы не являлись функциями, то они не могли быть переданы как аргументы для функций высшего порядка, таких как MAPCAR; если кто-то хотел вызвать метод для всех элементов списка, используя MAPCAR, то он должен был писать вот так:

(mapcar #'(lambda (object) (send object 'foo)) objects)

вместо:

(mapcar #'foo objects)

В конечном счете, люди, работавшие над объектными системами Lisp, унифицировали методы и функции путём введения нового их вида, называемого обобщенным (generic). В дополнение к решению описанных выше проблем, обобщенные функции открыли новые возможности объектной системы, включая множество таких, которые попросту не имели смысла в объектных системах с передачей сообщений.

Обобщенные функции суть сердце объектной системы Common Lisp и тема оставшейся части данной главы. Несмотря на то, что я не могу говорить об обобщенных функциях без упоминания классов, сначала я остановлюсь на том, как определить и использовать обобщенные функции. А в следующей главе я покажу вам как определять ваши собственные классы.

Обобщенные функции и методы

Обобщенная функция определяет абстрактную операцию, указывая имя операции и список параметров, но не определяет реализацию. Вот, например, как вы можете определить обобщенную функцию draw, которая будет использоваться для отрисовки различных фигур на экране:

(defgeneric draw (shape)
  (:documentation "Draw the given shape on the screen.")
)

Я опишу синтаксис DEFGENERIC в следующем разделе; сейчас лишь замечу, что это определение совсем не содержит кода.

Обобщенная функция являются таковой в том смысле, что она может (по крайней мере в теории) принимать в качестве аргументов любые объекты6). Однако, сама эта функция не делает ничего; если вы просто определили её, то при вызове с любыми аргументами она будет выдавать ошибку. Действующая реализация обобщенной функции обеспечивается методами. Каждый метод предоставляет реализацию обобщенной функции для отдельных классов аргументов. Вероятно, наибольшим отличием между системами с обобщенными функциями и системами с передачей сообщений является то, что методы не принадлежат к классам; они относятся к обобщенной функции, которая ответственна за определение того, какой метод (или методы) будет исполняться в ответ на конкретный вызов.

Методы указывают, какой вид аргументов они могут обрабатывать путем специализации требуемых параметров, определенных обобщенной функцией. Например, для обобщенной функции draw вы можете определить один метод, который определяет специализацию параметра shape для объектов, которые являются экземплярами класса circle, в то время как другой метод специализирует shape для экземпляров класса triangle. Они могут выглядеть следующим образом (не вдаваясь в подробности рисования конкретных фигур):

(defmethod draw ((shape circle))
  ...
)


(defmethod draw ((shape triangle))
  ...
)

При вызове обобщенной функции, она сравнивает переданные аргументы со специализаторами каждого из ее методов с целью найти среди них апплицируемые – чьи специализаторы совместимы с фактическими параметрами (вызова). Если вы вызываете draw, передавая экземпляр circle, то применяется метод, который специализирует shape для класса circle, а если вы вызываете передавая triangle, то будет вызван метод, который специализирует shape для triangle. В простых случаях будет подходить только один метод, который и будет обрабатывать вызов. В более сложных случаях могут быть применимы несколько методов; они будут скомбинированы, как я опишу в разделе "Комбинация методов", в один действующий метод, который обработает данный вызов.

Вы можете специализировать параметры двумя способами – обычно вы указываете класс, экземпляром которого должен быть аргумент. Поскольку экземпляры класса также рассматриваются как экземпляры его суперклассов, то метод, специализированный для конкретного класса, также применим для аргументов, которые могут быть как экземлярами специализирующего класса, так и его подклассов. Другой вид специализации – так называемый EQL-специализатор, который определяет конкретный объект к которому применим данный метод.

Когда обобщенная функция имеет только методы, специализированные для одного параметра, и все специализации являются специализациями классов, то результат вызова обобщенной функции сильно похож на вызов метода в системе с передачей сообщений – комбинация имени операции и класса объекта, для которого она вызывается, определяет, какой метод будет вызван.

Однако изменение порядка поиска открывает новые возможности, которых нет в системах с передачей сообщений. Обобщенные функции поддерживают методы, которые специализируются для множества параметров, предоставляя каркас, который делает множественное наследование более управляемым, и который позволяет вам использовать декларативные конструкции для контроля того, как методы комбинируются в эффективный метод, поддерживая несколько общих методов использования без наличия кучи дополнительного кода. Я опишу эти вопросы очень скоро. Но сначала вам необходимо взглянуть на основы использования двух макросов, которые используются для определения обобщенных функций: DEFGENERIC и DEFMETHOD.

DEFGENERIC

Чтобы дать вам почувствовать эти макросы, и ознакомиться с возможностями, которые они предоставляют, я покажу вам некоторый код, который вы могли бы написать как часть банковского приложения, или иначе, игрушечное банковское приложение; главная задача – ознакомиться с возможностями языка, а не научиться писать банковское программное обеспечение. Например, данный код даже не пытается претендовать на работу с разными валютами, а также оставляет в стороне вопросы аудита и целостности транзакций.

Поскольку я буду обсуждать вопросы создания новых классов только в следующей главе, сейчас вы можете просто предположить, что определенные классы уже существуют: для начала предположим, имеется класс bank-account и он имеет два подкласса – checking-account и savings-account. Иерархия классов выглядит следующим образом:
Изображение

Первой обобщенной функцией будет withdraw, которая уменьшает баланс на указанную сумму. Если баланс меньше этой суммы, то она должна выдать ошибку и оставить баланс в неизменном виде. Вы можете начать с определения обобщенной функции при помощи DEFGENERIC.

Основная форма DEFGENERIC похожа на DEFUN, за тем исключением, что нет тела функции. Список параметров DEFGENERIC определяет параметры, которые должны приниматься всеми методами, определенными для данной обобщенной функции. Вместо тела DEFGENERIC может содержать различные опции. Одной из опций, которую вы должны всегда указывать, является :documentation, которая используется для указания строки с описанием назначения обобщенной функции. Поскольку обобщенная функция является полностью абстрактной, важно, чтобы и пользователь и программист имели четкое представление о том, что она делает. Таким образом, вы можете определить withdraw следующим образом:

(defgeneric withdraw (account amount)
  (:documentation "Withdraw the specified amount from the account.
Signal an error if the current balance is less than amount."
)
)

DEFMETHOD

Сейчас вы готовы к использованию DEFMETHOD для определения методов, которые реализуют withdraw7).

Список параметров метода должен быть конгруэнтен его обобщенной функции. В данном случае это означает, что все методы определенные для withdraw должны иметь два обязательных параметра. В более общих чертах, методы должны иметь то же самое количество обязательных и необязательных параметров, и кроме этого, должны уметь принимать любые аргументы, относящиеся к остаточным (&rest) или именованным (&key) параметрам, определенным в обобщенной функции.8)

Поскольку базовые действия по списанию денег со счета являются одинаковыми для всех счетов, то вы можете определить метод, который специализирует параметр account для класса bank-account. Вы можете предположить, что функция balance возвращает текущее значение суммы на счете и может быть использована вместе с функцией SETF (и таким образом, вместе с DECF) для установки значения баланса. Функция ERROR является стандартной функцией для сообщения об ошибках и я ее подробно опишу в главе 19. Используя эти две функции, вы можете определить основной метод withdraw примерно так:

(defmethod withdraw ((account bank-account) amount)
  (when (< (balance account) amount)
    (error "Account overdrawn.")
)

  (decf (balance account) amount)
)

Как видно из этого кода, форма DEFMETHOD более похожа на DEFUN по сравнению с DEFGENERIC. Основным отличием является то, что требуемые параметры могут быть специализированы путем замены имени параметра на список из двух элементов. Первым элементом является имя параметра, а вторым – специализатор, который может быть либо именем класса, либо EQL-специализатором, форму которого я опишу чуть позже. Имя параметра может быть любым – оно не обязательно должно совпадать с именем, указанным в объявлении обобщенной функции, несмотря на то, что чаще всего они совпадают.

Этот метод будет использоваться тогда, когда первый аргумент withdraw является экземпляром класса bank-account. Второй параметр, amount, неявно специализируется для класса T, а поскольку все объекты являются экземплярами T, это никак не затрагивает применимость метода.

Теперь предположим, что все чековые счета имеют защиту от перерасхода. Так что, каждый чековый счет связан с другим счетом в банке, с которого будет производиться списание, когда на чековом счету недостаточно денег для списания. Вы можете предположить, что функция overdraft-account получает объект класса checking-account и возвращает объект класса bank-account, представляющего собой связанный счет.

Таким образом, списание с объекта класса checking-account требует выполнения дополнительных шагов в сравнении со списанием с обычного объекта bank-account. Сначала вы должны проверить, является ли списываемая сумма большей, чем имеющаяся на счету, и если это так, то перенести недостающую сумму со связанного счета. Затем вы можете продолжать так же, как и с обычным объектом bank-account.

Так что вы можете захотеть определить метод withdraw, специализированного для checking-account для обработки перевода денег с другого счета и последующей передачи управления методу, специализированного для bank-account. Такой метод может выглядеть вот так:

(defmethod withdraw ((account checking-account) amount)
  (let ((overdraft (- amount (balance account))))
    (when (plusp overdraft)
      (withdraw (overdraft-account account) overdraft)
      (incf (balance account) overdraft)
)
)

  (call-next-method)
)

Функция CALL-NEXT-METHOD является частью системы обобщенных функций и используется для комбинации FIXMEподходящих методов. Она сообщает, что контроль должен быть передан от текущего метода, к методу, специализированному для bank-account.9) Когда он вызывается без аргументов, как это было сделано в нашем примере, следующий в цепочке метод будет вызван с теми же аргументами, которые были переданы обобщенной функции. Он также может быть вызван с явным указанием аргументов, которые будут переданы следующему методу.

Вам не обязательно вызывать CALL-NEXT-METHOD в каждом методе. Однако, если вы не будете вызывать эту функцию, то новый метод будет полностью отвечать за реализацию требуемого поведения обобщенной функции. Например, если вы хотите создать подкласс bank-account, названный proxy-account, который не будет отслеживать свой баланс, а вместо этого будет делегировать списание средств другому счету, то вы можете записать этот метод следующим образом (предполагая, что функция proxied-account возвращает соответствующий счет):

(defmethod withdraw ((proxy proxy-account) amount)
  (withdraw (proxied-account proxy) amount)
)

В заключение, DEFMETHOD также позволяет вам создавать методы, которые специализированы для конкретного объекта используя EQL-специализатор. Например, предположим, что банковское приложение FIXMEбудет развернуто в каком-то коррумпированном банке. Предположим, что переменная *account-of-bank-president* хранит ссылку на конкретный банковский счет, который относится (как это видно из имени) к президенту банка. ТакжеFIXMEДалее предположим, что переменная *bank* представляет весь банк, а функция embezzle крадет деньги у банка. Президент банка может попросить вас "исправить" функцию withdraw таким образом, чтобы она обрабатывала его счет другим способом.

(defmethod withdraw ((account (eql *account-of-bank-president*)) amount)
  (let ((overdraft (- amount (balance account))))
    (when (plusp overdraft)
      (incf (balance account) (embezzle *bank* overdraft))
)

  (call-next-method)
)
)

Однако заметьте, что форма, указанная в EQL-специализаторе, который используется для указания объекта (в нашем случае это *account-of-bank-president*) вычисляется один раз, когда вычисляется DEFMETHOD. Этот метод будет специализирован для значения *account-of-bank-president* в тот момент, когда этот метод был определен; последующие изменения переменной не изменяют метод.

Комбинирование методов

Вне кода метода использование CALL-NEXT-METHOD не имеет смысла. Внутри метода использование этой функции обретает смысл за счет того, что механизмы реализации обобщенных функций при каждом запуске создают эффективный метод из методов, для которых применимы текущие параметры. Идея построения эффективного метода путем комбинации методов, для которых применимы текущие аргументы, является основой концепции обобщенных функций и это позволяет обобщенным функциям реализовывать возможности, которые недоступны в системах с передачей сообщений. Так что следует поближе познакомиться с тем, что в действительности происходит. Люди, которые давно знакомы с концепциями систем с передачей сообщений должны обратить на данный материал особое внимание, поскольку обобщенные функции кардинально меняют диспатчеризацию методов по сравнению с передачей сообщений, делая обобщенную функцию (а не класс) ответственной за передачу управления.

В соответствии с концепцией, эффективный метод строится в три шага: на первом шаге обобщенная функция строит список методов, для которых применимы переданные аргументы. На втором шаге полученный список методов сортируется в соответствии со специализированными параметрами. И в заключение, методы по порядку берутся из списка и их код комбинируется образуя эффективный метод.10)

Для нахождения методов, для которых применимы данные аргументы, обобщенная функция сравнивает аргументы с соответствующими специализаторами параметров всех определенных методов. Метод считается допустимым, только если все специализаторы совместимы с соответствующими параметрами.

Когда специализатор является именем класса, он считается совместимым, если указанное имя совпадает с именем класса аргумента (или именем одного из суперклассов аргумента). (Заметьте, что параметры без явных специализаторов, неявно специализируются классом T, так что они будут совместимы с любым аргументом). EQL-специализатор считается совместимым, если аргумент является тем же объектом, что указан в специализаторе.

Поскольку все аргументы проверяются относительно соответствующих специализаторов, все они влияют на результаты выбора подходящих методов. Методы, которые явно специализируют более одного параметра, называются мультиметодами; я опишу их в разделе "Мультиметоды".

После того, как все соответствующие методы найдены, необходимо отсортировать их, чтобы затем скомбинировать в эффективный метод. Для упорядочения двух методов, обобщенная функция сравнивает их специализаторы параметров слева направо,11) и первый специализатор, который отличается в списке параметров методов будет определять их порядок, где первым ставится метод с более специфичным специализатором.

Поскольку сортируются только подходящие методы, вы знаете все классы специализаторов, для которых соответствующий аргумент является экземпляром. В типичном случае, если два специализатора класса отличаются, то один будет подклассом другого. В этом случае специализатор, именующий подкласс, считается более специфичным. Поэтому метод, который специализирован для счета с классом checking-account будет рассматриваться как более специфичный, чем метод, специализированный для класса bank-account.

Множественное наследование, немного усложняет идею специфичности, поскольку аргумент может быть экземпляром двух классов, ни один из которых не является подклассом другого. Если такие классы используются как специализаторы параметров, то обобщенная функция не может упорядочить их используя только правило, что подклассы являются более специфичными, чем их суперклассы. В следующей главе я опишу как понятие специфичности было расширено для обработки множественного наследования. Сейчас достаточно сказать, что существует алгоритм для упорядочения специализаторов классов.

В заключение надо отметить, что EQL-специализатор всегда более специфичен, чем любой специализатор класса, и поскольку рассматриваются только подходящие методы, то если EQL-специализатор конкретного параметра имеют более одного метода, то все они должны иметь одинаковые EQL-специализаторы. Сравнение данных методов происходит на основе других параметров.

Стандартный комбинатор методов

Теперь, когда вы понимаете как подходящие методы находятся и сортируются, вы готовы поближе взглянуть на последний шаг – как отсортированный список методов комбинируется в один эффективный метод. По умолчанию обобщенные функции используют так называемый "стандартный комбинатор методов". Стандартный комбинатор объединяет методы таким образом, что CALL-NEXT-METHOD работает как это вы уже увидели – сначала запускается наиболее специфичный метод, и каждый метод может передать управление следующему методу используя CALL-NEXT-METHOD.

Однако тут есть больше возможностей. Методы, которые я обсуждал, называются основными методами. Основные методы (как и предполагает их имя) отвечают за реализацию основной функциональности обобщенных функций. Стандартный комбинатор методов также поддерживает три вида вспомогательных методов: :before, :after и :around. Определение вспомогательных методов записывается с помощью DEFMETHOD также как и для основных методов, но кроме этого, между именем метода и списком параметров указывается квалификатор метода, который именует тип метода. Например, метод :before для функции withdraw, которые специализирует параметр account для класса bank-account будет начинаться со следующей строки:

(defmethod withdraw :before ((account bank-account) amount) ...)

Каждый вид вспомогательных методов комбинируется в эффективный метод разными способами. Все применимые методы :before (не только наиболее специфические) запускаются как часть эффективного метода. Они запускаются (как и предполагается их именем) до наиболее специфического основного метода и запускаются, начиная с самого специфического метода. Таким образом, методы :before могут быть использованы для выполнения любой подготовительной работы, которая может понадобиться, чтобы убедиться что основной метод может быть выполнен. Например, вы можете использовать метод :before специализированный для checking-account для реализации защиты от перерасхода для чековых счетов:

(defmethod withdraw :before ((account checking-account) amount)
  (let ((overdraft (- amount (balance account))))
    (when (plusp overdraft)
      (withdraw (overdraft-account account) overdraft)
      (incf (balance account) overdraft)
)
)
)

Этот метод :before имеет три преимущества по сравнению с использованием основного метода. Во первых, он явно показывает как метод изменяет общее поведение функции withdraw – он не пересекается с основным поведением и не изменяет возвращаемое значение.

Следующим преимуществом является то, что главный метод, специализированный для класса, более специфичного, чем checking-account не будет противоречить этому методу :before, что позволяет автору подкласса checking-account более легко расширить поведение withdraw, при этом сохраняя старую функциональность.

И наконец, поскольку метод :before не должен вызывать CALL-NEXT-METHOD для передачи управления оставшимся методам, нет возможности сделать ошибку, забыв указать эту функцию.

Другие вспомогательные методы также организуются в эффективные методы способами, которые отражены в их именах. Все методы :after запускаются после основного метода в порядке, когда наиболее специфичный метод вызывается последним (порядок противоположен вызову методов :before). Таким образом, методы :before и :after комбинируются для создания вложенных обвязок вокруг основной функциональности, обеспечиваемой основными методами – каждый более специфичный метод :before получит шанс сделать что-то для менее специфичного метода :before, основные методы могут успешно выполняться, и затем каждый более специфичный метод :after получает шанс убрать что-то за основным методом и менее специфичными методами :after.

И наконец, методы :around комбинируются практически также как и основные методы, за исключением того, что они выполняются "вокруг" остальных методов. Так что код наиболее специфического метода :around запускается до любого кода. Внутри кода метода :around, вызов CALL-NEXT-METHOD приведет к тому, что будет выполняться код следующего метода :around, или, при вызове из наименее специфического метода :around, приведет к выполнению цепочки методов :before, основного метода и затем методов :after. Почти все методы :around будут иметь в своем коде вызов CALL-NEXT-METHOD, поскольку метод :around не будет полностью реализовывать (перехватывать) действия обобщенной функции и всех ее методов, за исключением более специфичных методов :around. FIXMEПоследнее предложение и в оригинале рвёт мозг. Я бы попроще объяснил: мол, поскольку методы around срабатывают первыми и наследуются "вовнутрь", то отсутствие call-next-method в любом из них повлечёт невызов всех менее специфичных around и вообще всех before, after и частей собственно метода. Такое поведение нетривиально и чревато, но допустимо для достижения определённых целей (см. ниже).

Иногда требуется полный перехват действий, но обычно, методы :around используются для установки некоторого динамического контекста в котором будут выполняться остальные методы – например, для связывания динамической переменной, или для установки обработчика ошибок (это я буду обсуждать в главе 19). Метод :around может не вызывать CALL-NEXT-METHOD в тех случаях, если он, например, возвращает кэшированое значение, которое было получено при предыдущих вызовах CALL-NEXT-METHOD. В любом случае, метод :around, не вызывающий CALL-NEXT-METHOD, ответственен за корректную реализацию семантики обобщенной функции для всех классов аргументов, для которых этот метод может применяться, включая и будущие подклассы.

Вспомогательные методы являются лишь удобным и лаконичным способом для выражения некоторых общих подходов (шаблонов). Они не позволяют сделать ничего невозможного посредством объединения основных методов с аккуратным соблюдением нескольких соглашений по кодированию и небольшим количеством дополнительного кода. Вероятно, наибольшей выгодой является то, что они обеспечивают унифицированный каркас для расширения функциональности обобщенных функций. Библиотеки часто определяют обобщенные функции и основные методы для них, позволяя пользователям изменять их поведение с помощью вспомогательных методов.

Другие комбинаторы методов

В добавление к стандартному комбинатору методов, язык реализует девять других встроенных комбинаторов методов, известных как "простые встроенные комбинаторы методов". Вы также можете определить собственный комбинатор методов, хотя это довольно редко используемая возможность, описание которой не является предметом этой книги. Я коротко опишу как использовать простые комбинаторы методов, чтобы дать общее представление об имеющихся возможностях.

Все простые комбинаторы используют одинаковую стратегию: вместо запуска наиболее специфического метода, и разрешения ему запускать менее специфичные методы через CALL-NEXT-METHOD, простые комбинаторы методов создают эффективный метод, содержащий код всех основных методов, расположенных по порядку, и обернутых вызовом к функции, макросу или специальному оператору, который и дал комбинатору методов соответствующее имя. Девять комбинаторов получили имена от операторов: +, AND, OR, LIST, APPEND, NCONC, MIN, MAX и PROGN. Простые комбинаторы поддерживают только два типа методов – основные методы, которые объединяются так, как было описано выше, и методы :around, которые работают также, как и методы :around в стандартном комбинаторе.

Например, обобщенная функция, которая использует комбинатор методов +, вернет сумму всех результатов, возвращенных вызванными основными методами. Отметьте, что комбинаторы AND и OR не обязательно будут выполнять все основные методы, поскольку эти макросы могут использовать сокращенную схему работы – обобщенная функция, использующая комбинатор AND вернет значение NIL сразу же, как один из методов вернет его, или в противном вернет значение, возвращенное последним вызванным методом. Аналогичным образом, комбинатор OR вернет первое значение не равное-NIL, возвращенное любым из его методов.

Для определения обобщенной функции, которая использует конкретный комбинатор методов, вы должны указать опцию :method-combination при объявлении DEFGENERIC. Значение, указанное данной опцией определяет имя комбинатора методов, который вы хотите использовать. Например, для определения обобщенной функции priority, которая возвращает сумму значений, возвращаемых отдельными методами, используя комбинатор методов +, вы можете написать следующий код:

(defgeneric priority (job)
  (:documentation "Return the priority at which the job should be run.")
  (:method-combination +)
)

По умолчанию, все эти комбинаторы методов комбинируют методы в порядке начиная с наиболее специфичного. Однако вы можете изменить порядок, путем указания ключевого слова :most-specific-last после имени комбинатора в объявлении функции с помощью DEFGENERIC. Порядок скорее всего не имеет значения если вы используете комбинатор + и методы не имеют побочных эффектов, но в целях демонстрации, я изменю код priority чтобы он использовал порядок most-specific-last:

(defgeneric priority (job)
  (:documentation "Return the priority at which the job should be run.")
  (:method-combination + :most-specific-last)
)

Основные методы обобщенных функций, которые используют один из этих комбинаторов, должны быть квалифицированы именем комбинатора методов. Таким образом, основной метод, определенный для функции priority может выглядеть следующим образом:

(defmethod priority + ((job express-job)) 10)

Это станет более ясным, если вы рассмотрите определение метода, как конкретную часть обобщенной функции.

Все простые встроенные комбинаторы методов поддерживают методы :around, которые работают также как и методы :around в стандартном комбинаторе: наиболее специфичный метод :around выполняется до любого метода, и он может использовать CALL-NEXT-METHOD для передачи контроля менее специфичному методу :around до тех пор, пока не будет достигнут основной метод. Опция :most-specific-last не влияет на порядок вызова методов :around. И, как я отметил ранее, встроенные комбинаторы методов не поддерживают методы :before и :after.

Подобно стандартному комбинатору методов, эти комбинаторы не позволяют вам сделать ничего из того, что вы не можете сделать "вручную". Вместо этого, они позволяют вам выразить то, что вы хотите, а язык возьмет на себя заботу о связывании всего вместе, делая ваш код более кратким и выразительным.

Честно говоря, примерно в 99 процентах случаев вам будет достаточно стандартного комбинатора методов. Оставшийся один процент случаев скорее всего будет обработан простыми встроенными комбинаторами методов. Но если вы попадете в ситуацию (вероятность - примерно одна сотая процента), когда вам не будет подходить ни один из встроенных комбинаторов, то вы можете посмотреть на описание DEFINE-METHOD-COMBINATION в вашем любимом справочнике по Common Lisp.

Мультиметоды

Методы, которые явно специализируют более одного параметра обобщенной функции называются мультиметодами. Мультиметоды - это то, в чем реально расходятся обобщенные функции и передача сообщений. Языки с передачей сообщений не приспособлены к мультиметодам, поскольку они не относятся к конкретному классу; вместо этого, каждый мультиметод определяет часть реализации конкретной обобщенной функции, которая применяется тогда, когда эта функция вызывается с аргументами, которые соответствуют всем специализированным параметрам.

FIXME (начало выделенного блока)


Мультиметоды против перегрузки методов

Программисты, использовавшие статически типизованные языки с системами передачи сообщений, такие как Java и C++, могут подумать, что мультиметоды похожи на такую возможность этих языков, как перегрузка методов. Однако эти две возможности языка в достаточной мере отличаются, поскольку перегруженные методы выбираются во время компиляции, основываясь на информации об аргументах, полученной во время компиляции, а не во время исполнения. Чтобы посмотреть как это работает, рассмотрим два следующих класса на языке Java:

public class A {
 public void foo(A a) { System.out.println("A/A"); }
 public void foo(B b) { System.out.println("A/B"); }
}

public class B extends A {
 public void foo(A a) { System.out.println("B/A"); }
 public void foo(B b) { System.out.println("B/B"); }
}

Теперь посмотрим, что случится, когда вы запустите метод main из этого класса.

public class Main {
 public static void main(String[] argv) {
   A obj = argv[0].equals("A") ? new A() : new B();
   obj.foo(obj);
 }
}

Когда вы заставляете main создать экземпляр A, она выдаст A/A, как вы и ожидали.

bash$ java com.gigamonkeys.Main A
A/A

Однако, если вы заставите main создать экземпляр B, то настоящий тип объекта obj будет принят во внимание не полностью.

bash$ java com.gigamonkeys.Main B
B/A

Если бы перегруженные методы работали также как и мультиметоды в Common Lisp, то программа бы выдала ожидаемый результат: B/B. Существует возможность реализации множественной диспатчеризации для систем с передачей сообщений вручную, но это будет противоречить модели системы с передачей сообщений, поскольку метод с множественной диспатчеризацией не должен относиться ни к одному из классов.


FIXME конец блока

Мультиметоды полезны в ситуациях, когда вы не знаете к какому классу определенное поведение должно относиться (в языках с передачей сообщений). Звук, который издает барабан, когда вы стучите по нему палочкой, является функцией барабана, или функцией палочки? Конечно, он принадлежит обоим предметам. Для моделирования такой ситуации в Common Lisp, вы просто определяете функцию beat, которая принимает два аргумента.

(defgeneric beat (drum stick)
  (:documentation
   "Produce a sound by hitting the given drum with the given stick."
)
)

Затем, вы можете определять разные мультиметоды для реализации beat для комбинаций, которые вам нужны. Например:

(defmethod beat ((drum snare-drum) (stick wooden-drumstick)) ...)
(defmethod beat ((drum snare-drum) (stick brush)) ...)
(defmethod beat ((drum snare-drum) (stick soft-mallet)) ...)
(defmethod beat ((drum tom-tom) (stick wooden-drumstick)) ...)
(defmethod beat ((drum tom-tom) (stick brush)) ...)
(defmethod beat ((drum tom-tom) (stick soft-mallet)) ...)

Мультиметоды не помогают от комбинаторного взрыва – если вам необходимо смоделировать пять видов барабанов и шесть видов палочек, и каждая из комбинаций дает отдельный звук, то нет способа упростить это; вам необходимо определить тридцать различных методов для реализации всех комбинаций, используя мультиметоды или нет. Мультиметоды помогут вам не писать собственный код для диспатчинга, позволяя использовать встроенные средства диспатчеризации, которые были очень удобны при работе с методами, специализированными для одного параметра.12)

Мультиметоды также спасают вас от требования чтобы один набор классов был тесно связан с другим набором. В нашем примере барабанов/палочек, не требуется чтобы класс, представляющий барабаны знал о различных классах, представляющих барабанные палочки, и наоборот. Мультиметоды связывают независимые классы для описания их совместного поведения, при этом не требуя никакого взаимодействия между самими классами.

Продолжение следует ...

Я описал основы (и немного больше) обобщенных функций – "глаголов" объектной системы Common Lisp. В следующей главе я покажу вам как определять ваши собственные классы.

1) Язык Simula, который сейчас считается первым объектно-ориентированным языком, был создан в начале 1960-х годов, лишь несколько лет после создания McCarthy первого Lisp. Однако, объектно-ориентированный подход не был популярен до начала 1980-х годов, когда была выпущена первая доступная версия Smalltalk, за которой последовал выпуск C++ несколько лет спустя. Smalltalk позаимствовал часть идей из Lisp и объединил их с идеями из Simula, что в результате привело к появлению динамического, объектно-ориентированного языка, в то время как C++ комбинировал идеи Simula и Си, что породило статический объектно-ориентированный язык. Это первоначальное разделение, привело к множеству неясностей в определении того, что есть объектно-ориентированный подход. Люди, которые привыкли к C++, говорят, что некоторые его аспекты, такие как строгая инкапсуляция данных, являются ключевыми характеристиками ООП. А люди, воспитанные на Smalltalk, в свою очередь указывают, что многие возможности C++ являются лишь возможностями C++, а не основами ООП. Говорят, Alan Kay, отец Smalltalk, однажды сказал: "Я ввёл термин объектно-ориентированный, и могу сказать, что C++ – это не то, что я имел ввиду".
2)Читается "see-loss". Источник: P. Norvig. "Paradigms of Artificial Intelligence Programming: Case Studies in Common Lisp". - Прим. перев.
3)Есть люди, который полностью отрицают, что Common Lisp является объектно-ориентированным языком. В частности, люди рассматривающие строгую инкапсуляцию данных в качестве ключевой характеристики ООП (обычно это приверженцы статических языков, таких как C++, Eiffel или Java), не считают Common Lisp "правильным" объектно-ориентированным языком. Конечно, при использовании данного определения, Smalltalk – один из первых объектно-ориентированных языков, также не будет им считаться. С другой стороны, люди, рассматривающие передачу сообщений как ключевую составляющую ООП, также не будут рады утверждению о объектной ориентированности Common Lisp, поскольку обобщенные функции Common Lisp предоставляют степени свободы, не предлагаемые чистой передачей сообщений.
4)Языки, основанные на прототипах (prototype-based languages) являются другим видом объектно-ориентированных языков. В таких языках (самым известным примером будет, пожалуй, JavaScript) объекты создаются путем клонирования объекта-прототипа. После клонирования, объект может быть модифицирован и использован как прототип для других объектов.
5)T является значением-константой, и класс T не имеет никакого отношения к этой константе, за тем исключением, что они имеют одинаковые имена. Значение T является экземпляром класса SYMBOL и, косвенно, экземпляром класса T.
6)Здесь, как и везде, под объектом понимается любой тип данных Lisp – Common Lisp не делает различия, как это делают некоторые языки, между объектами и "примитивными" типами данных; все типы данных в Common Lisp являются объектами, и каждый объект является экземпляром класса.
7)С технической точки зрения, вы можете вообще не использовать DEFGENERIC – если вы определяете метод с помощью DEFMETHOD и соответствующая обобщенная функция не определена, она будет создана автоматически. Однако хорошим тоном считается явное определение обобщенной функции, поскольку это предоставляет хорошее место для документирования её предназначения.
8)Метод может "принимать" именованные и остаточные аргументы, определенные в обобщенной функции, путем указания параметра &rest; тех же именованных параметров, или FIXME&rest, указания такого же параметра &key или указывая &allow-other-keys вместе с &key. Метод также может указывать именованные параметры, не указанные в списке параметров обобщенной функции: когда вызывается обобщенная функция, будет принят любой именованный параметр, указанный обобщенной функцией или любым другим подходящим методом. FIXMEОдним следствием из правила соответствия является то, что все методы одной и той же обобщенной функции будут иметь совпадающие списки параметров. Common Lisp не поддерживает перегрузку методов так, как это делают некоторые статически типизированные языки как С++ и Java, где одно и тоже имя может использоваться для методов с разными списками параметров.
9)CALL-NEXT-METHOD аналогиченFIXMEявляется приблизительным аналогом вызову метода для super в Java или использованию явно указанного метода или функции класса в Python или C++.
10)Хотя построение эффективного метода кажется медленным, был достигнут достаточный прогресс в части обеспечения его эффективности и разработки быстрых реализаций Common Lisp. Одной из стратегий является кэширование эффективных методов, так что следующие вызовы с теми же аргументами, будут обрабатываться сразу.
11)в действительности, порядок сравнения специализаторов настраивается через опцию :argument-precedence-order макроса DEFGENERIC, хотя она редко используется.
12)В языках без мультиметодов, вы должны сами писать диспатчеризующий код для реализации поведения, которое зависит от нескольких объектов. Назначением популярного паттерна проектирования "Визитор (Visitor)" является упорядочение серии методов, диспатчеризуемых по одному классу, таким образом, чтобы они обеспечивали множественную диспатчеризацию. Однако, это требует, чтобы один набор классов знал о других. Паттерн "Визитор" также вязнет в комбинаторном росте диспатчеризуемых методов, если он используется для диспатчеризации более чем двух объектов.
Предыдущая Оглавление Следующая
@2009-2013 lisper.ru