Главная мысль урока: маленький язык предметной области в 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/. Макрос перепишет эту запись в ту же мапу. Обрати внимание: он не вводит новой логики, он переводит одну форму данных в другую. Квест после раскрытия тот же, интерпретатор тот же.
Вся работа макроса идёт при раскрытии, на обычных функциях над списками. Пройдём её на квесте с волками:
clausesприходит списком из двух форм:(requires (level>= 3) (has-item :sword))и(reward :gold 50 :xp 120).(juxt first rest)делит каждую форму на голову и хвост,into {}складывает их в мапу с ключами-символамиrequiresиreward.- Каждое условие
(level>= 3)разбирается наopиargs,keywordпревращает символlevel>=в:level>=, и получается вектор[:level>= 3]. - Хвост награды
(:gold 50 :xp 120)идёт вhash-mapи становится{:gold 50 :xp 120}. - Шаблон собирает
(def wolves {...}), и только эта форма попадает к вычислителю.
Последняя строка сниппета показывает раскрытие: там уже готовая мапа, никаких следов requires и reward. Гигиена здесь не нужна: шаблон не вводит локальных имён и не повторяет аргументы.
У макроса есть одно честное преимущество перед данными: он может проверить квест во время раскрытия. Опечатка (rewad :gold 5) в данных молча даст квест без награды. Макрос может отказаться раскрываться:
Ошибка всплывёт при загрузке файла с квестами, до того как игрок дойдёт до таверны. В задаче ниже ты соберёшь обе части в один макрос.
Справедливости ради, та же проверка пишется и на данных. Функция обходит условия и возвращает список проблем, пустой у правильного квеста:
Такую функцию вызывают в тесте над всеми квестами игры или при загрузке EDN-файла. Разница с макросом только во времени проверки: макрос ругается при компиляции, функция при первом вызове. Зато она проверит и квест, который пришёл с сервера, а макрос такого квеста не увидит никогда.
Что выбрать
Сравним две версии по тому, что с ними можно делать:
| Вопрос | Данные и интерпретатор | Макрос defquest |
|---|---|---|
| Как выглядит для сценариста | векторы и ключевые слова | свои слова и скобки |
| Хранить в файле или базе | pr-str и edn/read-string | только как исходник на Clojure |
| Создать квест в коде | assoc, merge, map | нельзя: макрос не значение, в map его не передать |
| Проверить опечатки | отдельной функцией или схемой | при раскрытии, бесплатно |
| Тестировать | вызвать функцию на мапе | раскрыть, сравнить, вызвать |
| Добавить слово в язык | одна ветка в met? | ветка в met? и разбор в макросе |
Строка «создать квест в коде» решает чаще остальных. Генератор ежедневных заданий, который берёт случайного монстра и случайную награду, на данных пишется одной функцией. С макросом он должен строить исходный код и звать eval.
Отсюда правило, которое в сообществе Clojure повторяют как поговорку: данные лучше функций, функции лучше макросов. Начни с данных. Добавь функции, когда данным нужно поведение. Макрос пиши в последнюю очередь, и пусть он раскрывается в те же данные, а не заменяет их. Тогда у сценаристки красивая запись, а у программы значения, с которыми работает вся библиотека.
Где макрос оправдан
Макрос нужен, когда функцией задачу не решить. Таких случаев три:
- Управление вычислением.
unlessиз урока про код как данные,when,and,orрешают, вычислять ли аргумент. Функция этого не умеет. - Новые формы связывания.
let,for,doseq,with-openвводят имена для тела. Функция не может объявить переменную в чужом коде. - Работа с формой, а не со значением.
dbgпечатает исходное выражение,defquestпроверяет раздел до запуска,->переставляет вызовы. Значение этих сведений уже не содержит.
defquest попадает только в третий пункт, и то наполовину: проверку можно сделать функцией над мапой. Поэтому квесты разумнее держать в EDN-файлах, а макрос, если он нужен сценаристке, делать тонким. Шире про уровни DSL, от данных до своего синтаксиса, рассказывает урок про AST-инструменты и DSL.
Попробуй руками
Ты допишешь обе половины. Интерпретатор: проверка условия с новым словом :done, доступность квеста и выдача награды герою. Макрос: defquest, который раскрывается в мапу и отказывается раскрываться при неизвестном разделе или условии.
Проверь себя
Задание
Дальше
Квест, сохранённый как мапа, уже почти готов к записи в файл. Как читать такие файлы безопасно, своими тегами и без eval, разберём в уроке про EDN.
домашка