syntax-rules:写你自己的控制结构
上一章讲清了宏是什么。这一章讲怎么写 —— 而好消息是,最常用的宏系统 syntax-rules 简单得惊人:你只需要写「什么样的代码,变成什么样的代码」,一个模式,一个模板。没有 AST 遍历,没有 visitor,没有节点构造器。就像第 11 章的 match,只不过匹配的对象是代码,产出的也是代码。这一章我们会写出五六个真正有用的宏。而如果你写过 Rust 的 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:括号里列的是字面关键字 —— 它们必须原样出现,不是占位符。这让 else 在 my-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:1 和 t:3 —— 不同的东西。虽然拼写都是 t。
这个「自动区分同名变量」的机制,就是卫生(hygiene)。它是 syntax-rules 默认就有的、而 C 宏永远没有的东西。下一章整章讲它 —— 而你现在已经带着一个具体的问题去了:那个 t 为什么不会撞车?
回流
① 如果你写 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 宏版本,一字不差 —— 但它是对的。为什么?因为编译器认的根本不是名字。