Регистрация | Войти
Lisp — программируемый язык программирования

Протокол инициализации CLOS

© 2009 Nathan Froyd. Originally published at http://www.method-combination.net/blog/archives/2009/12/22/clos-initialization-protocol.html

Перевод Ивана Болдырева

Добавление: Тобиас Риттвейлер указал, что в моих объявлениях методов не нужно использовать &ALLOW-OTHER-KEYS, т.к. он уже объявлен в DEFGENERIC и не обязателен в индивидуальных методах. Более того, &ALLOW-OTHER-KEYS в ваших объявлениях методов, если он уже был объявлен в DEFGENERIC, препятствует полезной проверке аргументов. Так что не делаете этого. Я исправил нижеследующие примеры.


Как мы видели в прошлом посте, REINITIALIZE-INSTANCE — очень полезный способ уменьшения выделения памяти. Вы можете сказать: «Конечно, вот почему в библиотеке/программе, которую я пишу, есть функции RESET-FOO». Хочу сказать, что если вы пишите подобные функции, то лучше вместо них объявлять методы REINITIALIZE-INSTANCE (если вы реинициализируете структуры), либо писать :AFTER-методы обычных методов протокола инициализации CLOS. Это больший Lisp-way, чем обычные функции сброса. Использование существующего протокола поощряет вас точнее определить, что является инициализацией, что реинициализацией, а что используется ими обеими.

«ОК» - вы скажете, — «но что это за методы и как их использовать?» Рад, что вы спросили!

Есть три главные обобщённые функции, используемые в протоколе:

Как вы могли догадаться, INITIALIZE-INSTANCE вызывается из MAKE-INSTANCE, а REINITIALIZE-INSTANCE, хм, из REINITIALIZE-INSTANCE. SHARED-INITIALIZE вызывается как из INITIALIZE-INSTANCE, так и REINITIALIZE-INSTANCE и содержит общий для обоих методов код. Мы сфокусируемся на SHARED-INITIALIZE, так как она является основанием для двух других, а также вызывается при изменении объявлении класса и смене класса экземпляра. Вы бесплатно получаете многое, объявляя метод SHARED-INITIALIZE.

Начнём по порядку: обычно вам нужно объявить лишь :AFTER-методы SHARED-INITIALIZE:

(defmethod shared-initialize :after ((object my-class) slot-names &rest initargs &key)
  ...
)

Причина этого в том, что главный метод SHARED-INITIALIZE делает множество полезных вещей, таких как инициализация слотов по их :INITFORM и :INITARG. (По той же причине вы обычно должны определять :AFTER-методы у INITIALIZE-INSTANCE и REINITIALIZE-INSTANCE). Конечно, вы можете написать:

(defmethod shared-initialize ((object my-class) slot-names &rest initargs &key)
  (call-next-method)
  ...
)
     

Но обычно делают не так. Я считаю, что написание :AFTER-методов делает ваши намерения более ясными: вы делаете дополнительную работу после выполнения стандартного кода. Кроме того, легко забыть вызвать CALL-NEXT-METHOD, что приведёт к загадочному поведению, а также к странным баг-репортам типа «Инициализация CLOS не работает у классов, определённых пользователем». Кроме того, использование комбинации методов часто полезнее и декларативнее, чем вызовы CALL-NEXT-METHOD в нужном месте. Это не значит, что CALL-NEXT-METHOD не надо использовать, это просто значит, по моему мнению, что нужно предпочитать комбинацию методов во всех возможных случаях.

ОК, теперь давайте займёмся инициализацией. Что означает параметр SLOT-NAMES? Это (возможно, пустой) список имён слотов, которые должны быть проинициализированы по их :INITFORM, либо T, что означает «все слоты». Для наших целей мы сейчас будем предполагать, что SLOT-NAMES всегда равен T (когда SHARED-INITIALIZE вызывается из INITIALIZE-INSTANCE) или из NIL (когда SHARED-INITIALIZE вызывается из REINITIALIZE-INSTANCE). В случае T, главный метод SHARED-INITIALIZE обрабатывает<!– TODO –> логику инициализации. Так нам не надо сильно беспокоиться о SLOT-NAMES.

