Главная мысль урока: протокол это именованный набор функций, которые выбирают реализацию по типу первого аргумента. Запись это мапа с известными полями, которая умеет реализовать протокол. А
extend-protocolдобавляет реализацию типам, которые ты не писал, дажеnilи строкам, поэтому новые операции и новые типы растут независимо.
После боя на клетке остаются герой без сознания, туша волка и разбитая бочка. По каждой можно ударить, у каждой можно спросить, жива ли она, и каждую можно описать в журнале. В прошлом уроке ты бы написал три мультиметода, и у всех трёх была бы одна и та же функция диспетчеризации: «какого вида эта штука». Мультиметод выбирает по любым данным, но здесь выбор всегда один и тот же, по типу. Для такого случая в Clojure есть отдельный инструмент:
Здесь два новых понятия сразу, разберём их по очереди.
Протокол: имена без реализаций
Протокол описывает, что можно делать с вещью, и не говорит как. defprotocol объявил две обычные функции, take-hit и alive?. Их можно передать в map, положить в comp, вызвать через apply. Вызов (alive? irma) проходит так:
- Функция протокола смотрит на тип первого аргумента:
irmaэтоHero. - Находит реализацию
alive?, которуюHeroобъявил для протоколаCombatant. - Вызывает её с теми же аргументами.
Сравни с мультиметодом: там на первом шаге вызывалась твоя функция диспетчеризации, здесь вместо неё всегда «тип первого аргумента». Выбор беднее, зато быстрее. На JVM протокол становится интерфейсом, и вызов функции протокола для записи стоит почти как вызов обычного метода Java.
Если ты писал на Go, картинка знакомая: интерфейс в Go тоже набор методов, и тип реализует его без слова implements на стороне интерфейса. Разница в том, где пишется реализация, к этому вернёмся ниже.
Запись: мапа с известными полями
Запись это мапа, у которой есть имя типа и заранее известные поля. defrecord заодно создаёт две функции-конструктора: ->Hero принимает поля по порядку, map->Hero принимает мапу.
Всё, что ты знаешь о мапах из урока про сущности, работает и здесь: ключевое слово достаёт поле, assoc и update возвращают новую запись, старая не меняется. Лишний ключ тоже можно положить, запись его примет. Три отличия от обычной мапы стоит запомнить:
dissocобъявленного поля превращает запись в обычную мапу. Тип держится на полях, без поля это уже неHero.- Запись не равна мапе с теми же ключами:
=сравнивает и тип. - Поля записи это простые ключевые слова (
:hp), не квалифицированные (:hero/hp). Имена полей становятся именами локальных переменных в теле методов.
Зачем записи, если есть мапы? На JVM поле записи читается быстрее, чем ключ мапы, и, главное, запись умеет реализовать протокол прямо в defrecord, как Hero в первом примере. Мапа без типа так не может: протокол не знает, по чему её узнать.
Чужие типы: extend-protocol
Вернёмся к журналу боя. Описать хочется всё, что лежит на клетке: героя, число монет, табличку с надписью, пустоту. Строки и числа писал не ты, а nil вообще не тип в привычном смысле. В Java добавить интерфейс к String нельзя. В Clojure можно:
extend-protocol перечисляет типы и реализации для каждого. Тип Hero объявлен без протокола, и реализация пришла к нему позже, из другого места. Ключевому слову реализацию не дали, поэтому satisfies? отвечает false, а вызов (describe :rune) бросил бы исключение «No implementation of method».
Имена хостовых типов здесь из ClojureScript, ведь урок работает в браузере: string, number, а для «всего остального» default. На JVM это классы Java, и тот же код выглядит так:
(defprotocol Describe
(describe [x]))
(extend-protocol Describe
nil (describe [_] "пустая клетка")
String (describe [s] (str "надпись «" s "»"))
Number (describe [n] (str n " золотых"))
Object (describe [x] (str "что-то: " (pr-str x))))
(assert (= "надпись «Стой!»" (describe "Стой!")))
(assert (= "3/2 золотых" (describe 3/2)))
(assert (= "что-то: :rune" (describe :rune)))
(println (describe 12) "·" (describe nil))
Number на JVM покрывает и целые, и дроби вроде 3/2, а реализация для Object ловит всё, у чего нет своей.
Обратная форма, extend-type, берёт один тип и добавляет ему реализации нескольких протоколов сразу. Выбирай по тому, что у тебя в руках: новый протокол для многих типов или новый тип для многих протоколов.
Проблема выражения
Почему возможность расширять чужие типы так важна? Представь таблицу: строки это виды вещей, столбцы это операции.
take-hit alive? describe price-of (новое)
Hero ✓ ✓ ✓ ?
Barrel ✓ ✓ ✓ ?
String · · ✓ ?
Dragon (новое) ? ? ? ?
Программа растёт в обе стороны: появляются новые виды вещей (новая строка) и новые операции (новый столбец). В классическом ООП строку добавить легко, новый класс реализует интерфейс, а столбец дорого: новый метод придётся вписать в каждый класс, включая чужие. В функциональном стиле с case наоборот: новая функция пишется в одном месте, а новый вид вещи требует правки каждой функции. Это противоречие называют проблемой выражения. В уроке про дженерики Rust она разобрана на enum и трейтах.
Протоколы закрывают оба направления. Новый тип пишет defrecord с реализацией существующих протоколов. Новая операция это новый defprotocol плюс extend-protocol на старые типы, включая String и nil, и ни одна строчка старого кода не меняется. Мультиметоды из прошлого урока решают ту же задачу ещё свободнее, но медленнее.
reify: реализация без имени
Иногда тип нужен ровно один раз. Ловушка на клетке «живёт», пока не сработала, и больше ничего не умеет. Заводить ради неё defrecord лишне. reify создаёт объект прямо на месте:
Методы reify замыкают локальные имена вокруг, как обычная fn: здесь это атом fired?. В отличие от записи, у такого объекта нет полей, assoc к нему не применить. Обрати внимание, что take-hit здесь меняет атом и возвращает тот же объект. Для одноразовой ловушки это нормально, а состояние героев и мира лучше держать значениями, как в уроке про атом.
Чего протокол не умеет
Протокол выбирает реализацию только по типу и только по первому аргументу. Правило «топор против лат» на протоколе не выразить: важна пара, и важны данные внутри мапы, а не её тип. Обычная мапа {:hero/name "Ирма"} для протокола вообще неотличима от любой другой мапы. Так что граница проходит так: если поведение зависит от вида вещи и операций несколько, бери протокол; если от значений внутри данных или от нескольких аргументов, бери мультиметод. Подробнее о выборе в следующем уроке.
Ещё одна ловушка ждёт в REPL. Если ты заново вычислил defrecord, появился новый тип с тем же именем. Записи, созданные раньше, принадлежат старому типу, и реализации, добавленные к новому, к ним не относятся. После правки записи пересоздай и значения.
Попробуй руками
В задаче протокол Loot оценивает и взвешивает добычу. Ты реализуешь его для записи Item, для сундука Chest, внутри которого может лежать что угодно, включая другие сундуки, для монет-чисел и для пустоты nil. Сверху ляжет функция loot-summary, которой всё равно, что ей дали. Тесты добавят свой тип и проверят, что твоя функция работает и с ним.
Проверь себя
Задание
Дальше
У тебя теперь три способа описать поведение: функция над мапой, мультиметод и протокол с записью. В сводном уроке блока соберём дерево решений и проведём один пример через все три.
домашка