Раздел 28 · Clojure

Когда что: мапа, запись, протокол, мультиметод

middle~20 мин

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

Главная мысль урока: начинай с мапы и обычной функции. Запись бери, когда поля известны и нужна скорость или протокол. Протокол нужен для набора операций, которые различаются по типу вещи. Мультиметод нужен, когда выбор зависит от данных внутри значения или от нескольких аргументов. Каждый следующий шаг добавляет и возможности, и цену, поэтому делай его, только когда упёрся в предыдущий.

За три урока у тебя собрался ящик с инструментами: мапа и функция из урока про сущности, мультиметод, протокол и запись. Новичок в Clojure обычно делает одну из двух ошибок. Либо пишет defrecord на каждую сущность, потому что это похоже на класс. Либо пишет мультиметод там, где хватило бы case. Обе ошибки не ломают программу, они делают её тяжелее, чем нужно.

В этом уроке нет новых форм. Проведём один пример через три версии и посмотрим, что заставляет переходить от одной к другой.

Пример: журнал боя

В «Хрониках Пограничья» всё, что происходит, пишется в лог событий. Событие это мапа с :event/type:

{:event/type :hit   :event/who "Ирма" :event/target "Волк" :event/damage 7}
{:event/type :heal  :event/who "Ой"   :event/amount 5}
{:event/type :loot  :event/who "Тим"  :event/item :potion}

Журнал на экране показывает каждое событие строкой. Нужна функция describe-event.

Шаг 0: мапа и case

Первая версия самая короткая:

Чем она хороша: всё видно в одном месте, события остаются данными (их можно записать в файл, прочитать обратно, сравнить через =), а case по ключевым словам выбирает ветку за постоянное время. Если видов событий пять и все они в твоих руках, на этом можно остановиться навсегда. Это не «временное решение до настоящего полиморфизма», это нормальный код на Clojure.

Есть и промежуточный вариант, о котором часто забывают: мапа из вида события в функцию. (def describers {:hit describe-hit, :heal describe-heal}) и вызов ((describers (:event/type e)) e). Набор обработчиков становится данными: его можно собрать на лету, отфильтровать, передать аргументом.

Шаг 1: появилось дополнение

Команда дополнения с драконами приносит события :dragon-breath и :egg-hatched. Дописывать их в чужой case значит править боевой модуль ради дополнения, и каждое следующее дополнение будет делать то же самое. Это и есть сигнал к мультиметоду: набор вариантов перестал быть закрытым.

Что сохранилось: события по-прежнему обычные мапы, из лога они читаются как есть. Что добавилось: каждый может объявить свой метод, не трогая чужой код, а иерархия через derive позволит сгруппировать события (:event/dragon-breath это :event/combat) и писать правила для групп. Что подорожало: ветки разбросаны по файлам, и чтобы узнать все виды событий, приходится спрашивать (methods describe-event), а не читать одну форму. И каждый вызов сначала вызывает функцию диспетчеризации.

Шаг 2: событий миллион, операций три

Проходит полгода. Сервер воспроизводит лог при каждом запуске, чтобы восстановить мир: миллионы событий. Над каждым событием теперь три операции: описать для журнала, применить к миру, посчитать опыт. Три мультиметода с одной и той же функцией диспетчеризации :event/type это сигнал к протоколу: операции ходят группой, а выбор зависит только от вида вещи.

Что добавилось: операции события собраны в одном месте, поля известны заранее, на JVM вызов функции протокола и чтение поля записи быстрее мультиметода и поиска по мапе. Что подорожало, и это важно:

  • На границе появилось преобразование. Лог хранит мапы, а apply-to хочет записи, значит при чтении каждую мапу надо превратить в запись: (map->Hit m) с выбором конструктора по :event/type. Выбор этот, кстати, тот же case или мультиметод.
  • Запись печатается как #user.Hit{...}, и clojure.edn/read-string такую строку не прочтёт без своего обработчика тега. Подробнее про теги в уроке про EDN.
  • Поля записи это простые ключевые слова: :who, а не :event/who. Квалифицированные ключи из лога придётся переименовывать.

Если сервер не тормозит, шаг 2 не нужен. Протокол ради скорости, которую никто не мерил, ничего не даёт, а преобразования на границе стоят кода и внимания.

Дерево решений

Сведём всё в одну схему. Читай сверху вниз и останавливайся на первом «да»:

Нужно описать сущность или поведение
│
├─ Это данные без поведения (герой, событие, предмет)?
│     └─ да → мапа с квалифицированными ключами. Стоп.
│
├─ Поведение различается по видам, и видов мало, все твои?
│     └─ да → функция с case или cond. Стоп.
│
├─ Виды будут добавлять другие модули, или выбор зависит
│  от значений внутри данных, от пары аргументов, от иерархии?
│     └─ да → мультиметод (defmulti + defmethod, derive). Стоп.
│
├─ Есть набор операций, которые ходят вместе и различаются
│  только по типу первого аргумента, нужна скорость
│  или реализация для чужих типов (String, nil)?
│     └─ да → протокол + defrecord / extend-protocol.
│
└─ Нужен одноразовый объект под протокол? → reify.

И таблица цены, чтобы решение было осознанным:

Мапа + функцияМультиметодПротокол + запись
Выбор почему угодно, руками в caseлюбой функции от аргументовтипу первого аргумента
Расширение снаружинет, правка функциида, defmethod из любого неймспейсада, extend-protocol, и для чужих типов
Иерархиянетderive и isa?нет
Скоростьcase по ключам быстрыйфункция диспетчеризации плюс поиск методаблизко к вызову метода на JVM
Данные на границечитаются и пишутся как естьчитаются и пишутся как естьнужен перевод из мапы в запись
Где видны все вариантыв одной форме(methods f)в defrecord и extend-protocol

Дерево работает и в обратную сторону. Если из трёх операций протокола осталась одна, а скорость перестала быть важной, протокол можно свернуть обратно в функцию над мапой. Если в мультиметоде за год не появилось ни одного метода снаружи, он может стать case. Рефакторинг в Clojure дешёвый, потому что данные везде одни и те же: мапы с ключевыми словами. Меняется только то, как функция выбирает ветку, а сами события, герои и предметы остаются прежними.

Частые ошибки

  • Запись там, где хватило бы мапы. defrecord Hero без единого протокола и без замеров скорости даёт только неудобства: поля без пространства имён, dissoc поля превращает запись в мапу, EDN не читает запись обратно. Мапа делает всё то же самое.
  • Протокол ради одной функции. Протокол с одной операцией и одной реализацией это обычная функция в костюме. Протокол окупается, когда операций несколько или реализаций много, включая чужие типы.
  • Протокол на мапах. Все мапы для протокола одного типа, и {:event/type :hit} от {:event/type :heal} он не отличит. Выбор по значению поля это работа мультиметода.
  • Мультиметод на три закрытых варианта. Если виды события не будут добавлять снаружи, case честнее: все ветки видны сразу, и нет ловушки с повторным defmulti в REPL.
  • deftype для предметной области. Есть ещё deftype, тип без поведения мапы. Он нужен для своих коллекций и низкоуровневых обёрток, а не для героев и событий.

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

Проверь себя

Задание

Дальше

Блок «Полиморфизм» закрыт. Следующий блок про макросы: в уроке про код как данные ты увидишь, что программа на Clojure это те же списки и векторы, с которыми ты работал всё это время, и научишься писать функции, которые получают код и возвращают код.

домашка

Домашка