Мультиметоды и множественное наследование

Мультиметоды и множественное наследование: диспетчеризация сразу по нескольким аргументам и линеаризация классов, решающая «ромбовидную» проблему.

Мультиметод — метод обобщённой функции, специализированный по нескольким аргументам; выбор реализации зависит от классов всех специализированных параметров, а не одного «получателя».

Зачем это: когда «у кого вызвать» — неправильный вопрос

Классический ООП спрашивает «у какого объекта вызвать метод?» — и привилегирует один аргумент (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 множественное наследование безопасно; идиома — миксины, подмешивающие способности.
Проверьте себя
1. Что такое мультиметод в CLOS?
AМетод, который можно вызвать несколько раз
BМетод, специализированный по нескольким аргументам, где выбор реализации зависит от классов всех специализированных параметров
CМетод, принадлежащий нескольким классам сразу
DМетод без аргументов
2. Как CLOS решает ромбовидную проблему множественного наследования?
AЗапрещает множественное наследование
BСтроит class precedence list (CPL) — детерминированный линейный порядок всех суперклассов, по которому идут наследование и call-next-method
CВыбирает суперкласс случайно
DДублирует общий базовый класс