卷 V · 扩展CH 19深度 19/24

宏:在编译期改写代码

第 4 章说括号买到了一张票,这一章去兑现。宏被神化得厉害,其实它朴素得让人失望:一个形式进、形式出的普通函数,只不过它跑在求值之前,而且拿到的参数是还没求值的代码。

形式进,形式出语法引号宏 vs 函数

▷ 先猜一下

假设 Clojure 没有 when,你想自己写一个: 「条件为真时,依次执行后面所有表达式」。

你先试着用函数写:

(defn my-when [test & body]
  (if test (last body) nil))

(my-when false (println "boom") :done)

问:会打印出 boom 吗?

A 不会,test 是 false,函数体里的 if 走了 nil 分支 B 会 C 不会,但会报错 D 取决于 Clojure 的求值顺序设置

函数为什么不够

答案是 B:会打印

原因是函数调用的铁律:所有参数在进入函数之前就求好值了。 所以 (println "boom")my-when 的第一行代码执行之前 就已经跑完了,函数体里那个 if 完全来不及阻止它。

这一点在任何语言里都一样。想想 Java:

boolean myAnd(boolean a, boolean b) { return a && b; }

myAnd(false, expensive());   // expensive() 照样会被调用

这就是为什么 &&||?:if 在 Java 里必须是语法, 不能是方法——它们要求「有些参数可能根本不该被求值」。

◆ 可以带走的判断

凡是「某个参数可能不该被求值」或者「我想拿到参数写成什么样」的地方, 函数就无能为力了。其他语言的解法是把这些做成内置语法(你加不了), Clojure 的解法是给你一个能加语法的口子。

宏就是那个口子

(defmacro my-when [test & body]
  (list 'if test (cons 'do body) nil))

只改了一个字:defndefmacro。而语义变了两处:

  1. 参数不求值了。body 拿到的是 ((println "boom") :done) 这个列表, 里面装着还没跑的代码。
  2. 返回值不是结果,是一段代码。 Clojure 会拿这段代码替换掉原来的调用,然后再求值。
你写的:      (my-when false (println "boom") :done)
                            ↓ 宏展开(编译期)
变成:        (if false (do (println "boom") :done) nil)
                            ↓ 求值(运行期)
结果:        nil,而且没有打印

下面这台是真的展开器。输入任何宏调用,它会给你展开一层、展开到底、以及最终结果:

语法引号:把代码写得像代码

上面那个 (list 'if test (cons 'do body) nil) 能用,但难读—— 你得在脑子里把它拼回一段代码。Clojure 提供了一个「按模板拼代码」的写法:

