Раздел 28 · Clojure

DSL квестов: макрос и данные

middle-senior~15 мин

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

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

Сценаристка «Хроник Пограничья» пишет квесты десятками. Ей не нужен Clojure, ей нужен язык, на котором квест читается как карточка: название, кто может взять, что получит. Ты уже умеешь писать макросы без ловушек, прошлый урок научил. Рука тянется написать красивый defquest. Подожди с ним и опиши квест так, как Clojure описывает всё остальное: мапой.

Условия записаны векторами: первый элемент говорит, что проверить, остальные это аргументы. [:level>= 3] значит «уровень героя не ниже трёх», [:has-item :sword] значит «в сумке есть меч». Это уже язык. У него есть словарь и грамматика, а программы на нём лежат в обычной мапе. Такой язык называют DSL.

Интерпретатор: функция над данными

Язык без исполнителя мёртв. Исполнитель здесь это интерпретатор: функция, которая берёт условие и героя и отвечает, выполнено ли оно. Один case на каждое слово языка:

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

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

Квест без условий ([]) доступен всем: every? на пустой коллекции отвечает true. А сохранить доску в файл и прочитать обратно можно через pr-str и clojure.edn/read-string, как в уроке про EDN. Квесты можно хранить в базе, присылать с сервера, генерировать скриптом. Всё это потому, что квест это значение.

Тот же квест макросом

Теперь красивая версия. Сценаристка хочет писать так:

(defquest wolves "Волки у мельницы"
  (requires (level>= 3) (has-item :sword))
  (reward :gold 50 :xp 120))

Скобки вместо векторов, слова без двоеточий, никаких :quest/. Макрос перепишет эту запись в ту же мапу. Обрати внимание: он не вводит новой логики, он переводит одну форму данных в другую. Квест после раскрытия тот же, интерпретатор тот же.

Вся работа макроса идёт при раскрытии, на обычных функциях над списками. Пройдём её на квесте с волками:

  1. clauses приходит списком из двух форм: (requires (level>= 3) (has-item :sword)) и (reward :gold 50 :xp 120).
  2. (juxt first rest) делит каждую форму на голову и хвост, into {} складывает их в мапу с ключами-символами requires и reward.
  3. Каждое условие (level>= 3) разбирается на op и args, keyword превращает символ level>= в :level>=, и получается вектор [:level>= 3].
  4. Хвост награды (:gold 50 :xp 120) идёт в hash-map и становится {:gold 50 :xp 120}.
  5. Шаблон собирает (def wolves {...}), и только эта форма попадает к вычислителю.

Последняя строка сниппета показывает раскрытие: там уже готовая мапа, никаких следов requires и reward. Гигиена здесь не нужна: шаблон не вводит локальных имён и не повторяет аргументы.

У макроса есть одно честное преимущество перед данными: он может проверить квест во время раскрытия. Опечатка (rewad :gold 5) в данных молча даст квест без награды. Макрос может отказаться раскрываться:

Ошибка всплывёт при загрузке файла с квестами, до того как игрок дойдёт до таверны. В задаче ниже ты соберёшь обе части в один макрос.

Справедливости ради, та же проверка пишется и на данных. Функция обходит условия и возвращает список проблем, пустой у правильного квеста:

Такую функцию вызывают в тесте над всеми квестами игры или при загрузке EDN-файла. Разница с макросом только во времени проверки: макрос ругается при компиляции, функция при первом вызове. Зато она проверит и квест, который пришёл с сервера, а макрос такого квеста не увидит никогда.

Что выбрать

Сравним две версии по тому, что с ними можно делать:

ВопросДанные и интерпретаторМакрос defquest
Как выглядит для сценариставекторы и ключевые словасвои слова и скобки
Хранить в файле или базеpr-str и edn/read-stringтолько как исходник на Clojure
Создать квест в кодеassoc, merge, mapнельзя: макрос не значение, в map его не передать
Проверить опечаткиотдельной функцией или схемойпри раскрытии, бесплатно
Тестироватьвызвать функцию на мапераскрыть, сравнить, вызвать
Добавить слово в языкодна ветка в met?ветка в met? и разбор в макросе

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

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

Где макрос оправдан

Макрос нужен, когда функцией задачу не решить. Таких случаев три:

  1. Управление вычислением. unless из урока про код как данные, when, and, or решают, вычислять ли аргумент. Функция этого не умеет.
  2. Новые формы связывания. let, for, doseq, with-open вводят имена для тела. Функция не может объявить переменную в чужом коде.
  3. Работа с формой, а не со значением. dbg печатает исходное выражение, defquest проверяет раздел до запуска, -> переставляет вызовы. Значение этих сведений уже не содержит.

defquest попадает только в третий пункт, и то наполовину: проверку можно сделать функцией над мапой. Поэтому квесты разумнее держать в EDN-файлах, а макрос, если он нужен сценаристке, делать тонким. Шире про уровни DSL, от данных до своего синтаксиса, рассказывает урок про AST-инструменты и DSL.

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

Ты допишешь обе половины. Интерпретатор: проверка условия с новым словом :done, доступность квеста и выдача награды герою. Макрос: defquest, который раскрывается в мапу и отказывается раскрываться при неизвестном разделе или условии.

Проверь себя

Задание

Дальше

Квест, сохранённый как мапа, уже почти готов к записи в файл. Как читать такие файлы безопасно, своими тегами и без eval, разберём в уроке про EDN.

домашка

Домашка