Раздел 28 · Clojure

Мультиметоды и иерархии: правила боя

middle~15 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Главная мысль урока: мультиметод выбирает реализацию по любым данным, а не по классу объекта. Функция диспетчеризации смотрит на аргументы и возвращает значение, по нему находится метод. Иерархия ключевых слов через derive даёт «наследование» без классов, и новое правило добавляется снаружи, не трогая старый код.

В «Хрониках Пограничья» Ирма бьёт мечом волка в кожаной шкуре, Тим лупит булавой рыцаря в латах, Ой рубит топором кольчугу. Урон зависит сразу от двух вещей: чем бьют и во что. Первая версия правил пишется сама собой:

Работает. Теперь автор игры приносит топор, кинжал, молот, кольчугу и тканевую робу. Для каждой пары оружия и брони в cond появляется новая строка, порядок строк начинает влиять на результат, а дополнение с драконами придётся вписывать в тот же файл. На TypeScript ты бы завёл классы Weapon и Armor с методом hit, но метод класса выбирается по одному объекту, а здесь важна пара. Clojure предлагает другой инструмент.

Функция, которая выбирает функцию

Мультиметод состоит из двух частей. defmulti задаёт имя и функцию диспетчеризации. Каждый defmethod объявляет реализацию для одного значения, которое эта функция может вернуть. Начнём с одного аргумента: бросок атаки зависит от типа атаки героя.

Функцией диспетчеризации здесь служит ключевое слово :attack/type: как ты помнишь из урока про мапы, оно умеет доставать себя из мапы. Вызов (attack-roll irma) проходит три шага:

  1. Мультиметод вызывает функцию диспетчеризации с теми же аргументами: (:attack/type irma) даёт :melee.
  2. По этому значению он находит метод, объявленный через (defmethod attack-roll :melee ...).
  3. Вызывает найденный метод с исходными аргументами и возвращает его результат.

Похоже на case, только ветки не живут в одной форме. Каждая ветка это отдельное объявление, и его можно написать где угодно.

Диспетчеризация по паре

Функция диспетчеризации возвращает любое значение, в том числе вектор. Для правил боя это пара «вид оружия, вид брони»:

Ни у меча, ни у лат нет метода hit. Правило принадлежит паре, и мультиметод выражает это прямо. Такой выбор реализации по нескольким аргументам называют множественной диспетчеризацией. В Java для неё пишут паттерн «Посетитель» с двойным вызовом, в Clojure хватает вектора.

Метод по умолчанию

Что будет, если ударить кинжалом по тканевой робе, а метода для [:dagger :cloth] нет? Мультиметод бросит исключение «No method in multimethod». Чтобы этого не случилось, объявляют метод для особого значения :default: он ловит всё, что не нашло своей пары.

Во втором вызове брони нет совсем. (:armor/kind nil) даёт nil, пара [:dagger nil] не нашла метода, сработал :default, а (:armor/defense nil 0) вернул ноль. Мапы и nil дружат, и мультиметоду не нужна отдельная ветка для героя без доспеха.

Иерархии без классов

Вернёмся к новому оружию. Меч, топор и кинжал режут, булава и молот дробят. Латы и кольчуга металлические, кожа и ткань мягкие. Писать метод на каждую из 5 × 4 = 20 пар не хочется: правила говорят о группах, а не о парах. Классов в Clojure нет, зато есть иерархия ключевых слов:

derive записывает отношение «ребёнок считается родителем» в глобальную иерархию. Ключевые слова для неё должны быть квалифицированными, поэтому здесь :weapon/sword, а не :sword. isa? проверяет отношение с учётом всей цепочки предков, а последняя строка показывает главное: векторы isa? сравнивает поэлементно. Пара «топор и латы» подходит под пару «клинок и металл».

Мультиметод ищет метод через isa?, а не через =. Значит, методы можно писать для родителей:

Три метода вместо двадцати. Кинжал появился в последних строках, и для него не пришлось писать ни одного метода: одна строка derive, и он бьёт по коже как клинок.

У ключевого слова может быть несколько родителей, это не дерево, а граф. А если глобальная иерархия кажется слишком общей (она одна на всю программу), make-hierarchy создаёт отдельную, и её передают в defmulti опцией :hierarchy. Для правил одной игры глобальной обычно хватает.

Когда подходят два правила

Добавим правило «топор рубит металл» и правило «клинок соскальзывает с лат». Топор против лат подходит под оба: топор это клинок, латы это металл. Какой метод главнее?

Clojure не угадывает. Ни одна из двух пар не потомок другой, поэтому мультиметод бросает «Multiple methods … neither is preferred». Решение принимает автор правил через prefer-method:

Явное правило «топор важнее лат» лучше неявного «кто первым объявлен». Порядок defmethod в файле на выбор не влияет, и это хорошо: методы могут лежать в разных файлах.

Открыт для расширения

Последнее и главное свойство мультиметода. Метод можно объявить в другом неймспейсе, который ничего не меняет в исходном. Дополнение с драконами приносит морозный клинок и драконью чешую:

Боевой модуль не знает о драконах, а драконы встраиваются в бой одной формой. Это то, чего не хватало версии на cond: там новое правило означало правку чужой функции. В объектном языке похожую свободу дают интерфейсы, но реализация интерфейса живёт рядом с классом. Мультиметод разрешает добавить поведение и для данных, которые ты не писал. В Janet полиморфизм строится на прототипах таблиц и выбирает поведение по одному объекту; разницу с классическим ООП подробно разбирает урок про ООП без карго-культа.

В REPL мультиметод ведёт себя с одной ловушкой. Повторный defmulti с тем же именем ничего не делает: старая функция диспетчеризации и все методы остаются. Так задумано, чтобы перезагрузка файла не стирала методы из других неймспейсов. Но если ты поправил функцию диспетчеризации и вычислил форму заново, ничего не изменится. Сначала убери старое имя через ns-unmap:

methods возвращает мапу «значение диспетчеризации → функция», ей удобно проверять, какие правила уже объявлены. Лишний метод убирает remove-method.

Цена гибкости тоже есть. Каждый вызов мультиметода сначала вызывает функцию диспетчеризации и ищет метод в таблице с учётом иерархии (результат кэшируется). Это медленнее прямого вызова функции. Для правил боя, которые срабатывают раз за ход, разницы не видно. Для горячего цикла на миллион элементов есть протоколы, у которых диспетчеризация только по типу первого аргумента, зато быстрая.

Попробуй руками

В задаче ты соберёшь правила боя целиком: иерархию оружия и брони, мультиметод damage с пятью правилами и prefer-method для спорной пары, а поверх него обычную функцию attack. Тесты добавят в иерархию новое оружие и новый метод снаружи, так что правила должны держаться на иерархии, а не на перечне конкретных видов.

Проверь себя

Задание

Дальше

Мультиметод выбирает поведение по любым данным, и платит за это скоростью. В следующем уроке разберём протоколы и записи: набор операций с быстрой диспетчеризацией по типу. А в сводном уроке блока решим, когда хватает мапы и case, когда нужен мультиметод и когда протокол.

домашка

Домашка