卷 V · 宏CH 18深度 18/25

syntax-rules:写你自己的控制结构

上一章讲清了宏是什么。这一章讲怎么写 —— 而好消息是,最常用的宏系统 syntax-rules 简单得惊人:你只需要写「什么样的代码,变成什么样的代码」,一个模式,一个模板。没有 AST 遍历,没有 visitor,没有节点构造器。就像第 11 章的 match,只不过匹配的对象是代码,产出的也是代码。这一章我们会写出五六个真正有用的宏。而如果你写过 Rust 的 macro_rules! —— 你其实已经会了,连省略号都一样。

syntax-rules模式匹配省略号macro_rules!

最简单的形式:define-syntax-rule

你前面已经见过它了。一个模式,一个模板:

(define-syntax-rule (swap! a b)
;                    └──── 模式:长这样的代码 ────┘
  (let ([tmp a]) (set! a b) (set! b tmp)))
;  └──────────── 模板:变成这样 ────────────┘

规则:凡是遇到 (swap! 任意X 任意Y),就把它替换成模板,其中 a 换成 X,b 换成 Y。就这么直接 —— 它就是一个「结构化的查找替换」,只不过替换的单位是子树,而且(下一章会讲)它是卫生的。

多条规则:syntax-rules

define-syntax-rule 只能有一条规则。要想根据不同的形状走不同的模板,用完整的 syntax-rules,它可以列多条规则,从上到下匹配(像 cond):

