Мультиметоды и множественное наследование
Мультиметоды и множественное наследование: диспетчеризация сразу по нескольким аргументам и линеаризация классов, решающая «ромбовидную» проблему.
Мультиметод — метод обобщённой функции, специализированный по нескольким аргументам; выбор реализации зависит от классов всех специализированных параметров, а не одного «получателя».
Зачем это: когда «у кого вызвать» — неправильный вопрос
Классический ООП спрашивает «у какого объекта вызвать метод?» — и привилегирует один аргумент (this/self). Но множество задач симметричны по природе: столкновение двух тел, сложение двух чисел разных типов, взаимодействие героя и предмета. Загнать их в «один получатель» неудобно — рождаются паттерны вроде «двойной диспетчеризации» и «посетителя». CLOS решает это прямо: метод может специализироваться по нескольким аргументам, и система выбирает реализацию по комбинации их типов. Это мультидиспетчеризация — одна из главных причин, по которым CLOS считают мощнее мейнстримного ООП.
Чтобы почувствовать боль, которую снимает мультидиспетч, вспомним классическую проблему «двойной диспетчеризации» в одиночно-диспетчеризуемых языках. Допустим, нужна операция collide(a, b), результат которой зависит от типов обоих тел (астероид-астероид, астероид-корабль, корабль-корабль). В языке с a.collide(b) вы диспетчеризуете только по типу a; чтобы учесть и тип b, приходится внутри метода a делать второй вызов b.collideWith<ТипA>(a) — и так получается громоздкий паттерн «visitor» с методами на каждую комбинацию, разбросанными по классам. Код выходит запутанным, а добавление нового типа тела требует править все классы. В CLOS это исчезает: вы просто пишете метод collide, специализированный по обоим аргументам, и система делает выбор за один шаг. Сравнение этих двух подходов — лучший способ понять, почему мультидиспетчеризацию называют не «синтаксическим сахаром», а фундаментально более выразительной моделью.
Мультиметоды: диспетч по нескольким аргументам
Рассмотрим классический пример — взаимодействие сущностей в игре. Реакция зависит от типов обоих участников, поэтому специализируем оба параметра.
(defclass entity () ())
(defclass hero (entity) ())
(defclass monster (entity) ())
(defclass potion (entity) ())
(defgeneric interact (a b))
;; метод выбирается по ТИПАМ ОБОИХ аргументов:
(defmethod interact ((h hero) (m monster))
"герой атакует монстра")
(defmethod interact ((h hero) (p potion))
"герой выпивает зелье")
(defmethod interact ((m monster) (h hero))
"монстр кусает героя")
(interact (make-instance 'hero) (make-instance 'monster)) ; => "герой атакует монстра"
(interact (make-instance 'hero) (make-instance 'potion)) ; => "герой выпивает зелье"
(interact (make-instance 'monster) (make-instance 'hero)) ; => "монстр кусает героя"
Здесь нет «главного» объекта: interact симметрична, и метод подбирается по паре типов. В Java/C++ это потребовало бы либо вложенных проверок типов, либо двойной диспетчеризации через паттерн «посетитель». В CLOS — естественно. Если расширить иерархию (новый тип сущности), просто добавляются методы — существующий код не трогается.
Специфичность при нескольких параметрах
Когда применимы несколько мультиметодов, специфичность определяется слева направо: сначала сравнивается специфичность по первому параметру, при равенстве — по второму и так далее. Это даёт предсказуемый порядок: метод ((a circle) (b shape)) специфичнее ((a shape) (b circle)), потому что выигрывает по первому аргументу. Понимание этого правила важно, чтобы предвидеть, какой из нескольких подходящих методов выберется.
Наследование: от простого к ромбу
CLOS поддерживает наследование, в том числе множественное: класс может иметь несколько суперклассов и наследовать слоты и методы от всех. Это мощно — но порождает классическую «ромбовидную проблему»: если класс D наследует от B и C, а оба — от A, то какой метод (B или C, A) и в каком порядке участвует?
(defclass a () ())
(defclass b (a) ())
(defclass c (a) ())
(defclass d (b c) ()) ; множественное наследование: D от B и C
(defmethod greet ((x a)) "A")
(defmethod greet ((x b)) (format nil "B->~a" (call-next-method)))
(defmethod greet ((x c)) (format nil "C->~a" (call-next-method)))
;; для экземпляра D применимы методы B, C и A. В каком порядке?
(greet (make-instance 'd)) ; => "B->C->A"
Результат "B->C->A" не случаен: CLOS строит линеаризацию (class precedence list, CPL) — однозначный линейный порядок всех суперклассов. Для D он получается (D B C A standard-object t). По этому порядку и идёт call-next-method: из метода B следующий — C, затем A. Линеаризация превращает запутанный граф наследования в предсказуемую цепочку — это и есть решение ромбовидной проблемы.
Обратите внимание на тонкость, которая часто удивляет: из метода класса B следующим оказывается метод класса C — хотя C вовсе не предок B (они «братья», оба наследуют от A)! В обычном мышлении «вызвать родителя» из B ожидался бы A, но линеаризация ведёт по всему CPL, а не по дереву предков конкретного класса. Это важное свойство: call-next-method следует не «вверх по своей ветке», а по единому линейному порядку всех классов экземпляра. Именно поэтому миксины работают: метод миксина может через call-next-method передать управление методу другого миксина или базового класса, выстроенным в CPL, даже если между ними нет прямого наследования. Понимание, что цепочка идёт по CPL экземпляра, а не по дереву предков отдельного класса, — ключ к правильной интуиции о множественном наследовании в CLOS.
Class precedence list: правила линеаризации
CPL строится по двум правилам: (1) класс предшествует всем своим суперклассам; (2) порядок прямых суперклассов в defclass сохраняется (левый — раньше правого). CLOS применяет детерминированный алгоритм (близкий к C3-линеаризации), который из этих ограничений выводит единственный согласованный порядок — или сигналит ошибку, если порядок противоречив. Посмотреть CPL можно интроспективно: (class-precedence-list (find-class 'd)). Знать CPL важно, потому что он определяет всё: какие слоты наследуются (при конфликте имён выигрывает более ранний в CPL), какой метод применяется первым, как идёт call-next-method.
Практический вывод из правила «левый суперкласс — раньше правого» прямо влияет на то, как располагать классы в списке наследования. Идиома такая: миксины пишут слева, основной (базовый) класс — справа. Например, (defclass document (serializable-mixin timestamped-mixin base-entity) ...) — миксины впереди, чтобы их методы (и :around/:before) имели приоритет и «обрамляли» поведение базового класса, а базовый шёл последним как «фундамент». Если перепутать порядок, поведение поменяется: методы базового класса могут перекрыть то, что должны были добавить миксины. Это не произвол, а следствие того, что CPL читается слева направо и более ранние классы специфичнее. Понимание этой связи «порядок в defclass → порядок в CPL → приоритет методов» превращает множественное наследование из «магии» в предсказуемый инструмент: вы располагаете предков сознательно, зная, как это отразится на выборе и порядке методов.
;; слоты тоже наследуются по CPL; при одинаковом имени слота
;; опции комбинируются, а :initform берётся от более раннего в CPL класса
(defclass named () ((name :initarg :name :accessor name :initform "?")))
(defclass aged () ((age :initarg :age :accessor age :initform 0)))
(defclass person (named aged) ()) ; person имеет и name, и age
(let ((p (make-instance 'person :name "Лев" :age 40)))
(list (name p) (age p))) ; => ("Лев" 40)
Миксины: множественное наследование с пользой
Множественное наследование в CLOS — не «опасная экзотика», а рабочий инструмент благодаря линеаризации. Идиоматичное применение — миксины: маленькие классы-добавки, каждый из которых приносит одну способность (слот + методы), которые «подмешиваются» к основному классу. Поскольку CPL детерминирован, комбинировать миксины безопасно и предсказуемо.
Почему множественное наследование заслужило дурную славу в C++ и почему в CLOS её нет — поучительная история. В C++ проблема «ромба» обостряется тем, что общий базовый класс по умолчанию дублируется (нужны «виртуальные» базовые классы, чтобы этого избежать), а порядок разрешения неочевиден программисту, — отсюда путаница и баги. CLOS подходит принципиально иначе: общий предок в CPL встречается ровно один раз, а порядок строго определён детерминированным алгоритмом линеаризации, который можно явно посмотреть и на который можно положиться. Поэтому миксины в CLOS — не «хрупкая экзотика для смелых», а повседневная, надёжная техника композиции: вы собираете класс из ортогональных способностей-миксинов, как из кубиков, зная, что порядок их методов предсказуем. Это превращает множественное наследование из источника проблем в инструмент чистого дизайна. Урок здесь шире CLOS: «опасность» языковой фичи часто не в самой фиче, а в том, насколько чётко определена её семантика, — строгая линеаризация делает то же самое МН безопасным.
(defclass serializable-mixin () ()) ; миксин: умеет сериализоваться
(defmethod serialize ((x serializable-mixin))
"...сериализация...")
(defclass timestamped-mixin () ; миксин: хранит время создания
((created :initform (get-universal-time) :reader created)))
;; основной класс подмешивает обе способности:
(defclass document (serializable-mixin timestamped-mixin)
((title :initarg :title :accessor title)))
Как работает под капотом
При вызове мультиметода система находит все применимые методы (каждый специализатор должен покрывать класс соответствующего аргумента) и сортирует их по специфичности слева направо, используя CPL каждого аргумента: «насколько близок класс аргумента к специализатору метода» измеряется позицией в CPL. Линеаризация вычисляется один раз при определении/финализации класса и кешируется. Конфликты слотов разрешаются по CPL: при одинаковом имени слот «один» (общий), а его эффективные опции собираются из всех определений (наследуемые опции комбинируются, :initform — от ближайшего в CPL). Поскольку и CPL, и набор применимых методов детерминированы, поведение множественного наследования полностью предсказуемо — в отличие от языков, где порядок «непонятен» и потому МН считается опасным. Именно строгая линеаризация делает миксины в CLOS надёжными.
Частые ошибки
- Думать, что диспетч только по первому аргументу. Мультиметод выбирается по типам всех специализированных параметров; CLOS — мультидиспетч, а не «получатель + аргументы».
- Игнорировать порядок суперклассов. Порядок в
(defclass d (b c) ...)влияет на CPL и, значит, на наследование иcall-next-method.(b c)и(c b)— разное поведение. - Конфликт несовместимых иерархий. Если порядок суперклассов противоречив (нельзя построить согласованный CPL), CLOS сигналит ошибку при определении класса.
- Забывать про специфичность слева направо. При нескольких применимых мультиметодах решает первый параметр, затем второй. Это меняет, какой метод выиграет.
- Считать множественное наследование «опасным как в C++». В CLOS оно безопасно благодаря детерминированной линеаризации; миксины — штатная практика.
Итоги
- Мультиметод специализируется по нескольким аргументам; реализация выбирается по комбинации их типов (мультидиспетч).
- Это решает симметричные задачи (взаимодействие пар) без паттернов «посетитель»/«двойная диспетчеризация».
- Специфичность мультиметодов сравнивается слева направо по параметрам.
- Множественное наследование разрешается линеаризацией — class precedence list (CPL), детерминированным порядком всех суперклассов.
- CPL определяет наследование слотов, выбор метода и путь
call-next-method; порядок суперклассов важен. - Благодаря строгому CPL множественное наследование безопасно; идиома — миксины, подмешивающие способности.