CLOS: классы и слоты через defclass
CLOS — объектная система Common Lisp: классы через defclass, слоты с их опциями, создание объектов make-instance и доступ к состоянию.
CLOS (Common Lisp Object System) — встроенная объектная система: классы описывают структуру (слоты), а поведение задаётся отдельно через обобщённые функции; класс через
defclassопределяет лишь данные и их свойства.
Зачем это: классы как данные, поведение — отдельно
CLOS — один из самых мощных и необычных объектных систем в индустрии, и его устройство переворачивает привычную картину ООП. Если в Java/Python/C++ метод «живёт внутри класса» и вызывается «у объекта» (obj.method()), то в CLOS поведение не принадлежит классу: классы хранят только данные (слоты), а методы определяются снаружи как части обобщённых функций. Этот первый урок — про «данные»: как defclass описывает структуру объекта. Понимание этого разделения «состояние в классе / поведение в обобщённых функциях» — ключ ко всей мощи CLOS, которую мы раскроем в следующих уроках (мультиметоды, комбинирование, множественное наследование).
Стоит сразу настроить ожидания: CLOS появился раньше большинства мейнстримных объектных систем (он стандартизирован в ANSI Common Lisp в начале 1990-х) и при этом во многом мощнее их до сих пор. Это не «ООП как в Java, только в Lisp» — это другая, более общая модель, из которой классический «методы-внутри-класса» получается как частный случай. Многие идеи, которые в других языках считаются продвинутыми или появились спустя десятилетия (мультиметоды, аспектное программирование через обёртки методов, метапрограммирование классов), в CLOS встроены изначально и работают вместе. Поэтому, изучая CLOS, держите ум открытым: привычки из языков с obj.method() здесь местами мешают. Награда — понимание объектной модели, по гибкости превосходящей почти всё, что вы видели, и при этом строго определённой стандартом, а не набором ad-hoc-правил.
Зачем вообще такое разделение данных и поведения? У него есть глубокая практическая мотивация — расширяемость без модификации. Раз методы не «заперты» внутри класса, кто угодно может добавить новое поведение к существующему классу (даже из чужой библиотеки), просто определив метод снаружи. И раз диспетчеризация идёт по типам аргументов, а не по «получателю», операции над парами типов (сложить число с матрицей, столкнуть два тела) выражаются естественно. Эти две возможности — открытое расширение и мультидиспетчеризация — прямо вытекают из решения «данные в классе, поведение в обобщённых функциях». Так что необычность CLOS не самоцель: она покупает конкретные инженерные преимущества, которые мы увидим в действии дальше.
defclass: объявление класса
Класс объявляется через defclass: имя, список суперклассов, список слотов с опциями. Слот — это «поле» объекта; но в отличие от defstruct, каждый слот настраивается богатым набором опций: как к нему обращаться, как инициализировать, виден ли он экземпляру или всему классу.
(defclass animal () ; имя, () = нет суперклассов (кроме базового)
((name :initarg :name ; :initarg — имя для make-instance
:accessor animal-name) ; :accessor — порождает читатель И setf
(sound :initarg :sound
:accessor animal-sound
:initform "..." ; :initform — значение по умолчанию
)
(legs :initarg :legs
:reader animal-legs ; :reader — только чтение (без setf)
:initform 4)))
;; создание экземпляра — make-instance с :initarg-ключами
(defparameter *dog*
(make-instance 'animal :name "Рекс" :sound "Гав" :legs 4))
(animal-name *dog*) ; => "Рекс"
(animal-sound *dog*) ; => "Гав"
(setf (animal-name *dog*) "Бобик") ; accessor работает и на запись
(animal-name *dog*) ; => "Бобик"
Опции слотов: точный контроль над состоянием
Опции слота — это сердце defclass. Разберём главные, потому что именно они отличают «структуру» от полноценного объекта:
| Опция | Что задаёт |
:initarg | ключевое имя для передачи в make-instance |
:initform | значение по умолчанию, если :initarg не передан |
:accessor | порождает обобщённую функцию-читатель И setf-писатель |
:reader | только читатель (без возможности setf) |
:writer | только писатель |
:allocation | :instance (по умолчанию) или :class (общий для всех экземпляров) |
:type | декларация типа слота (подсказка/проверка) |
:documentation | строка-описание слота |
Особенно важна разница :accessor против :reader/:writer: :accessor foo — это сразу и чтение (foo obj), и запись (setf (foo obj) val). Если поле должно быть «только для чтения» снаружи — берите :reader. Ещё тонкость: аксессоры — это обобщённые функции, а не «магические поля»; их можно специализировать и переопределять, что недостижимо в классическом ООП с прямым доступом к полям.
На этой тонкости стоит задержаться, потому что она имеет далеко идущие последствия. Раз аксессор — это обобщённая функция, а не прямой доступ к памяти, вы можете вмешаться в чтение или запись слота, определив свой метод (или :around-метод) на аксессоре: например, логировать каждое изменение, валидировать присваиваемое значение, лениво вычислять значение при первом чтении, уведомлять наблюдателей. В языках, где поле читается напрямую (obj.field), для этого приходится заранее прятать поле за «геттер/сеттер», иначе перехватить доступ нельзя без переписывания всех обращений. В CLOS обращение с самого начала идёт через обобщённую функцию, поэтому добавить поведение к доступу можно в любой момент, не трогая код, который этим аксессором пользуется. Это устраняет извечную дилемму «делать ли сразу геттеры на всякий случай»: в CLOS аксессор и есть геттер, всегда расширяемый. Такое единообразие «доступ к слоту — это вызов функции» — ещё один пример того, как CLOS оказывается гибче там, где классический ООП требует предусмотрительности.
:allocation :class — общее состояние
Опция :allocation даёт то, чего нет в defstruct: слот, общий для всех экземпляров класса (аналог статического поля). Все объекты разделяют одно хранилище — удобно для счётчиков, конфигурации, разделяемых ресурсов.
(defclass counter ()
((count :initform 0 :accessor counter-count
:allocation :class))) ; ОДИН на весь класс
(defparameter *a* (make-instance 'counter))
(defparameter *b* (make-instance 'counter))
(incf (counter-count *a*)) ; меняем через один объект
(counter-count *b*) ; => 1 — виден через другой!
Доступ к слотам напрямую: slot-value
Помимо аксессоров, к слоту можно обращаться напрямую через slot-value — это «низкоуровневый» доступ по имени слота. Его используют редко снаружи (аксессоры предпочтительнее, они часть интерфейса), но он необходим внутри методов, где аксессоры ещё не определены, и для метапрограммирования.
(slot-value *dog* 'name) ; => имя, прямой доступ к слоту
(setf (slot-value *dog* 'legs) 3) ; прямая запись, минуя :reader
;; with-slots — удобный доступ к нескольким слотам как к переменным:
(defun describe-animal (a)
(with-slots (name sound legs) a
(format nil "~a говорит ~a и имеет ~d ног" name sound legs)))
Макрос with-slots связывает имена слотов с локальными «псевдопеременными»: внутри тела name читается и пишется как переменная, но фактически обращается к слоту объекта. Это устраняет повторение (slot-value a 'name) и делает методы читаемыми. Есть и with-accessors — то же, но через аксессоры (предпочтительно, если они заданы).
Инициализация: initialize-instance
Когда нужна логика при создании объекта (вычислить производный слот, провалидировать), CLOS даёт точку расширения — обобщённую функцию initialize-instance. Определив метод :after для неё, вы выполняете код после стандартной инициализации слотов. Это идиоматичная замена «конструктору с телом».
(defclass circle ()
((radius :initarg :radius :accessor circle-radius)
(area :accessor circle-area))) ; area вычислим из radius
;; :after-метод выполнится после установки слотов из initarg
(defmethod initialize-instance :after ((c circle) &key)
(setf (circle-area c)
(* pi (circle-radius c) (circle-radius c))))
(circle-area (make-instance 'circle :radius 2)) ; => ~12.566
Это первое прикосновение к комбинированию методов (:after), которое мы подробно разберём позже. Пока важно: создание объекта в CLOS — это вызов обобщённой функции, в которую можно «вклиниться», а не жёсткий конструктор. Заметьте, насколько это гибче «конструктора» из других языков: вместо одного жёстко зашитого тела конструктора у вас есть точка расширения, к которой подклассы и даже сторонний код могут добавлять свою инициализацию через :after-методы, и все они выполнятся в правильном порядке по иерархии. Не нужно ни вызывать родительский конструктор вручную, ни бояться его забыть — система соберёт всю цепочку инициализации сама.
Как работает под капотом
Экземпляр CLOS физически — это объект со ссылкой на свой класс и хранилищем слотов (обычно вектор; смещение каждого слота класс знает). Класс — это тоже объект (метакласса standard-class), хранящий список слотов, суперклассы и вычисленный «порядок предшествования». Аксессоры и читатели, порождённые defclass, — это методы обобщённых функций, специализированные на данном классе: (animal-name x) — это вызов обобщённой функции animal-name, которая диспетчеризуется по типу x и достаёт нужный слот. Поэтому добавление подкласса со своим слотом или переопределение аксессора встраивается в ту же обобщённую функцию. Слот :allocation :class хранится не в экземпляре, а в объекте класса, поэтому общий для всех. То, что и классы, и обобщённые функции, и методы — полноценные объекты, — основа метаобъектного протокола (MOP), который позволяет менять саму объектную систему изнутри (обзор — в последнем разделе). Запомните этот факт: он объясняет, почему CLOS так легко интроспектировать и расширять — система описывает саму себя в собственных терминах.
Частые ошибки
- Путать
:accessorи:reader.:accessorдаёт и чтение, и запись (через setf);:reader— только чтение. Для неизменяемого снаружи поля берите:reader. - Забыть
:initarg. Без:initargслот нельзя задать черезmake-instance— он получит лишь:initform(или останется несвязанным). - Ожидать, что методы внутри класса. В CLOS поведение — в обобщённых функциях снаружи класса, а не «внутри» него.
defclassописывает только данные. - Случайный
:allocation :class. Такой слот общий для всех экземпляров — изменение через один объект видно всем. Для обычного поля нужен (дефолтный):instance. - Обращение к несвязанному слоту. Если слот без
:initformне задан, чтение сигналит ошибку «unbound slot». Давайте:initformили передавайте:initarg.
Итоги
- CLOS разделяет данные (классы/слоты) и поведение (обобщённые функции снаружи) — в этом его суть.
defclassобъявляет имя, суперклассы и слоты; объект создаётся черезmake-instanceс:initarg-ключами.- Опции слота (
:initarg,:initform,:accessor/:reader/:writer,:allocation) дают точный контроль над состоянием. :accessorпорождает обобщённые функции чтения и записи;:allocation :classделает слот общим для всех экземпляров.slot-value/with-slots/with-accessors— прямой и удобный доступ к слотам внутри методов.- Инициализацию настраивают
:after-методом дляinitialize-instance; и классы, и методы — объекты (основа MOP).