Раздел 28 · Clojure

Мапы как сущности

junior~15 мин

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

Главная мысль урока: в Clojure сущность предметной области это обычная мапа. Не класс с полями и геттерами, а данные, к которым подходят все функции стандартной библиотеки. Изменить такую сущность значит получить новую мапу, старая остаётся как была.

Представь, что ты пишешь «Хроники Пограничья», ту самую игру, лог событий которой разбирали в курсе аналитика. Нужен герой: имя, здоровье, место на карте, сумка с вещами. На TypeScript рука сама тянется написать class Hero с конструктором, приватными полями и методом heal. На Clojure ты просто записываешь героя:

Это весь «класс». Никакой схемы, никакого конструктора. У героя пять ключей, и любая функция, которая умеет работать с мапами, уже умеет работать с героем: count, keys, select-keys, merge, =. В прошлом уроке мапа была одной из четырёх коллекций, теперь она становится главным способом моделировать мир.

Ключевое слово как функция

Значение из мапы достают тремя способами, и все три встречаются в живом коде:

get это обычная функция. Сама мапа тоже функция от ключа. А ключевое слово умеет искать себя в мапе. Чаще всего пишут третий вариант, (:hero/hp irma): он читается как «здоровье Ирмы» и не падает, если вместо мапы пришёл nil. Второй аргумент задаёт значение по умолчанию: у Ирмы нет маны, и мы получаем 0 вместо nil.

Раз ключевое слово это функция, его можно передать в map. Так достают одно поле из целой коллекции сущностей:

Квалифицированные ключи

Ты заметил, что ключи выглядят как :hero/hp, а не просто :hp. Часть до слэша это пространство имён ключа. Зачем оно? Затем, что данные в Clojure постоянно смешиваются: героя кладут в событие, событие в лог, лог в отчёт. У героя есть :name, у предмета тоже :name, у квеста тоже. Если склеить их в одну мапу через merge, одно имя затрёт другое. С :hero/name и :item/name такого не случится.

Это соглашение, а не требование языка. В маленьком скрипте :hp вполне хватит. В программе, где сущности ходят между модулями, квалифицированные ключи окупаются быстро: grep ':hero/hp' находит каждое место, где трогают здоровье героя.

Изменить значит получить новую мапу

Ирма выпила зелье и восстановила семь единиц здоровья. На TypeScript ты бы написал irma.hp += 7. В Clojure мапа не меняется никогда, поэтому «изменить» значит построить новую мапу с другим значением:

assoc вернул новую мапу, а irma осталась прежней. Звучит расточительно: неужели каждое изменение копирует всего героя? Нет. Новая и старая мапа делят почти всю внутреннюю структуру, копируется только путь до изменённого ключа. Как именно это устроено, разберём в уроке про персистентность. Пока запомни практическое следствие: старую версию можно держать сколько угодно, это дёшево. Отсюда бесплатная отмена хода, история состояний и тесты, в которых «до» и «после» лежат рядом.

Записывать новое значение руками неудобно, когда оно зависит от старого. Для этого есть update: он берёт функцию и применяет её к текущему значению ключа. Дополнительные аргументы update передаёт в функцию после значения:

(update irma :hero/hp + 7) читается как «обнови здоровье Ирмы функцией + с аргументом 7», то есть вызови (+ 18 7). Последняя строка показывает, что Clojure не знает правил игры: здоровье ушло в минус. Ограничивать его будешь ты, в задаче в конце урока.

Вложенные данные: пути

Позиция героя это мапа внутри мапы, инвентарь это вектор мап внутри мапы. Чтобы дотянуться до координаты :x, нужен путь из ключей. Функции с суффиксом -in принимают такой путь вектором:

Во втором вызове в пути стоит число 1: вектор тоже ассоциативная коллекция, ключи у него это индексы. Так get-in прошёл от героя к инвентарю, от инвентаря ко второму предмету, от предмета к его id.

assoc-in умеет создавать промежуточные мапы, если их нет. Это удобно для заготовок, но иногда скрывает опечатку в ключе: (assoc-in irma [:hero/positon :x] 10) молча заведёт новый ключ с ошибкой в имени. Квалифицированные ключи и grep здесь снова помогают.

Мапа как граница модуля

Почему сообщество Clojure так держится за мапы? Классы прячут данные за методами, и каждый новый класс приносит свой маленький язык: hero.getHp(), item.weight(), quest.isComplete(). Мапа приносит один язык на всё: get, assoc, update, merge, select-keys. Функция, которая считает суммарный вес, не знает, чей это инвентарь: героя, торговца или сундука. Ей нужен вектор мап с ключом :item/weight.

Последняя строка печатает героя в строку и читает обратно, результат равен исходнику. Данные Clojure печатаются так же, как записываются в коде, поэтому сохранение в файл и чтение из него не требуют отдельного формата. Подробнее про это в уроке про EDN.

Цена такой свободы: никто не проверит, что у героя есть :hero/hp. Функция получит nil и, скорее всего, упадёт дальше по коду. Для границ системы, где данные приходят извне, в Clojure есть схемы, им посвящён урок про spec и malli. Внутри программы обычно хватает дисциплины и тестов.

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

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

Проверь себя

Задание

Дальше

В следующем уроке заглянем внутрь: как вектор и мапа умудряются быть неизменяемыми и при этом быстрыми. А разбирать героя на части прямо в аргументах функции научимся в уроке про деструктуризацию.

домашка

Домашка