Комбинирование методов: :before, :after, :around

Комбинирование методов: основные методы и вспомогательные :before, :after, :around — как CLOS собирает поведение из слоёв и почему это сильнее переопределения.

Комбинирование методов — правило, по которому CLOS объединяет несколько применимых методов в один «эффективный метод»; стандартное комбинирование оборачивает основной метод вспомогательными :before, :after и :around.

Зачем это: поведение слоями, а не копированием

Часто к существующему поведению нужно что-то добавить: логировать вызов, проверить аргументы до, прибрать после, обернуть в транзакцию. В классическом ООП это делают переопределением с ручным вызовом super — хрупко и многословно. CLOS даёт декларативный механизм: вы объявляете вспомогательные методы с квалификаторами :before/:after/:around, а система сама собирает их вокруг основного метода в правильном порядке. Это «аспектное» по духу программирование, встроенное в ядро объектной системы. Освоив его, вы перестаёте копировать логику и начинаете наслаивать её.

Стоит увидеть, чем это лучше ручного super.method(), который используют в Java/Python. При ручном подходе каждый переопределяющий метод обязан помнить вызвать родителя в нужном месте: забыл — потерял базовое поведение; вызвал не там — нарушил порядок; а порядок «до» и «после» родителя приходится кодировать вручную, расставляя вызов super в начале или в конце тела. Это источник тонких багов и дублирования. CLOS снимает эту заботу: вы декларируете намерение через квалификатор (:before — «до», :after — «после», :around — «вокруг»), а система гарантирует правильный порядок и автоматически собирает цепочку с учётом всей иерархии. Вам не нужно помнить про super и его место — достаточно сказать, когда ваш код должен выполниться относительно остального. Это переход от императивного «не забудь вызвать родителя правильно» к декларативному «вот роль моего метода», и он устраняет целый класс ошибок.

Четыре роли метода

В стандартном комбинировании метод играет одну из ролей, заданную квалификатором перед списком параметров:

КвалификаторРольПорядок
(нет)основной (primary) — главная логиканаиболее специфичный выполняется
:beforeвыполнить ДО основного (ради эффекта)все, от специфичного к общему
:afterвыполнить ПОСЛЕ основного (ради эффекта)все, от общего к специфичному
:aroundобернуть весь вызов; решает, звать ли остальноесамый специфичный снаружи
(defgeneric withdraw (account amount))

(defclass account ()
  ((balance :initarg :balance :accessor balance :initform 0)))

;; основной метод — собственно логика
(defmethod withdraw ((acc account) amount)
  (decf (balance acc) amount)
  (balance acc))

;; :before — проверка ДО (побочный эффект/валидация)
(defmethod withdraw :before ((acc account) amount)
  (when (> amount (balance acc))
    (error "Недостаточно средств")))

;; :after — журналирование ПОСЛЕ
(defmethod withdraw :after ((acc account) amount)
  (format t "Снято ~a, остаток ~a~%" amount (balance acc)))