Вы также можете большей частью игнорировать INITARGS. Большинство ключевых аргументов будет обработана стандартными функциями CLOS. Всё, что вам интересно, может быть вытащено индивидуальными &KEY-аргументами; мы рассмотрим такой пример чуть ниже.

ОК, какие же полезные вещи мы можем делать в SHARED-INITIALIZE? Мы можем пересчитать слоты, которые зависят от значений других слотов и поэтому не могут быть инициализированы полезными значениями с помощью :INITFORM или :INITARGS:

(defclass triangle ()
  ((a :initarg :a :reader a)
   (b :initarg :b :reader b)
   (c :initarg :c :reader c)
   (area :reader area)
)
)


(defmethod shared-initialize :after ((o triangle) slot-names &rest initargs &key)
  (let ((area (compute-area-of-triangle (a o) (b o) (c o))))
    (setf (slot-value o 'area) area)
)
)

В подобных случаях вы, возможно, захотите написать какую-нибудь обёртку (с соответствующим изменением определением TRIANGLE):

(defun make-triangle (a b c)
  (let ((area (compute-area-of-triangle a b c)))
    (make-instance 'triangle :a a :b b :c c :area area)
)
)

что, конечно, возможно (я оставлю тему о том, обёртывать ли MAKE-INSTANCE или сделать доступной пользователю вашего API, на будущее). Но подобная обёртка не позволяет легко организовать реинициализацию. Представьте, как могла бы выглядеть написанная вами функция RESET-TRIANGLE, обрабатывающая все случаи, которые SHARED-INITIALIZE обрабатывает бесплатно:

(defun reset-triangle (triangle &key a b c)
  (let ((a (or a (a triangle)))
        (b (or b (b triangle)))
        (c (or c (c triangle)))
)

    (setf (slot-value triangle 'a) a
          (slot-value triangle 'b) b
          (slot-value triangle 'c) c
          (slot-value triangle 'area) (compute-area-of-triangle a b c)
)

    triangle
)
)

Обратите внимание, что вы продублировали установку слотов (которые внутренности CLOS делают за вас) и вычисляете площадь треугольника в двух разных, но связанных (оба — часть инициализации) местах. Вы можете сказать, что хотите, чтобы пользователи переустанавливали все три стороны треугольника, и API должно это отражать. Но в этом случае именно вы должны вызывать REINITIALIZE-INSTANCE, а значит и SHARED-INITIALIZE. Я также считаю, что метод SHARED-INITIALIZE лучше отражает роль вычисление площади: вычисление площади является неотъемлемой частью инициализации, а не то, что выс делаете заранее перед созданием объекта.

Для менее педагогичного примера давайте представим, что у вас есть классный алгоритм шифрования:

(defclass les () ; the LISP encryption standard
 ((round-keys :reader round-keys)
   (n-rounds :reader n-rounds)
)
)


(defmethod shared-initialize :after ((o les) slot-names &rest initargs &key key)
  (multiple-value-bind (round-keys n-rounds) (schedule-key key)
    (setf (slot-value o 'round-keys) round-keys
          (slot-value o 'n-rounds) n-rounds
)
)
)

Возможно, вы заметили, что SHARED-INITIALIZE принимает ключевой аргумент KEY, хотя в DEFCLASS и нет :INITARG :KEY. Одно из достоинств SHARED-INITIALIZE состоит в том, что любой его ключевой аргумент автоматически становится кандидатом на использование с MAKE-INSTANCE и остальными обобщёнными функциями, участвующими в протоколе инициализации. Это позволяет вам передавать ключевые аргументы в MAKE-INSTANCE, которые не связаны напрямую со слотами, но требуют какой-либо обработки для вычисления значения какого-либо поля. Так что, используя вышеприведённый код, вы можете написать:

(defvar *l* (make-instance 'les :key #(#xde #xad #xbe #xef)))
...lots of code...
;; sometime later
(reinitialize-instance *l* :key #(#xca #xfe #xbe #xbe))

И снова вы могли бы использовать функции-обёртки. Но я думаю, здесь применимы те же аргументы о ясности и избежании повторов. (А если вы действительно всерьёз занимаетесь шифрованием, вы можете захотеть иметь возможность перестанавливать вектор инициализации для вашего режима шифрования и, возможно, даже полностью менять режим шифрования. После того, как вы это сделаете, вы практически перепишете SHARED-INITIALIZE и, возможно, продублируете логику вашей оболочки MAKE-INSTANCE в вашей функции переустановки).

Другая причина иметь методы SHARED-INITIALIZE — они естественным способом взаимодействуют с созданием подклассов. Скажем, вы работаете с каким-то форматом сжатия, и вы создаёте записи из байтовых векторов:

(defclass entry ()
  ...slots...
)


(defun make-entry-from-buffer (buffer &key (start 0))
  (let (...parse out individual slots from BUFFER...)
    (make-instance 'entry ...initargs for slots...)
)
)

Дёшево и сердито. Теперь давайте представим, что ваш клиент сообщает вам о второй версии формата, которые в основном совпадает с первой, но создаёт возможность указывать дополнительные метаданные как часть записи. ОК:

(defclass entry-v2 (entry)
  ...more slots...
)


(defun make-entry-from-buffer (buffer &key (start 0))
  ;; A `2' at the start of the buffer indicates a version 2 entry.
 (if (= (aref buffer start) 2)
      (make-entry-v2-from-buffer buffer :start start)
      (make-entry-v1-from-buffer buffer :start start)
)
)

Хм. Нам нужно инициализировать слоты записи в одном месте, чтобы избежать повторения:

(defun initialize-common-entries (entry buffer &key (start 0))
  (let (...parse out individual slots from BUFFER...)
    (setf ...lots of slots...)
    entry
)
)


(defun make-entry-v2-from-buffer (buffer &key (start 0))
  (let ((entry (make-instance 'entry-v2)))
    (setf ...new slots for ENTRY-V2...)
    (initialize-common-entries entry buffer :start start)
)
)

и так далее. Возможно, понадобится небольшое изменение, так как разбор слотов для ENTRY-V2 может зависеть от значения одного или нескольких слотов в ENTRY. Я заявляю, что это элегантнее решать так:

(defmethod shared-initialize :after ((o entry) slot-names &rest initargs &key buffer start)
  (let (...parse out individual slots from BUFFER...)
    (setf ...lots of slots...)
    o
)
)


(defmethod shared-initialize :after ((o entry-v2) slot-names &rest initargs &key buffer start)
  (let (...parse out individual slots from BUFFER...)
    (setf ...slots for ENTRY-V2...)
    o
)
)


(defun make-entry-from-buffer (buffer &key (start 0))
  ;; A `2' at the start of the buffer indicates a version 2 entry.
 (if (= (aref buffer start) 2)
      (make-instance 'entry-v2 :buffer buffer :start start)
      (make-instance 'entry :buffer :start start)
)
)

Так как :AFTER-методы выполняются начиная с наиболее общего, первым будет вызван :AFTER-метод для класса ENTRY. Поэтому :AFTER для класса ENTRY-V2 может свободно использовать значение любого слота из ENTRY. Использование SHARED-INITIALIZE в данном случае не только даёт возможность использовать REINITIALIZE-INSTANCE, но и определяет правильный общий код с точки зрения проектирования программного обеспечения.

Конечно, если у вас есть особый код, который должен выполняться во время MAKE-INSTANCE или REINITIALIZE-INSTANCE, то вы можете добавить :AFTER-методы к MAKE-INSTANCE или REINITIALIZE-INSTANCE соответственно. Например, если у всех ваши объектов должен быть уникальный идентификатор, то вы наверняка не захотите устанавливать его в SHARED-INITIALIZE.

(defclass unique-id-mixin ()
  ((unique-id :reader unique-id))
)


(defmethod initialize-instance :after ((o unique-id-mixin) &rest initargs &key)
  (setf (slot-value o 'unique-id) (get-unique-id-for-instance o))
)

(Однако вы можете захотеть присвоить другой идентификатор, если кто-нибудь изменит класс вашего объекта. Это будет темой будущего обсуждения; у меня лишь примерное представление о протоколе, используемом в CHANGE-CLASS, и мне нужно сначала увидеть действительно прагматичные причины для добавления методов к используемым обобщённым функциям).

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

@2009-2013 lisper.ru