Комбинирование методов: :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
Порядок выполнения: точная механика
Это самая важная часть урока. Эффективный метод собирается так:
- Выполняются все
:around-методы, начиная с самого специфичного; внутри:aroundвызовcall-next-methodпередаёт управление «внутрь» (к менее специфичным:around, а затем к ядру). - «Ядро» = все
:before(от специфичного к общему) → наиболее специфичный основной метод → все:after(в обратном порядке, от общего к специфичному). - Значение, которое вернёт обобщённая функция, — это значение основного метода (или то, что вернул внешний
: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) — это выходит за рамки классического ООП.