宏:在编译期改写代码
第 4 章说括号买到了一张票,这一章去兑现。宏被神化得厉害,其实它朴素得让人失望:一个形式进、形式出的普通函数,只不过它跑在求值之前,而且拿到的参数是还没求值的代码。
假设 Clojure 没有 when,你想自己写一个:
「条件为真时,依次执行后面所有表达式」。
你先试着用函数写:
(defn my-when [test & body] (if test (last body) nil)) (my-when false (println "boom") :done)
问:会打印出 boom 吗?
函数为什么不够
答案是 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))
只改了一个字:defn → defmacro。而语义变了两处:
- 参数不求值了。
body拿到的是((println "boom") :done)这个列表, 里面装着还没跑的代码。 - 返回值不是结果,是一段代码。 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)。
没有运行期开销,没有新语义,没有魔法。它纯粹是一个代码变形器, 解决的是「嵌套调用要从里往外读」这个可读性问题。
这就是宏最典型、也最健康的用法:让代码读起来顺,而不改变它做什么。
那什么时候该写宏
只有三种情况,记住这三条能省掉你很多麻烦:
- 需要控制求值时机。某个参数不该被求值,或者要被求值多次。
when、and、or、with-open(自动关资源)都是这类。 - 需要拿到代码本身。比如断言库:
(is (= 1 (+ 0 1)))失败时能打印出 「期望(= 1 (+ 0 1))成立,实际左边是 1 右边是 1」—— 它把表达式的写法也打印出来了,只有宏能做到。 - 需要引入新的绑定形式。比如
let那样的东西。
其余所有情况——包括你现在想到的那个——都应该用函数。 下一章会专门讲为什么。
你能想到的最接近的东西是注解处理器(Lombok、KSP、Dagger)。 它们确实在编译期生成代码,做的事情是同一类。差别在票价:
- 要单独的构建配置、单独的处理器项目、单独的调试方式。
- 用的是编译器的 AST API,和你写业务的语言是两套东西。
- IDE 里经常看不见生成的代码,出错时的信息很难读。
Clojure 的宏用的就是那门语言本身,写在同一个文件里,
macroexpand-1 随时能看展开结果。
这不是说 Clojure 赢了。注解处理器的门槛高, 某种意义上是件好事——它让「生成代码」这件事有仪式感, 不会有人随手写一个。而宏太便宜了,于是滥用的诱惑也大得多。 这正是下一章的主题。
with-open:自动关闭资源, 展开成try ... finally (.close ...)。 Java 得等到 7 才有 try-with-resources, 而且那是改语言才做到的;在 Clojure 里它是库里的十行代码。- 测试断言:
clojure.test的is能报告「你写的表达式长什么样」,靠的就是宏。 - 路由 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 的变量。