Раздел 28 · Clojure

Код как данные

middle~15 мин

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

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

Геймдизайнер «Хроник Пограничья» держит формулы урона в конфиге, строками. У огненного шара формула "(- (* power 2) armor)": удвоенная сила заклинания минус броня цели. Тебе нужно эту строку понять. В JavaScript пришлось бы писать парсер или звать eval на сырой строке. В Clojure хватает одного вызова:

read-string не вычислил формулу. Он превратил текст в обычный список из трёх элементов: символ -, вложенный список (* power 2) и символ armor. С этим списком работают first, count и вся библиотека последовательностей из урока про seq. Формула стала данными, и её можно разглядывать, как инвентарь героя.

Два шага: прочитать и вычислить

Clojure запускает любой код в два шага. Сначала reader читает текст и строит из него данные. Потом вычислитель получает эти данные и выполняет: список с символом в голове считается вызовом, символ ищется среди имён, число и строка вычисляются сами в себя.

"(- (* power 2) armor)"      текст
        │  reader
        ▼
(- (* power 2) armor)        список: символ, список, символ
        │  eval
        ▼
10                           значение, если power = 7, armor = 4

Второй шаг можно вызвать руками через eval. Формуле нужны значения power и armor, поэтому оборачиваем её в let, и снова собираем код как обычный список:

Это свойство языка называют гомоиконичностью: текст программы и её данные устроены одинаково. В JavaScript код и массив живут в разных мирах, чтобы переписать функцию, нужен AST-парсер вроде Babel (такие инструменты разбирает урок про AST и DSL). В Clojure AST это и есть программа, какой ты её написал. Тот же фокус в Janet подробно разобран в уроке про код как данные.

Сразу предупреждение: eval на строке от игрока или из сети означает выполнить чужую программу у себя. Конфиг геймдизайнера можно, сообщение из чата нельзя. Для чужих данных есть clojure.edn/read-string, он читает только данные и не умеет вызывать функции, об этом урок про EDN.

quote: остановить вычисление

Ты заметил апостроф в 'let и '[power 7 armor 4]. Без него вычислитель попробовал бы найти функцию let или значение power и упал бы. Апостроф это quote: «не вычисляй это, отдай как есть».

'(+ 1 2) и (list '+ 1 2) дают один и тот же список. Первое записывает код целиком, второе собирает его из частей. С частями можно делать что угодно: заменить голову, переставить аргументы, вставить новые.

Первая строка превратила вычитание брони в сложение: огненный шар стал лечебным. Вторая обошла всё дерево формулы и заменила символ power на mana. Ни одна из них не знает, что работает с кодом. Для cons и postwalk-replace это список, внутри которого лежат символы.

Syntax-quote: шаблон кода

Собирать код через list и cons быстро надоедает: скобок становится больше, чем смысла. Для этого есть syntax-quote, обратная кавычка. Она работает как шаблонная строка в JavaScript, только собирает не строку, а список. Тильда ~ подставляет значение, ~@ вклеивает элементы коллекции по одному:

~ называют unquote, ~@ называют unquote-splicing. Разница видна во второй и третьей строке: ~items положил вектор целиком, ~@items разложил его на два аргумента.

Последние три строки показывают то, чего нет у обычной цитаты: syntax-quote дописывает к каждому символу неймспейс. map стал clojure.core/map, heal и hp стали user/heal и user/hp, потому что сниппет живёт в неймспейсе user. Особые формы вроде if и do остаются как есть. Зачем это нужно, увидишь в уроке про гигиену макросов: квалификация спасает от целого класса ошибок.

Первый макрос: unless

Ирме нужно правило «если герой не жив, напиши некролог». Попробуем функцию unless, обратную if:

Условие истинно, герой жив, а некролог всё равно напечатан. Функция получает аргументы уже вычисленными: (println "Герой пал") сработал до того, как unless-fn начала думать. Функцией нельзя решить, вычислять ли аргумент.

Макрос получает аргументы невычисленными, как данные, и возвращает код. Clojure подставит этот код на место вызова и вычислит уже его:

Теперь на первом вызове ничего не напечатано. Макрос не вычислял (println ...), он переложил эту форму внутрь if, а if её пропустил. Тело & body собирается в список форм, и ~@body вклеивает их в do по одной, как в when.

Сначала раскрыть, потом вычислить

Что подставил макрос, показывает macroexpand-1. Ему дают вызов макроса в цитате и получают код, в который этот вызов превратится:

Вторая строка открывает секрет: when тоже макрос, и раскрывается он почти в то же, что наш unless. Третья строка возвращает к потоковым макросам: -> не делает ничего во время выполнения, он переставляет формы, и вычислитель видит обычный вложенный вызов.

macroexpand-1 раскрывает один слой. macroexpand раскрывает голову формы, пока там макрос, но во вложенные формы не заходит. Раскрыть всё дерево целиком умеет clojure.walk/macroexpand-all:

Получается такой порядок для каждой формы верхнего уровня:

  1. Reader читает текст и строит данные.
  2. Если в голове списка стоит макрос, Clojure вызывает его с невычисленными аргументами и берёт результат.
  3. Шаг 2 повторяется, пока в голове не окажется функция или особая форма.
  4. Вычислитель выполняет итоговый код. Макросов в нём уже нет.

Отсюда два следствия. Макрос не видит значений, только формы: (unless (pos? hp) ...) получает список (pos? hp), а не true. И макрос нельзя передать в map или вызвать через apply: во время выполнения его уже нет. Как это устроено в Janet, где у макроса тот же договор, рассказывает урок про макросы Janet.

Ты уже пользовался макросами

Раскрой форму, которую писал с первого урока:

defn оказался макросом над двумя особыми формами: def даёт имя, fn строит функцию. Неймспейс ядра в раскрытии называется cljs.core, потому что песочница работает на ClojureScript. На JVM там будет clojure.core/fn, а смысл тот же. Из макросов собраны when, cond, and, or, ->, for, doseq, defn, ns. Особых форм в Clojure около двух десятков: def, if, do, let*, fn*, loop*, recur, quote, try, throw и ещё несколько. Всё остальное, что выглядит как синтаксис языка, написано на Clojure, и ты можешь написать своё того же ранга.

Это и отличает лисп от языков, где синтаксис закрыт. Чтобы в JavaScript появился оператор ?., понадобился стандарт ECMAScript и годы ожидания. В Clojure some-> из урока про потоковые макросы это десяток строк в библиотеке.

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

Странное имя v# пока прими на веру, его объяснит следующий урок.

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

Геймдизайнер хочет писать формулы в привычной инфиксной записи: ((power * 2) - armor). Ты напишешь функцию, которая переворачивает такую формулу в префиксную, макрос infix поверх неё и свой unless. Функция работает с кодом как с данными, макрос отдаёт её результат вычислителю.

Проверь себя

Задание

Дальше

У макросов есть две классические ловушки: имя из шаблона перекрывает имя пользователя, а аргумент вычисляется дважды. Как их обойти и при чём тут v#, разберём в уроке про гигиену макросов.

домашка

Домашка