【问题标题】:Understanding how to implement once-only lisp macro了解如何实现一次性 lisp 宏
【发布时间】:2026-02-17 02:45:01
【问题描述】:

在 Peter Seibel 的《Practical Common Lisp》一书中,我们可以找到非常复杂的一次性宏的定义(参见页面底部http://www.gigamonkeys.com/book/macros-defining-your-own.html)。

我在过去 3 周内第 10 次阅读此宏定义,但无法理解它是如何工作的。 :( 更糟糕的是,我无法自己开发这个宏,即使我了解它的用途和使用方法。

我对逐步系统地“推导”这个臭名昭著的硬宏特别感兴趣!有什么帮助吗?

【问题讨论】:

标签: macros lisp common-lisp practical-common-lisp


【解决方案1】:

你在看这个吗:

(defmacro once-only ((&rest names) &body body)
  (let ((gensyms (loop for n in names collect (gensym))))
    `(let (,@(loop for g in gensyms collect `(,g (gensym))))
      `(let (,,@(loop for g in gensyms for n in names collect ``(,,g ,,n)))
        ,(let (,@(loop for n in names for g in gensyms collect `(,n ,g)))
           ,@body)))))

它并没有那么复杂,但它确实有一个嵌套的反引号,并且有多个相互相似的层级,即使对于有经验的 Lisp 编码人员来说也很容易混淆。

这是一个宏,用于编写宏的扩展:一个宏,用于编写宏的部分主体。

在宏本身有一个普通的let,然后是一次反引号生成的let,它将存在于使用once-only的宏的主体内。最后,有一个双重反引号let,它将出现在that宏的宏扩展中,在用户使用宏的代码站点中。

这两轮生成gensyms是必要的,因为once-only本身就是一个宏,所以为了它自己必须要卫生;所以它会在最外面的let 中为自己生成一堆 gensyms。而且,once-only 的目的是简化另一个卫生宏的编写。所以它也会为那个宏生成 gensyms。

简而言之,once-only 需要创建一个宏扩展,它需要一些值为 gensyms 的局部变量。这些局部变量将用于将 gensyms 插入另一个宏扩展中以使其卫生。而且这些局部变量本身必须是卫生的,因为它们是宏扩展,所以它们也是 gensyms。

如果你正在编写一个普通的宏,你有保存 gensyms 的局部变量,例如:

