Раздел 31 · Janet на практике

Гигиена макросов и три ловушки

middle-senior~30 мин

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

Гигиена макросов и три ловушки

Первый макрос почти наверняка содержит одну из трёх классических ошибок. Две из них ломаются молча: код выглядит правильным с обеих сторон, а делает не то. Третья терпеливо ждёт дня, когда ты вынесешь макрос в модуль, и падает на компиляции. Разберём каждую и научимся лечить.

Ловушка первая: многократное вычисление

Напишем макрос, который выполняет выражение дважды:

(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 есть свой козырь.

домашка

Домашка