(defmacro my-when [test & body]
  `(if ~test (do ~@body) nil))

三个记号,够用九成的宏:

记号   名字         意思
──────────────────────────────────────────────────────
`      语法引号     下面这一整块都是代码模板,别求值
~      unquote      除了这个,这个要求值,把结果填进来
~@     unquote-拼接 这个是个列表,把它的元素<摊开>填进来

就是字符串模板,只不过填的是代码而不是文字。 ~@ 那个「摊开」很关键:body 是一个列表, 你要的是把它的元素一个个放进 do 里,而不是把整个列表塞进去。

看几个你天天在用的宏

Clojure 里大量「看起来像语法」的东西,其实都是宏。用展开器验一下:

(macroexpand-1 '(when a b))
;; => (if a (do b))

(macroexpand-1 '(-> 5 inc (* 2)))
;; => (* (inc 5) 2)

(macroexpand-1 '(->> [1] (map inc)))
;; => (map inc [1])

第二行值得盯一会儿。-> 这个你从第 6 章起就在用的「线程宏」, 展开之后什么新东西都没有——它只是把 (-> 5 inc (* 2)) 重新排成了 (* (inc 5) 2)

没有运行期开销,没有新语义,没有魔法。它纯粹是一个代码变形器, 解决的是「嵌套调用要从里往外读」这个可读性问题。

这就是宏最典型、也最健康的用法:让代码读起来顺,而不改变它做什么

那什么时候写宏

只有三种情况,记住这三条能省掉你很多麻烦:

  1. 需要控制求值时机。某个参数不该被求值,或者要被求值多次。 whenandorwith-open(自动关资源)都是这类。
  2. 需要拿到代码本身。比如断言库: (is (= 1 (+ 0 1))) 失败时能打印出 「期望 (= 1 (+ 0 1)) 成立,实际左边是 1 右边是 1」—— 它把表达式的写法也打印出来了,只有宏能做到。
  3. 需要引入新的绑定形式。比如 let 那样的东西。

其余所有情况——包括你现在想到的那个——都应该用函数。 下一章会专门讲为什么。

☕ 写 Java 的你

你能想到的最接近的东西是注解处理器(Lombok、KSP、Dagger)。 它们确实在编译期生成代码,做的事情是同一类。差别在票价:

  • 要单独的构建配置、单独的处理器项目、单独的调试方式。
  • 用的是编译器的 AST API,和你写业务的语言是两套东西
  • IDE 里经常看不见生成的代码,出错时的信息很难读。

Clojure 的宏用的就是那门语言本身,写在同一个文件里, macroexpand-1 随时能看展开结果。

这不是说 Clojure 赢了。注解处理器的门槛高, 某种意义上是件好事——它让「生成代码」这件事有仪式感, 不会有人随手写一个。而宏太便宜了,于是滥用的诱惑也大得多。 这正是下一章的主题。

▸ 在现实里
  • with-open:自动关闭资源, 展开成 try ... finally (.close ...)。 Java 得等到 7 才有 try-with-resources, 而且那是改语言才做到的;在 Clojure 里它是库里的十行代码。
  • 测试断言clojure.testis 能报告「你写的表达式长什么样」,靠的就是宏。
  • 路由 DSL / 查询 DSL: 把 (GET "/user/:id" [id] ...) 这种写法展开成实际的处理函数。
✗ 这个直觉是错的

「宏是高级技巧,能力比函数强,所以能用宏的地方用宏更好、更高效。」

宏在另一个时间工作,不是「更强的函数」。 而且它有三个函数没有的硬缺陷:

  • 不能当值传。(map when xs) 是错的—— 宏在运行期根本不存在,它在编译期就被展开没了。
  • 会传染。调用宏的代码必须在宏定义之后编译; 改了宏,所有用到它的代码都得重新编译。
  • 调试更难。你在报错信息里看到的是展开之后的代码, 和你写的对不上。

社区那句「能用函数就别用宏」不是保守,是这三条的直接后果。 还有一个更精确的版本:数据 > 函数 > 宏—— 能用数据表达的别写成函数,能用函数的别写成宏。

◇ 另存一版

答案是 B:会打印 boom。 因为参数在进入函数之前就求好值了。

A 「if 走了 nil 分支所以不打印」—— 函数体里的 if 确实走了 nil 分支, 但那已经太晚了:println 在函数被调用之前就跑完了。 这道题真正想让你注意的是「太晚了」这三个字—— 函数能控制的只有「返回什么」,控制不了「参数要不要算」。

C 「会报错」——不会。它是一段完全合法的代码, 只是行为不是你想要的。这类「合法但不对」比报错危险得多。

D 「取决于求值顺序设置」——没有这种设置。 Clojure 和 Java 一样是严格求值(参数先算完), 这不是可配置项,而正是宏存在的理由。

你进来时:宏是 Lisp 的神秘武器,很强,也很危险。

你出去时:宏是一个形式进、形式出的普通函数, 跑在求值之前;它的能力只有一样——决定参数要不要被求值、怎么被重排

第 4 章那张票在这里兑现了: 因为代码是数据,所以「改写代码」只需要普通的数据操作。 这一章只重建了一条路径:那张票能换什么

这一章的一句话

宏在求值之前跑,拿到的是没求值的代码,吐出来的还是代码。 它唯一的能力是控制求值时机和重排代码——不是「更强的函数」。

下一章把账单摆出来。我们会写一个只有两行的宏, 它在测试里完美工作,在生产环境里给出 [1 1] 而不是 [1 99]—— 唯一的区别是调用方碰巧用了一个叫 x 的变量。