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).
Проверьте себя
1. Чем CLOS принципиально отличается от классического ООП (Java/Python)?
AВ CLOS нельзя создавать объекты
BВ CLOS классы хранят только данные (слоты), а поведение задаётся отдельно через обобщённые функции снаружи класса
CВ CLOS нет наследования
DВ CLOS методы всегда статические
2. В чём разница опций слота :accessor и :reader?
A:accessor только читает, :reader читает и пишет
B:accessor порождает и читатель, и setf-писатель; :reader даёт только чтение
Cони полностью идентичны
D:reader работает быстрее :accessor