Раздел 28 · Clojure

Гигиена макросов

middle-senior~15 мин

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

Главная мысль урока: макрос вставляет свой код в чужое место программы, и там его имена встречаются с именами пользователя. Syntax-quote защищает от половины ошибок сам, дописывая неймспейсы. Вторую половину закрывает автор: свежие имена через x# и gensym и одно вычисление каждого аргумента через let.

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

Первый вызов верный. Во втором у игрока свой bonus, равный единице, и ждём мы 101. Пришло 200. Раскроем вызов и посмотрим, что увидел вычислитель:

Пользователь передал символ bonus, макрос положил его внутрь своего let, и там имя bonus уже значит сотню. Это захват символа, первая из двух классических ошибок макросов.

Захват бывает в обе стороны

У захвата есть зеркальная версия. Макрос не вводит имён, но зовёт функцию pos?. Пользователь в своём коде назвал так локальную переменную:

Код макроса раскрылся внутри let пользователя, и pos? там означает чужую функцию, которая всегда отвечает false. Автор макроса ни в чём не ошибся у себя, сломалось у того, кто его вызвал.

Обе ошибки одной природы. Макрос пишет код, который будет жить в чужой области видимости, а символ без неймспейса означает то, что ему скажут соседи. В Janet те же ловушки разобраны в уроке про гигиену, а Rust решает их по-другому, в самом компиляторе, как показано в уроке про макросы Rust.

Syntax-quote защищает по умолчанию

Перепишем when-alive через обратную кавычку и сравним:

Раскрытие всё объясняет. Syntax-quote записал не pos?, а clojure.core/pos?. Квалифицированный символ не перекрыть локальной переменной: let вводит только простые имена. Вторая сторона захвата закрыта сама собой, и поэтому в Clojure макросы почти всегда пишут обратной кавычкой, а не через list.

А первая сторона? Перепишем with-bonus тем же способом:

Внутренний bonus стал user/bonus и больше не совпадает с bonus пользователя. Но let с квалифицированным именем это бессмыслица: локальная переменная не принадлежит неймспейсу. Настоящий Clojure на JVM отвергает такое раскрытие ещё при компиляции (проверка формы let требует простой символ), и ты сразу узнаёшь об ошибке. SCI в браузере и babashka такой let пропускают молча, поэтому здесь сниппет не падает, но на это поведение не опирайся.

Итог: syntax-quote не даёт тебе случайно захватить имя, но и ввести своё локальное имя напрямую не даёт. Для этого нужен свежий символ.

Свежие имена: x# и gensym

Свежий символ это имя, которого гарантированно нет в коде пользователя. Его выдаёт gensym, а внутри syntax-quote есть сокращение: символ с решёткой на конце, авто-gensym.

Теперь 101. Раскрытие показывает имя вроде bonus__286__auto__: число каждый раз другое, поэтому у этих строк нет сверки. Все bonus# внутри одного шаблона превращаются в одно имя, так что связать и использовать переменную можно в разных местах шаблона.

Ручной gensym нужен, когда одно имя должно пройти через несколько шаблонов. Авто-gensym живёт в пределах одной обратной кавычки: h# в двух разных шаблонах превратится в два разных имени. Тогда символ заводят заранее и подставляют через ~:

(defmacro counting-turn [hero & body]
  (let [h (gensym "hero")
        header `(println "ход:" (:hero/name ~h))
        footer `(update ~h :hero/turns (fnil inc 0))]
    `(let [~h ~hero]
       ~header
       ~@body
       ~footer)))

(assert (= 1 (:hero/turns (counting-turn {:hero/name "Ирма"} :атака))))

Три шаблона собраны отдельно, и во всех трёх ~h означает один и тот же символ. С h# заголовок и подвал ссылались бы на имена, которых нет в let.

Двойное вычисление

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

Второй вызов напечатал «удар!» два раза. Третий вернул 2 вместо 1: раскрытие (* (swap! hits inc) (swap! hits inc)) дважды увеличило счётчик и перемножило единицу с двойкой. Макрос копирует форму аргумента, а не его значение, и каждая копия выполнится отдельно. С функцией такого не бывает: функция получает уже вычисленное значение.

Лечится одним let вокруг аргумента. Сначала вычисли форму один раз и положи в свежее имя, дальше работай с именем:

Тот же приём ты уже видел в dbg из прошлого урока: v# держал значение формы, которое потом печаталось и возвращалось. Теперь понятно, зачем он там был.

Так поступает и сама библиотека. Раскрой or:

or сначала связывает первый аргумент со свежим именем вида or__123__auto__, проверяет его и возвращает его же. Без этого let бросок кубика случался бы дважды: один раз для проверки, второй для результата.

Тонкий макрос, толстая функция

Самый верный способ не ошибиться с именами: почти ничего не писать в шаблоне. Логику переносят в обычную функцию, а макрос только заворачивает тело в fn без аргументов и передаёт его этой функции:

Имя result живёт внутри функции run-turn, тело игрока его не видит и перекрыть не может. Тело стоит внутри fn ровно один раз, значит и вычислится, когда run-turn его вызовет. Функцию можно протестировать без макроса, передав ей любую функцию. Макросу осталась одна работа, которую функция не умеет: не вычислять тело сразу. Такую функцию без аргументов называют thunk, и этот приём ты встретишь в раскрытии with-redefs, future и во многих библиотеках.

Осознанный захват: анафорический макрос

Иногда захват нужен. Анафорический макрос вводит имя it, на которое тело ссылается без объявления. В syntax-quote такое имя пишут как ~'it: тильда выходит из шаблона, цитата возвращает голый символ без неймспейса.

Это удобно в коротком коде, но у пользователя пропадает собственное it внутри макроса, и вложенные aif перекрывают друг друга. В стандартной библиотеке Clojure анафорических макросов нет: if-let и when-let решают ту же задачу, а имя выбирает сам пользователь. Если пишешь ~'имя, напиши об этом в докстринге.

Проверь себя перед публикацией макроса

  1. Каждое имя, которое вводит макрос (let, fn, loop), записано как x# или пришло из gensym.
  2. Каждый аргумент, который используется в шаблоне больше одного раза, сначала связан через let.
  3. Функции в шаблоне вызываются через syntax-quote, а не через list и 'имя.
  4. Каждое ~'имя сделано нарочно и описано в докстринге.
  5. macroexpand-1 на двух-трёх вызовах показывает тот код, который ты бы написал руками.

Как раскрытие макросов устроено внутри интерпретатора, можно посмотреть в уроке, где лисп пишут на Haskell.

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

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

Проверь себя

Задание

Дальше

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

домашка

Домашка