Метаобъектный протокол 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

ссылка на оригинал статьи http://habrahabr.ru/post/230619/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *