卫生:C 宏为什么是灾难,Racket 为什么不是
「宏 = 邪恶」这个偏见流传了几十年。这一章要告诉你:它冤枉的是 C 的宏,不是宏本身。而 Racket 解决这个问题的方式,会颠覆你的直觉 —— 我用 Racket v9.2 亲手展开给你看:Racket 展开出来的代码,字面上就是那个有 bug 的 C 宏版本,一字不差,连变量名都没改。但它是对的。为什么?因为编译器判断「两个变量是不是同一个」,靠的根本不是名字。看懂这一章,你就摸到了 Rust、Scala、乃至现代所有宏系统的设计底线。
先复现那个灾难
第 1 章题 7,那个 C 宏:
#define SWAP(a, b) { int tmp = a; a = b; b = tmp; }
int tmp = 1, y = 2;
SWAP(tmp, y);
printf("%d %d\n", tmp, y); // 输出: 1 2 —— 没换成!零警告
展开后:
{
int tmp = tmp; // ← 宏的 tmp「捕获」了调用者的 tmp
tmp = y; // ← 改的是内层那个
y = tmp;
}
宏作者用了个临时变量 tmp,调用者碰巧也有个 tmp。宏里的 int tmp 把外面的遮住了,整段代码在错误的变量上忙活。这就是变量捕获。
C 程序员的「解法」是把临时变量取个怪名字 __swap_tmp_x9z_,然后祈祷没人重名。这不是解法,是希望。
Racket 的同一个宏
现在看 Racket 版本。结构一模一样,同样用了个叫 tmp 的临时变量:
(define-syntax-rule (swap! a b) (let ([tmp a]) (set! a b) (set! b tmp))) ; 调用者的变量也叫 tmp: (let ([tmp 1] [y 2]) (swap! tmp y) (list tmp y)) ; ⇒ '(2 1) ← 对了!换成了。
它是对的。换成了。可我们用的是同样的宏、同样的重名。C 崩了,Racket 没崩。
你的第一反应大概是:「Racket 一定是偷偷把宏里的 tmp 改名了。」这是几乎所有教程会告诉你的模型 —— 而它是错的。
真相:看看它真的展开成了什么
我用 Racket 的 expand-once 把它真的展开出来打印(不是我编的,是 Racket v9.2 的输出):
(let ([tmp 1] [y 2])
(let ([tmp tmp]) ← 字面上!一!模!一!样!
(set! tmp y)
(set! y tmp))
(list tmp y))
Racket 展开出来的东西,和那个有 bug 的 C 版本字面上完全相同。tmp 一个字母都没改。里层的 (let ([tmp tmp]) ...) 看起来就该「捕获」外层的 tmp,就像 C 那样。
可它跑出来是对的 (2 1)。
所以卫生不是靠改名字实现的。那个「Racket 会把 tmp 改成 tmp.1」的模型,解释不了你眼前这个展开结果 —— 因为这里根本没有 tmp.1,只有两个一模一样的 tmp。
那它到底是怎么做到的?
编译器认的不是名字,是「出身」
Racket 里,每一个标识符不只是一个名字。它还随身带着一个不可见的标签 —— 它的词法上下文,记录着「我是在哪里被写下来的」。
宏展开时,宏定义处写的 tmp,带着标签「我来自 swap! 的定义」。调用者写的 tmp,带着标签「我来自使用处」。
编译器判断「两个变量是不是同一个」,比的是标签,不是拼写。标签不同 ⇒ 两个不同的变量,哪怕名字一模一样。
不信?这是 Racket 自己的回答。我把展开后那两个 tmp 揪出来,问 Racket 它们是不是同一个:
; 拼写一样吗? (eq? (syntax->datum 宏的-tmp) (syntax->datum 用户的-tmp)) ⇒ #t ; 「名字完全相同」 ; 是同一个绑定吗? (bound-identifier=? 宏的-tmp 用户的-tmp) ⇒ #f ; 「不是同一个变量」
拼写相同(#t),但不是同一个变量(#f)。这就是卫生。编译器看得见那个你看不见的标签。
注意上面第一行那个 syntax->datum。它把标识符身上的标签丢掉了,只留下拼写。
所以你看到的 (let ([tmp tmp]) ...) 那个展开结果,是一张褪了色的照片 —— expand-once 为了打印给人看,不得不用 syntax->datum 把标签抹掉。真正在编译器内存里的那棵树,每个 tmp 都还带着各自的出身。
这就是为什么「用眼睛读展开结果」会骗你,而 Macro Stepper 不会(第 2 章)——它在展开轨迹里用 tmp:1、tmp:3 这样的后缀,把那个隐形标签显示出来了。那些数字不是改名,是标签的可视化。
亲手做几个捕获实验
下面这台机器,让你看三个宏在「如果是文本替换」和「Racket 真实展开」下的对比。特别注意:右边那栏(Racket 真实展开)字面上和左边(会出 bug 的文本替换)是一样的,但结果不同。
卫生是双向的
卫生保护的不只是「宏不污染调用者」,还有反过来:宏里用到的名字,指向宏定义处的含义,不受调用处的干扰。
(define-syntax-rule (my-inc x) (+ x 1)) ; ↑ 这个 + 指的是 swap! 定义时那个 + ; 就算调用者把 + 重新定义成减法: (let ([+ -]) ; 疯狂,但合法 (my-inc 10)) ; ⇒ 11,不是 9! ; 宏里的 + 还是那个加法,因为它带着「我来自宏定义处」的标签
| 方向 | 卫生保证 |
|---|---|
宏引入的绑定(swap! 里的 tmp) | 不会捕获调用者的同名变量 |
宏引用的名字(my-inc 里的 +) | 指向宏定义处的含义,不被调用处重定义干扰 |
两者都源于同一件事:每个标识符记得自己的出身。宏引入的标识符,出身是「宏定义处」;调用者写的,出身是「使用处」。井水不犯河水。
这个性质叫「引用透明性」(referential transparency,注意和第 16 章那个同名但不同义)—— 它让宏成为一个可以被独立推理的、封装的抽象,而不是一段会和环境相互渗透的文本。
为什么这是一件大事
没有卫生,宏是不可组合的。你不敢在宏里调用别的宏,因为你不知道它们用了什么临时变量名,会不会撞。你不敢把宏给别人用,因为你不知道调用者会用什么变量名。每个宏都是一颗地雷,而地雷是不能拼在一起的。
有了卫生,宏变成了真正的抽象。你可以放心地嵌套宏、组合宏、把宏做成库分发 —— 就像你放心地组合函数一样。名字冲突这个整类问题,在语言层面消失了。
Racket 之所以敢把「几乎整门语言都用宏实现」(第 17 章:连 let、and、cond 都是宏)当成可行的工程策略,前提就是卫生。没有卫生,这么多宏堆在一起早就名字打架、天下大乱了。卫生不是一个便利特性,它是「大规模用宏」这件事能成立的地基。
回流
① 认识这条谱系 —— 它决定了一个宏系统「能信到什么程度」:
| 系统 | 卫生程度 | 后果 |
|---|---|---|
| C/C++ 预处理器 | 零卫生(纯文本) | 变量捕获、多次求值、括号地狱。「宏 = 邪恶」的来源 |
| Racket / Scheme | 完全卫生 | 宏可组合、可分发、可大规模使用 |
Rust macro_rules! | 部分卫生 | 局部变量卫生(抄自 Scheme),但类型名/函数名不卫生 —— 一个务实的折中 |
| Scala 3 macros / quotes | 卫生 | 基于类型的、卫生的元编程 |
| Java 注解处理 / Kotlin KSP | 不适用(不生成到同作用域) | 用「只能新增文件、不能改现有代码」来回避整个问题 |
看最后一行 —— 这是一个深刻的设计选择。KSP / 注解处理器故意限制自己「只能生成新文件,不能修改你现有的代码」。为什么?因为一旦能修改现有代码,就要面对卫生问题(生成的代码里的变量会不会和你的撞),而 Java 生态选择不趟这滩水,用限制换简单。这也解释了为什么这些工具用起来总感觉「隔一层」——它们不能真的融入你的代码,只能在旁边生成伴生文件。
② 一个能直接用的判断框架:评估任何元编程 / 代码生成方案时,问三个问题:
- 它卫生吗?生成的代码里的名字,会不会和我的代码撞?(不卫生 → 你要手动加前缀祈祷)
- 它能组合吗?两个这样的东西一起用会打架吗?(不能组合 → 你不敢在一个项目里用两个)
- 它在哪个阶段、能看见什么?(编译期看代码结构,运行期看值 —— 第 17 章)
这三个问题能让你在「用注解处理器 vs 用编译器插件 vs 用运行时反射 vs 干脆用函数」之间做出有依据的选择,而不是凭感觉。
③ 最实在的一条:下次有人跟你说「宏很危险,别用」,你能分清他说的对不对了。如果他指的是 C 宏 —— 对,躲远点。如果他把这个判断不加区分地套到 Rust 宏、Racket 宏、Scala 宏上 —— 他停留在了 1980 年代。危险的从来不是「宏」这个概念,是「不卫生的文本替换」这个实现。
下一章
到这里,你会写卫生的 syntax-rules 宏了 —— 它覆盖了绝大多数需求。但它有个天花板:它是「模式 → 模板」的,你没法在展开时运行任意代码(比如「如果这个参数是偶数就这样、否则那样」,或者「编译期查一张表」)。
下一章我们捅破这个天花板:syntax-case 和过程宏 —— 展开时可以跑任意 Racket 代码的宏。然后,我们用它来看清一件你一直好奇的事:KSP、Babel plugin、Kotlin 编译器插件,为什么写起来那么痛苦?