(withdraw (make-instance 'account :balance 100) 30)
;; печатает "Снято 30, остаток 70", возвращает 70

Порядок выполнения: точная механика

Это самая важная часть урока. Эффективный метод собирается так:

  1. Выполняются все :around-методы, начиная с самого специфичного; внутри :around вызов call-next-method передаёт управление «внутрь» (к менее специфичным :around, а затем к ядру).
  2. «Ядро» = все :before (от специфичного к общему) → наиболее специфичный основной метод → все :after (в обратном порядке, от общего к специфичному).
  3. Значение, которое вернёт обобщённая функция, — это значение основного метода (или то, что вернул внешний :around). :before и :after существуют только ради побочных эффектов: их возвращаемые значения отбрасываются.

Запомните мнемонику: :before — «снаружи внутрь» (специфичный первым), :after — «изнутри наружу» (специфичный последним), как открытие и закрытие вложенных скобок. Это даёт естественную семантику «установить контекст до / снять контекст после», причём подкласс обрамляет суперкласс.

Эта зеркальность :before и :after не случайна — она отражает естественный жизненный цикл «вход в контекст / выход из контекста», и сравнение со скобками здесь буквальное. Представьте вложенные обёртки: внешняя (суперкласс) открывается первой и закрывается последней, внутренняя (подкласс) открывается после и закрывается раньше — ровно как (( )). Поэтому :before-методы выполняются от специфичного к общему (подкласс «входит» внутрь контекста родителя), а :after — от общего к специфичному (подкласс «выходит» первым, родитель прибирает последним). Это идеально ложится на задачи «подготовить-сделать-прибрать»: подкласс может добавить свою подготовку поверх родительской и свою уборку, и порядок гарантированно симметричен. Когда вы проектируете цепочку :before/:after по иерархии, держите в голове этот образ вложенных скобок — он сразу подсказывает, в каком порядке всё сработает, и избавляет от необходимости заучивать правила.

:around — самый мощный квалификатор

:around оборачивает весь вызов и решает, выполнять ли остальное (через call-next-method) и что вернуть. Это идеальное место для измерения времени, кеширования, обёртки в блокировку/транзакцию, перехвата результата. Именно :around может заменить или преобразовать результат — в отличие от :before/:after, которые на результат не влияют.

(defgeneric compute (x))

(defmethod compute ((x integer))
  (* x x))                          ; основной метод

;; :around оборачивает весь вызов, может изменить результат
(defmethod compute :around ((x integer))
  (format t "вход: ~a~%" x)
  (let ((result (call-next-method)))   ; вызвать ядро (основной метод)
    (format t "выход: ~a~%" result)
    (* result 10)))                    ; ПРЕОБРАЗОВАТЬ результат

(compute 4)
;; печатает "вход: 4" и "выход: 16", ВОЗВРАЩАЕТ 160 (16*10)

Заметьте: :around сам решил умножить результат ядра на 10 — ни :before, ни :after так не могут. Если :around не вызовет call-next-method, ядро (и :before/:after) вообще не выполнится — это позволяет, например, вернуть кешированное значение, минуя дорогой расчёт.

Эта способность :around «обернуть и при желании подменить» делает его инструментом для целого класса «сквозных» (cross-cutting) забот, которые иначе пришлось бы вшивать в каждый метод вручную. Кеширование (мемоизация): :around проверяет кеш, при попадании возвращает сохранённое, не вызывая ядро, иначе считает и кеширует. Управление ресурсами: :around открывает соединение/блокировку/транзакцию, через call-next-method выполняет работу, затем гарантированно закрывает (часто внутри unwind-protect). Профилирование: замерить время вокруг call-next-method. Авторизация: проверить права, и лишь при успехе вызвать ядро. Во всех этих сценариях основная логика (ядро) остаётся чистой и ничего не знает о кешировании, блокировках или правах — забота вынесена в :around-обёртку, которую можно добавить, убрать или поменять независимо. Это и есть аспектно-ориентированное программирование, но не как отдельный фреймворк, а как органичная часть объектной системы. Понимание :around как «места для сквозных забот» — один из самых практически ценных навыков в CLOS.

Зачем это сильнее обычного переопределения

Стоит подчеркнуть, что стандартное комбинирование — это лишь умолчание, причём настраиваемое, и сам факт его настраиваемости говорит о глубине дизайна CLOS: даже «как методы складываются вместе» не зашито намертво, а является политикой, которую можно заменить. Большинство кода живёт на стандартном комбинировании, и его одного хватает для подавляющего числа задач. Но знание, что под ним лежит общий механизм (а не магия компилятора), помогает не воспринимать :before/:after/:around как загадочные ключевые слова: это просто роли в стандартном правиле сборки, и при желании можно определить своё правило с другими ролями. Эта «настраиваемость до самого низа» — лейтмотив CLOS, который достигнет кульминации в теме MOP.

Стандартное комбинирование решает реальные инженерные задачи декларативно. Валидация, логирование, кеширование, управление ресурсами — «сквозные» заботы, которые в обычном ООП размазываются по телам методов или требуют ручного super. В CLOS они выносятся в отдельные вспомогательные методы, не загромождая основную логику, и автоматически встраиваются в правильном порядке с учётом наследования. Подкласс может добавить :before/:after, не переписывая основной метод родителя; библиотека может вклинить :around в чужую обобщённую функцию. Это и есть «расширение без модификации» на уровне отдельных аспектов поведения. Более того, стандартное комбинирование — лишь одно из доступных; CLOS позволяет задавать свои правила комбинирования (например, + — суммировать результаты всех методов, and, progn, list) через define-method-combination, что выходит далеко за рамки классического ООП.

;; нестандартное комбинирование: + суммирует результаты всех методов
(defgeneric total-cost (item)
  (:method-combination +))

(defclass laptop () ())
(defmethod total-cost + ((x laptop)) 1000)  ; базовая цена
(defmethod total-cost + ((x laptop)) 50)    ; доставка (другой аспект)
;; (на практике аспекты дают разные классы/миксины)
;; total-cost вернёт СУММУ всех применимых + -методов

Как работает под капотом

При вызове CLOS строит «эффективный метод» — фактически собирает форму вроде вложенных вызовов: внешние :around оборачивают «ядро», где :before идут последовательно, затем основной метод, затем :after в обратном порядке. call-next-method внутри любого метода ссылается на «следующий» в этой собранной цепочке. Список применимых методов и их порядок определяются специфичностью (через CPL аргументов, как в прошлом уроке), а сама сборка эффективного метода кешируется по классам аргументов — поэтому накладные расходы малы. Стандартное комбинирование — встроенное правило сборки; define-method-combination позволяет заменить его: вы описываете, как именно объединять результаты квалифицированных методов (суммой, списком, логическим and и т. д.). Эта открытость — часть метаобъектного протокола: даже «как методы комбинируются» — настраиваемая часть системы, а не зашитая магия компилятора.

Частые ошибки

  • Ждать, что :before/:after влияют на результат. Их значения отбрасываются; функция возвращает значение основного метода. Менять результат может только :around (или сам основной).
  • Путать порядок :after. :after идут от общего к специфичному (специфичный последним) — зеркально :before. Это важно для «снятия контекста».
  • Забыть call-next-method в :around. Без него ядро (и :before/:after) не выполнится. Иногда это и нужно (кеш), но чаще — забывчивость.
  • Сложная логика в :before. Вспомогательные методы — для эффектов (проверка, лог). Основную вычислительную логику держите в primary-методе.
  • Смешивать квалификаторы из разных комбинирований. :before/:after/:around — для стандартного комбинирования; при :method-combination + квалификатор иной (+). Не путайте правила.

Итоги

  • Комбинирование методов собирает несколько применимых методов в один эффективный метод.
  • Роли: основной (primary) — логика; :before/:after — эффекты до/после; :around — обёртка всего вызова.
  • Порядок: :around снаружи → :before (специфичный первым) → primary → :after (специфичный последним).
  • Результат функции — значение primary (или :around); :before/:after на результат не влияют.
  • :around с call-next-method может преобразовать или подменить результат и даже не звать ядро (кеш).
  • Стандартное комбинирование — одно из многих; define-method-combination задаёт свои правила (сумма, список, and) — это выходит за рамки классического ООП.
Проверьте себя
1. Влияет ли возвращаемое значение :after-метода на результат обобщённой функции?
AДа, :after всегда определяет результат
BНет, значения :before и :after отбрасываются; результат — это значение основного (primary) метода или :around
CДа, но только если нет primary-метода
D:after вообще не может возвращать значение
2. Чем :around мощнее :before и :after?
A:around выполняется быстрее
B:around оборачивает весь вызов: через call-next-method решает, выполнять ли ядро, и может преобразовать или подменить результат
C:around нельзя использовать с наследованием
Dмежду ними нет разницы