卷 V · 宏CH 17深度 17/25

展开时:求值之前还有一个世界

从这一卷开始,我们不再理解那台求值机器,而是改造它。这就是第 1 章反复预告的主题:语言设计权的下放。但在写第一个宏之前,你必须先接受一件反直觉的事:宏不是「花哨的函数」。它活在一个和你的普通代码不同的时间里 —— 一个在求值之前就已经结束了的世界。这一章我会给你一个能亲眼看见这个时间的证明,然后你就再也不会把宏和函数搞混了。

展开期编译期阶段define-syntax

先看见那两个时间

看这段代码。它定义了一个宏,宏里既有一句在编译期跑的打印,又有一句在运行期跑的打印:

#lang racket

(define-syntax (show-time stx)
  (printf "  [这行在<编译期>打印]\n")        ; ← 宏展开时执行
  #'(printf "  [这行在<运行期>打印]\n"))       ; ← 宏生成的代码,之后才执行

(displayln "定义完宏了。下面调用它:")
(show-time)

猜一下打印顺序。按代码从上到下,你可能觉得是「定义完宏了 → 编译期 → 运行期」。实际跑出来是:

  [这行在<编译期>打印]          ← 它跑到最前面去了!
定义完宏了。下面调用它:
  [这行在<运行期>打印]
◆ 那句「编译期」的打印,插到了所有东西前面

它在代码里明明写在 (displayln "定义完宏了") 后面,却打印。为什么?

因为它属于另一个时间。Racket 运行你的程序,分成清清楚楚的两个阶段:

  1. 展开期(编译期):把所有的宏调用全部展开完define-syntax 里的代码就在这时候跑
  2. 运行期:展开完的、干干净净的代码,从头开始求值。

第一阶段整个结束了,第二阶段才开始。所以任何编译期的动作,都必然排在任何运行期动作之前 —— 不管它们在源码里的先后。

这就是「宏活在另一个时间」的字面意思。它不是比喻。

宏和函数,到底差在哪

把它们并排钉死:

函数
什么时候运行运行期展开期(运行期之前)
输入是什么(参数已经被求值了)代码(未求值的语法树)
输出是什么一个一段代码(会替换掉原来的调用)
能不能不求值某个参数不能(应用序,全求值)(它拿到的是代码,爱怎么处理怎么处理)
能不能引入新语法不能
◆ 一句话抓住区别

函数在运行期处理值。宏在展开期处理代码

还记得第 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 分支。那段代码从来没被求值过。

宏能做到函数永远做不到的事,唯一的原因就是:它在参数被求值之前就介入了。它拿到的是代码,不是值 —— 于是它有权决定这些代码要不要怎么被求值。

这就是「语言设计权」的物质基础。语言内置的 ifandwhile 之所以能「特殊」,就是因为它们在这个更早的时间点上工作。而宏,把这个时间点开放给了你。

宏能做的三件函数做不到的事

把宏的能力具体化。它能做三类事:

① 推迟 / 跳过求值(控制流)

; 造一个 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!,你其实已经会了。