展开时:求值之前还有一个世界
从这一卷开始,我们不再理解那台求值机器,而是改造它。这就是第 1 章反复预告的主题:语言设计权的下放。但在写第一个宏之前,你必须先接受一件反直觉的事:宏不是「花哨的函数」。它活在一个和你的普通代码不同的时间里 —— 一个在求值之前就已经结束了的世界。这一章我会给你一个能亲眼看见这个时间的证明,然后你就再也不会把宏和函数搞混了。
先看见那两个时间
看这段代码。它定义了一个宏,宏里既有一句在编译期跑的打印,又有一句在运行期跑的打印:
#lang racket (define-syntax (show-time stx) (printf " [这行在<编译期>打印]\n") ; ← 宏展开时执行 #'(printf " [这行在<运行期>打印]\n")) ; ← 宏生成的代码,之后才执行 (displayln "定义完宏了。下面调用它:") (show-time)
猜一下打印顺序。按代码从上到下,你可能觉得是「定义完宏了 → 编译期 → 运行期」。实际跑出来是:
[这行在<编译期>打印] ← 它跑到最前面去了! 定义完宏了。下面调用它: [这行在<运行期>打印]
它在代码里明明写在 (displayln "定义完宏了") 后面,却先打印。为什么?
因为它属于另一个时间。Racket 运行你的程序,分成清清楚楚的两个阶段:
- 展开期(编译期):把所有的宏调用全部展开完。
define-syntax里的代码就在这时候跑。 - 运行期:展开完的、干干净净的代码,从头开始求值。
第一阶段整个结束了,第二阶段才开始。所以任何编译期的动作,都必然排在任何运行期动作之前 —— 不管它们在源码里的先后。
这就是「宏活在另一个时间」的字面意思。它不是比喻。
宏和函数,到底差在哪
把它们并排钉死:
| 函数 | 宏 | |
|---|---|---|
| 什么时候运行 | 运行期 | 展开期(运行期之前) |
| 输入是什么 | 值(参数已经被求值了) | 代码(未求值的语法树) |
| 输出是什么 | 一个值 | 一段代码(会替换掉原来的调用) |
| 能不能不求值某个参数 | 不能(应用序,全求值) | 能(它拿到的是代码,爱怎么处理怎么处理) |
| 能不能引入新语法 | 不能 | 能 |
函数在运行期处理值。宏在展开期处理代码。
还记得第 4 章吗 —— 代码就是数据(表)。所以「宏处理代码」= 「宏是一个在展开期运行的、输入一个表、输出另一个表的函数」。
宏就是第 4 章那个 eval 的孪生兄弟,但站在了正确的时间点上:不是在运行期把数据当代码跑(那是危险的 eval),而是在展开期把代码变形成新代码,然后交给编译器正常编译。零运行时开销,完整编译检查。
为什么这个时间差如此重要:回到 if
第 7 章那个 myIf 崩溃的问题,现在有了最终答案。
函数版(两个分支都会跑,因为它们在调用前就被求值成了值):
(define (fn-if c t e) (if c t e)) (fn-if #t (displayln "then") (displayln "else")) ; 打印: then 和 else ← 两句都打印了!
宏版(只跑一个分支):
(define-syntax-rule (macro-if c t e) (if c t e)) (macro-if #t (displayln "then") (displayln "else")) ; 打印: then ← 只有一句!
函数版:(displayln "else") 在运行期被求值 —— 而且是在 fn-if 被调用之前(应用序)。所以它无法避免执行。
宏版:macro-if 在展开期拿到的是未求值的代码 (displayln "else"),它把这段代码原封不动塞进了 if 的 else 位置。然后到运行期,那个 if 选择不执行 else 分支。那段代码从来没被求值过。
宏能做到函数永远做不到的事,唯一的原因就是:它在参数被求值之前就介入了。它拿到的是代码,不是值 —— 于是它有权决定这些代码要不要、怎么被求值。
这就是「语言设计权」的物质基础。语言内置的 if、and、while 之所以能「特殊」,就是因为它们在这个更早的时间点上工作。而宏,把这个时间点开放给了你。
宏能做的三件函数做不到的事
把宏的能力具体化。它能做三类事:
① 推迟 / 跳过求值(控制流)
; 造一个 unless —— Racket 其实内置了,但你完全可以自己写
(define-syntax-rule (my-unless cond body ...)
(if cond (void) (begin body ...)))
(my-unless (logged-in?)
(redirect-to-login) ; ← 只在没登录时才求值
(show-warning))
; 造一个 while —— Racket 没有 while!它用递归。但你可以加一个
(define-syntax-rule (while test body ...)
(let loop ()
(when test
body ...
(loop))))
停一下:Racket 语言本身没有 while 循环。而你刚才加了一个,五行。在 Kotlin 里,while 是编译器写死的关键字,你碰不了;在 Racket 里,它可以是一个普通库函数。这就是「内置」和「用户定义」之间那道墙的消失。
② 引入新的绑定形式
; 连 let 都可以自己造(它其实就是 lambda 的糖,第 6 章说过) (define-syntax-rule (my-let ([name val] ...) body ...) ((lambda (name ...) body ...) val ...)) (my-let ([a 10] [b 20]) (+ a b)) ; ⇒ 30
看那个 ...(省略号):它匹配「任意多个」[name val] 对儿,然后在生成的代码里对应地展开。这是 syntax-rules 的核心机制,下一章会讲透。Rust 的 macro_rules! 直接抄了它,连 ... 都一样。
③ 在编译期做计算 / 检查
; 编译期就把常量算好,运行期零开销 (define-syntax-rule (kb n) (* n 1024)) (kb 8) ; 展开成 (* 8 1024),而 8*1024 在编译期常量折叠 → 运行期就是个 8192 ; 甚至能在编译期报错 —— 把运行时 bug 提前到编译时 ; (需要 syntax-case,第 20 章)
你的框架里那些「运行时才炸」的错误 —— SQL 拼错了、路由参数对不上、JSON 字段名打错了 —— 其中很大一部分,如果用宏来做,可以变成编译期错误。
因为宏在编译期就看得见你写的代码结构,它可以检查「这个 SQL 的占位符数量对不对」「这个格式字符串的参数够不够」,在你按下运行之前就报错。
Rust 的 println!("{} {}", a)(参数数量不对)是编译错误,不是运行时崩溃 —— 因为 println! 是个宏,它在编译期数了占位符。这是宏相对函数的一个实打实的安全优势。第 24 章(契约)会从另一个角度回到「把错误提前」这件事。
宏的危险,和一条纪律
宏是这本书里最锋利的工具,也最容易被滥用。滥用的后果比滥用函数严重得多:
- 宏创造的是新语法,而新语法要别人重新学。一个读你代码的人,遇到一个函数,他知道「这是个函数调用,参数会先求值」;遇到一个宏,他什么都不能假设 —— 参数可能不求值、可能求值多次、可能引入新变量。每一个宏都是你的项目的一小块「方言」。
- 宏难调试。它在运行前就消失了,留下的是它生成的代码。没有 Macro Stepper(第 2 章)的话,你是在调试一段你没写过的代码。
- 宏会传染复杂度。一个宏里用另一个宏,展开的组合爆炸能让错误信息变得完全无法理解。
Racket 社区的纪律,值得你记住并搬用:
只有当你确实需要那三件函数做不到的事(推迟求值、引入绑定、编译期处理)时,才动用宏。如果一个东西用函数能表达(哪怕稍微啰嗦一点),就用函数 —— 因为函数有一份所有人都懂的、稳定的合约,而宏没有。
回流
「展开期 vs 运行期」不是 Lisp 的独家概念。你的整个工具链都是分阶段的,认出这些阶段,很多困惑会消失:
| 你的世界里的「展开期」 | 它在编译期对代码做的事 |
|---|---|
| Kotlin 编译器插件 / KSP | 在编译期读你的代码、生成新代码(Room、Hilt、Moshi) |
| 注解处理器(APT) | 同上,更老的一代 |
| Babel / TypeScript 编译 | 把 JSX、async/await、装饰器在编译期变形 |
Rust 的 macro_rules! / 过程宏 | 货真价实的宏,和 Racket 同源 |
| C/C++ 预处理器 | 最原始的展开期(纯文本,不卫生 —— 第 19 章的反面教材) |
| 构建期代码生成(protobuf、GraphQL codegen) | 在编译之前再加一个阶段 |
三个能立刻用上的认识:
① 你之所以觉得 KSP / 注解处理器「难」,一部分原因是你没意识到它活在另一个阶段。它不能访问运行时的值(因为运行时还没到),它只能看代码结构。一旦你用「展开期」这个心智模型去想它,它的种种限制就都合理了。
② 「为什么这个注解不生效 / 这个泛型信息运行时没了」类的问题,答案常常是「阶段」。泛型擦除、注解的 RetentionPolicy、编译期常量 vs 运行期值 —— 全是「这个东西活在哪个阶段」的问题。
③ 当你下次想「在编译期做点什么」(生成代码、提前检查),你会知道这是一个真实存在的、有名字的能力,而不是黑魔法。只不过在你的语言里,进入这个阶段的门票很贵(要写 KSP 处理器);在 Racket 里,它就是 define-syntax。
下一章
这一章讲了宏是什么(活在展开期的、代码到代码的变换)、以及它为什么强大(那个时间差)。
下一章讲怎么写。我们会正式学 syntax-rules —— 那个用「模式 → 模板」来定义宏的机制,以及那个神奇的 ...。你会写出几个真正有用的宏,而且会认出:你如果写过 Rust 的 macro_rules!,你其实已经会了。