;; silly example
(defmacro repeat-times (count-form &body forms)
  (let ((counter-sym (gensym)))
    `(loop for ,counter-sym below ,count-form do ,@forms)))

在编写宏的过程中,您发明了一个符号counter-sym。这个变量是在普通视图中定义的。你,人类,以这样一种方式选择了它,它不会与词汇范围内的任何东西发生冲突。有问题的词法范围是您的宏的词法范围。我们不必担心counter-sym 会意外捕获count-formforms 中的引用,因为forms 只是进入一段代码的数据,该代码最终将插入到某个远程词法范围(网站使用宏的地方)。我们必须担心不要将counter-sym 与宏中的另一个变量混淆。例如,我们不能给局部变量命名为count-form。为什么?因为该名称是我们的函数参数之一;我们会隐藏它,造成编程错误。

现在,如果您想要一个宏来帮助您编写该宏,那么机器必须完成与您相同的工作。在写代码的时候,它必须要发明一个变量名,而且要小心它发明的名字。

但是,与您不同,编写代码的机器看不到周围的范围。它不能简单地查看存在哪些变量并选择不冲突的变量。这台机器只是一个函数,它接受一些参数(一段未评估的代码)并生成一段代码,然后在该机器完成其工作后将其盲目地替换到一个作用域中。

因此,机器必须格外明智地选择名称。事实上,要完全防弹,它必须是偏执狂并使用完全独特的符号:gensyms。

继续这个例子,假设我们有一个机器人会为我们编写这个宏体。那个机器人可以是宏,repeat-times-writing-robot:

(defmacro repeat-times (count-form &body forms)
  (repeat-times-writing-robot count-form forms))  ;; macro call

机器人宏可能是什么样的?

(defmacro repeat-times-writing-robot (count-form forms)
  (let ((counter-sym-sym (gensym)))     ;; robot's gensym
    `(let ((,counter-sym-sym (gensym))) ;; the ultimate gensym for the loop
      `(loop for ,,counter-sym-sym below ,,count-form do ,@,forms))))

您可以看到它如何具有once-only 的一些特性:双重嵌套和(gensym) 的两个层次。如果你能理解这一点,那么到once-only 的飞跃就很小了。

当然,如果我们只是想让机器人编写重复次数,我们可以把它做成一个函数,然后这个函数就不必担心发明变量:它不是宏,所以它不会需要卫生:

 ;; i.e. regular code refactoring: a piece of code is moved into a helper function
 (defun repeat-times-writing-robot (count-form forms)
   (let ((counter-sym (gensym)))
     `(loop for ,counter-sym below ,count-form do ,@forms)))

 ;; ... and then called:
(defmacro repeat-times (count-form &body forms)
  (repeat-times-writing-robot count-form forms))  ;; just a function now

但是once-only不能是一个函数,因为它的工作是代表它的老板发明变量,使用它的宏,一个函数不能引入变量进入它的调用者。

【讨论】:

  • P.S.您可以感谢反引号符号使这种事情变得可行。如果没有反引号,once-only 将是可怕的。只有在 Lisp 编程中具有“白痴专家”能力的人才能看穿它。反引号符号是 Lisp 宏编写中真正的主力。
  • 反引号语法在嵌套时特别强大。这主要发生在宏定义宏中;因为这些代码主要是由巫师编写的,所以编写和解释嵌套反引号表达式的能力很快就被某种神秘所包围。麻省理工学院的 Alan Bawden 在 Lisp 机器的早期获得了反引号大师的特别声誉。 - “Lisp 的演变”,Gabriel,Steele。
  • Backquote 和 DEFMACRO 有很大的不同。这种表达能力的飞跃,以标准形式提供,开始了语言扩展的新浪潮,因为现在以标准、可移植的方式定义新的语言结构要容易得多,这样实验方言就可以被共享。 " Lisp 的演变”,Gabriel,Steele。
【解决方案2】:

Practical Common Lisp 中的once-only 宏的替代方案源自Let Over Lambda(参见第三章中的“仅一次”部分)。

【讨论】:

    【解决方案3】:

    Kaz 对它进行了精美而广泛的解释。

    但是,如果您不太关心双重卫生问题,您可能会发现这个更容易理解:

    (defmacro once-only ((&rest symbols) &body body)
      ;; copy-symbol may reuse the original symbol name
      (let ((uninterned-symbols (mapcar 'copy-symbol symbols)))
        ;; For the final macro expansion:
        ;; Evaluate the forms in the original bound symbols into fresh bindings
        ``(let (,,@(mapcar #'(lambda (uninterned-symbol symbol)
                               ``(,',uninterned-symbol ,,symbol))
                           uninterned-symbols symbols))
            ;; For the macro that is using us:
            ;; Bind the original symbols to the fresh symbols
            ,(let (,@(mapcar #'(lambda (symbol uninterned-symbol)
                                 `(,symbol ',uninterned-symbol))
                             symbols uninterned-symbols))
               ,@body))))
    

    第一个 let 被反引号两次,因为它将成为最终扩展的一部分。目的是将原始绑定符号中的形式评估为新的绑定。

    第二个let 被反引号一次,因为它将成为once-only 用户的一部分。目的是将原始符号重新绑定到新符号,因为它们的形式将在最终扩展中被评估并绑定到它们。

    如果原始符号的重新绑定是在最终宏扩展之前,则最终宏扩展将引用非内部符号而不是原始形式。

    使用once-onlywith-slots 实现是一个需要双重卫生的示例:

    (defmacro with-slots ((&rest slots) obj &body body)
      (once-only (obj)
        `(symbol-macrolet (,@(mapcar #'(lambda (slot)
                                         `(,slot (slot-value ,obj ',slot)))
                                     slots))
           ,@body)))
    
    ;;; Interaction in a REPL    
    > (let ((*gensym-counter* 1)
            (*print-circle* t)
            (*print-level* 10))
        (pprint (macroexpand `(with-slots (a) (make-object-1)
                                ,(macroexpand `(with-slots (b) (make-object-2)
                                                 body))))))
    
    ;;; With the double-hygienic once-only
    (let ((#1=#:g2 (make-object-1)))
      (symbol-macrolet ((a (slot-value #1# 'a)))
        (let ((#2=#:g1 (make-object-2)))
          (symbol-macrolet ((b (slot-value #2# 'b)))
            body))))
    
    ;;; With this version of once-only
    (let ((#1=#:obj (make-object-1)))
      (symbol-macrolet ((a (slot-value #1# 'a)))
        (let ((#1# (make-object-2)))
          (symbol-macrolet ((b (slot-value #1# 'b)))
            body))))
    

    第二个扩展显示内部let 隐藏了外部let 对变量#:obj 的绑定。因此,在内部with-slots 中访问a 实际上会访问第二个对象。

    请注意,在此示例中,外部宏扩展获取一个名为 g2 的生成符号,内部为 g1。在正常的评估或编译中,情况正好相反,因为表单是从外部走到内部的。

    【讨论】: