Обобщённые функции и методы: defgeneric/defmethod
Обобщённые функции и методы: defgeneric и defmethod, специализация по классу аргумента и почему диспетчеризация в CLOS — это полиморфизм без «принадлежности» методу.
Обобщённая функция — это функция, поведение которой складывается из нескольких методов, каждый из которых применим к аргументам определённых классов; нужный метод выбирается во время вызова по типам аргументов.
Зачем это: полиморфизм, вынесенный наружу
В прошлом уроке классы описали данные. Теперь — поведение. В CLOS поведение организовано не «методами объекта», а обобщёнными функциями: одна функция (например, describe-shape) имеет несколько реализаций-методов под разные типы аргументов, и при вызове система выбирает подходящий. Это и есть полиморфизм, но устроенный иначе, чем в мейнстриме: метод не «внутри» класса и не «у объекта» — он самостоятелен и связывает функцию с типами. Такой подход развязывает руки: можно добавлять методы к существующим функциям и классам, не трогая их исходники, и диспетчеризовать по нескольким аргументам сразу (мультиметоды — следующий урок).
Полезно сразу перестроить ментальную модель вызова. В языке с obj.method(arg) вы думаете «у объекта obj вызвать метод method, передав arg» — объект главный, он «владеет» методом и выбирает его реализацию. В CLOS вы думаете иначе: «вызвать функцию method с аргументами obj и arg; система сама подберёт подходящую реализацию по типам всех аргументов». Синтаксически это просто (method obj arg) — обычный вызов функции, без привилегированного «получателя». Эта смена точки зрения с «объект владеет поведением» на «функция полиморфна по аргументам» — главный концептуальный сдвиг урока. Поначалу непривычно, что нет this и нет «точки», но именно отсутствие привилегированного аргумента открывает мультидиспетчеризацию: когда ни один аргумент не «главный», все они равноправно участвуют в выборе метода.
defgeneric и defmethod
defgeneric объявляет обобщённую функцию (её имя и список параметров; тело необязательно). defmethod добавляет конкретный метод — реализацию для аргументов определённых классов. Специализация записывается прямо в списке параметров: (параметр класс).
;; объявляем обобщённую функцию (можно опустить — defmethod создаст её сам)
(defgeneric describe-shape (shape)
(:documentation "Словесно описать фигуру."))
;; классы фигур
(defclass shape () ())
(defclass circle (shape)
((radius :initarg :radius :accessor radius)))
(defclass square (shape)
((side :initarg :side :accessor side)))
;; методы — реализации под конкретные классы (специализация (s circle)):
(defmethod describe-shape ((s circle))
(format nil "круг радиуса ~a" (radius s)))
(defmethod describe-shape ((s square))
(format nil "квадрат со стороной ~a" (side s)))
(describe-shape (make-instance 'circle :radius 5)) ; => "круг радиуса 5"
(describe-shape (make-instance 'square :side 4)) ; => "квадрат со стороной 4"
Обратите внимание: describe-shape — одна функция, но две реализации. Какую вызвать — решает класс переданного аргумента. Это работает и без явного defgeneric: первый defmethod автоматически создаст обобщённую функцию. Но defgeneric полезен для документации и объявления интерфейса.
Специализация: по классу и по конкретному значению
Параметр метода можно специализировать двумя способами: по классу — (x circle) — метод применим к экземплярам класса (и его подклассов); и по конкретному значению (eql-специализатор) — (x (eql 0)) — метод применим, только если аргумент eql данному значению. Второе позволяет «особый случай» для конкретного значения, не заводя класс.
(defgeneric factorial (n))
;; eql-специализатор: особый метод ровно для n = 0
(defmethod factorial ((n (eql 0))) 1)
;; общий метод для целых (специализация по классу integer)
(defmethod factorial ((n integer))
(* n (factorial (1- n))))
(factorial 5) ; => 120 (база n=0 ловится eql-методом)
Здесь два метода одной функции: один срабатывает строго при n=0 (eql), другой — для любого integer. Система сама выбирает более специфичный при n=0 (eql-специализатор специфичнее класса). Это элегантная замена ветвлению: вместо if (= n 0) внутри одной функции — два метода, и диспетчер сам разводит случаи.
Этот приём — «разнести случаи по методам вместо ветвления внутри функции» — стоит обдумать, потому что он меняет архитектуру кода. В классическом стиле обработка разных типов или случаев собирается в одном теле через цепочку if/cond или switch: одна функция знает обо всех вариантах. В CLOS-стиле каждый случай — отдельный метод, и они открыты для расширения: добавить новый тип — значит добавить новый метод, не трогая существующие. Это прямое следствие принципа открытости/закрытости. Обратная сторона — логика «размазана» по нескольким методам, и чтобы увидеть все случаи, нужны инструменты (IDE показывает все методы обобщённой функции). Выбор между «всё в одной функции с ветвлением» и «случаи по методам» — это инженерное решение: для замкнутого, редко меняющегося набора случаев ветвление компактнее; для расширяемого набора типов методы лучше. CLOS даёт второй вариант как первоклассный, и в этом его сила для больших расширяемых систем.
Параметры специализируемые и нет
В методе специализируются не обязательно все параметры. Неспециализированный параметр (просто имя без класса) принимает что угодно — как обычный аргумент функции. Все методы одной обобщённой функции должны иметь совместимый лямбда-список (одинаковое число обязательных параметров и согласованные &key/&optional). Это требование когерентности — одна функция, единый «контракт вызова», много реализаций.
Требование совместимости сигнатур не каприз, а смысловая необходимость: обобщённая функция — это одна функция с точки зрения вызывающего, поэтому у неё должен быть единый «контракт» — сколько аргументов она принимает и какие из них именованные. Методы лишь по-разному реализуют этот общий контракт для разных типов, но не могут менять сам контракт. Это полезно держать в голове при проектировании: сначала вы продумываете сигнатуру обобщённой функции (что она принимает и возвращает концептуально), объявляете её через defgeneric с документацией — а затем наполняете методами. Такой порядок («сперва интерфейс, потом реализации») делает defgeneric местом, где живёт замысел операции, а defmethod — где живут её воплощения. В больших системах это ценно: читатель смотрит на defgeneric и понимает «что это за операция», не вникая в десяток методов. Поэтому, хотя defgeneric технически необязателен, в серьёзном коде его пишут осознанно — как декларацию интерфейса.
(defgeneric scale (shape factor)) ; два параметра
;; специализируем только первый (shape), второй (factor) — любой:
(defmethod scale ((s circle) factor)
(setf (radius s) (* (radius s) factor))
s)
(defmethod scale ((s square) factor)
(setf (side s) (* (side s) factor))
s)
call-next-method: цепочка к менее специфичному
Внутри метода доступна функция call-next-method — она вызывает «следующий» применимый метод (обычно — метод суперкласса). Это механизм переиспользования: метод подкласса делает своё и делегирует общую часть методу родителя. Аналог super.method(), но более гибкий — он встроен в правила комбинирования методов (подробно — в уроке про :before/:after/:around).
(defmethod describe-shape ((s shape))
"некоторая фигура") ; общий метод для всех shape
(defmethod describe-shape ((s circle))
;; уточняем и дополняем результат родителя
(format nil "~a: круг радиуса ~a"
(call-next-method) ; => "некоторая фигура"
(radius s)))
(describe-shape (make-instance 'circle :radius 3))
;; => "некоторая фигура: круг радиуса 3"
Почему это мощнее «методов в классе»
Развязка «функция ↔ класс» даёт три практических сверхспособности. Первая — расширение чужого кода: вы можете добавить метод describe-shape для класса из чужой библиотеки, не меняя ни её, ни своего класса; в классическом ООП это потребовало бы наследования или паттерна «посетитель». Вторая — диспетчеризация по нескольким аргументам (мультиметоды): метод выбирается по типам всех специализированных параметров, а не только «первого/this». Третья — симметрия: вызов (describe-shape x) не привилегирует ни один аргумент синтаксически, в отличие от x.describe(), где x особенный. Эти свойства делают CLOS инструментом для задач, где классический ООП ломается: бинарные операции над парами типов, протоколы, открытая расширяемость.
Первая сверхспособность — расширение чужого кода — заслуживает отдельного восхищения, потому что она решает знаменитую «проблему выражения». Суть проблемы: в одних языках легко добавить новый тип (но трудно — новую операцию над всеми типами), в других — наоборот. CLOS обходит дилемму: новый тип — это новый класс с методами, новая операция — это новая обобщённая функция с методами на существующие классы, и оба расширения делаются снаружи, не трогая существующий код. Вы можете взять класс из библиотеки, к которой у вас нет доступа на изменение, и добавить ему любое новое поведение, просто определив методы. В языках с «методами внутри класса» это невозможно без наследования или обёрток — отсюда громоздкие паттерны. Эта открытость особенно ценна в больших экосистемах, где код собирается из множества независимых библиотек: CLOS позволяет «склеивать» их поведение постфактум, что делает его исключительно мощным для интеграции.
Как работает под капотом
При вызове обобщённой функции система: (1) вычисляет аргументы, (2) определяет их классы, (3) находит все применимые методы (те, чьи специализаторы покрывают классы аргументов), (4) сортирует их по специфичности (более конкретный класс/eql — раньше), (5) выстраивает «эффективный метод» — комбинацию из основного метода и вспомогательных (:before/:after/:around), (6) исполняет его; call-next-method переходит к следующему в отсортированном списке. Это называется диспетчеризацией во время выполнения по типам. Чтобы не делать дорогой поиск при каждом вызове, реализации кешируют результат диспетчеризации по классам аргументов — поэтому повторные вызовы быстры. Сам процесс «как выбираются и комбинируются методы» — часть метаобъектного протокола и его можно переопределить, но это уже продвинутая тема.
Частые ошибки
- Ждать «метод внутри объекта». Методы принадлежат обобщённой функции, не объекту. Вызов —
(func obj ...), а не(obj.func ...). - Несовместимые лямбда-списки методов. Все методы одной функции должны иметь согласованную сигнатуру (число обязательных параметров,
&key). Иначе — ошибка определения. - Забыть базовый/общий метод. Если ни один метод не применим к аргументу, сигналится «no applicable method». Дайте метод для общего суперкласса или
t. - Путать класс- и eql-специализатор.
(n integer)— по типу;(n (eql 0))— ровно для значения 0. eql-метод специфичнее и выигрывает при совпадении. call-next-methodбез следующего метода. Если следующего метода нет, вызов сигналит ошибку. Проверяйте черезnext-method-p, когда не уверены.
Итоги
- Обобщённая функция — это набор методов; нужный выбирается во время вызова по классам аргументов.
defgenericобъявляет функцию,defmethodдобавляет реализацию со специализацией(параметр класс).- Специализировать можно по классу
(x circle)или по значению(x (eql 0)); eql-метод специфичнее. - Не все параметры обязаны быть специализированы; сигнатуры методов одной функции должны быть совместимы.
call-next-methodвызывает следующий (обычно родительский) метод — гибкий аналогsuper.- Развязка «функция ↔ класс» даёт расширение чужого кода, мультидиспетч и симметрию аргументов — то, чего нет в классическом ООП.