Метаобъектный протокол Common Lisp на примере реализации прототипной объектной системы

в 20:33, , рубрики: common lisp, метапрограммирование, ооп, Программирование, метки:

Введение

Common Lisp, а точнее, его объектная система, CLOS, предоставляет пользователю языка совершенно замечательный механизм, а именно, метаобъектный протокол.

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

Вообще, что такое метаобъектный протокол? Очевидно, это слой объектной системы, который, судя по названию, каким-либо образом оперирует над ней самой, и управляет ей.

Для чего он нужен? На самом деле, в зависимости от языка и объектной системы, список применений может быть практически безграничен. Это как добавление коду декларативности(аннотации в Java и аттрибуты в C#), так и разнообразная генерация кода и классов в рантайме(здесь можно вспомнить разнообразные persistance и ORM фреймворки), так и многое другое.

С моей лично точки зрения, лучше всего метаобъектные протоколы себя зарекомендовали со стороны закрепления паттернов проектирования на уровне объектной системы. Такие паттерны, как, скажем, синглтон, которые в языках без достаточно развитого ООП приходится снова и снова реализовывать методом copy-n-paste, в моем любимом Common Lisp создаются буквально из пары десятков строчек кода и переиспользуются в дальнейшем исключительно указанием метакласса[1].

Тем не менее, в нижеследующем тексте я хочу сосредоточиться на кое-чем более интересном, а именно — на изменении правил работы самой объектной системы, самих ее основ. Именно добавление возможностей подобного изменения и было ключевой целью разработчиков метаобъектного протокола для Common Lisp.

Итак, дальнейший текст будет посвящен созданию прототипной объектной системы, подобной JavaScript, в Common Lisp, с использованием метаобъектного протокола и интеграцией ее в CLOS. Полный код проекта доступен на github[2].

Поехали

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

(defclass prototype-class (standard-class)
  ()
  (:documentation "Metaclass for all prototype classes"))

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

В CLOS MOP каждый слот объекта в классе представляется так называемыми slot-definition. Slot-definition, как понятно из названия, определяют метаинформацию о полях класса, а бывают они двух видов:

  • direct-slot-definiton Собственно, как, возможно, понятно из названия, представляют собой они то, что мы непосредственно указали при определении класса, скажем с помощью формы defclass.
  • effective-slot-definition — «Определение фактического слота». Они описывают слоты, которые существуют, грубо говоря, в объектах нашего класса.

Чтобы разница была понятна, стоит подробнее описать протокол инициализации классов.

В CLOS, при создании(определении) класса в нем(в его метаобъекте) до определенного времени хранится непосредственно только та информация, которую мы указали(скажем, в defclass). Это какая-то информация об определенных в нем полях(direct-slot-definition), это список классов от которых он наследуется, и разные другие вещи которые мы, еще раз повторюсь, непосредственно указали при создании. После создания класса, мы некоторое время спустя можем его редактировать.

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

В принципе, можно провести некоторые параллели со статическими конструкторами классов в языках вроде C#. Финализация, грубо говоря, завершает создание класса. В этот момент высчитывается так называемый Class Precedence List(а если по-русски, «список порядка наследования» класса, грубо говоря топологическая сортировка всех классов, от которых наш наследуется), и на основе этой информации определяются «фактические» слоты, которые объекты нашего класса будут хранить.

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

В принципе, все описанные механизмы можно переопределить через метаобъектный протокол, но мы ограничимся лишь несколькими.

Создадим наши классы определений слотов.

(defclass direct-hash-slot-definition (standard-direct-slot-definition)
  ()
  (:default-initargs :allocation :hash))

(defclass effective-hash-slot-definition (standard-effective-slot-definition)
  ()
  (:default-initargs :allocation :hash))

Теперь переопределим две обобщенные функции из MOP, которые указывают, классы каких определений слотов наш метакласс должен использовать, при их, определений слотов, создании.

(defmethod direct-slot-definition-class ((class prototype-class) &rest initargs)
  (declare (ignore initargs))
  (find-class 'direct-hash-slot-definition))

(defmethod effective-slot-definition-class ((class prototype-class) &rest initargs)
  (declare (ignore initargs))
  (find-class 'effective-hash-slot-definition))

Выше видно, что метаобъекты определений слотов принимают аргумент :allocation. Что это? Это спецификатор, указывающий, где выделяется место под поля объектов. Стандарт CL упоминает о двух видах таких спецификаторов. Первый — :class, который означает что место будет выделяться в самом классе, т.е. это аналог статических полей из других языков, и второй — :instance — место будет выделяться для каждого объекта класса, обычно в некотором массиве связаным с ним. Мы же указали свой спецификатор — :hash. Зачем? А затем, что по дефолту, поля у нас будут храниться в некоторой хэш-табличке, связанной с объектом, подобно тому как это делается в JavaScript.

Где же мы определим слот с хэш-табличкой? И, мы ведь где-то еще хотим хранить прототип объекта. Мы поступим следующим образом — мы определим класс prototype-object, который будет у нас вершиной иерархии всех классов, работающих с нашей системой. Как видно ниже, слоты с прототипом и с полями мы определим с instance allocation.

Прежде, чем мы создадим этот класс, мы должны разрешить нашим классам вида prototype-class наследоваться от стандартных классов и обратно. Функция validate-superclass вызывается в процессе финализации, который описан выше. В случае если хотя бы один из вариантов наследник-родитель, для любого из наследуемых классов, вернул nil, стандартный механизм CLOS сигнализирует исключение.

(defmethod validate-superclass ((class prototype-class) (super standard-class))
  t)

(defmethod validate-superclass ((class standard-class) (super prototype-class))
  t)

(defclass prototype-object ()
  ((hash :initform (make-hash-table :test #'eq)
         :reader hash
         :allocation :instance
         :documentation "Hash table holding :HASH object slots")
   (prototype :initarg :prototype
              :accessor prototype
              :allocation :instance
              :documentation "Object prototype or NIL."))
  (:metaclass prototype-class)
  (:default-initargs :prototype nil)
  (:documentation "Base class for all prototype objects"))

Давайте дополнительно определим две функции, подобные аналогичным из стандартной CLOS. Что они делают, думаю понятно:

(defun prototype-of (object)
  "Retrieves prototype of an OBJECT"
  (let ((class (class-of object)))
    (when (typep class 'prototype-class)
      (prototype object))))

(defgeneric change-prototype (object new-prototype)
  (:documentation "Changes prototype of OBJECT to NEW-PROTOTYPE")
  (:method ((object prototype-object) new-prototype)
    (setf (prototype object) new-prototype)))

Теперь небольшой хак. В стандартной CLOS в случае, если мы в defclass не указали ни одного класса-родителя являющегося standard-object, а метакласс нашего класса — обычный standard-class, то такой класс, собственно сам standard-object, инжектится в список классов, от которых мы наследуемся. Мы поступим так же с нашими prototype-class и prototype-object. Для этого нужно переопределить стандартные функции, используемые конструктором объектов.

(defun fix-class-initargs (class &rest args &key ((:direct-superclasses dscs) '()) &allow-other-keys)
"Fixup :DIRECT-SUPERCLASSES argument for [RE]INITIALIZE-INSTANCE gf
  specialized on prototype classes to include PROTOTYPE-OBJECT in
  superclass list"
  (remf args :direct-superclasses)
  (unless (or (eq class (find-class 'prototype-object))
              (find-if (lambda (c)
                         (unless (symbolp c) (setf c (class-name c)))
                         (subtypep c 'prototype-object))
                       dscs))
    (setf dscs (append dscs (list (find-class 'prototype-object)))))
  (list* :direct-superclasses dscs args))

(defmethod initialize-instance :around ((class prototype-class) &rest args &key &allow-other-keys)
  (apply #'call-next-method class (apply #'fix-class-initargs class args)))

(defmethod reinitialize-instance :around ((class prototype-class) &rest args &key &allow-other-keys)
  (apply #'call-next-method class (apply #'fix-class-initargs class args)))

Теперь самое интересное.

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

(defmethod slot-boundp-using-class ((class prototype-class) (object prototype-object) slotd)
  (if (eq :hash (slot-definition-allocation slotd))
    (nth-value 1 (gethash (slot-definition-name slotd) (hash object)))
    (call-next-method)))

(defmethod slot-makunbound-using-class ((class prototype-class) (object prototype-object) slotd)
  (if (eq :hash (slot-definition-allocation slotd))
    (remhash (slot-definition-name slotd) (hash object))
    (call-next-method)))

(defmethod slot-value-using-class ((class prototype-class) (object prototype-object) slotd)
  (if (eq :hash (slot-definition-allocation slotd))
    (values (gethash (slot-definition-name slotd) (hash object)))
    (standard-instance-access object (slot-definition-location slotd))))

(defmethod (setf slot-value-using-class) (new-value (class prototype-class) (object prototype-object) slotd)
  (if (eq :hash (slot-definition-allocation slotd))
    (values (setf (gethash (slot-definition-name slotd) (hash object))
                  new-value))
    (setf (standard-instance-access object (slot-definition-location slotd))
          new-value)))

Теперь прототипы. Как известно, в JavaScript значение поля ищется по цепочке прототипов. В случае, если поля в объекте нет, рекурсивно обходится вся иерархия, и в случае отсутствия поля у какого-либо из объектов, возвращается undefined. В то же время, в JS существует механизм «перекрытия» полей. Это значит, что если в объекте устанавливается/определяется поле с именем, аналогичным имени из полей какого-либо из объектов в иерархии прототипов, то при следующем доступе к этому полю, значение будет браться именно из него, без какого-либо следования по иерархии.

Мы реализуем аналогичную функциональность. Для этого нам потребуется переопределить обобщенную функцию slot-missing. Вызывается она тогда, когда функции работы со слотами(slot-value, (setf slot-value), slot-boundp, slot-makunbound) обнаруживают отсутствия поля с запрашиваемым именем в классе объекта. Эта обобщенная функция принимает крайне удобный набор аргументов — метаобъект класса объекта, сам объект, имя поля, имя «провалившейся» операции, и, для операции установки значения — новое значение поля.

Поступим следующим образом. До переопределения этой функции, создадим дополнительный класс сигналов(иключений Common Lisp), объекты которого будут выбрасываться в случае обнаружения отсутствия прототипа у объекта. Также, создадим дополнительный аналог вышеопределенной функции prototype-of.

(define-condition prototype-missing (condition)
  ()
  (:documentation
   "Signalled when an object is not associated with a prototype."))

(defun %prototype-of (class instance)
"Internal function used to retreive prototype of an object"
  (if (typep class 'prototype-class)
    (or (prototype instance) (signal 'prototype-missing))
    (signal 'prototype-missing)))

Теперь определим наш метод. Схема работы следующая: для двух из четырех операций, мы рекурсивно обходим иерархию прототипов, и в конечном итоге выбрасываем исключение prototype-missing. Сверху стека вызовов мы устанавливаем обработчик, который, перехватывая сигнал, возвращает нам некоторое дефолтное значение — в данном случае nil. Две другие операции, как было объяснено выше, в рекурсивном обходе прототипов не нуждаются.

(defvar *prototype-handler* nil
  "Non-NIL when PROTOTYPE-MISSING handler is already installed on call stack.")

(defun %slot-missing (class instance slot op new-value)
"Internal function for performing hash-based slot lookup in case
of it is missing from class definition."
  (let ((hash (hash instance)))
    (symbol-macrolet ((prototype (%prototype-of class instance)))
      (case op
        (setf
         (setf (gethash slot hash) new-value))
        (slot-makunbound
         (remhash slot hash))
        (t (multiple-value-bind
                 (value present) (gethash slot hash)
             (ecase op
               (slot-value
                (if present
                  value
                  (slot-value prototype slot)))
               (slot-boundp
                (if present
                  t
                  (slot-boundp prototype slot))))))))))

(defmethod slot-missing ((class prototype-class) (instance prototype-object) slot op &optional new-value)
  (if *prototype-handler*
    (%slot-missing class instance slot op new-value)
    (handler-case
        (let ((*prototype-handler* t))
          (%slot-missing class instance slot op new-value))
      (prototype-missing () nil))))

Готово! Собственно, не более чем за 150 строк кода мы получили работающую прототипную объектно-ориентированную систему, подобную таковой в JavaScript. Более того, эта система полностью интегрирована со стандартной CLOS, и допускает, скажем, участие «обычных» объектов в иерархии прототипов. Другая особенность — мы можем совсем не создавать своих классов объектов, а обходиться лишь одним prototype-object, в случае если мы хотим от нее поведения, полностью идентичного JS.

Что можно добавить? Наверное, поверх такой системы с помощью reader-макросов можно сделать JSON-подобный синтаксис. Но, это уже тема отдельной статьи :)

Напоследок несколько примеров:

(defvar *proto* (make-instance 'prototype-object))

(defclass foo ()
  ((a :accessor foo-a))
  (:metaclass prototype-class))

(defvar *foo* (make-instance 'foo :prototype *proto*))

(defvar *bar* (make-instance 'prototype-object :prototype *foo*))

(setf (slot-value *proto* 'x) 123)

(slot-value *bar* 'x)
;;; ==> 123

(setf (foo-a *foo*) 456)

(slot-value *bar* 'a)
;;; ==> 456

(setf (slot-value *bar* 'a) 789)

(setf (foo-a *foo*) 'abc)

(slot-value *bar* 'a)
;;; ==> 789
;;; because we've introduced new property for *bar*

(defclass quux ()
  ((the-slot :initform 'the-value))
  (:documentation "Simple standard class"))

(defvar *quux* (make-instance 'quux))

(change-prototype *bar* *quux*)

(slot-value *bar* 'the-slot)
;;; ==> THE-VALUE

(slot-value *bar* 'x)
;;; When attempting to read the slot's value (slot-value), the slot
;;; X is missing from the object #<QUUX {255A4C89}>.
;;;   [Condition of type SIMPLE-ERROR]

[1] http://love5an.livejournal.com/306670.html
[2] https://github.com/Lovesan/Prototype

Автор: love5an

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js