Гигиена и захват переменных: gensym и syntax-rules
Гигиена макросов: захват переменных, проблема двойного вычисления, gensym в Common Lisp и автоматически гигиеничный syntax-rules в Scheme.
Захват переменной — это ошибка, когда имя, введённое макросом, случайно «перехватывает» одноимённую переменную из кода пользователя (или наоборот), искажая смысл программы.
Зачем это: невидимые имена ломают макросы
Макрос вставляет свой код в чужой контекст. Если он вводит временные переменные, возникает риск: имя его переменной может совпасть с именем из пользовательского кода. Тогда подстановка «склеит» два разных смысла одного имени — это и есть захват. Такие баги коварны: код выглядит правильно, но ведёт себя странно лишь при определённых именах переменных у пользователя. Гигиена — это набор приёмов (и в Scheme — встроенный механизм), который гарантирует, что имена из макроса и из кода пользователя не пересекаются. Без понимания гигиены вы рано или поздно напишете «протекающий» макрос.
Почему это особенно опасно? Потому что баг проявляется избирательно. Макрос проходит все ваши тесты, работает в продакшене месяцами — а потом ломается у конкретного пользователя только потому, что он назвал свою переменную так же, как ваша внутренняя временная. Воспроизвести трудно: «у меня работает». Это худший вид ошибки — недетерминированный с точки зрения автора, зависящий от имён в чужом коде, которые автор не контролирует и даже не видит. Поэтому гигиена — не «академическая придирка», а вопрос надёжности библиотек: любой публичный макрос обязан быть гигиеничным, иначе он — мина замедленного действия. Хорошая новость: в Common Lisp дисциплина проста и механична (всегда gensym для временных), а в Scheme проблема решена самим языком. Плохая — забыть о ней легко, потому что «обычно работает».
Проблема 1: захват переменной
Рассмотрим классику — макрос swap, написанный «в лоб» с временной переменной:
;; ПЛОХОЙ swap — вводит temp, которая может захватить пользовательскую
(defmacro bad-swap (a b)
`(let ((temp ,a))
(setf ,a ,b)
(setf ,b temp)))
;; Обычно работает:
(let ((x 1) (y 2))
(bad-swap x y)
(list x y)) ; => (2 1), всё хорошо
;; Но что, если пользователь сам назвал переменную temp?
(let ((temp 100) (z 5))
(bad-swap temp z) ; РАЗВЕРНЁТСЯ С КОНФЛИКТОМ ИМЁН
(list temp z)) ; => НЕ (5 100), а сломанный результат
Что происходит во втором случае? Разворот даёт (let ((temp temp)) (setf temp z) (setf z temp)) — переменная пользователя temp и переменная макроса temp сливаются в одну, и обмен ломается. Имя temp, введённое макросом, «захватило» пользовательское. Это и есть нарушение гигиены.
Решение в Common Lisp: gensym
Common Lisp не делает гигиену автоматически — ответственность на авторе макроса. Стандартный приём: для каждой временной переменной создавать уникальный, гарантированно неповторимый символ через gensym. (gensym) возвращает свежий символ, не eq никакому другому, — его невозможно случайно написать в коде пользователя.
;; ПРАВИЛЬНЫЙ swap — temp заменена на gensym
(defmacro good-swap (a b)
(let ((tmp (gensym "TEMP-"))) ; свежий уникальный символ
`(let ((,tmp ,a))
(setf ,a ,b)
(setf ,b ,tmp))))
(let ((temp 100) (z 5))
(good-swap temp z)
(list temp z)) ; => (5 100), теперь верно
Тонкость в фазах: (gensym) вызывается в теле макроса, то есть во время расширения, и создаёт символ один раз на каждое расширение. Мы кладём его в обычную переменную tmp и подставляем через ,tmp в шаблон. В результате каждое использование good-swap получит свой уникальный символ вроде #:TEMP-42, который не совпадёт ни с чем. Когда временных переменных несколько, заводят несколько gensym; для удобства часто пишут вспомогательный макрос with-gensyms, который сразу связывает список имён со свежими символами.
;; Утилита with-gensyms — связывает имена со свежими gensym
(defmacro with-gensyms (names &body body)
`(let ,(mapcar (lambda (n) `(,n (gensym ,(string n)))) names)
,@body))
;; Применение: чисто и без ручного перечисления gensym
(defmacro my-swap (a b)
(with-gensyms (tmp)
`(let ((,tmp ,a))
(setf ,a ,b)
(setf ,b ,tmp))))
Проблема 2: двойное вычисление
Вторая классическая ловушка не про имена, а про число вычислений аргумента. Если макрос подставляет аргумент в результат дважды, выражение вычислится дважды — с побочными эффектами это катастрофа.
;; ПЛОХО: x подставлен дважды -> вычислится дважды
(defmacro bad-square (x)
`(* ,x ,x))
(let ((c 0))
(flet ((next () (incf c))) ; каждый вызов увеличивает c
(bad-square (next)))) ; (* (next) (next)) -> 1*2 = 2, не 1*1!
;; ХОРОШО: вычислить один раз, сохранить в gensym
(defmacro good-square (x)
(let ((v (gensym)))
`(let ((,v ,x)) ; вычисляем x РОВНО один раз
(* ,v ,v))))
В bad-square вызов (next) подставлен в код дважды и выполнится дважды, дав 1*2=2 вместо ожидаемого «квадрата одного значения». good-square сначала вычисляет аргумент в gensym-переменную, а потом дважды использует уже готовое значение. Правило: каждый аргумент макроса вычисляй ровно один раз, сохраняя в gensym-переменную, если он используется в результате больше одного раза или его порядок вычисления важен.
Обратите внимание: две проблемы — захват и двойное вычисление — ортогональны, хотя лечатся похоже (через gensym + let). Захват — про имена: совпадение идентификаторов макроса и пользователя. Двойное вычисление — про число и порядок исполнений переданного выражения. Можно нарушить одно и не нарушить другое: макрос без временных переменных не страдает захватом, но легко может вычислить аргумент дважды; и наоборот. Поэтому, проверяя свой макрос, прогоняйте обе проверки независимо: «ввожу ли я имена, которые могут столкнуться?» и «вычисляю ли я какой-то аргумент не ровно один раз?». Удобный мысленный тест на двойное вычисление — подставить вместо аргумента выражение с побочным эффектом (как (next) или (incf x)) и спросить, сколько раз оно сработает. Если больше одного там, где ожидался один, — баг.
Scheme: гигиена встроена (syntax-rules)
Scheme подходит иначе: его система макросов гигиенична по умолчанию. Основной инструмент — syntax-rules: декларативные макросы по образцу (pattern → template). Они не пишут код императивно, а сопоставляют форму с шаблоном и переписывают по правилу. Важнейшее свойство: реализация автоматически переименовывает введённые макросом имена, исключая захват, — программисту не нужны gensym.
;; Scheme: гигиеничный swap через syntax-rules
(define-syntax swap!
(syntax-rules ()
((swap! a b)
(let ((tmp a)) ; tmp АВТОМАТИЧЕСКИ гигиеничен:
(set! a b) ; реализация переименует его так,
(set! b tmp))))) ; чтобы не пересечься с кодом пользователя
(let ((temp 100) (z 5))
(swap! temp z)
(list temp z)) ; => (5 100), гигиена «из коробки»
Здесь tmp не захватит пользовательскую temp, даже если они называются одинаково, потому что Scheme отслеживает «откуда» каждое имя и держит миры макроса и пользователя раздельными. syntax-rules — это «безопасный, но менее мощный» подход: он декларативен и легко читается, но не позволяет произвольных вычислений над кодом (для этого в Scheme есть более мощные и тоже гигиеничные syntax-case). Это фундаментальное различие философий: Common Lisp даёт полную процедурную мощь и доверяет дисциплине автора (gensym), Scheme жертвует частью мощи ради безопасности по умолчанию.
Стоит отметить нюанс: иногда захват — это цель, а не баг. Существует приём «анафорических» макросов, где макрос намеренно вводит видимое пользователю имя — классика — aif, который связывает результат проверки с переменной it, чтобы в теле можно было написать (aif (find-user id) (greet it)). Здесь захват it — задуманное удобство. В Common Lisp такое легко: просто не используйте gensym для этого конкретного имени. А вот в гигиеничном Scheme намеренный «захват» сделать сложнее — нужны специальные средства (datum->syntax в syntax-case), чтобы «пробить» гигиену осознанно. Это обратная сторона медали: автоматическая гигиена защищает от случайного захвата, но и намеренный захват требует усилий. CL же оставляет оба варианта одинаково доступными, перекладывая ответственность на автора. Понимать это различие важно, чтобы не считать гигиену однозначно «лучше» — это компромисс с разными сторонами. На практике большинство макросов не нуждаются в анафоре и должны быть гигиеничны, так что правило «всегда gensym» остаётся верным по умолчанию; анафорические макросы — осознанное исключение, которое автор делает явно и документирует.
Сравнение подходов
| Свойство | Common Lisp (defmacro) | Scheme (syntax-rules) |
| Гигиена | вручную, через gensym | автоматическая |
| Стиль | процедурный (строим код кодом) | декларативный (образец → шаблон) |
| Мощность | максимальная (любые вычисления) | ограниченная (но есть syntax-case) |
| Риск захвата | есть, нужна дисциплина | исключён по построению |
Как работает под капотом
Захват возможен потому, что макрорасширение Common Lisp работает с «голыми» символами: символ temp из макроса и temp из кода — это один и тот же объект-символ в пакете, поэтому let их объединяет. gensym обходит это, создавая безымянный символ (uninterned), которого нет ни в одном пакете и который нельзя ввести чтением исходника — значит, столкнуться с ним невозможно. Scheme же строит макросистему на «синтаксических объектах», которые несут не только имя, но и «контекст» (откуда пришли); при сопоставлении и подстановке реализация автоматически α-переименовывает связанные имена макроса, гарантируя несовпадение. По сути обе системы решают одну задачу — различить одинаковые имена из разных миров — но CL делает это явным инструментом, а Scheme встраивает в сам механизм.
Частые ошибки
- Временная переменная без gensym. Любое введённое макросом имя — кандидат на захват. Используйте
gensymдля всех временных. - Двойное вычисление аргумента. Если аргумент встречается в результате дважды — он вычислится дважды. Сохраняйте его в gensym-переменную и используйте её.
- Вызывать gensym не в теле макроса.
gensymдолжен вызываться во время расширения (в телеdefmacro), а не внутри шаблона — иначе он попадёт в рантайм-код и создаст символ при каждом запуске, а не при расширении. - Считать defmacro гигиеничным. Common Lisp не делает гигиену сам — это работа автора. Не переносите интуицию из Scheme.
- Путать порядок вычисления. Если важен порядок (несколько аргументов с эффектами) — вычисляйте их в
let*в нужной последовательности через gensym.
Итоги
- Захват переменной — случайное склеивание одноимённых имён макроса и пользователя.
- В Common Lisp гигиену обеспечивает автор: каждую временную переменную создавайте через
gensym. - Двойное вычисление аргумента лечат сохранением его в gensym-переменную (вычислить ровно один раз).
- Утилита
with-gensymsделает заведение свежих имён удобным. - Scheme гигиеничен по умолчанию:
syntax-rulesавтоматически переименовывает имена макроса. - CL — максимальная мощь и ручная дисциплина; Scheme — безопасность по построению ценой части мощи.
Эти две ловушки — захват имён и двойное вычисление — отделяют «макрос, который иногда работает» от «макроса, на который можно положиться». Выработав привычку прогонять обе проверки на каждом макросе, вы пишете надёжный код, которому не страшны ни чужие имена переменных, ни выражения с побочными эффектами в аргументах.