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

什么时候不要写宏

上一章讲了宏能做什么。这一章讲它会怎么咬你。一个两行的宏,在测试里完美工作,上线后给出错误答案——唯一的区别是调用方碰巧用了一个叫 x 的变量。

变量捕获gensym数据 > 函数 > 宏

▷ 先猜一下

一个宏,想在展开出来的代码里用一个临时变量,随手起名叫 x

(defmacro dirty [expr]
  (list 'let ['x 1] ['x expr]))

然后调用它。注意调用方有一个叫 x 的变量:

(let [x 99]
  (dirty x))

问:结果是什么?

A [1 99]——宏里的 x 是 1,传进去的 x 是 99 B [1 1] C [99 99] D 报错:x 重复定义

展开一看就明白了

答案是 B:[1 1]。你想要的 99 不见了。

把展开的结果写出来,问题一目了然:

你写的:      (let [x 99] (dirty x))

宏展开后:    (let [x 99]
                (let [x 1]        ← 宏引入的 x
                  [x x]))         ← 这两个 x 现在都指向 1
                       ▲
                       └── 你传进来的那个 x,本来该是 99,
                           但它被展开位置上的 x 挡住了

你传进去的不是值 99,是符号 x(第 19 章:宏拿到的是没求值的代码)。 而这个符号被贴到了宏的 let 里面, 于是它解析成了宏自己那个 x,而不是你的。

✎ 术语正名

变量捕获(variable capture): 宏引入的名字,意外地遮住了调用方的同名变量。

它的可怕之处在于触发条件是「调用方碰巧用了同一个名字」。 你写测试时用 result,同事在业务代码里用 x—— 于是它在你的测试里永远是对的,在生产环境里错。 而且不报错,只是算出一个不对的值。

解法:让名字保证没人用过

Clojure 的解法很直接:在语法引号里, 名字后面加一个 #,编译期自动换成一个独一无二的名字。

(defmacro clean [expr]
  `(let [x# 1] [x# ~expr]))

(let [x 99] (clean x))    ;; => [1 99]   ✓

展开看看它换成了什么:

(macroexpand-1 '(clean x))
;; => (clojure.core/let [x__2 1] [x__2 x])
;;                        ▲            ▲
;;                        │            └─ 你的 x,没被碰
;;                        └─ 自动生成的名字,保证不撞

这个机制叫 gensym(generated symbol)。 每次展开都会生成一个全新的编号,所以不可能和任何人的变量重名—— 连宏嵌套调用自己都不会撞。

顺带说一句语法引号的另一半保护:它会把符号解析成全限定名

`(let [x# 1] ...)
;;  ↑ 展开成 clojure.core/let,不是裸的 let

所以就算调用方自己定义了一个叫 let 的函数, 你的宏也不会被劫持。这两条加起来,让 Clojure 的宏 「默认是卫生的」——只要你老老实实用语法引号和 #

◆ 可以带走的判断

宏是把代码到调用处。贴过去之后, 它就和那里的名字挤在同一个作用域里了。

函数没有这个问题——函数有自己的作用域, 它内部叫什么名字,调用方永远看不见。 这就是「能用函数就别用宏」最具体的理由。

数据 > 函数 > 宏

这是 Clojure 社区最重要的一条经验法则,值得单独讲。它说的是: 同一件事,优先用左边的方式表达。

举个具体的例子。你要做一个「路由表」:

;; 三档写法,从好到差

;; ① 数据
(def routes
  {"/users"     handle-users
   "/users/:id" handle-user})

;; ② 函数
(defn make-router [routes] (fn [path] ...))

;; ③ 宏
(defroutes app
  (GET "/users" [] ...)
  (GET "/users/:id" [id] ...))

为什么数据最好?因为数据能被程序处理

  • 想列出所有路由做一份文档?(keys routes)
  • 想在测试里替换掉某个路由?(assoc routes ...)
  • 想从配置文件读路由?直接读,它本来就是数据。
  • 想检查有没有重复路由?普通的 map 操作。

换成宏之后,这些全都做不了了——那些路由被编译进代码里了, 你没法在运行期看见它们、遍历它们、修改它们。 你只能再写一个宏去处理那个宏,然后就滑进去了。

这条排序的另一个说法是:宏把信息从运行期挪到了编译期, 而挪过去之后就拿不回来了。只有当你确实需要编译期的能力 (控制求值、拿到代码写法)时,这笔交易才划算。

一份检查清单

你想写宏的时候,按顺序问自己:

1. 能不能用数据表达?        能 → 用数据,别写代码
2. 能不能用函数?            能 → 用函数
   (所有参数都该被求值吗?   都该 → 那就是函数)
3. 是不是只想少打几个字?    是 → 别写。可读性不值这个价
4. 真的需要控制求值时机?    是 → 写宏,用 ` 和 #
5. 需要拿到「代码写成什么样」?是 → 写宏

写完再问:
6. 我的宏能不能只做一层薄壳,把活交给一个函数?   ← 最佳实践

第 6 条是老手的做法:宏只负责「延迟求值」这一件事, 真正的逻辑写在普通函数里。

;; 逻辑在函数里,可测试、可传递、可复用
(defn with-timing* [label f]
  (let [start (System/nanoTime)
        result (f)]
    (println label (/ (- (System/nanoTime) start) 1e6) "ms")
    result))

;; 宏只是一层壳:把代码包成函数,好推迟执行
(defmacro with-timing [label & body]
  `(with-timing* ~label (fn [] ~@body)))

这样一来,那个宏只有两行、没有任何逻辑、几乎不可能出错, 而所有实际工作都在一个可以单独测试的函数里。

▸ 在现实里

变量捕获不是 Lisp 独有的怪癖,它是所有「文本替换」型抽象的通病:

  • C 的宏#define MAX(a,b) ((a)>(b)?(a):(b))—— 写成 MAX(i++, j++) 就会自增两次。 C 的宏比 Lisp 的更危险,因为它连语法结构都不认识,纯文本替换。
  • Shell 脚本:变量没加引号就展开, 遇到带空格的文件名就散架。同一类问题。
  • SQL 拼接:SQL 注入的本质就是变量捕获—— 你以为传进去的是一个值,实际它被当成了代码。

解法也都是同一个方向:让「值」和「代码」的边界不可被跨越。 参数化查询之于 SQL,就是 gensym 之于宏。

✗ 这个直觉是错的

「Clojure 的宏是卫生的,所以不会有变量捕获的问题, 随便写就行。」

Clojure 的宏是「用对了就卫生」, 不是 Scheme 那种语言强制的卫生宏。

上面那个 dirty 就是反例:它用 list 手工拼代码, 绕过了语法引号,于是捕获照样发生。

Clojure 的选择是:给你一个默认安全的工具(`#), 但不禁止你手工拼代码——因为有时候你确实需要故意捕获 (比如 if-let 这类要引入绑定的宏)。 自由和保护,它选了自由加一个好用的默认值。

◇ 另存一版

答案是 B:[1 1]。宏引入的 x 挡住了调用方的 x

A 「[1 99]」——这是你想要的答案, 也是用 x# 之后会得到的答案。它错在假设「宏里的名字和外面的名字是隔开的」, 而宏根本没有自己的作用域:它是把代码贴过去,不是调用过去

C 「[99 99]」——反了。内层的 let 后绑定, 所以赢的是宏里那个 1。

D 「报错」——不会。内层 let 遮住外层是完全合法的, 你每天都在依赖这个规则。正因为它合法,这个 bug 才没人拦得住。

你进来时:宏更强大,所以能用宏的地方用宏更好。

你出去时:宏是把代码到调用处, 于是它和调用处的名字挤在一起;它不能当值传、会传染重编译、调试更难。 排序是数据 > 函数 > 宏,而且宏最好只做一层薄壳。

上一章关于「宏能做什么」的结论一个字没变—— ->with-open 还是好东西。 这一章只重建了一条路径:什么时候该动用它

这一章的一句话

宏把代码贴到调用处,于是它和那里的名字挤在一起。 能用数据就别写函数,能用函数就别写宏。

卷 V 结束。卷 VI 全是手上的活。 下一章那台 demo 是这本书里我最想让你去玩的一个: 一台真的 REPL。你会定义一个函数、用它、 在程序还活着的时候把它换掉、再用一次—— 没有重启,没有重新编译,没有丢掉任何状态。