Clojure 宏编写终极教程:unless 与 bench 宏的 4 步演进全过程
【免费下载链接】programming-clojureSample code for the book项目地址: https://gitcode.com/gh_mirrors/pr/programming-clojure
在开源项目programming-clojure(《Programming Clojure》一书的官方示例代码库)中,藏着两条最经典的 Clojure 宏(Macro)演进路线:unless与bench。本教程带你像翻书一样,逐版本走一遍这两个宏从"写错的函数"到"正确宏"的完整演变,帮你彻底搞懂 defmacro 背后的求值顺序、引用(quote)与反引用(unquote)三大核心机制——这是所有 Clojure 宏编写教程中最值得细读的一段源码。
一、为什么普通函数写不出 unless?
🤔 直觉上,unless只是"取反的 if",一个函数就够了对吧?项目里的第一个版本正是这么想的:
(defn unless [expr form] (if expr nil form))问题出在求值时机:Clojure 函数参数是"先求值、再传参"的。当写成(unless-1 true (throw (Exception.)))时,form里的异常会在进入函数之前就抛出,取反逻辑根本救不了场。
作者很聪明地加了一行println "About to test..."(第 2 版)来直观证明:参数一定在测试条件之前就被执行了。对应的测试也精确锁定了这一行为:
- 演进版源码:src/examples/macros.clj
- 行为验证测试:test/examples/test/macros.clj
结论:任何需要"控制代码何时、是否被求值"的场景,函数都不够用,必须用宏。
二、defmacro 登场:两行代码修复求值顺序
defn改成defmacro,世界立刻不同:
(defmacro unless [expr form] (list 'if expr nil form))宏接收的是未求值的代码,返回值则是"展开后的新代码"。测试(is (thrown? Exception (unless false (throw (Exception.)))))从此稳稳通过,而(unless true (throw ...))则什么都不做——延迟求值,正是宏超能力的来源(src/examples/macros.clj)。
三、陷阱实演:bad-unless 教你什么叫"引用丢失"
项目里藏着一个故意写坏的bad-unless:
(defmacro bad-unless [expr form] (list 'if 'expr nil form))多了一个对expr的引用(quote),导致展开结果里出现了字面符号 expr而不是你传入的实际代码。用macroexpand-1一眼看穿(test/examples/test/macros.clj):
(macroexpand-1 '(examples.macros/bad-unless false :foo)) ;; => (if expr nil :foo) ← 参数被"吞"了💡 记住这个调试大招:macroexpand-1 是宏调试的第一工具。而list+ 手动引用的写法,很快就让位于更优雅的反引号与反引用——这正是下面 bench 宏演进的主线。
四、bench 宏的 4 步演进:从命名冲突到自动符号
第 1 步:裸符号的失败(bench-1)
最初的想法是让 bench 宏给表达式计时:
(defmacro bench [expr] `(let [start (System/nanoTime) result ~expr] {:result result :elapsed (- (System/nanoTime) start)}))它"看起来"能跑,实则暗坑重重:start、result、System都是裸符号,会被自动挂上宏所在命名空间,if/let/-也可能与用户命名空间的同名符号冲突。测试用macroexpand-1把展开结果逐字符比对,把这个坑暴露得淋漓尽致(src/examples/macros/bench_1.clj、test/examples/test/macros/bench_1.clj)。
第 2 步:自动生成的符号(终极形态)
(defmacro bench [expr] `(let [start# (System/nanoTime) result# ~expr] {:result result# :elapsed (- (System/nanoTime) start#)}))符号名后加#就是 Clojure 的自动 gensym:每次展开都生成独一无二的内部变量名,彻底杜绝与调用方代码撞名(src/examples/macros.clj)。一行#,就是新手与合格宏作者的分水岭。
番外:函数式 bench 与"邪恶"写法
项目还附赠了一个纯函数版bench-fn(把无副作用的计时封装成可复用函数,src/examples/macros.clj),以及一个演示"手动引用符号"写法的evil-bench——它能工作,却把符号处理全交给作者手工管理,可读性和安全性都不如 gensym 方案。
五、彩蛋:chain 宏展示反引用与拼接的完整进化
同目录下的五个chain_*文件,是 quote / unquote / splice-unquote 的微型进化史,每个文件只改一行:
- 基础版:
list+ 手动引用,实现..宏原型 - 多参重载:变参收集后续调用
- 反引用登场:反引号 +
~取代手工 list - 拼接翻车:变参列表未展开
- 拼接正确:
~@展开变参,递归调用完成链式
对比阅读这五份小文件,比任何理论都直观:src/examples/macros/chain_1.clj、src/examples/macros/chain_2.clj、src/examples/macros/chain_3.clj、src/examples/macros/chain_4.clj、src/examples/macros/chain_5.clj
六、上手验证:跑通全部宏测试
git clone https://gitcode.com/gh_mirrors/pr/programming-clojure cd programming-clojure && lein deps && lein test测试文件 test/examples/test/macros.clj 用macroexpand-1+are断言把每个演进版本的展开结果钉死在预期上——这就是宏编写可测试性的最佳示范。
七、核心知识点速查清单 ✅
| 演进阶段 | 关键机制 | 一句话心得 |
|---|---|---|
| unless-1/2 | 函数求值时机 | 参数先行求值,控制不了执行顺序 |
| unless-3 | defmacro + list | 宏操作的是代码本身 |
| bad-unless | 引用丢失 | 用 macroexpand-1 查展开结果 |
| bench-1 | 命名冲突 | 裸符号会跨命名空间"串味" |
| bench-2 | gensym(#) | 内部变量名必须唯一 |
| chain 系列 | ~与~@ | 反引用取值,拼接反引用展开 |
从defn到defmacro,从手工 list 到反引号模板,从裸符号到 gensym——这条不超过 60 行的代码演进路径,浓缩了 Clojure 宏编写的全部核心心智模型。打开 src/examples/macros.clj 对照阅读,你已具备写出安全、可维护宏的全部装备。🚀
【免费下载链接】programming-clojureSample code for the book项目地址: https://gitcode.com/gh_mirrors/pr/programming-clojure
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考