(define-syntax my-list
  (syntax-rules ()
    [(_)              '()]                          ; 没有参数 → 空表
    [(_ x rest ...)   (cons x (my-list rest ...))]))  ; 至少一个 → cons + 递归

(my-list 1 2 3 4)    ; ⇒ '(1 2 3 4)

两件新东西:

① 那个 _:它是宏名字的占位符(这里就是 my-list 本身)。习惯上写 _,表示「我不关心这个位置」。

② 那个 ...(省略号):这是 syntax-rules 的灵魂。它的意思是「前面那个模式,重复零次或多次」

省略号:一次讲透

...模式里和在模板里,是配对使用的:

(define-syntax-rule (my-let ([name val] ...) body ...)
;                            └─模式里─┘         └模式┘
  ((lambda (name ...) body ...) val ...))
;           └模板里┘  └模板┘    └模板┘
◆ 省略号的规则,一句话

在模式里,x ... 匹配「任意多个 x」,把它们全收集起来。
在模板里,x ... 把收集到的那些依次展开回去。

关键在于它保持对应关系。上面的 my-let:模式里 [name val] ... 收集了若干个「名字-值」对,模板里 name ... 把所有名字展开到 lambda 的参数位置,val ... 把所有展开到调用的参数位置。名字和值被自动分开、各归各位。

展开验证:

(my-let ([a 10] [b 20]) (+ a b))
; 展开成 →
((lambda (a b) (+ a b)) 10 20)
;         └名字┘        └值┘        ← 自动分好了

这就是全部。你现在已经掌握了 syntax-rules 的核心。match 的模式(第 11 章)、syntax-rules 的模式 —— 是同一种思维:照着形状拆,照着形状装。

写几个真有用的

my-or:短路,而且不重复求值

这个宏很经典,因为它示范了「为什么需要那个临时变量」:

(define-syntax my-or
  (syntax-rules ()
    [(_)              #f]
    [(_ e)            e]
    [(_ e rest ...)   (let ([t e])          ; ← 先算 e 一次,存起来
                        (if t t (my-or rest ...)))]))

(my-or #f #f 3 (error "永不执行"))    ; ⇒ 3   (短路了,error 没跑)

为什么要那个 (let ([t e]) ...)?因为如果直接写 (if e e (my-or rest ...)),那么 e 会被求值两次(判断一次,返回一次)。如果 e(pop! stack) 这种有副作用的,就弹了两次栈 —— 一个真实的 bug 类别。先用 let 存一次,是宏作者的标准防御。

但这个 t 又带来了新问题:如果调用者的代码里有个变量叫 t 呢?——这正是下一章的主题。先记住这个悬念。

unless / while:Racket 没有,你加上

(define-syntax-rule (unless* c body ...)
  (if c (void) (begin body ...)))

; Racket 语言本身没有 while 循环。加一个:
(define-syntax-rule (while test body ...)
  (let loop ()
    (when test
      body ...
      (loop))))

; 用起来和内置的没区别:
(define i 0)
(while (< i 3)
  (displayln i)
  (set! i (add1 i)))

再强调一次这件事的分量:你刚给一门语言加了一个循环结构,五行,而且用起来和内置语法完全无法区分这是 Kotlin 的 inline 函数做不到的(第 7 章:调用者得写花括号),更是 Java 想都不敢想的。

log-debug:推迟求值,省掉字符串拼接

这个直接解决第 7 章那个「Java 日志白拼字符串」的问题:

(define log-enabled #f)

(define-syntax-rule (log-debug msg ...)
  (when log-enabled
    (printf "[DEBUG] ~a\n" (string-append msg ...))))

; 日志关着的时候:
(log-debug "结果是:" (number->string (expensive-computation)))
;                     └──────────────────────────────────┘
;          这坨根本不会被求值 —— 因为 when 是假,body 不执行

因为 log-debug 是宏,那段昂贵的字符串拼接被放进了 when 的 body 里,只有 log-enabled 为真时才求值。日志关着时,零开销。这正是 Kotlin 的 logger.debug { "..." } 用 lambda 实现的效果 —— 但宏的版本调用点更干净(不用写花括号)。

my-assert:把变量名也带进错误信息

这个展示了宏一个函数绝对做不到的能力:它能看见你写的代码本身,不只是代码的值。

(define-syntax-rule (my-assert e)
  (unless e
    (error 'assert "断言失败: ~a" 'e)))
;                              ↑ 注意这个引号:把表达式本身当数据

(my-assert (= 1 2))
; 错误信息: 断言失败: (= 1 2)
;                     ↑ 它打印出了你写的那个表达式,不只是 #f!
◆ 这是函数不可能做到的

如果 my-assert 是函数,它收到的是 #f(= 1 2) 求值后的结果)。看不到 (= 1 2) 这个表达式了 —— 那个信息在求值的瞬间就丢了。

而宏在求值之前拿到的是代码,所以它能同时用到「代码的值」(e)和「代码本身」('e)。

这就是为什么好的测试框架、断言库,几乎都是宏:它们要在失败信息里告诉你「(= x.size 3) 失败了,而 x.size 实际是 5」—— 既要值,又要表达式。Kotlin 的 assert、Rust 的 assert_eq!、Groovy 的 Power Assert 全靠这个。在没有宏的语言里,这种「打印出表达式源码」的能力要靠字节码插桩或编译器插件硬凑。

递归宏 + 关键字:造一个 cond

综合运用。cond 都可以自己造(它其实就是嵌套的 if):

(define-syntax my-cond
  (syntax-rules (else)                          ; ← else 是字面关键字
    [(_ [else e ...])          (begin e ...)]
    [(_ [test e ...] clause ...)
     (if test
         (begin e ...)
         (my-cond clause ...))]))               ; ← 宏递归调用自己

(my-cond
  [(> 1 2) 'a]
  [(> 3 2) 'b]
  [else    'c])                                 ; ⇒ 'b

两个新技巧:

(syntax-rules (else) ...) 里的 else:括号里列的是字面关键字 —— 它们必须原样出现,不是占位符。这让 elsemy-cond 里有了特殊含义,就像它在真的 cond 里一样。

② 宏递归:my-cond 的模板里又调用了 my-cond宏可以像函数一样递归展开 —— 每次剥掉一个分支,直到剩下 else 或空。

去 Macro Stepper 里看它展开

用第 2 章那个工具(或者 expand/step-text)看 my-or 的展开,你会看到一个关键的细节:

(my-or a b c)
  ==>
(let:1 ((t:1 a)) (if:1 t:1 t:1 (my-or:1 b c)))
                    ↑ 注意 t 后面的 :1

; 再展开一层里面那个 my-or:
  ==>
... (let:3 ((t:3 b)) (if:3 t:3 t:3 (my-or:3 c)))
         ↑ 这次是 t:3,不是 t:1!
◆ 那些 :1 :3 就是下一章的全部

递归展开的 my-or 里,每一层都用了一个叫 t 的临时变量。如果它们真的是同一个 t,这个宏就会自己把自己搞坏(内层的 t 遮住外层的)。

但 Macro Stepper 告诉你:它们是 t:1t:3 —— 不同的东西。虽然拼写都是 t

这个「自动区分同名变量」的机制,就是卫生(hygiene)。它是 syntax-rules 默认就有的、而 C 宏永远没有的东西。下一章整章讲它 —— 而你现在已经带着一个具体的问题去了:那个 t 为什么不会撞车?

回流

⟲ 回流 · Rust、以及「什么时候该动用这个」

① 如果你写 Rust,你已经会 syntax-rules 了。Rust 的 macro_rules!直接照搬的:

; Racket
(define-syntax-rule (my-vec x ...) (list x ...))
// Rust —— 看那个 $x:expr 和 $(...)*,和 Racket 的 x ... 是一个东西
macro_rules! my_vec {
    ($($x:expr),*) => { vec![$($x),*] };
}

Rust 的 $(...)* 就是 Racket 的 ... ,连「模式里收集、模板里展开」的语义都一样。Rust 团队公开承认设计参考了 Scheme 的 syntax-rules你在这一章学的东西,写 Rust 宏时能直接用。

② 大多数语言给不了你这个,但认出「什么问题本该用宏解决」很有价值。当你在 Kotlin/JS 里遇到下面这些,你就是在缺一个宏:

  • 想让某个参数不被求值 / 延迟求值 → 你只能退而求其次用 lambda(带花括号的语法负担)
  • 想在错误信息里打印出表达式源码 → 你只能靠字节码插桩(Kotlin power-assert 编译器插件)或者手动传字符串
  • 想引入一个新的代码块结构 → 你只能用带接收者的 lambda 模拟(Kotlin 的 DSL),而且逃不掉 { }
  • 编译期检查字符串格式 / SQL / 路由 → 你只能靠外部 lint 或 IDE 插件

看清这些「缺口」,能让你更准确地评估技术方案:当有人说「我们用注解处理器 / 编译器插件来做这个」,你会明白他其实是在用很贵的方式模拟一个宏,然后你能判断这值不值。

③ 把那条纪律也带走:「能用函数就别用宏」。这一章教了你怎么写宏,但真正的高手标志是知道什么时候不写。你写的每个宏都是给读代码的人的一小笔学习税 —— 只在那三件函数做不到的事上花这笔钱。

下一章

整章我都在回避那个问题:my-or 里的 t,swap! 里的 tmp —— 它们为什么不会和调用者的同名变量撞车?

下一章正面回答。而答案会颠覆你的直觉:Racket 展开出来的代码,字面上就是那个有 bug 的 C 宏版本,一字不差 —— 但它是对的。为什么?因为编译器认的根本不是名字。