Гигиена макросов и три ловушки
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Гигиена макросов и три ловушки
Первый макрос почти наверняка содержит одну из трёх классических ошибок. Две из них ломаются молча: код выглядит правильным с обеих сторон, а делает не то. Третья терпеливо ждёт дня, когда ты вынесешь макрос в модуль, и падает на компиляции. Разберём каждую и научимся лечить.
Ловушка первая: многократное вычисление
Напишем макрос, который выполняет выражение дважды:
(defmacro naive-twice [x]
~(do ,x ,x))
Выглядит невинно. Но ,x подставляет форму, а не значение, значит форма попадёт в два места и вычислится дважды:
Для функции такое невозможно: аргумент вычисляется один раз до вызова. Для макроса это поведение по умолчанию, и его надо осознанно исправлять, сохранив результат во временную переменную:
(defmacro twice [x]
~(let [value ,x]
(do value value)))
Теперь ,x встречается в шаблоне ровно один раз. Но мы только что создали вторую проблему.
Ловушка вторая: захват переменной
Наша временная переменная называется value. А что, если пользователь передаст выражение, в котором есть своя переменная с таким же именем?
Посмотрим на упрощённом примере:
(defmacro with-capture [x]
~(let [tmp 100] (+ ,x tmp)))
Само по себе работает:
(with-capture 1) # 101
А теперь передадим выражение, использующее переменную tmp:
Почему 200? Смотрим раскрытие:
Внешний tmp со значением 5 заслонён внутренним tmp со значением 100. Пользователь имел в виду свою переменную, а получил переменную макроса. Это и есть захват переменной.
Пощёлкай обе ловушки с тумблером with-syms и без него:
Лекарство: with-syms
Нужны имена, которые гарантированно ни с чем не совпадут. Их даёт gensym:
(= (gensym) (gensym)) # false, каждый вызов даёт уникальный символ
Макрос with-syms связывает несколько таких символов сразу:
(defmacro without-capture [x]
(with-syms [$tmp]
~(let [,$tmp 100] (+ ,x ,$tmp))))
Теперь всё на месте:
Имя _00000Z сгенерировано и не может совпасть с пользовательским. Конкретный суффикс у тебя будет другим: каждый вызов gensym выдаёт новое имя.
Соглашение об именовании. Символы от with-syms принято называть с долларом: $tmp, $result, $i. Это не требование языка, а подсказка читателю, что здесь сгенерированное имя, а не обычная переменная.
Обрати внимание на структуру: with-syms вызывается снаружи квазицитаты. Он выполняется при раскрытии макроса, а внутрь шаблона попадает уже готовый символ через ,$tmp.
Правило, соединяющее обе ловушки
(defmacro twice [x]
(with-syms [$value]
~(let [,$value ,x]
(do ,$value ,$value))))
,xвстречается ровно один раз, значит аргумент вычисляется один раз.- Временное имя получено через
with-syms, значит захвата не будет.
Именно так устроен встроенный and, чьё раскрытие мы видели в уроке про окружения:
(macex1 '(and a b))
# (if (def _00000Z a) b _00000Z)
Ловушка третья: рекурсивная ссылка на себя
Макрос может раскрываться рекурсивно. Например, cond раскладывается на вложенные if. Напрашивается такая запись:
(defmacro my-cond [& clauses]
(cond
(empty? clauses) nil
(= 1 (length clauses)) (first clauses)
~(if ,(clauses 0) ,(clauses 1) (my-cond ,;(slice clauses 2))))) # ссылка на себя
В одном файле это работает. Но стоит вынести макрос в модуль и импортировать с префиксом, всё ломается:
(import ./my-macros :as m)
(m/my-cond a 1 b 2 3)
# compile error: unknown symbol my-cond
Причина в том, что раскрытие содержит голое имя my-cond, а на месте вызова макрос известен только как m/my-cond. Раскрытый код компилируется в окружении вызывающего, и там такого имени нет:
┌ my-macros.janet ───┐ import :as m ┌ app.janet ───────────┐
│ (defmacro my-cond) │ ────────────> │ вызов: (m/my-cond …) │
│ здесь имя видно │ │ видно: m/my-cond │
└────────────────────┘ │ не видно: my-cond │
└──────────────────────┘
раскрытие компилируется в правой коробке
Правильное решение это выполнять рекурсию во время раскрытия, а наружу отдавать только базовые формы:
(defmacro my-cond [& clauses]
(defn build [i]
(def remaining (- (length clauses) i))
(cond
(<= remaining 0) nil
(= remaining 1) (clauses i)
~(if ,(clauses i) ,(clauses (+ i 1)) ,(build (+ i 2)))))
(build 0))
Теперь раскрытие не содержит ссылок на сам макрос:
Общее правило, которое стоит запомнить целиком. Раскрытие макроса компилируется там, где макрос вызвали, а не там, где его написали. Поэтому любое имя в шаблоне должно быть доступно на месте вызова. Это касается не только рекурсии: если макрос использует твою вспомогательную функцию, она тоже должна быть импортирована пользователем либо вставлена в шаблон как значение через ,. Та же поломка на минимальном примере:
(defn step [x] (+ x 1))
(defmacro with-helper [x]
~(step ,x)) # в раскрытии голое имя: у пользователя его нет
(defmacro with-helper [x]
~(,step ,x)) # в раскрытие вставлена сама функция: работает везде
Разница в одной запятой: ,step подставляет значение, то есть саму функцию, и раскрытие (<function step> 2) больше ни от каких имён не зависит.
Отладка макросов
Инструменты у тебя уже есть из 31-janet/08 · Макросы: функция вычисляет, макрос нет: macex1 раскрывает один уровень, macex до конца. Новое здесь одно: печатай раскрытие через %p, а не %j. Раскрытие может содержать значения-функции, как ,step выше, а они в jdn-формат не печатаются:
Что запомнить
,xподставляет форму, поэтому каждое вхождение это отдельное вычисление.- Правило: аргумент вставляй в шаблон ровно один раз, дальше работай с временной переменной.
- Временные имена бери через
with-syms, иначе получишь захват переменной. with-symsвызывается снаружи квазицитаты, внутрь идёт готовый символ.- Соглашение: имена от
with-symsпиши с долларом. - Раскрытие компилируется на месте вызова, значит все имена в шаблоне должны быть видны там.
- Рекурсию макроса разворачивай во время раскрытия, а не ссылкой на себя.
macex1,macex,%pэто весь набор для отладки.
Упражнения
Дальше
Метапрограммирование мы продолжим в уроке про верхний слой макросов, где разберём макросы, порождающие определения, и генерацию API из данных. А следующий урок меняет тему: в 31-janet/10 · PEG: грамматики вместо регулярок займёмся разбором текста, где у Janet есть свой козырь.
домашка