更大的力量:syntax-case 与编译器插件的真相
syntax-rules 是「模式 → 模板」,它有个天花板:展开时你不能运行任意代码。这一章捅破它 —— syntax-case 让宏在展开时跑完整的 Racket,于是你能在编译期算斐波那契、能把一个除零 bug提前到编译期报错。然后,带着这份理解,我们回答第 1 章题 8 那个悬了整本书的问题:KSP、Babel plugin、Kotlin 编译器插件,为什么写起来那么痛苦?答案是,它们在做的事和 Racket 的宏完全一样 —— 只是缺了三样 Racket 免费给的东西。
syntax-rules 的天花板
syntax-rules 只能做结构性的变换:输入长这样,输出就长那样。它看不懂值,也不能计算。
比如你想写一个宏:「如果参数是偶数,生成 A;是奇数,生成 B。」syntax-rules 做不到 —— 它不能「判断偶数」,那是一个计算。
syntax-case 可以。它的规则体里,可以写任意的 Racket 代码,在展开期运行:
(require (for-syntax racket/base)) ; ← 把 racket 带进「展开期」这个阶段
(define-syntax (my-macro stx) ; stx = 整个调用的语法对象
(syntax-case stx ()
[(_ n)
;; ↓ 这里是普通的 Racket 代码,在展开期运行
(let ([val (syntax->datum #'n)])
(if (even? val)
#'(list 'even-branch) ; #' 造一段代码返回
#'(list 'odd-branch)))]))
(my-macro 4) ; ⇒ '(even-branch)
(my-macro 5) ; ⇒ '(odd-branch)
syntax-rules | syntax-case | |
|---|---|---|
| 展开逻辑 | 模式 → 模板,固定 | 任意 Racket 代码 |
| 能计算吗 | 不能 | 能(偶数判断、查表、递归……) |
| 能报错吗 | 不能 | 能(编译期报错) |
| 卫生 | 自动 | 自动(但你可以显式打破,见下) |
| 什么时候用 | 默认用这个,覆盖 90% | 需要计算或检查时 |
那两个新符号:#'x 是 (syntax x) 的简写,意思是「造一个代码片段」(类比第 4 章的 quote,但保留了卫生标签);syntax->datum 把语法对象扒成普通数据,好让你用普通函数处理它。
威力一:编译期计算
既然展开期能跑任意代码,那就能在编译期把值算好,运行期直接用现成的:
(define-syntax (fib stx)
(syntax-case stx ()
[(_ n)
(let loop ([n (syntax->datum #'n)] [a 0] [b 1])
(if (= n 0)
(datum->syntax stx a) ; 把算好的数变回语法对象
(loop (- n 1) b (+ a b))))]))
(fib 10) ; ⇒ 55
; ↑ 这个 55 是编译期就算好的。运行期这里就是一个字面量 55,零计算。
这是 C++ 模板元编程、Rust const fn、Zig comptime 想做的同一件事 —— 把计算从运行期挪到编译期。区别是,在 Racket 里它就是「写普通代码」,而在 C++ 里它是一套另外的、以难懂著称的模板语言。
威力二:把 bug 提前到编译期
这是 syntax-case 最实用的能力。看这个 —— 一个会在编译期拒绝除以字面量 0 的除法:
(define-syntax (checked-div stx)
(syntax-case stx ()
[(_ a b)
(let ([bv (syntax->datum #'b)])
(when (and (number? bv) (zero? bv))
(raise-syntax-error 'checked-div "除数不能是字面量 0" stx))
#'(/ a b))]))
(checked-div 10 2) ; ⇒ 5,正常
(checked-div 10 0) ; ← 编译不通过!
最后一行的真实报错(编译期,不是运行期):
checked-div: 除数不能是字面量 0 in: (checked-div 10 0)
这不是玩具。它是 Rust 的 println!("{}")(参数数量对不上 → 编译错误)、format 字符串检查、类型安全的 SQL 查询、编译期路由校验背后的同一个机制。
你在框架里遇到的那些「运行时才炸」的低级错误 —— 格式串占位符数量不对、SQL 列名拼错、JSON 字段对不上 —— 其中相当一部分,原理上都可以用「在编译期检查代码结构」的宏,变成编译错误。第 24 章讲契约时,会从「运行时保证」这一侧再谈一次「把错误提前」;而这里是「编译期保证」这一侧。两侧夹击,就是把 bug 往前赶的完整武器库。
威力三:故意打破卫生(受控地)
上一章说卫生是自动的、防捕获的。但偶尔你就是想要捕获 —— 最经典的例子是 anaphoric if:一个能在体内自动绑定一个叫 it 的变量的 if。
; 想要的效果:
(aif (find-user id)
(displayln (user-name it)) ; ← it 自动绑定到 (find-user id) 的结果
(displayln "没找到"))
这需要宏引入的 it 去「捕获」——让调用者能用它。syntax-rules 的卫生会阻止这件事。syntax-case 让你用 datum->syntax 显式地把 it 的标签改成「来自调用处」,从而故意让它可见。
Racket 的默认是「卫生」(安全),打破卫生需要显式、费力地去做(datum->syntax)。
对比 C 宏:默认是「不卫生」(危险),想要安全反而要靠命名约定去凑。
好的默认值是「安全」,而把「危险但偶尔必要」的能力藏在一个需要你明确请求的门后面。这个设计原则(safe by default, dangerous on request)你在别处也见过 —— Kotlin 的空安全(可空要显式写 ?)、Rust 的 unsafe 块、Java 的 @SuppressWarnings。它们都是同一个智慧:让正确的事简单,让危险的事显眼。
现在,回答那个悬了整本书的问题
第 1 章题 8:为什么在 Java/Kotlin 里,「操作代码」需要一整套专门的、庞大的 API(KSP、注解处理器、Babel plugin)?
你现在有了完整的答案。它们做的事,和 Racket 的宏一模一样 —— 都是「在编译期,把代码变成另一段代码」。区别不在做什么,在于它们缺了 Racket 免费给的三样东西:
| Racket 免费给的 | KSP / Babel 里的替代品 | 痛在哪 |
|---|---|---|
| 代码 = 数据(同像性) | 一整套「代码的影子类型」:KSClassDeclaration、KSFunctionDeclaration;Babel 的 AST 节点类型 | 你要先学会这套庞大的、和普通数据不同的类型体系,才能开始干活 |
| 造代码 = 造数据(quasiquote) | KotlinPoet 的 FunSpec.builder().addStatement("...");Babel 的 t.binaryExpression(...) | 你在用字符串或构造器拼代码,一个节点一个节点砌。语法错误要到最后才发现 |
| 卫生 | 没有 —— 靠「只能生成新文件,不能改现有代码」来回避问题 | 生成的代码是隔一层的伴生文件,不能真正融入你的代码 |
看清这三行,你就彻底理解了那股「写 KSP 时的烦躁」的确切来源。它不是因为你笨,也不是因为 KSP 设计得差(它在给定约束下其实做得很好)。它是因为这些工具在一门「代码 ≠ 数据」的语言里,努力模拟一件「代码 = 数据」才自然的事。每一处别扭,都是那堵不该存在的墙(第 4 章)投下的影子。
不能只说好话。完整的 syntax-case / 过程宏,也是有学习曲线的 —— 「阶段」(phase)的概念(for-syntax、编译期和运行期的代码要分开)会把新手绕晕,写复杂宏时的调试也确实费劲(尽管有 Macro Stepper)。
区别在于「地板」和「天花板」:Racket 的简单宏(syntax-rules)极其简单(地板低),复杂宏需要学阶段(但天花板高且连续)。而 KSP 是没有简单版的 —— 哪怕最简单的代码生成,你也得先搭起整套 processor、KSP gradle 配置、影子类型。Racket 让简单的事简单;KSP 让所有事都不简单。
回流
这一卷的最终回报,是让你在面对「我需要生成/变换代码」时,能做出有依据的技术选择。按成本从低到高排:
| 手段 | 什么时候用它 | 代价 |
|---|---|---|
| 普通函数 / 高阶函数 | 永远先试这个。能用函数解决就绝不上元编程 | 几乎为零。有稳定合约,人人会读 |
| inline 函数 + lambda(Kotlin) | 只需要「推迟求值」(日志、资源管理、DSL) | 调用点要写 {};inline 传染性 |
| 带接收者的 lambda / builder DSL | 构造数据结构(Compose、Ktor 路由、HTML DSL) | 只能造数据,造不了新语法/控制流 |
| 反射(运行时) | 实在拿不到编译期信息,且能接受性能损失 | 慢、无类型检查、混淆器噩梦。尽量避免 |
| 注解处理 / KSP(编译期) | 需要真正的代码生成(DI、序列化、Room) | 整套影子类型 + gradle 配置;只能新增文件 |
| 编译器插件(Kotlin/Babel) | 需要修改现有代码或改变语义 | 最贵。要懂编译器内部;跨版本易碎 |
三条能直接用的判断:
① 这张表的每一行,都是在为「缺少宏」付一种税。知道自己在付哪种税、值不值,就是这一卷给你的核心能力。
② 「往上一格」之前先确认真的必要。大量项目里的 KSP / 反射,其实用一个函数或一点样板就能替代 —— 人们用重武器,常常只是因为没意识到轻武器够用。元编程的复杂度是会传染的,而且它传染给的是每一个读你代码的人。
③ 当你确实需要重武器时,这一卷让你能读懂它。下次调试一个 KSP 处理器或 Babel plugin,你不会再把它当黑魔法 —— 你知道它在编译期把 AST 变来变去,你知道它为什么只能生成新文件,你知道去哪里找它的「Macro Stepper」(看它生成的中间产物)。祛魅本身就是生产力。
卷 V 结束
四章下来,你走完了这本书的核心论点:
- 展开期(第 17 章):宏活在求值之前的另一个时间
- syntax-rules(第 18 章):模式 → 模板,写你自己的控制结构
- 卫生(第 19 章):编译器认的是出身不是名字 —— 宏因此可组合
- syntax-case(第 20 章):展开期跑任意代码,把 bug 提前到编译期
这就是第 1 章那句话的兑现:「宏不是高级技巧,是语言设计权的下放。」你现在真的信了 —— 因为你亲手加过 while、加过 unless、造过编译期检查。「内置」和「用户定义」之间那堵墙,在 Racket 里是不存在的。
而在最后一卷,我们要做这本书从第 1 章就承诺的那件终极的事。
不是改造那台求值机器了 —— 是从零造一台。用 Racket 写一个能跑的 Racket。一百行。
然后你会明白,为什么这件事被称为整个计算机科学里最深刻的一跳。