卷 V · 宏CH 20深度 20/25

更大的力量:syntax-case 与编译器插件的真相

syntax-rules 是「模式 → 模板」,它有个天花板:展开时你不能运行任意代码这一章捅破它 —— syntax-case 让宏在展开时跑完整的 Racket,于是你能在编译期算斐波那契、能把一个除零 bug提前到编译期报错。然后,带着这份理解,我们回答第 1 章题 8 那个悬了整本书的问题:KSP、Babel plugin、Kotlin 编译器插件,为什么写起来那么痛苦?答案是,它们在做的事和 Racket 的宏完全一样 —— 只是缺了三样 Racket 免费给的东西。

syntax-case过程宏编译期检查KSP编译器插件

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 的分工
syntax-rulessyntax-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 免费给的三样东西:

◆ KSP / Babel plugin 痛在哪:缺的三样东西
Racket 免费给的KSP / Babel 里的替代品痛在哪
代码 = 数据(同像性)一整套「代码的影子类型」:KSClassDeclarationKSFunctionDeclaration;Babel 的 AST 节点类型你要先学会这套庞大的、和普通数据不同的类型体系,才能开始干活
造代码 = 造数据(quasiquote)KotlinPoet 的 FunSpec.builder().addStatement("...");Babel 的 t.binaryExpression(...)你在用字符串或构造器拼代码,一个节点一个节点砌。语法错误要到最后才发现
卫生没有 —— 靠「只能生成新文件,不能改现有代码」来回避问题生成的代码是隔一层的伴生文件,不能真正融入你的代码

看清这三行,你就彻底理解了那股「写 KSP 时的烦躁」的确切来源。它不是因为你笨,也不是因为 KSP 设计得差(它在给定约束下其实做得很好)。它是因为这些工具在一门「代码 ≠ 数据」的语言里,努力模拟一件「代码 = 数据」才自然的事。每一处别扭,都是那堵不该存在的墙(第 4 章)投下的影子。

◆ 一个诚实的补充:Racket 的宏也不是没有代价

不能只说好话。完整的 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。一百行。

然后你会明白,为什么这件事被称为整个计算机科学里最深刻的一跳