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