Skip to content

LISP - 变量捕获与卫生宏

宏(Macros)是 Lisp 最强大的特性之一,它允许你编写能够生成代码的代码,从而有效地扩展语言本身。然而,这种强大性也伴随着一个隐秘的陷阱:变量捕获(variable capture)。当宏引入的变量无意中与使用该宏的代码中的变量发生冲突时,就会发生这种情况。易受此问题影响的宏被称为不卫生宏(unhygienic macro)。

本章将探讨什么是变量捕获、如何识别它,以及如何使用标准 Lisp 工具编写卫生宏(hygienic macro)——即能够避免这个问题的健壮宏。

让我们通过一个经典(尽管有些刻意)的示例来演示变量捕获:一个用于交换两个变量值的宏。虽然内置的 rotatef 是完成这项工作的正确工具,但 swap 却能很好地说明这个问题。

;; 一个用于交换两个位置值的非卫生宏。
(defmacro unhygienic-swap (x y)
`(let ((temp ,x)) ; 这个 'temp' 是问题根源。
(setf ,x ,y)
(setf ,y temp)))
;; 让我们尝试使用名为 'a' 和 'b' 的变量。
(let ((a 1) (b 2))
(unhygienic-swap a b)
(format t "a: ~a, b: ~a~%" a b))
;; 现在,我们来看看当其中一个参数名为 'temp' 时会发生什么。
(let ((temp 10) (my-var 20))
(format t "Before swap: temp=~a, my-var=~a~%" temp my-var)
(unhygienic-swap temp my-var) ; 糟糕!问题就出在这里。
(format t "After swap: temp=~a, my-var=~a~%" temp my-var))
a: 2, b: 1
Before swap: temp=10, my-var=20
After swap: temp=20, my-var=20

第一次使用 a 和 b 进行的交换完美地工作了。问题出现在第二种情况。为了理解原因,我们需要看看宏展开后的代码。你可以在 Lisp 环境中使用 macroexpand-1 来实现这一点。

;; (unhygienic-swap temp my-var) 的展开形式如下:
(let ((temp temp)) ; <-- 捕获发生在这里!
(setf temp my-var)
(setf my-var temp))
  • 宏引入了它自己的局部变量,名为 temp。
  • 调用宏的代码也使用了名为 temp 的变量。
  • 在展开后的代码中,(let ((temp temp))...) 创建了一个新的 temp,它遮蔽了外部的 temp。内部的 setf 调用只修改这个新的内部 temp。
  • 外部的 temp(初始值为 10)从未被赋值给 my-var。结果是 my-var 被赋予了内部 temp 的新值,而这个新值也是由 my-var 设置的。实际上,my-var 被赋值给了它自己,而外部 temp 的原始值丢失了。

为了解决这个问题,我们必须确保宏创建的任何临时变量都具有唯一的名称,这些名称绝不会与用户代码冲突。Common Lisp 提供了 gensym 函数正是为了这个目的。gensym 每次调用都会生成一个新的、唯一的符号。

;; 一个使用 GENSYM 创建唯一临时变量的卫生宏。
(defmacro hygienic-swap (x y)
(let ((temp-sym (gensym))) ; 1. 创建一个唯一的符号,例如 #:G1023
`(let ((,temp-sym ,x)) ; 2. 在生成的代码中使用该唯一符号。
(setf ,x ,y)
(setf ,y ,temp-sym))))
(let ((temp 10) (my-var 20))
(format t "Before swap: temp=~a, my-var=~a~%" temp my-var)
(hygienic-swap temp my-var) ; 现在它正常工作了!
(format t "After swap: temp=~a, my-var=~a~%" temp my-var))
Before swap: temp=10, my-var=20
After swap: temp=20, my-var=10

让我们看看卫生宏的展开形式:

;; (hygienic-swap temp my-var) 的展开形式大概会是这样:
;; 注意:#:G1023 只是 GENSYM 可能创建的符号示例。
(let ((#:G1023 temp))
(setf temp my-var)
(setf my-var #:G1023))
  • (let ((temp-sym (gensym))) ... ):这部分在宏定义时运行。它创建一个新符号并存储在 temp-sym 变量中。
  • ** (let ((,temp-sym ,x)) … ) **:在反引号模板内部,temp-sym被取消引用(unquoted)。这会将**唯一的符号本身**(例如#:G1023`)注入到宏生成的代码中。
  • 无冲突:生成的代码现在使用一个名为 #:G1023 的变量,它不可能与用户的变量 temp 发生冲突。交换逻辑现在按预期工作。
  • 尽可能使用内置函数:对于交换操作,正确、符合习惯且卫生(hygienic)的方法是使用 (rotatef place1 place2)。始终优先使用内置函数或宏,而不是自己编写。
  • 宏的临时变量始终使用 gensym:宏内部引入的任何变量,如果不是其参数之一,都应该用 gensym 创建,以保证卫生性(hygiene)。
  • 使用 macroexpand-1 调试:调试宏最重要的工具是 macroexpand-1。它能准确地显示你的宏正在生成什么样的代码,这几乎总能揭示问题根源。