Раздел 28 · Clojure

Протоколы и записи

middle~15 мин

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

Главная мысль урока: протокол это именованный набор функций, которые выбирают реализацию по типу первого аргумента. Запись это мапа с известными полями, которая умеет реализовать протокол. А extend-protocol добавляет реализацию типам, которые ты не писал, даже nil и строкам, поэтому новые операции и новые типы растут независимо.

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

Здесь два новых понятия сразу, разберём их по очереди.

Протокол: имена без реализаций

Протокол описывает, что можно делать с вещью, и не говорит как. defprotocol объявил две обычные функции, take-hit и alive?. Их можно передать в map, положить в comp, вызвать через apply. Вызов (alive? irma) проходит так:

  1. Функция протокола смотрит на тип первого аргумента: irma это Hero.
  2. Находит реализацию alive?, которую Hero объявил для протокола Combatant.
  3. Вызывает её с теми же аргументами.

Сравни с мультиметодом: там на первом шаге вызывалась твоя функция диспетчеризации, здесь вместо неё всегда «тип первого аргумента». Выбор беднее, зато быстрее. На 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, которой всё равно, что ей дали. Тесты добавят свой тип и проверят, что твоя функция работает и с ним.

Проверь себя

Задание

Дальше

У тебя теперь три способа описать поведение: функция над мапой, мультиметод и протокол с записью. В сводном уроке блока соберём дерево решений и проведём один пример через все три.

домашка

Домашка