精通 Clojure 宏
编写更干净、更快、更智能的代码

Table of Contents

1. 各界赞誉

你以为自己了解 Clojure? 这本书将改变一切. 从其看似简单的开篇, 到极具挑战性的结尾, 书中内容将帮助你掌握这门本已十分强大的语言中最高阶的强力工具.

  • Robert C. “Uncle Bob” Martin Uncle Bob Consulting

本书对 Clojure 宏做了很好的介绍, 而非一本泛泛的 Clojure入门书籍. 书中包含了宏如何帮助提升性能和进行错误检测的实践范例. 市面上关于 Clojure 的好书很多, 但这是我所见第一本如此深入细致地探讨 Clojure 宏的著作.

  • Charles Norton Manager of Software Development, Boston, MA

Clojure 宏是一个重量级话题, 但 Colin 却能以轻松愉快的方式讲解, 且毫不避重就轻. 他分享了关键概念和宝贵思想, 这将提升你的编程技能.

  • Micah Martin President, founder, 8th Light

我写过数千行 Clojure 代码, 但对宏仍然敬而远之. Colin Jones 以简洁和幽默的方式解释了复杂的概念, 这是很少有作者能做到的. 这本书引人入胜且谦逊的文笔最终帮助我理解了宏, 足以让我在自己的代码中使用它们.

  • Eric Smith Director of Training Services

2. 致谢

我深深感谢 Bob Martin, 是他建议社区非常需要一本关于 Clojure 宏的书. 如果没有去年六月那次晚餐的交谈, 我绝不会写这本书.

我的编辑 Fahmida Y. Rashid, 一直以来都是著名的 Pragmatic 风格的洞察力来源, 我从她那里学到了很多关于写作和沟通的知识.

我很幸运一路走来有许多优秀的技术审稿人. 从 Michael Baker, Alex Baranosky, Gary Fredericks, Bob Martin, Micah Martin, Carin Meier, Connor Mendenhall, Dave Moore, Charles Norton, Nhu Nguyen, Eric Smith, Jim Suchy, Zach Tellman, 和 Ben Trevor 那里学习宏和写作的清晰性, 感觉很棒. 我要特别感谢 Gary Fredericks, Bob Martin, Micah Martin, 和 Charles Norton, 他们的反馈在深度和实质上都超出了预期.

当然, 如果没有 Clojure 的创造者 Rich Hickey, 以及在开源世界中从事和使用 Clojure 宏的许多其他人的努力, 一本关于 Clojure 宏的书就不可能存在. 他们的工作既有启发性又有指导性, 我们都很幸运能拥有它.

最重要的是, 感谢我出色的妻子 Kathy 和我可爱的儿子 Owen, 他们容忍我深夜工作和清晨早起, 并在整个过程中给予我如此多的支持.

最后, 感谢你, 亲爱的读者. 是的, 就是正在读这句话的你! 希望你喜欢这本书!

3. 引言

学习这本书是关于 Clojure 中的宏, 这可能不会让你感到惊讶. 书名打破了保守这个秘密的任何希望, 这很好. 我反正也不太喜欢惊喜.

此外, 这本书是关于 精通 宏. 现在, 精通是一个相当大胆的目标: 有人说精通任何技能都需要 10,000 小时, 这比你把这本书从头到尾背下来所需的时间要长得多. 总有更多的东西要学.

但这是通往精通之路的一部分. 所以收拾好你的行囊——这将是一次有趣的旅行!

3.1. 为何选择 Clojure?

学习 Clojure 的理由有很多. 默认保持不可变的纪律, 函数作为一等实体的简洁性, JVM 的实用性, 以及……好吧, 我可以滔滔不绝, 但这本书的目的不是向你推销 Clojure. 已经有好几本很棒的书了.

我的第一本 Clojure 书是 Programming Clojure [Hal09], 我最近的 Clojure 入门推荐是 Clojure Programming [ECG12] (相信我, 尽管名字相似, 但这实际上是两本不同的书).

The Joy of Clojure [FH11] 也非常出色, 但对于 Clojure 新手来说可能有点进阶. 这本书可能属于同一类别: 你应该已经了解一些 Clojure, 以便能从本书中获得最大收益.

因此, 为了专注和简洁, 我将假设你在本书中已经了解一些 Clojure. 也就是说, 如果你以前从未写过一行 Clojure, 别担心! 你应该暂时把这本书放在一边, 去完成 Clojure Koans1, 我会在几个小时后在这里等你回来.

我们将在这本书中讨论一些高级主题, 所以如果你还没有完成 koans, 一本书, 或一个 Clojure 项目, 你可能需要花一些额外的时间来学习本书中的某些章节, 以便更好地掌握 Clojure.

3.2. 为什么需要宏?

Clojure 站在巨人的肩膀上, 其影响来自多种函数式和面向对象的语言、数据库和分布式系统技术, 当然还有其创造者 Rich Hickey 这股巨大的自然力量. 尽管 Clojure 还很年轻, 我们已经开始看到一些跨语言社区的交叉影响.

但 Clojure 真正的杀手级特性之一是宏系统, 它在很多方面与 Common Lisp 的相似, 但也带来了自己的现代风格.

Clojure 中的宏是一个优雅的元编程系统, 是实现其他语言中看似不可能的目标的一种方式. 以库的形式为你的语言添加模式匹配或新的控制流结构(而不是修补核心语言)有多难? 在 Clojure 中, 像你我这样的人都有能力自己做这些事情.

从某种意义上说, 所有通用语言都同样强大, 这是对的, 但我们程序员知道得更清楚. 我们的限制和目标与图灵机的不同. 我们想要精简、干净的代码, 能够清晰地表达我们的意图, 同时让我们能够尽可能简洁地告诉机器该做什么. 宏是实现这一目标的一种方式.

3.3. 非 Lisp 语言中的元编程

Lisp 当然不是唯一具有元编程能力的语系. C 语言有一个宏系统, 允许在编译开始时作为预处理步骤进行文本替换. C++ 有一个复杂而强大的模板元编程特性, 它本身是图灵完备的.

许多动态语言, 如 JavaScript, Python, 和 Ruby, 都有在字符串上操作以产生结果的 eval 函数. Ruby尤其有一些不错的特性, Scala 的宏系统甚至允许操作解析树! 一旦你体验了 Clojure 的宏, 你可能就不想回去了.

你可能会注意到, 在这本书里, 我有时会提到 Clojure, 有时会提到 Lisp (因为 Clojure 是 Lisp 语系的一部分). 这应该不会太令人困惑, 但让我们花点时间澄清一下, 以防万一. 当我谈到 Lisp 时, 这个建议将适用于所有主要的 Lisp 方言 (包括 Clojure, Scheme, 和 Common Lisp). Elixir 是一个有趣的边缘案例: 它不完全是 Lisp, 但它的宏系统确实与 Lisp 的宏系统言行一致.

但仅仅因为我特别提到 Clojure, 并不意味着这个建议只适用于 Clojure. 它很可能也适用于其他 Lisp, 但不一定.

3.4. 这本书适合谁?

这本书假设你已经用 Clojure 编程有一段时间了, 但你还不是 Clojure 宏的大师. 经验丰富的 Clojure 程序员无疑会认出他们以前遇到过的情况, 而较新的 Clojure 人士将学会避免我们这些在学习宏时犯过的一些错误.

宏通常是最困难的特性之一, 这本书将帮助任何想要了解它们如何工作以及何时使用它们的人.

3.5. 本书内容

  • 第 1 章, 夯实基础, 在第 1 页, 介绍了基本的构建块, 让你了解宏是如何工作的, 以及可以用它们做什么样的事情.
  • 第 2 章, 提升你的宏技巧, 在第 15 页, 展示了如何以及为什么使用语法引用、反引用和 gensyms. 这些是新宏编写者最棘手的概念之一, 但也是最有用的概念之一.
  • 第 3 章, 明智地使用你的力量, 在第 27 页, 退后一步, 深入探讨宏可能引起的一些问题, 以及如何避免这些问题.
  • 第 4 章, 在上下文中求值代码, 在第 35 页, 开始了本书的实战指南部分, 涵盖了你在野外看到的第一个主要用例: 将一段代码包装在一个上下文中执行.
  • 第 5 章, 为你的系统提速, 在第 49 页, 深入探讨了宏如何帮助你编写非常快速的 Clojure 代码, 而不牺牲简洁性和清晰性.
  • 第 6 章, 构建名副其实的 API, 在第 59 页, 概述了宏允许你提供易于使用的 API 的一些方式, 让用户只写真正需要的代码.
  • 第 7 章, 随心所欲地控制流程, 在第 65 页, 向你展示了如何发明自己的循环和其他控制流机制, 而不依赖语言为你提供新的.
  • 第 8 章, 实现新的语言特性, 在第 77 页, 更进一步, 展示了如何从其他语言中借鉴一些最好的特性, 并通过宏将它们引入到你的 Clojure 代码中.

3.6. 如何阅读本书

这本书里有很多代码示例, 我建议你在自己的 REPL 中尝试它们, 为更长的示例创建一个玩具项目. Leiningen3 是 Clojure 构建工具的首选, lein new macrobook 将为你创建一个新的项目供你玩耍. 本书中的示例已经使用 Clojure 1.6.0 进行了测试, 所以当你尝试这些东西时, 最好使用那个版本. 但由于宏系统很少改变, 我预计许多早期和未来的版本也能正常工作.

这本书旨在从头到尾阅读, 但如果你是那种喜欢冒险或时间紧迫的人, 这里有一些想法可以帮助你找到所需的信息:

前两章, 第 1 章, 夯实基础, 在第 1 页和第 2 章, 提升你的宏技巧, 在第 15 页, 教会你编写自己的宏所需的所有构建块. 如果你已经对宏有很好的工作知识, 并且自己编写和调试了几个, 你可能希望直接跳到第 2 章, 提升你的宏技巧, 在第 15 页.

每个人都应该阅读第 3 章, 明智地使用你的力量, 在第 27 页, 在那里你会看到宏可能引起的一些问题, 以及为什么你不想一直使用它们.

在剩下的章节中, 从第 4 章, 在上下文中求值代码, 在第 35 页开始, 我们将深入探讨宏的一些用例, 并附有示例.

3.7. 在线资源

请看这本书的官方网站4, 你可以在那里订购这本书作为礼物, 并下载本书的源代码. 你也可以提交错误报告.5

请通过官方网站上的论坛直接向我发送具体的书本问题或评论, 但对于更一般性的问题, 或超出本书范围的问题, 也有一些很棒的社区资源. 我强烈推荐访问 Freenode 上的 #clojure IRC 频道和 Clojure 邮件列表6 来提问那些与本书不特别相关的问题.

这本书很简短, 但别被骗了——有很多材料要讲. 不要犹豫, 在 REPL 中尝试一下, 并重读那些进展得有点快的部分. 现在投入一点时间, 以后可能会在更深的理解上得到回报.

现在, 让我们开始深入了解 Clojure 宏是如何工作的细节吧.

4. 第 1 章 夯实基础

在本书的整个过程中, 你将学习如何以及何时在 Clojure 中编写宏, 但首先你需要确保基础知识到位. 在本章中, 你将看到读取、引用和宏展开是如何工作的. 别担心——你不需要等很久就能写一些简单的宏. 你还将看到一些 Clojure 为开发者提供的内置工具, 用于在宏行为不当时进行调查.

你不需要太多设置就可以开始: 只需要 REPL 就行. 如果你还没有安装 Leiningen1, 我建议你现在就去安装, 因为它是使用 Clojure 最简单的方法之一, 并且内置了一个不错的 REPL2. 本书中的示例假设使用 Clojure 1.6.0, 所以当你把它们输入到 REPL 中时, 最好使用那个版本 (尽管其他版本很可能也能正常工作).

如果你已经做了相当多的 Clojure, 这一章的某些部分将是复习, 但对于除了最博学的宏学者之外的所有人来说, 应该会有一些新的思考方式.

4.1. 代码即数据

你是否曾听过Lisp程序员宣称 “代码即数据!”, 并想知道为什么会有人把源代码存储在数据库里? 好的, 当然你知道她不是那个意思, 但这个表达的真正含义并不一定很明显.

她的意思是代码本身只使用语言中的数据结构来构建. 另一方面, 非 Lisp 语言通常有一些相对较庞大的语法结构, 其中数据结构的语法只是整个语言语法的一小部分.

用一个例子来形象化这一点要容易得多, 所以让我们来看看 clojure.core 中的一些 Clojure 代码所包含的数据结构:

;; basics/defn.clj
(defn mapcat
  "Returns the result of applying concat to the result of applying map
  to f and colls. Thus function f should return a collection."
  {:added "1.0"
   :static true}
  [f & colls]
  (apply concat (apply map f colls)))

尽力忽略这段代码的作用——让我们转而关注它的句法结构. 这里的 defn 是一个 Clojure 符号, 围绕它的著名的 Lisp 圆括号意味着从开括号到闭括号的所有内容都代表一个列表.

同样, 围绕两个 apply 调用的圆括号也代表列表. 文档字符串( "Returns the result..." )只是一个字面字符串, :added:static 是元数据 map 中的关键字.

参数列表 [f & colls] 写成一个包含三个符号 f, &, 和 colls 的 vector.

我们有两种方式来解释这段代码. 我们可以像刚才那样, 将其视为数据结构, 或者我们可以看它如何求值. 这两种解释与宏的工作方式密切相关.

mapcat 定义中, 代码的数据结构表示允许 defn (事实证明, 它只是一个宏) 来操作传递给它的源代码.

你能想象为什么——除了琐事和美学——我们实际上关心代码可以被视为普通数据吗? 最有说服力的答案是, 它使得操作程序变得相对直接.

当我们在 Clojure 中进行元编程时, 我们可以从表达式层面而不是文本层面进行思考.

现在这可能看起来不是什么大问题, 但正如你将在本书中看到的, 这个简单的概念是让你能够编写宏的关键.

4.2. 代码转换

现在你有了这样一种抽象的想法, 即你可以用两种不同的方式看待代码: 作为代码或作为数据. 但这具体意味着什么呢?

让我们用你已经知道的术语来思考 REPL (Read-Eval-Print loop, 还记得吗?) 的前两个阶段. 第一个阶段, read, 接收一个基于字符的表示 (通常是一个输入流), 并将其 转换为 Clojure 数据结构.

所以 read 阶段的输出是数据, 然后在第二个阶段, eval, 作为代码来解释! 让我们仔细看看, 这样你就能理解如何将来自 reader 的代码即数据转换为将被求值的数据即代码.

read 是我们构建宏最终将要操作的表达式的一种便捷方式. 让我们看几个 read 的输入和输出, 确保你明白它们为什么会这样工作, 这就足以让你一头扎进你的第一个宏了.

Clojure 中实际的 read 函数消费流, 设置起来可能有点啰嗦. 所以为了这些例子的目的, 我们将使用它的兄弟 read-string, 它消费字符串.

当我们从字符串中读取这个表达式时, 我们得到一个列表:

;; basics/read_string.clj
(read-string "(+ 1 2 3 4 5)")
;; => (+ 1 2 3 4 5)
(class (read-string "(+ 1 2 3 4 5)"))
;; => clojure.lang.PersistentList

这个列表就是我们一直在谈论的数据片段之一. 它是一个 Clojure 列表, 同时也是 Clojure 代码. 从 read-string 返回的格式, 它已经准备好被求值了. 当我们 eval 那个列表时, 我们得到了我们期望的该表达式的值:

;; basics/eval_expression.clj
(eval (read-string "(+ 1 2 3 4 5)"))
;=> 15
(class (eval (read-string "(+ 1 2 3 4 5)")))
;=> java.lang.Long
(+ 1 2 3 4 5)
;=> 15

这基本上就是当你在 Clojure REPL 中编写代码, 或者当你运行一个完整的 Clojure 项目时发生的事情: 代码被读入数据结构然后被求值.

鉴于这两个截然不同的步骤, 不难想象我们可以在 readeval 步骤之间插入自己, 以便改变将被求值的代码. 例如, 我们可以用乘法替换加法:

;; basics/replace_addition.clj
(let [expression (read-string "(+ 1 2 3 4 5)")]
  (cons (read-string "*")
        (rest expression)))
;=> (* 1 2 3 4 5)
(eval *1) ;; *1 holds the result of the previous REPL evaluation
;=> 120

当然, 如果我们 eval 这个新的列表, 我们会得到一个与原来完全不同的结果. 这里的代码不错, 但让我们朝着一个从头开始构建表达式而不是从字符串 中读取它的版本努力, 并在此过程中稍微清理一下. read-string 工作得很好, 但如果能直接以列表的形式输入它们, 而不是费力地通过字符串, 那就更好了.

但是如果我们想创建列表 (+ 1 2 3 4 5), 我们不能直接输入 (+ 1 2 3 4 5), 因为那会实际求值该表达式.

;; basics/replace_addition_broken.clj
(let [expression (+ 1 2 3 4 5)] ;; expression is bound to 15
  (cons
    (read-string "*") ;; *
    (rest expression))) ;; (rest 15)
; IllegalArgumentException Don't know how to create ISeq from: java.lang.Long
; clojure.lang.RT.seqFrom (RT.java:505)

所以我们需要一个不同的解决方案, 一个能以某种方式抑制执行的方案. 幸运的是, Clojure 中有一个叫做 quote 的动词, 正好可以做到这一点:

;; basics/replace_addition_2.clj
(let [expression (quote (+ 1 2 3 4 5))]
  (cons (quote *)
        (rest expression)))
;=> (* 1 2 3 4 5)

但是等等, 我不是刚说过我们不能输入 (+ 1 2 3 4 5) 吗, 但我们现在就是这么做的, 而且看到了正确的事情发生! 怎么回事? 注意我说的是 动词 而不是 函数.

quote 动词实际上是 Clojure 的一个特殊形式, 而不是一个函数. 我们可以把动词 (不是一个官方的语言术语, 只是一个方便思考的概念) 看作是函数、宏和特殊 形式的并集——那些出现在列表第一个位置, 开括号后面的东西. 列表也可以出现在动词的位置, 但它们在求值时必须归约为一个函数.

只有函数才会在将控制权传递给实现该动词的代码之前统一地对其参数进行求值. 动词总结在第 5 页的表 1, Clojure 动词中.

让我们仔细看看函数是如何求值它们的参数的:

;; basics/function_argument_evaluation.clj
(defn print-with-asterisks [printable-argument]
  (print "*****")
  (print printable-argument)
  (println "*****"))

(print-with-asterisks "hi")
; *****hi*****
;=> nil
动词 我们如何定义? 参数何时求值?
函数 (Function) defn 在执行主体之前
宏 (Macro) defmacro 取决于宏; 可能多次或从不
特殊形式 (Special form) 我们不能! 它们仅由语言定义. 取决于特殊形式; 可能多次或从不

当我们只是像字符串一样发送东西到 print-with-asterisks 中时, 我们并不真的需要考虑参数求值的时间. 但是如果我们为参数使用一个表达式, 我们会看到表达式在任何函数主体被求值之前就被求值了.

;; basics/function_argument_evaluation-2.clj
(print-with-asterisks
  (do (println "in argument expression")
      "hi"))
; in argument expression
; *****hi*****
;=> nil

另一方面, 宏和特殊形式不受此规则约束, 并且可以按它们定义的任何顺序求值参数 (或做完全不同的事情!). 这是函数和宏之间的一个关键区别, 也许是 the 关键区别, 我们稍后会看到其后果.

quote 表达式内部的任何代码, 通常由圆括号或方括号或花括号界定, 都不会被求值. 我说 通常 而不是 总是, 因为你也可以引用更小的 token, 比如数字、字符串、关键字和符号. 我们倾向于最常在符号、列表和 (稍不那么频繁地) vector上使用 quote.

;; basics/quoting_tokens.clj
(quote 1)
;=> 1

(quote "hello")
;=> "hello"

(quote :kthx)
;=> :kthx

(quote kthx)
;=> kthx

这里有一个潜在的令人困惑的事实, 那就是像数字、字符串和关键字这样的东西在被读取时似乎已经是它们自己了. 这在我们实际操作这些表达式时简化了事情, 而且事实是, 你并没有太多好的理由想要在这里有不同的行为.

符号和列表是特殊的: 符号用于表示局部变量、var、类、协议或其他绑定; 列表用于调用动词.

信不信由你, quote 变得更好, 因为 Clojure 中有一个叫做 reader macro 的简写, 它将单个引号字符转换成包装后面表达式的 quote 形式. Reader macro 不是你可以自己创建的东西——它们是内置在语言中的, 而且只有少数几个. 所以我知道这很难, 但别太激动!

;; basics/quote_reader_macro.clj
'(+ 1 2 3 4 5)
;=> (+ 1 2 3 4 5)

'map
;=> map

这次引用机制探索的回报是, 我们可以把我们之前的庞然大物 (使用 read-string) 撕掉, 用更简洁的方式表达它:

;; basics/replace_addition_3.clj
(let [expression '(+ 1 2 3 4 5)]
  (cons '* (rest expression)))
;=> (* 1 2 3 4 5)

现在你开始更清楚地理解人们说在 Clojure 中, 代码是数据, 数据是代码时的意思了. 我们很快就会接触到更复杂的引用用法和变体, 但这足以让你开始编写你的第一个宏了.

4.3. 求值你的第一个宏

为了思考宏是如何工作的, 想象一个梯子是很有用的, 就像下面这张图一样. 当我们在这梯子上向上走一步时, 我们是从代码思维转向数据思维, 当我们向下走一步时, 我们是从数据思维转回代码思维.

在这个世界里, 当我们在一段代码中遇到宏调用时, 我们会把它想象成一个函数, 但它操作的是宏梯子上一级的未求值代码, 而不是已求值的数据. 当我们需要展开一个宏时, 我们会向上走一步梯子, 一旦我们展开完毕, 我们会向下走一步梯子.

      ^
      |
|-----|
|     |  Code
|-----|  becomes
|     |  data
|-----|
|     |
      |
      v

      ^
      |
|-----|
|     |  Data
|-----|  becomes
|     |  code
|-----|
|     |
      |
      v

事不宜迟, 让我们来看看内置的 when 宏.

;; basics/when.clj
(defmacro when
  "Evaluates test. If logical true, evaluates body in an implicit do."
  {:added "1.0"}
  [test & body]
  (list 'if test (cons 'do body)))

因为我们把宏看作是操作宏梯子上一级数据(代码)的函数, 我们可以把 when 看作是一个接收参数 test 和一个被打包成序列 body 的可变数量参数的函数. 现在让我们看一个实际的 when 调用, 看看这在实践中是如何运作的.

;; basics/when_call.clj
(when (= 2 (+ 1 1))
  (print "You got")
  (print " the touch!")
  (println))

就像一个普通函数一样, 当 when 执行时, 它有绑定到 testbody 的值, 但与普通函数不同的是, 此时还没有任何东西被求值. test 实际上被绑定到列表 (= 2 (+ 1 1)). 而这个列表只是数据. 听起来熟悉吗?

此时没有加法或比较发生; 这就是我们之前提到的 readeval 之间的那个楔子. 继续, body 被绑定到列表 ((print "You got") (print " the touch!") (println)): 实际上是一个列表的列表!

额外的括号来自于 & body 的可变参数 (varargs) 语法将三个参数打包起来.

所以如果我们继续把 when 当作一个函数来读, 替换代码中名称的绑定, 然后执行宏的主体 (不是结果代码), 结果是另一个列表.

;; basics/when_manual_expand.clj
;; with indentation and newlines added for clarity
(list 'if
      '(= 2 (+ 1 1))
      (cons 'do
            '((print "You got")
              (print " the touch!")
              (println))))

实际上, 这就是 when 宏生成的代码, 但在我们把宏看作是作用于数据的函数的模型中, 我们仍然把这个返回值看作是一个列表, 位于宏梯子底部之上一步.

所以现在我们只需要下降一级宏梯子, 在那里我们可以求值我们构建的这段代码. 当然, 结果将是我们打印出一部精彩电影中的一段可爱歌词 (好吧, 我选择把它记作是精彩的).

;; basics/when_result.clj
;; when evaluated at the macro level:
(if (= 2 (+ 1 1))
  (do (print "You got")
      (print " the touch!")
      (println)))

;; and when later evaluated as code:
;You got the touch!
;=> nil

这基本上就是宏的工作方式. 在读取和求值代码之间确实有机会插入行为, 而这个楔子就叫做宏展开. 每当 Clojure 在它试图执行的表达式的动词位置上遇到一个宏时 , 它就会迭代我们刚刚经历的这个过程.

宏展开是向上爬一级宏梯子: 我们把宏的参数代码当作数据来处理. 这些参数以宏所说的任何方式被使用, 最终我们通过求值构成宏主体的代码来创建一个新的表达式.

我们把那个结果表达式放进口袋里, 再下降一级宏梯子, 从口袋里拿出表达式, 用执行得到的结果表达式替换原来的宏表达式.

这可能感觉有点像你在别处见过的宏, 比如在 C 或 C++ 中, 甚至在 Scala 中. 不同之处在于, 用 Lisp 我们用普通的序列操作而不是字符串或 AST 操作来写宏.

我们不需要写自己的解析器, 也不需要知道如何生成编译器的语法树节点. 在 Lisp 中, 元编程感觉更像是常规编程.

当然, 在实践中事情往往更复杂. 在一个更大的例子中, 我们可能会在展开第一个宏的过程中遇到另一个宏. 让我们考虑另一个用 when 定义的宏: cond.

;; basics/cond.clj
(defmacro cond
  "Takes a set of test/expr pairs. It evaluates each test one at a
  time. If a test returns logical true, cond evaluates and returns
  the value of the corresponding expr and doesn't evaluate any of the
  other tests or exprs. (cond) returns nil."
  {:added "1.0"}
  [& clauses]
  (when clauses
    (list 'if (first clauses)
          (if (next clauses)
            (second clauses)
            (throw (IllegalArgumentException.
                     "cond requires an even number of forms")))
          (cons 'clojure.core/cond (next (next clauses))))))

这个展开起来需要多一点功夫, 因为我们需要继续攀登宏梯子, 直到我们拥有的表达式不再是宏调用. 当我们试图宏展开 cond 的最小可能调用, 传递零个参数时 , 我们已经从底部向上爬了一级宏梯子, 所以当我们在动词位置找到 when (一个宏) 时, 我们可能会觉得有点卡住了.

;; basics/cond_expansion_1.clj
(cond)

;; expanding, up a ladder rung and treating ~cond~ as a function:
'(when clauses
   (list 'if (first clauses)
         (if (next clauses)
           (second clauses)
           (throw (IllegalArgumentException.
                    "cond requires an even number of forms")))
         (cons 'clojure.core/cond (next (next clauses)))))

作为人类, 我们知道 clauses 将是 nil, 因为没有传递任何子句, 但我们还不能在这个 when 表达式中用 nil 替换 clauses, 因为我们不在梯子的底部.

when 是一个宏, 宏作用于代码, 评估它们选择的 ifwhen. 我们需要再爬一级梯子, 看看有什么在等着我们, 但我们会记住, 当我们回到第一级宏梯子时, clauses 将被绑定到 nil.

;; basics/cond_expansion_2.clj
;; ascending another ladder rung, treating ~when~ as a function:
(list 'if 'clauses
      (cons 'do
            '((list 'if (first clauses)
                    (if (next clauses)
                      (second clauses)
                      (throw (IllegalArgumentException.
                               "cond requires an even number of forms")))
                    (cons 'clojure.core/cond (next (next clauses)))))))

现在既然 list 是一个函数, 我们可以求值参数并向下移动一级, 以更接近我们最终的返回值.

;; basics/cond_expansion_3.clj
;; descending a rung:
(if clauses
  (do (list 'if (first clauses)
            (if (next clauses)
              (second clauses)
              (throw (IllegalArgumentException.
                       "cond requires an even number of forms")))
            (cons 'clojure.core/cond (next (next clauses))))))

现在我们的结果以一个特殊形式 if 开始, 我们仍然比开始时高出一级, 所以我们需要以 Clojure 语言定义 if 的方式来求值这个形式. 因为 if 说我们首先求值测试 (第一个参数) 来决定是求值第二个还是第三个参数, 我们需要首先求值 clauses.

因为我们在第 1 级, 而第 1 级是我们把 clauses 绑定到 nil 的地方, 所以我们有那个绑定可用于这次求值. 因为 nil 是一个假值, 我们通常会走 ifelse 分支 (第三个参数), 但这里没有第三个参数! 这给我们留下了 nil 作为结果, 因为 Clojure 默认 else 分支为 nil.

所以, 经过漫长的等待, 我们终于达到了我们的目标: 宏展开表达式 (cond) 的结果. 而那个结果就是 nil. 所以我们把它放进口袋里, 沿着最后一级梯子回到最底层, 把那个 nil 作为代码插入到原始的上下文中. 好吧, 作为代码的 nil 求值结果就是它自己, 所以求值 (cond) 的结果也是 nil.

4.4. 宏展开

唉——那真是虎头蛇尾, 而且手工整理出来花了很多乏味且容易出错的工作. 相信我: 宏的编写并不总是这样的感觉. 随着你写的每个宏, 它都会变得更容易, 你使用的宏会融入你系统的隐性知识中.

幸运的是, 我们确实有工具来管理这类复杂性, 并在编写宏时检查我们的工作. 让我们看看那些工具, 在我们开始对所有这些梯子的话题感到恐高之前.

手动遍历宏展开, 正如你在上一节中看到的, 是一项相当乏味的工作. 但正如你可能猜到的那样, 你不总是需要手动去做. Clojure 向用户暴露了它内部使用的完全相同的宏展开工具. 这样, 你就可以看到一个宏将生成的表达式, 而不必经历你刚才看到的整个过程.

macroexpand-1 是这些工具中最简单的, 它将一个宏表达式转换为其结果表达式.

;; basics/macroexpand_1.clj
(macroexpand-1 '(when (= 1 2) (println "math is broken")))
;=> (if (= 1 2) (do (println "math is broken")))

(macroexpand-1 nil)
;=> nil

(macroexpand-1 '(+ 1 2))
;=> (+ 1 2)

注意这里与我们上一节用手做的与我们第一个 when 表达式的相似之处. macroexpand-1 在调试宏问题时是一个很大的帮助, 因为它精确地告诉你一个宏表达式在宏展开步骤中将如何被替换. 确保你给 macroexpand-1 一个引用的表达式或生成一个表达式的东西, 否则你会大吃一惊!

;; basics/macroexpand_1_2.clj
(macroexpand-1 (when (= 1 2) (println "math is broken")))
;=> nil

在这里省略引用没有做到我们想要的, 因为 macroexpand-1 是一个常规函数. 这意味着它会在将控制权传递给构成 macroexpand-1 的代码之前求值它的参数. 所以 when 表达式实际上执行了, 返回 nil, 因为 nil 不是一个宏表达式, macroexpand-1 的工作就完成了. 宏展开对给定的表达式不是宏调用时没有效果.

值得注意的是, 宏必须返回一些有意义去求值的东西! when 宏之所以有效, 是因为它返回一个列表, 其第一个元素是引用的符号 if. 让我们看看如果我们重写自己的 when 宏版本, 但忘记了宏的结果需要是一个可求值的形式会发生什么. 我们将删除 'if 引用的符号来证明这一点——删除文档字符串和其他元数据只是为了更容易聚焦.

;; basics/broken_macro_1.clj
(defmacro broken-when [test & body]
  (list test (cons 'do body)))

(broken-when (= 1 1) (println "Math works!"))
; ClassCastException java.lang.Boolean cannot be cast to clojure.lang.IFn
; user/eval316 (NO_SOURCE_FILE:1)

当我学习新东西时, 我有时会发现自己在练习 EDD (异常驱动开发). 我试着求值一些代码, 得到一个异常或错误消息, 然后 Google 错误消息来找出到底发生了什么. 我们可以在这里这样做, 我们可能会找到正确的答案, 但既然我们现在知道了 macroexpand-1, 让我们先试试那个.

;; basics/broken_macro_1_macroexpand.clj
(macroexpand-1
  '(broken-when (= 1 1) (println "Math works!")))
; ((= 1 1) (do (println "Math works!")))

啊哈! 我们在这里生成的表达式是一个列表, 它的第一个元素是另一个列表, 所以为了决定如何求值顶层列表, 我们需要求值第一个元素. (= 1 1)true, 所以顶层列表的第一个元素将是 true. 但 true 不是任何类型的动词 (函数、宏或特殊形式)——它是一个布尔值! 这完全解释了错误消息: 我们期望有一个 IFn (一个函数), 但我们得到了一个布尔值, 这没有任何意义. 你可能以前在 Clojure 中见过这种事情, 当你在 REPL 中意外地输入未引用的列表时. 只要我们确保我们从宏展开返回的表达式是可以求值的, 我们就会避免这类错误.

我们可以用 macroexpand 更进一步, 它做 macroexpand-1 做的事情, 但它实际上以同样的方式继续下去, 直到返回的表达式要么不是列表, 要么是其第一个元素不再是宏的列表. 所以, 例如, 如果我们有一个展开为另一个宏调用的宏, macroexpand 会为我们做第二个展开, 但 macroexpand-1 不会. 这与梯子的想法形成对比, 在梯子的想法中, 一个宏可能会使用另一个宏而不是展开到另一个宏.

;; basics/macroexpand.clj
(defmacro when-falsy [test & body]
  (list 'when (list 'not test)
        (cons 'do body)))

(macroexpand-1 '(when-falsy (= 1 2) (println "hi!")))
;=> (when (not (= 1 2)) (do (println "hi!")))

(macroexpand '(when-falsy (= 1 2) (println "hi!")))
;=> (if (not (= 1 2)) (do (do (println "hi!"))))

两种工具各有其位, 取决于你是想检查单个宏展开还是看最终产品. 注意, 这只适用于正在展开的表达式开头的宏——展开表达式 内部 的宏需要更重的机器. 正确地宏展开所有东西是很棘手的, 内置的 clojure.walk/macroexpand-all 对某些表达式有效, 但太天真了, 无法覆盖所有情况. clojure.tools.macro3Riddley4 是另外两个更雄心勃勃的代码遍历项目, 旨在允许展开所有东西, 基于这些项目的目标采用不同的策略.

4.5. 坦白与下一步

所以, 宏梯子的类比有点牵强. 它是弄清楚哪个宏代码将执行的好方法, 但不一定是 何时 执行. 当我们编写一个使用宏来进行其展开的宏时, Clojure 会在编译我们的宏时执行必要的展开. 每当我们去使用一个宏时, 它使用的任何宏都已经被展开了. 在 cond 的情况下, Clojure 不需要攀登 when 之上的任何梯子, 因为在我们调用 (cond) 的时候, 它们已经被遍历过了. 这意味着通常只有一个梯级是我们任何时候都需要担心的.

但是梯子的概念在追踪新宏中的代码执行时仍然很有用, 因为与 Clojure 编译器不同, 当我们在脑子里做宏展开时, 我们不会把每个宏的展开都保存在内存里! 我们人类通常必须孤立地看待每个函数或宏, 要么凭记忆知道一个给定的宏调用是做什么的, 要么找到它的定义并心算展开它 (或者在我们探索时使用像 macroexpand-1 这样的工具).

在这一点上, 我们拥有了编写宏所需的大部分工具. 接下来我们将看一些高级技术和语法, 以避免到目前为止你所知的那些冗长, 甚至一些让疯狂的新事物成为可能的技巧.

5. 第 2 章 提升你的宏技巧

到目前为止, 你见过的大多数宏都很小且直截了当. 如果它们都能像那样该多好啊? 不幸的是, 随着你越来越多地使用宏, 你目前所知的语法可能会变得笨拙.

如果你必须编写一个像 Clojure 自带的 assert 宏, 你会怎么做? 根据你目前所知, 你需要做类似这样的事情:

;; advanced_mechanics/assert_no_syntax_quote.clj
(defmacro assert [x]
  (when *assert* ;; check the dynamic var `clojure.core/*assert*` to make sure
                 ;; assertions are enabled
    (list 'when-not x
          (list 'throw
                (list 'new 'AssertionError
                      (list 'str "Assert failed: "
                            (list 'pr-str (list 'quote x))))))))

user=> (assert (= 1 2))
;=> AssertionError Assert failed: (= 1 2) user/eval214 (NO_SOURCE_FILE:1)
user=> (assert (= 1 1))
;=> nil

而且这甚至不是一个完整的解决方案! 我们跳过了接受失败消息字符串的 arity1, 以免事情变得太荒谬. 但这里有很多要读和要学的东西, 对吧?

我不知道你怎么想, 但我发现解析所有这些嵌套的列表以发现宏展开后会出来什么非常困难. 幸运的是, 我们从第 11 页的 宏展开 中知道了 macroexpand, 所以它不必长时间保持神秘:

5.1. 语法引用与反引用

这个 assert 实现的大问题是, 从宏实现到宏展开的结果需要一个相当大的结构性飞跃. 这对编译器来说没问题, 我们人类的大脑也能弄明白, 但有一种更简单的方法. 语法引用给我们一种构建宏代码的方式, 使其看起来更像它的宏展开.

语法引用让我们创建列表的方式与我们用普通引用创建它们的方式相似, 但它有一个额外的好处, 就是让我们暂时跳出引用的列表, 并用一个反引用来插入值.

把它想象成一个模板, 我们可以在任何我们喜欢的地方打孔并插入值. 例如, 如果我们有一个列表, 我们想在其中插入一个值, 我们的普通引用就不起作用了:

;; advanced_mechanics/normal_quote_is_stubborn.clj
(def a 4)
;=> #'user/a
'(1 2 3 a 5)
;=> (1 2 3 a 5)

(list 1 2 3 a 5)
;=> (1 2 3 4 5)
(1 2 3 4 5)

我们创建的第一个列表中的第四个元素只是符号 a. 如果我们想要 a 的值, 我们要么必须使用更冗长的列表构建, 要么使用带有反引用的语法引用:

;; advanced_mechanics/syntax_quote_1.clj
(def a 4)
;=> #'user/a
`(1 2 3 ~a 5)
;=> (1 2 3 4 5)
(1 2 3 4 5)

如果你一开始没有看到这两个引号字符的区别, 再仔细看一点. 普通引号 (') 看起来很正直, 而语法引用 (`) 有点歪斜, 显然准备好去派对了. 你可能也知道语法引用是反引号. 反引用 (~), 好吧, 它反引用, 让我们将求值结果插入到语法引用的表达式中.

如果我们看一下 assert 实际上是如何实现的, 我们会发现语法引用和反引用完全解决了我们之前看到的冗长问题:

;; advanced_mechanics/assert_syntax_quote.clj
(defmacro assert [x]
  (when *assert*
    `(when-not ~x
       (throw (new AssertionError (str "Assert failed: " (pr-str '~x)))))))

哇! 这比嵌套列表版本少了很多代码, 而且看起来更接近宏展开. 因此, 它更容易理解和维护. 不过, 还剩下一个可能有点棘手的地方: 当我们在一个语法引用的表达式中说 '~x 时, 这是什么意思?

REPL 是一个很好的实验场所, 每当你看到一些你不理解的东西时. 我们为什么不试试呢?

;; advanced_mechanics/syntax_quote_2.clj
user=> (def a 4)
;=> #'user/a
user=> `(1 2 3 '~a 5)
;=> (1 2 3 (quote 4) 5)

啊哈! 所以这个奇怪的 '~ 舞蹈给了我们一种引用求值结果并将其插入到语法引用表达式中的一个槽位的方法.

回顾一下我们从第 6 页的 引用介绍 中学到的, 普通引用是一个扩展为 (quote ...) 的 reader macro. 好吧, 事实证明反引用 ~ 是另一个 reader macro. 所以这个的展开版本会是这样的:

;; advanced_mechanics/syntax_quote_3.clj
user=> `(1 2 3 (quote (clojure.core/unquote a)) 5)
;=> (1 2 3 (quote 4) 5)

在内部, Clojure 的 reader 有一些特殊的代码来遍历语法引用形式, 寻找 clojure.core/unquote 的出现并反引用那些东西. 我不会尝试在语法引用的范围之外使用 clojure.core/unquote, 尽管——它不会工作, 除非你写了一个让它工作的宏. Leiningen2 (从 2.3.4 版本开始) 实际上允许在 project.clj 中使用反引用进行求值, 但现在不鼓励使用, 转而使用 read-eval.

5.2. 使用 Gensym 实现卫生宏

如果你试图在不深入宏定义的情况下使用这个版本的 make-adder, 你会认为这完全是坏掉的, 不是吗? 我们无意中允许了所谓的 符号捕获, 即宏内部遮蔽了或捕获了一些该宏的用户可能期望在他们的表达式求值时可用的符号. 不过, 这里有一个解决方案, 它叫做 gensym.

为了避免像我们刚才看到的符号捕获问题, Clojure 给了我们一些工具, 都与 gensym 函数有关. gensym 的工作很简单: 它产生一个具有唯一名称的符号. 这些名称看起来会很奇怪, 因为名称需要对应用程序唯一, 但没关系, 因为我们永远不需要在代码中输入它们:

;; advanced_mechanics/gensym_2.clj
user=> (gensym)
;=> G__671
user=> (gensym)
;=> G__674
user=> (gensym "xyz")
;=> xyz677
user=> (gensym "xyz")
;=> xyz680

如你所见, gensym 的任何给定调用都会返回一个唯一的值——所以如果你想两次引用同一个值, 你需要用 let 绑定或类似的东西来持有这个值. 这些生成的符号 (gensyms) 对宏非常有用, 但因为它们是普通数据, 你可以在任何使用符号的地方使用它们. 在我们之前的 make-adder 宏中, 我们不能有 user/y 作为函数参数, 我们刚刚看到我们不想要普通的 y 作为函数参数, 但我们可以使用 gensym 作为函数参数:

;; advanced_mechanics/gensym_3.clj
(defmacro make-adder [x]
  (let [y (gensym)]
    `(fn [~y] (+ ~x ~y))))

user=> y
100
user=> ((make-adder (+ y 3)) 5)
108

现在这个版本使用了我们作为这个宏的用户所期望的 y 的值. 注意这里的 letgensym 是在语法引用之外的.

不幸的是, 这太啰嗦了——让我们使用更简洁和内置的版本. 我们将使用一个叫做 auto-gensym 的特性, 它看起来就像一个普通的符号, 后面有一个井号 (#), 像一个反向的 hashtag:

;; advanced_mechanics/gensym_4.clj
(defmacro make-adder [x]
  `(fn [y#] (+ ~x y#)))

user=> y
100
user=> ((make-adder (+ y 3)) 5)
108

这个, 而不是我们之前做过的任何一种方式, 应该是当你在宏中需要绑定一个名称时你应该使用的工具. 在绑定符号到值的许多其他非常相似的情况下, 我们也需要使用 gensym 来安全地构建宏:

;; advanced_mechanics/gensym_5.clj
(defmacro safe-math-expression? [expression]
  `(try ~expression
     true
     (catch ArithmeticException e# false)))

;; clojure.core/and
(defmacro and
  ([] true)
  ([x] x)
  ([x & next]
   `(let [and# ~x]
      (if and# (and ~@next) and#))))

由特殊形式如 let, letfntrycatch 子句建立的绑定与函数参数有相同的要求, 所以你通常也应该在这些情况下使用 auto-gensym.

Clojure 已经采取了很多措施来使宏的构建不那么容易出错. 这些变量捕获问题, 连同明确获取 gensym 的能力, 在 Common Lisp 中已经存在很长时间了, 但它需要一点巫术 (参见 Doug Hoyte 的 Let Over Lambda [Hoy08]) 才能得到像 Clojure 的 auto-gensym 特性这样的东西. 值得注意的是, 如果你漫步到嵌套语法引用的黑暗森林中, 你 (a) 可能永远回不来, (b) 可能想看看 Zach Tellman 的 Potemkin3 中的 unify-gensyms.

当然, 任何有 Scheme 背景的人此时可能都在嚎叫, 因为他们有一个卫生宏系统, 使得意外的变量捕获成为不可能. 允许我们在我们真正, 真正想要它的时候进行变量捕获, 使得 Clojure 的宏系统在技术上比卫生系统更危险. 通过让我们大部分时间都能做到, Clojure 给了我们比 Common Lisp 的宏系统更多的安全性, 以及在它能提供优雅解决方案时进行变量捕获的能力.

5.3. 宏的秘密魔法

除了 Clojure 为宏和普通编程提供的所有语法引用工具之外, 我们还有两个特殊值, &form&env, 它们只在宏内部可用. 这两者都允许我们对宏的使用方式进行一些内省. 让我们看看当我们使用它们时我们有哪些信息可用:

;; advanced_mechanics/secret_macro_variables_1.clj
(defmacro info-about-caller []
  (pprint {:form &form :env &env})
  `(println "macro was called!"))

user=> (info-about-caller)
;{:form (info-about-caller), :env nil}
;macro was called!
;=> nil
user=> (let [foo "bar"] (info-about-caller))
;{:form (info-about-caller),
; :env {foo #<LocalBinding clojure.lang.Compiler$LocalBinding@23ef55fb>}}
;macro was called!
;=> nil
user=> (let [foo "bar" baz "quux"] (info-about-caller))
;{:form (info-about-caller),
; :env
; {baz #<LocalBinding clojure.lang.Compiler$LocalBinding@3f68eac0>,
;  foo #<LocalBinding clojure.lang.Compiler$LocalBinding@55ab9655>}}
;macro was called!
;=> nil

&env 的值看起来很神奇: 它是局部变量的 map, 其中键是符号, 值是 Clojure 编译器内部某个类的实例. 事实证明, 如果我们真的想疯狂一下, 这让我们可以在宏展开期间访问各种有趣的数据——比如参数的 Java 类型以及局部变量是如何初始化的. 人们通常使用 &env 只是为了查看键 (即符号), 并将它们注入到展开的宏中. 如果我们想获得一个局部名称到局部值的 map, 我们可以这样做:

;; advanced_mechanics/secret_macro_variables_2.clj
(defmacro inspect-caller-locals []
  (->> (keys &env)
       (map (fn [k] [`'~k k]))
       (into {})))

user=> (inspect-caller-locals)
{}
user=> (let [foo "bar" baz "quux"] (inspect-caller-locals))
{baz "quux", foo "bar"}

这里有一些棘手的引用需要理解: '~k. 如果我们仔细思考, 这完全有道理. 我们想为宏展开中的每个局部变量生成一个引用的符号, 但我们希望那些引用的符号与 &env map 中的符号相同.

这有点太花哨了, 但你有时会在野外看到这种引用, 所以值得你花时间去理解它. 然而, 有几种等效的, 可能更易读的方式来说 '~k, 即 (quote ~k)(list 'quote k):

;; advanced_mechanics/secret_macro_variables_3.clj
(defmacro inspect-caller-locals-1 []
  (->> (keys &env)
       (map (fn [k] [`(quote ~k) k]))
       (into {})))

(defmacro inspect-caller-locals-2 []
  (->> (keys &env)
       (map (fn [k] [(list 'quote k) k]))
       (into {})))

user=> (inspect-caller-locals-1)
{}
user=> (inspect-caller-locals-2)
{}
user=> (let [foo "bar" baz "quux"] (inspect-caller-locals-1))
{baz "quux", foo "bar"}
user=> (let [foo "bar" baz "quux"] (inspect-caller-locals-2))
{baz "quux", foo "bar"}

这些都表现得完全一样, 所以选择哪一个取决于个人偏好. 请记住, 每当你在宏中遇到一个看起来令人困惑的引用组合时, 你总可以在 REPL 中隔离引用的部分, 看看它们是如何表现的.

&form 特殊变量更直接一些. 它包含用于调用宏的表达式, 这将永远是一个列表, 因为列表是你在 Clojure 中调用东西的方式. 这似乎并不能给你带来太多好处, 因为作为宏的作者, 你已经知道宏的名称是什么了, 而且你已经可以访问传入的参数表达式:

;; advanced_mechanics/secret_macro_variables_4.clj
(defmacro inspect-called-form [& arguments]
  {:form (list 'quote (cons 'inspect-called-form arguments))})

user=> (inspect-called-form 1 2 3)
;=> {:form (inspect-called-form 1 2 3)}

不过, &form 的可用性还有几个额外的好处. 首先, 在我们之前做事的方式中有重复, 所以如果你改变了对宏名称的想法, 你必须在宏定义中改变两个名称. 这个定义也是很多代码——使用 &form 比每次我们想检查宏调用的细节时都输入整个表达式要方便得多. 让 &form 特别的一个真正大的好处是, 当我们有实际的形式可用时, 我们也拥有附加在它上面的所有元数据:

;; advanced_mechanics/secret_macro_variables_5.clj
(defmacro inspect-called-form [& arguments]
  {:form (list 'quote &form)})

user=> ^{:doc "this is good stuff"} (inspect-called-form 1 2 3)
;=> {:form (inspect-called-form 1 2 3)}
user=> (meta (:form *1))
;=> {:doc "this is good stuff", :line 1, :column 1}

这可能是件大事! 这意味着我们可以通过包含行号和列号信息来改善宏中的错误消息——这是 &form 最常见的用法. 但我相信你能想象出一些其他花哨的方式来使用表达式上的元数据, 以向消费它们的宏提供信息.

5.4. 呼!

在本章中, 我们已经看过了宏构建的所有最先进的部分. 还有很多东西要学, 但不是在语法方面! 在这一点上, 你已经拥有了构建你想要的任何宏的构建块. 在本书的其余部分, 你将看到如何在日常工作中编写宏以及为什么要这样做. 但首先, 你将在下一章中看到为什么宏不是每个问题的解决方案, 以及如果你把它们当作是解决方案, 事情会如何出错.

6. 第 3 章 明智地使用你的力量

掌握了如何编写宏所需的知识后需要培养一种何时编写宏的直觉. 宏是有缺点的. 当函数能行时, 不要使用宏.

在本章中, 将看到一些宏固有的问题, 一些是应该竭力避免的错误, 一些可能使维护在未来变得更困难的设计决策.

6.1. 宏不是值

宏的最重要的缺点是不能把它们当作值来对待. 作为有原则的函数式程序员, 我们非常习惯于将函数视为值.

例如, 我们可以将函数作为参数传递给高阶函数, 如 mapfilter, 以将循环逻辑与转换和选择逻辑解耦.

当我们使用宏而不是函数时, 我们就失去了这种能力:

;; beware/not_values_1.clj
(defn square [x] (* x x))
;=> #'user/square
(map square (range 10))
;=> (0 1 4 9 16 25 36 49 64 81)
(defmacro square [x] `(* ~x ~x))
;=> #'user/square
user=> (map square (range 10))
;CompilerException java.lang.RuntimeException:
; Can't take value of a macro: #'user/square, compiling: (NO_SOURCE_PATH:1:1)

对于 square 动词的作者来说, 看起来不是什么大问题. 但对于不是 square 的使用者来说, 遇到这种限制真的很烦人.

如果你发现了 square 宏版本的另一个问题, 恭喜! 如果没有, 别担心——我们会在第 31 页的 写对宏可能很棘手 中仔细看看.

在这种特定情况下, 可以通过将宏包装在函数调用中来解决这个问题:

;; beware/not_values_2.clj
user=> (defmacro square [x] `(* ~x ~x))
;=> #'user/square
user=> (map (fn [n] (square n)) (range 10))
;=> (0 1 4 9 16 25 36 49 64 81)

这是可行的, 因为当匿名函数 (fn [n] (square n)) 被编译时, square 表达式被宏展开为 (fn [n] (clojure.core/* n n)). 这是一个合理的函数, 所以我们对编译器没有任何问题. 需要把宏的名称放在动词位置时, 这可以是一个不错的变通方法.

当然, 许多宏做更复杂的事情, 使得这种函数包装技术变得不可能. 即使是一个简单的宏, 如果它对其输入表达式做了任何有趣的事情, 也可以阻止你这样做:

;; beware/not_values_3.clj
(defmacro do-multiplication [expression]
  (cons `* (rest expression)))

user=> (do-multiplication (+ 3 4))
;=> 12
user=> (map (fn [x] (do-multiplication x)) ['(+ 3 4) '(- 2 3)])
; CompilerException java.lang.IllegalArgumentException:
; Don't know how to create ISeq from: clojure.lang.Symbol,
; compiling:(NO_SOURCE_PATH:1:14)

这个宏很傻——它只是忽略了它输入中的动词, 并用乘法替换了它. 它假设它的输入表达式是我们可以用来创建序列的东西. 所以当 Clojure 试图宏展开 (do-multiplication x) 时, 无法完成它的工作, 因为 x 是一个符号.

正如错误消息告诉我们的那样, Clojure 不知道如何从一个符号创建一个序列. 冒着啰嗦的风险, 宏以 代码 为输入——它们不 (也无法) 知道在运行时哪些值将被替换到代码中的符号位置.

在这里映射表达式以得到我们想要的结果的唯一方法是, 将 do-multiplication 转换为一个输出表达式的函数, 然后要么写一个小解释器, 要么使用可怕的 eval 来计算结果. 这些都不像宏作为值. 所以, 虽然我们可以作弊, 把一些极其简单的宏包装在函数里, 但在一般情况下, 我们完全没有办法把它们当作值来对待.

6.2. 宏具有传染性

接下来我们将看看宏不是值的一个重要后果: 接受可变数量参数的宏可以 感染 它们的调用者, 迫使作者编写更多的宏而不是函数. 让我们看一个例子, 并思考一下它对调用代码的影响:

;; beware/contagious_1.clj
(require '[clojure.string :as string])
(defmacro log [& args]
  `(println (str "[INFO] " (string/join " : " ~(vec args)))))

user=> (log "that went well")
;[INFO] that went well
;=> nil
user=> (log "item #1 created" "by user #42")
; [INFO] item #1 created : by user #42
;=> nil

如果你回想一下你在第 2 章, 提升你的宏技巧, 在第 15 页学到的东西, 你会注意到我们把 args 转换成了一个 vector, 而不是仅仅使用 ~args. 为什么? 因为我们希望 args 在宏展开的代码中是一个序列化的东西, 而不是一个要被求值的表达式. 如果我们没有使用 vector, Clojure 会把 args 序列 (一个列表, 实际上) 直接塞进宏展开的代码中, 并在运行时把第一个序列元素当作动词. 你可以想象, 如果我们试图用一个字符串作为第一个参数, 那会很糟糕. 这个宏工作得很好, 而且很容易直接调用, 但假设我们发现自己持有一些任意的消息集合, 也许是通过另一个函数的输入:

;; beware/contagious_2.clj
(defn send-email [user messages]
  (Thread/sleep 1000)) ;; this would send email in a real implementation

(def admin-user "kathy@example.com")
(def current-user "colin@example.com")

(defn notify-everyone [messages]
  (apply log messages)
  (send-email admin-user messages)
  (send-email current-user messages))
; CompilerException java.lang.RuntimeException:
; Can't take value of a macro: #'user/log, compiling:(NO_SOURCE_PATH:2:3)

宏实际上是函数! 正如你在第 1 章, 夯实基础, 在第 1 页看到的, 你可以把宏看作是把一段代码转换成另一段代码的函数. 事实证明, 通过一点小技巧, 你可以拿到那些函数并把它们当作值来使用. 你是否真的需要这样做是值得怀疑的, 但它给了你一个更清晰的画面, 让你了解到底发生了什么. 让我们用一种不同的方式来看这个琐碎的 square 宏:

;; beware/macro_as_function_1.clj
user=> (defmacro square [x] `(* ~x ~x))
;=> #'user/square
user=> @#'square
;=> #<user$square user$square@2a717ef5>
user=> (fn? @#'square)
;=> true

所以我们实际上可以通过解引用 var 来从我们定义宏的 var 中拉出一个函数! 但如果我们试图使用那个函数会发生什么呢?

;; beware/macro_as_function_2.clj
user=> (@#'square 9)
; ArityException Wrong number of args (1) passed to: user$square
; clojure.lang.AFn.throwArity (AFn.java:437)

Clojure 在抱怨我们传递了一个参数而不是正确的数量, 但这个函数的正确参数数量是多少呢? 事实证明, 这个函数接受两个初始参数, 一个 `form` 和一个 `environment` (还记得第 24 页的 宏的秘密魔法 中的 `&form` 和 `&env` 吗?), 以及宏定义的任何参数 (在我们的例子中只有一个). 这个宏碰巧不关心 `&form` 和 `&env`, 所以我们可以只传递虚拟值:

;; beware/macro_as_function_3.clj
user=> (@#'square nil nil 9)
;=> (clojure.core/* 9 9)

啊哈! 这看起来像宏展开, 事实上, 这正是 Clojure 编译器在宏展开期间调用的函数! 但就我而言, 这是在运行时可用的一个实现细节. 使用 (macroexpand-1 '(square 9)) 要清晰得多. 希望这能让你更好地了解宏在底层实际上是什么.

我们可能已经预见到这一点了: 每当你看到一个宏的名称出现在列表的第一个位置之外的任何地方, 你的脑海中都应该响起警钟, 因为我们不能把宏当作值来对待. 但我们如何解决这个问题呢? 如果我们知道正好有两个消息, 或三个消息, 等等, 我们或许可以从输入序列中拉出特定的元素来传递给宏.

花几分钟时间为这个用例写另一个宏, 假设你不能改变 log 宏或创建一个替代品.

也许你得出了类似这样的东西:

;; beware/contagious_3.clj
(defmacro notify-everyone [messages]
  `(do
     (send-email admin-user ~messages)
     (send-email current-user ~messages)
     (log ~@messages)))

user=> (notify-everyone ["item #1 processed" "by worker #72"])
;[INFO] item #1 processed : by worker #72
;=> nil

注意我们需要把三个表达式包装在一个 do 中, 因为一个宏展开只能返回一个表达式. 如果我们省略了它, 我们会在宏展开的代码中丢失前两个表达式. 这个宏实现确实有效. 但当然现在我们不能把 notify-everyone 当作一个值来使用, 所以我们对我们新代码的用户施加了同样的限制. 宏似乎正在接管我们的代码!

实际上, 这里更好的解决方案是 log 根本不是一个宏. 我们在这里不需要宏:

;; beware/contagious_4.clj
(require '[clojure.string :as string])
(defn log [& args]
  (println (str "[INFO] " (string/join " : " args))))

user=> (log "hi" "there")
;[INFO] hi : there
;=> nil

user=> (apply log ["hi" "there"])
;[INFO] hi : there
;=> nil

当然, 有时候我们确实想要一个带有可变参数的宏, 或者不那么明显有简单方法使用函数替代的情况. 在那些情况下, 我们真的想确定它们值得强迫客户端也使用宏的代价.

6.3. 写对宏可能很棘手

你已经看到了宏的许多微妙之处, 比如需要用 gensym 来避免符号捕获, 还有更多等着你. 虽然你处在一个很好的位置, 知道所有语言的宏构建规则, 但你可能仍然不清楚你所知道的东西的含义. 让我们再看几种你可能因为对宏代码做了错误的假设而无意中给自己带来麻烦的方式.

这里有一个 clojure.core/and 宏的重新实现, 看起来相当直接.

;; beware/and_1.clj
(defmacro our-and
  ([] true)
  ([x] x)
  ([x & next]
   `(if ~x (our-and ~@next) ~x)))

user=> (our-and true true)
;=> true
user=> (our-and true false)
;=> false
user=> (our-and true true false)
;=> false
user=> (our-and true true nil)
;=> nil
user=> (our-and 1 2 3)
;=> 3

它似乎与 clojure.core/and 有相同的行为, 对于递归的基本情况返回其参数, 如果失败则返回第一个非真值, 如果不失败则递归. 但如果调用者向 our-and 传递一个表达式会怎样?

;; beware/and_2.clj
user=> (our-and (do (println "hi there") (= 1 2)) (= 3 4))
;hi there
;hi there
;=> false

我们传入的表达式实际上被求值了两次! 回顾一下宏实现和宏展开, 很容易明白为什么会发生这种情况: 因为宏展开把整个表达式插入到了两个地方:

;; beware/and_3.clj
user=> (macroexpand-1 '(our-and (do (println "hi there") (= 1 2)) (= 3 4)))
;=> (if (do (println "hi there") (= 1 2))
;      (user/our-and (= 3 4))
;      (do (println "hi there") (= 1 2)))

在我们的例子中, 我们仍然得到了正确的答案, 但副作用是有问题的. 想象一下, 如果他们是向生产数据库写入而不是为程序员打印日志消息! 当然, 理想情况下我们不会有它们, 但在实践中, Clojure 程序员确实会使用 I/O, atom, ref, 以及其他有副作用的构造.

所以我们这里有两个选择: 我们要么 (a) 告诉 our-and 的客户端假设他们的表达式可能会被多次求值 (通过文档或口头相传), 要么 (b) 修复宏, 使其只求值表达式一次. 选择是明确的: 当我们有机会时, 让宏不那么令人惊讶总是更好的. 除非我们正在构建一个专门用于求值零次、多次或不确定次数的宏, 否则我们应该只求值参数一次. 所以这是我们的默认设置, 但宏的力量在于能够选择. 对我来说, 作为一个宏的用户, 重要的是我知道求值语义是什么, 我倾向于期望参数只被求值一次, 除非我有文档另有说明.

our-and 的情况下, 我们可以很容易地修复它, 使其只求值它的参数一次:

;; beware/and_4.clj
(defmacro our-and
  ([] true)
  ([x] x)
  ([x & next]
   `(if ~x (our-and ~@next) ~x)))

(defmacro our-and-fixed
  ([] true)
  ([x] x)
  ([x & next]
   `(let [arg# ~x]
      (if arg# (our-and-fixed ~@next) arg#))))

user=> (our-and-fixed (do (println "hi there") (= 1 2)) (= 3 4))
;hi there
;=> false

这里没有什么神奇的: 把一个局部变量提取到一个 let 绑定中, 和我们在函数内部有重复表达式时为避免重复求值而做的事情是一样的.

6.4. 谨慎行事

我这里的观点只有一部分是, 作为宏的作者, 我们应该避免多次执行输入表达式, 除非我们真的, 真的有意为之. 但更重要的是, 当我们编写宏时, 如果我们能保护那些使用宏的人, 使他们不必深入研究宏的实现来看为什么会发生一些令人惊讶的事情, 那就太好了. 我们的宏被邀请到用户的命名空间中展开, 我们应该通过尽可能少地制造混乱来欣赏和尊重这份邀请.

本章并没有详尽列出宏可能让你 tripped 的所有方式. 宏展开时的错误有时会生成令人困惑的编译器错误, 而没有对错误情况进行深思熟虑. 成功宏展开的代码中的错误会让你得到不知道引起错误的宏定义行号的堆栈跟踪. 需要被宏展开代码调用的辅助函数必须是公共的 (没有 defn-^:private), 以便宏调用能从其他命名空间工作. 我们在前几章中看到了其他潜在的绊脚石, 我们还会遇到更多. 这些是我们每次决定使用宏时必须代表我们的团队接受的权衡.

但事情是这样的: 有时函数就是不行. 尽管谦逊的函数是一个非常棒和多才多艺的工具, 但它并不完美适用于每项工作, 所以有时宏正是医生所嘱咐的. 在本书的其余部分, 我们将看看使用宏的好理由, 尽管它们可能会引起问题.

7. 第 4 章 在上下文中求值代码

到目前为止, 你已经看到了编写宏的构建块, 并且你也学到了为什么它们不是每个问题的理想解决方案. 在本书的其余部分, 我们将看几个宏的广泛类别, 以学习如何阐明你的代码意图, 添加你自己的控制流机制, 并利用你的其他宏超能力.

在本章中, 你将看到将它们所给的代码包装到一个新的上下文中求值的宏. 例如, 你可能想用动态绑定来求值一个给定的表达式, 在一个 try/catch 块内, 或者在一个资源被打开然后被清理的地方. 在这类宏中, 你并没有对输入表达式做任何太花哨的事情——它们只是被一些你想要从调用者那里抽象出来的逻辑包装起来. 你将看到一些方法来摆脱难以移除的结构性重复, 就像下面的例子, 其中变化的小部分代码 ([INFO] vs. [ERROR]) 被一堆从一个函数复制到另一个函数的代码包围着:

;; context/structural_duplication.clj
(require '[clojure.java.io :as io])
(defn info-to-file [path text]
  (let [file (io/writer path :append true)]
    (try
      (binding [*out* file]
        (println "[INFO]" text))
      (finally
        (.close file)))))

(defn error-to-file [path text]
  (let [file (io/writer path :append true)]
    (try
      (binding [*out* file]
        (println "[ERROR]" text))
      (finally
        (.close file)))))

7.1. 动态绑定

Clojure 社区热爱 词法作用域, 其中表达式中符号的值完全由其在代码中的位置决定, 而不是由其在调用堆栈中的位置决定. 也就是说, 我们喜欢函数为每次可能从一次调用到下一次调用变化的数据接受参数.

;; context/circle_area_lexical.clj
(defn circle-area [radius]
  (* Math/PI (* radius radius)))

(circle-area 10.0)
;=> 314.1592653589793

这里的 radius 是一个词法作用域的局部变量: 它的值完全由传递给函数的参数决定. 这使得一眼就能看出依赖关系是什么. 函数参数, 连同 letloop 绑定, 都是词法绑定的常见例子.

你能想象为什么我们中的许多人更喜欢词法作用域而不是 动态作用域 吗? 在动态作用域中, 函数用来完成其求值的值是从函数定义外部注入的.

;; context/circle_area_dynamic.clj
(declare ^:dynamic *radius*)
(defn circle-area []
  (* Math/PI (* *radius* *radius*)))

(binding [*radius* 10.0]
  (circle-area))
;=> 314.1592653589793

注意 *radius* 中的星号被称为 earmuffs (耳罩), 只是 Clojure 中动态作用域变量的一种命名约定——它们不是语言所要求的.

尽管传统上偏好词法作用域, 但 Clojure 将动态作用域作为一个选项是有充分理由的. 当通过一连串函数传递值会变得太麻烦时, 它给了我们一个逃生舱口, 而这些函数对这个值是无感的. 这种便利的主要例子是 I/O 变量 *out*, *in, 和 *err*. 在 Unix 中, 我们习惯于通过使用管道和命令行重定向来改变 stdout, stdin, 和 stderr 流的源和目的地. 在作用域方面, 重新绑定这些 Clojure 变量与我们如何重定向 Unix 输入和输出流以将程序连接在一起非常相似.

每当我们使用 Clojure 的任何打印函数时, 我们实际上都在使用动态绑定:

;; context/log.clj
(defn log [message]
  (let [timestamp (.format (java.text.SimpleDateFormat. "yyyy-MM-dd'T'HH:mmZ")
                           (java.util.Date.))]
    (println timestamp "[INFO]" message)))

log 能够调用 println (它使用 *out*) 而不必知道 *out* 实际上指向哪里, 这很好. 这样, log 只需要知道 println, 而不需要知道 println 的依赖 *out*. 所以如果我们想改变打印内容的位置, 我们只需要在调用使用 log 的代码时重新绑定 *out*.

;; context/log_to_file.clj
(defn process-events [events]
  (doseq [event events]
    ;; do some meaningful work based on the event
    (log (format "Event %s has been processed" (:id event)))))

(let [file (java.io.File. (System/getProperty "user.home") "event-stream.log")]
  (with-open [file (clojure.java.io/writer file :append true)]
    (binding [*out* file]
      (process-events [{:id 88896} {:id 88898}]))))

在这里, 我们决定将 *out* 重新路由到一个日志文件, 因为我们可能以后想看看它. 这工作得很好, 但有点嘈杂, 不是吗? 如果我们想在代码的几个地方这样做, 我们需要重复所有这些冗长的代码, 那会很可惜. 此外, 这段代码的主要目的是处理事件, 但这个意图被埋在这些设置输出流的 with-openbinding 表达式中. 只要花几分钟投资, 我们就可以写一个宏来抽象那个模式:

;; context/with_out_file.clj
(defmacro with-out-file [file & body]
  `(with-open [writer# (clojure.java.io/writer ~file :append true)]
     (binding [*out* writer#]
       ~@body)))

(let [file (java.io.File. (System/getProperty "user.home") "event-stream.log")]
  (with-out-file file
    (process-events [{:id 88894} {:id 88895} {:id 88897}])
    (process-events [{:id 88896} {:id 88898}])))

这感觉更符合问题领域. 我们不再需要指定输出流是如何创建和绑定的, 只需要我们想要追加输出的文件. 像 with-out-file 中那样设置绑定是宏在野外实际场景中非常常见的用例. Clojure 本身有几个内置的宏, 与 with-out-file 类似. 最相似的可能是 with-out-str:

;; context/with_out_str.clj
(defmacro with-out-str
  "Evaluates exprs in a context in which *out* is bound to a fresh
  StringWriter. Returns the string created by any nested printing
  calls."
  {:added "1.0"}
  [& body]
  `(let [s# (new java.io.StringWriter)]
     (binding [*out* s#]
       ~@body
       (str s#))))

看起来有点熟悉, 对吧? with-out-str 用重新绑定的 *out* 来求值 body 形式, 以便收集输出并将其作为字符串返回. 这对于单元测试函数产生的 I/O 非常有用, 连同它的兄弟 with-in-str, 它对输入做类似的事情:

;; context/with_in_str.clj
(defmacro with-in-str
  "Evaluates body in a context in which *in* is bound to a fresh
  StringReader initialized with the string s."
  {:added "1.0"}
  [s & body]
  `(with-open [s# (-> (java.io.StringReader. ~s)
                      clojure.lang.LineNumberingPushbackReader.)]
     (binding [*in* s#]
       ~@body)))

(defn join-input-lines [separator]
  (print (clojure.string/replace (slurp *in*) "\n" ",")))

(let [result (with-in-str "hello there\nhi\nsup\nohai"
               (with-out-str
                 (join-input-lines ",")))]
  (assert (= "hello there,hi,sup,ohai" result)))

尽管 join-input-lines 函数直接作用于 *in**out*, 我们能够设置这些流的假版本, 让我们能够注入我们想要的输入, 并查看输出以验证发生了什么. 而这一切都是因为那些宏允许我们在我们选择的上下文中求值 join-input-lines.

7.2. 非宏方法

然而, 除了宏之外, 还有其他方法来解决上下文求值的问题. 如果我们愿意稍微修改调用语法, 一个只用函数构建的 with-out-file 版本也是一个合理的选择:

;; context/with_out_file_fn.clj
(defn with-out-file [file body-fn]
  (with-open [writer (clojure.java.io/writer file :append true)]
    (binding [*out* writer]
      (body-fn))))

(let [file (java.io.File. (System/getProperty "user.home") "event-stream.log")]
  (with-out-file file
    (fn []
      (process-events [{:id 88894} {:id 88895} {:id 88897}])
      (process-events [{:id 88896} {:id 88898}]))))

这实际上一点也不差. 在这种情况下, 两者之间没有太多区别, 而且从语法角度来看, 哪个本质上更好也不清楚. 从代码简洁性的角度来看, 宏版本更好——我们不需要 (fn [] ...) 包装输入代码——但总是有权衡. 例如, 如果我们想把 with-out-file 作为一个值 (一个高阶函数) 传来传去, 我们就不能使用宏版本.

一般来说, 一个消耗待求值代码的宏通常可以重写为一个接受一个 thunk 的函数——一个没有参数的函数, 用于延迟代码求值. 这个宏替换函数可以选择多次求值 thunk, 或者根本不求值, 就像宏可以做的那样. 代价是在调用者端的代码清晰度上有一点小小的损失, 但我怀疑这就是为什么 with-* 宏在实践中如此普遍, 尽管它们可以被函数替换. 使用宏可以让你的调用者避免高阶函数需要的 (fn [] ...) 样板代码.

幸运的是, 我们可以通过将一个宏作为函数版本的薄包装来获得两全其美:

;; context/with_out_file_fn_wrapper.clj
(defn with-out-file-fn [file body-fn]
  (with-open [writer (clojure.java.io/writer file :append true)]
    (binding [*out* writer]
      (body-fn))))

(defmacro with-out-file [file & body]
  `(with-out-file-fn ~file
     (fn [] ~@body)))

(let [file (java.io.File. (System/getProperty "user.home") "event-stream.log")]
  (with-out-file file
    (process-events [{:id 88894} {:id 88895} {:id 88897}])
    (process-events [{:id 88896} {:id 88898}])))

这样, 用户就可以选择使用更简洁的宏版本或更灵活的函数版本. 当这种包装风格可行时, 它几乎总是一个好主意. 给你的队友和库用户 (以及你未来的自己!) 在他们想要的时候使用函数的选项, 这是一件好事.

7.3. 在特定时间和位置求值(或不求值)

Clojure 中最不复杂 (但最常用) 的宏之一是 comment, 它完全避免了对其包含的代码的求值:

;; context/comment.clj
(defmacro comment
  "Ignores body, yields nil"
  {:added "1.0"}
  [& body])

(comment
  (println "wow")
  (println "this macro is incredible"))
;=> nil

(+ 1 2) ; this is another type of comment
(+ 1 2) #_(println "this is yet another")

Clojure 中还有几种其他的注释机制, 但 comment 是唯一一个是宏的 (其他的都内置在 Clojure reader 中). comment 的返回值总是 nil, 传递给它的任何代码都永远不会被求值. 因为它是一个宏, 传入的代码必须是语法正确的, 这样才能被读取. 使用 comment 宏的一个好处是, 你可以在你选择的编辑器中获得语法高亮. 所以在实践中, 使用 comment 来展示如何在特定命名空间中使用代码的例子是相当普遍的. 我个人不常用它, 因为我更喜欢使用执行并且不会过时的单元测试. 但在我需要它的时候有它可用是很好的. 所以 comment 给了我们在其他上下文中求值代码的一个退化情况, 就像 /dev/null 给了我们一个发送输出流的退化地方一样.

一个更有趣的使用宏在不同上下文中求值代码的例子是 dosync, Clojure 的软件事务内存 (STM) 系统的入口点:

;; context/dosync.clj
(defmacro dosync
  "Runs the exprs (in an implicit do) in a transaction that encompasses
  exprs and any nested calls. Starts a transaction if none is already
  running on this thread. Any uncaught exception will abort the
  transaction and flow out of dosync. The exprs may be run more than
  once, but any effects on Refs will be atomic."
  {:added "1.0"}
  [& exprs]
  `(sync nil ~@exprs))

(defmacro sync
  "transaction-flags => TBD, pass nil for now

  Runs the exprs (in an implicit do) in a transaction that encompasses
  exprs and any nested calls. Starts a transaction if none is already
  running on this thread. Any uncaught exception will abort the
  transaction and flow out of sync. The exprs may be run more than
  once, but any effects on Refs will be atomic."
  {:added "1.0"}
  [flags-ignored-for-now & body]
  `(. clojure.lang.LockingTransaction
     (runInTransaction (fn [] ~@body))))

(def ant-1 (ref {:id 1 :x 0 :y 0}))
(def ant-2 (ref {:id 2 :x 10 :y 10}))

(dosync
  (alter ant-1 update-in [:x] inc)
  (alter ant-1 update-in [:y] inc)
  (alter ant-2 update-in [:x] dec)
  (alter ant-2 update-in [:y] dec))

你见过的任何 Clojure STM 例子都允许你发送代码在一个事务中被求值, 以便在冲突的情况下重试, 而 dosync 宏 (以及底层的 sync) 将参数表达式捆绑成一个合适的形式 (一个 thunk). 我发现大多数人不需要经常使用 STM 系统, 所以 dosync 在 Clojure 语言介绍之外相对少见, 但让它为你生成代码真的很方便. 否则你就需要输入 (或设置编辑器自动化) LockingTransaction, 匿名函数创建, 以及其余部分.

因为我们有能力说 何时 和在 什么上下文 中我们想要求值一段代码, 我们甚至可以做一些事情, 比如发送代码到 future1 (一个 java.util.concurrent.Future 的实例, 具体来说) 中被求值. clojure.core 中的 future 宏将其参数表达式包装成一个函数, 并将该函数提交给一个 Executor (这之所以可行, 是因为 Clojure 函数实现了 java.util.concurrent.Callable):

;; context/future.clj
(defmacro future
  "Takes a body of expressions and yields a future object that will
  invoke the body in another thread, and will cache the result and
  return it on all subsequent calls to deref/@. If the computation has
  not yet finished, calls to deref/@ will block, unless the variant of
  deref with timeout is used. See also - realized?."
  {:added "1.1"}
  [& body] `(future-call (^{:once true} fn* [] ~@body)))

(def f (future (Thread/sleep 5000)
               (println "done!")
               (+ 41 1)))

@f
;=> 42 (after sleeping 5 seconds and then printing "done!")

这里有几个有趣的事情正在发生. 首先是宏使用了一个底层的函数 future-call 来做大部分工作. 实际上, future 本身只是那个函数的一个薄包装. 就像我们最终的 with-out-file 实现一样, 这给了我们两全其美. 我们可以在需要时使用底层的 future-call 函数, 但我们仍然有宏版本的简洁性供正常使用. 第二个有趣的事情是 fn* 上的 {:once true} 元数据. 这是一个相当低级的编译器特性, 帮助我们避免意外的内存泄漏. 它确保函数中任何闭包的局部变量在函数被调用后被清除. 这样, Clojure 就不必无限期地持有那些值, 错误地认为你可能会再次调用该函数. 每当我们创建我们 知道 只会被调用一次的函数时 (或者如果我们至少知道我们不需要在函数调用中使用闭包的局部变量), 使用 (^:once fn* [] ...) 而不是普通的 (fn [] ...) 是一个避免泄漏内存的好主意. ^:keyword-here 顺便说一下, 只是 Clojure 元数据中常见的 ^{:keyword-here true} 模式的简写.

;; context/once.clj
(let [x :a
      f (^:once fn* [] (println x))]
  (f) ;; prints :a
  (f)) ;; prints nil

(let [x :a
      f (fn [] (println x))]
  (f) ;; prints :a
  (f)) ;; prints :a

^:once 修饰的版本中, 在 f 的第一次求值之后, 局部变量 x 被清除了, 所以第二次求值时 x 被绑定到 nil. 显然, 这只有在函数只会执行一次时才有用. 注意这里必须使用 fn* 而不是 fn 才能得到这个优化的好处. 当然, 如果函数会执行多次, 或者你不介意泄漏你的局部变量消耗的内存, 你总是可以使用通常的 fn 来定义你的函数.

7.4. 错误补救

宏的另一个常见用例是限制我们在求值期间遇到的任何错误的范围. 单元测试库通常 必须 这样做, 以便在继续运行其他测试的同时捕获失败. 例如, 在 clojure.test 中, is 宏使用一个内部宏 try-expr 来捕获异常并将它们报告给测试运行基础设施.

;; context/try_expr.clj
(defmacro try-expr [msg form]
  `(try ~(assert-expr msg form)
     (catch Throwable t#
       (do-report {:type :error, :message ~msg,
                   :expected '~form, :actual t#}))))

(defmacro is
  ([form] `(is ~form nil))
  ([form msg] `(try-expr ~msg ~form)))

所以当 form 实际上在 assert-expr 中被求值时, 如果一个意外的异常因为一些失败的代码而传播, 我们肯定想 (a) 阻止那个失败传播, 以便测试可以继续, (b) 捕获关于失败的信息以便向用户报告. 将求值包装在一个 try-catch 块中是唯一的方法. 这让我们能够编写像这样的测试用例:

;; context/test.clj
(require '[clojure.test :refer [is]])
(is (= 1 1))
;=> true

(is (= 1 (do (throw (Exception.)) 1)))
; ERROR in clojure.lang.PersistentList$EmptyList@1 (NO_SOURCE_FILE:1)
; expected: (= 1 (throw (Exception.)))
; actual: java.lang.Exception: null
;=> nil

在这里, 我们 几乎 可以通过要求客户端传递函数而不是表达式来避免宏. 这将要求用户传递一个 thunk 而不仅仅是一个表达式. 但 try-expr 的以下函数版本有一个问题. 你能发现它吗?

;; context/try_expr_fn.clj
(require '[clojure.test :as test])
(defn try-expr [msg f]
  (try (eval (test/assert-expr msg (f)))
    (catch Throwable t
      (test/do-report {:type :error, :message msg,
                       :expected f, :actual t}))))

(defn our-is
  ([f] (our-is (f) nil))
  ([f msg] (test/try-expr msg f)))

(our-is (fn [] (= 1 1)))
;=> true

(our-is (fn [] (= 1 2)))
; FAIL in clojure.lang.PersistentList$EmptyList@1 (NO_SOURCE_FILE:3)
; expected: f
; actual: false
;=> false

即使假设我们可以通过更新 assert-expr 及其所有依赖项为函数版本来替换丑陋的 eval 以避免 eval, 我们仍然会留下你在这里看到的可怕的错误消息. 当我们停下来思考 try-expr 宏在失败时实际上为我们做什么时, 很明显只有宏才能做到. 当我们向测试运行器报告这个失败时, 我们不仅给了它来自测试的消息和我们遇到的异常, 还给了它 失败的完整表达式! 而有了宏, 我们就可以访问那个原始的表达式, 我们可以求值它 (或者, 在这种情况下, 把它传递给 assert-expr 来求值), 或者根据我们的需要把它当作一个表达式来使用. 具有讽刺意味的是, 虽然这个例子向我们展示了宏如何帮助我们提供优秀的、特定于上下文的错误消息, 但在实践中, 宏往往是更令人困惑的错误消息的来源.

7.5. 清理资源

在许多语言中, 包括 Java, 如果你以前没有做过, 安全地清理资源有点棘手. 以从文件中读取为例: 首先我们需要打开文件资源, 然后用它做我们的工作, 然后当我们完成时, 我们需要关闭资源. 这只是一个三步过程, 但有很多可能出错的地方. 在读取时可能会有异常.

从文件中. 打开文件时可能会有异常. 由于这个原因, 看到像下面这样的 Java 代码示例来将文件内容读入字符串并不罕见:

// context/teardown_filestream.java
public String readFile(String filePath) throws IOException {
    FileInputStream fileStream = null;
    StringBuilder contents = new StringBuilder();
    byte[] buffer = new byte;
    try {
        fileStream = new FileInputStream(filePath);
        while (fileStream.read(buffer) != -1) {
            contents.append(new String(buffer));
        }
    } finally {
        if (fileStream != null) {
            fileStream.close();
        }
    }
    return contents.toString();
}

为了这次讨论的目的, 让我们忽略将文件读入字符串是一个工程错误 (因为如果我们给了一个巨大的文件怎么办?). 让我们把字符串追加看作是一个我们需要清理资源的普遍问题的一个特例. 现在, 诚然, Java 7 终于 (zing!) 引入了一个语言特性来使这更干净: try-with-resources.2 但许多其他语言的用户 (以及停留在 Java 6 或更低版本的 Java 用户) 就没那么幸运了. 这里的核心算法似乎被这个 try/finally 模式和为了绕过块作用域而单独的 fileStream 声明特别地模糊了. 有了允许上下文求值的宏, 一个很好的解决方案被内置到一个宏中, with-open:

;; context/with_open.clj
(defmacro with-open
  "bindings => [name init ...]

  Evaluates body in a try expression with names bound to the values
  of the inits, and a finally clause that calls (.close name) on each
  name in reverse order."
  {:added "1.0"}
  [bindings & body]
  (assert-args
    (vector? bindings) "a vector for its binding"
    (even? (count bindings)) "an even number of forms in binding vector")
  (cond
    (= (count bindings) 0) `(do ~@body)
    (symbol? (bindings 0)) `(let ~(subvec bindings 0 2)
                              (try
                                (with-open ~(subvec bindings 2) ~@body)
                                (finally
                                  (. ~(bindings 0) close))))
    :else (throw (IllegalArgumentException.
                   "with-open only allows Symbols in bindings"))))

7.6. 抽象掉细节

这里的要点, 以及本章中的其他例子, 是宏允许你消除清理打开资源, 或补救错误, 或设置动态绑定或其他上下文进行求值的嘈杂细节. 通过编写宏来抽象掉这些上下文细节, 你可以阐明你正在执行的核心操作, 使你的代码的目的对将来会读它的人 (包括你自己!) 更明显.

这是宏的一个核心能力, 你会看到这个类别和我们稍后将要介绍的类别之间有很多重叠.

现在你已经看到了一些你可以使用宏来抽象掉你的代码将求值的上下文的方式, 我们将看看如何使用抽象和更多宏特有的工具来提高我们的 Clojure 代码的运行时性能.

8. 第 5 章 为你的系统提速

所以你在这里, 深入分析你的网站流量日志的任务中. 你本希望昨天就完成它, 所以当你意识到你终于把所有东西都测试好并工作了时, 你的心跳加速. 现在是时候在完整的数据集上启动任务了, 然后等待. 等待.

再等待. 完成需要多长时间? 一天? 一周? 直到宇宙热寂? 大约一个小时后, 事情看起来相当黯淡.

现在, Clojure 很快. 语言的指导原则之一是, 它应该在 Java 有用的任何地方都有用, 包括需要高性能的用例, 就像你的日志分析工作一样. 与任何其他编程语言一样, 有时我们需要一袋子技巧来让我们的代码尽可能快.

有时, 像类型提示和其他 Java 互操作工件这样的性能技巧会导致代码更丑. 但有了宏, 我们可以隐藏噪音并保持我们代码的美感. 你将在本章中看到如何做到这一点, 你还将看到如何通过宏在某些情况下完全避免运行时成本.

但首先, 你需要通过基准测试来确保你优化了正确的东西.

8.1. 基准测试你的代码

关于性能的任何讨论都不会是完整的 (或者以我的思维方式, 甚至值得开始), 如果没有 Donald Knuth [Knu74] 的那句名言:

“我们应该忘记小的效率, 大约 97% 的时间: 过早的优化是万恶之源. 然而, 我们不应该放弃那关键的 3% 的机会.”

如果我们把我们有限的时间随机地应用到代码库上, 它可能不会改善任何存在的瓶颈. 我的经验倾向于与 Knuth 的建议一致: 首先找到我们程序中的问题区域, 可以节省我们的时间, 和很多浪费的努力. 如果我们的程序不需要快, 任何

性能工作都是不必要的, 或者至少是过早的. 如果我们的 web 应用程序大部分时间都花在 I/O 任务上, 比如从数据库传输数据到应用程序本身, 然后把它推送到用户的 web 浏览器, 我们做的任何速度改进都不会对用户产生明显的影响. 当我们做性能改进时, 我们有指标显示我们产生了影响是至关重要的.

令人高兴的是, 有一个很棒的基准测试工具可以帮助我们决定我们代码的哪些部分要优化以及如何优化. Hugo Duncan 的 Criterium1 是许多 Clojure 开发者的首选基准测试库, 而且有充分的理由. 开始使用 Criterium 非常容易, 因为它只是把它添加到你的 Leiningen 依赖项中的问题. Criterium 会多次运行你的代码, 以便将 JIT 和垃圾收集考虑在内. 它还收集运行时间的统计数据, 以提供更详细的信息, 如标准差和异常值. (别担心那个神秘的 :jvm-opts ^:replace [] 行——它只是启用了一些 JVM JIT 优化.)

顺便说一句, 优化会导致启动时间稍长, 但在性能测试和其他真实世界的生产场景中花额外的时间是值得的.

;; performance/project.clj
(defproject foo "0.1.0-SNAPSHOT"
  :dependencies [[org.clojure/clojure "1.6.0"]]
  :jvm-opts ^:replace []
  :profiles {:dev {:dependencies [[criterium "0.4.2"]]}})

Criterium 实际上在内部使用宏, 原因与我们在第 4 章, 在上下文中求值代码, 在第 35 页讨论的一些原因相同, 但在这里我们只是想衡量性能改进. 让我们在带有 project.clj 的项目中打开一个 REPL, 看看如何使用 Criterium 在优化之前衡量代码的速度:

;; performance/criterium_run.clj
user=> (use 'criterium.core)
user=> (defn length-1 [x] (.length x))
;=> #'user/length-1
user=> (bench (length-1 "hi there!"))
;Evaluation count : 26255400 in 60 samples of 437590 calls.
;             Execution time mean : 3.250388 µs
;    Execution time std-deviation : 850.690042 ns
;   Execution time lower quantile : 2.303419 µs ( 2.5%)
;   Execution time upper quantile : 5.038536 µs (97.5%)
;                  Overhead used : 2.193109 ns
;
;Found 1 outliers in 60 samples (1.6667 %)
;       low-severe      1 (1.6667 %)
; Variance from outliers : 94.6569 % Variance is severely inflated by outliers
;=> nil

8.2. 隐藏性能优化

还有各种其他的工具可以看到你的 Clojure 代码是如何执行的, 从像 JVisualVM2 (包含在 Oracle JDK 发行版中) 这样的分析器到像 no.disassemble.3 这样的反汇编器. 这些工具在那里帮助你选择正确的问题来解决, 所以一定要使用它们! 你不想浪费你宝贵的时间去优化已经足够快的代码.

现在我们已经证明了一段特定的代码确实太慢了, 我们能做些什么来让它更快呢? Shantanu Kumar 的 Clojure High Performance Programming [Kum13] 充满了提高 Clojure 程序性能的技术, 我们在本章中涉及了两个技巧. 通常这些技术意味着拥抱 Clojure 的美妙的 Java 互操作, 使用原生类型, 数组, 和类型提示来匹配 Java 的性能. 然而, 一个充满了这些特性的代码库会让 Clojure 感觉像 Java, 这对于一个函数式编程的信徒来说可能是令人震惊的. 让我们看看我们如何能加速, 而不至于得到丑陋, 不可读的代码.

想象一下, 你一直在为一个你的后台应用程序的功能工作, 该功能计算关于最新销售数字的一些统计数据. 你已经把最大的问题追踪到一个紧凑的循环, 它把一个数字向量加在一起, 使用一个名为 sum 的函数, 它是用一种非常典型的 Clojure 风格写的. 这里是你如何可能加速它的方法:

;; performance/sum.clj
(defn sum [xs]
  (reduce + xs))
(def collection (range 100))
(bench (sum collection))
;             Execution time mean : 2.078925 µs
;    Execution time std-deviation : 378.988150 ns

(defn array-sum [^ints xs]
  (loop [index 0
         acc 0]
    (if (< index (alength xs))
      (recur (unchecked-inc index) (+ acc (aget xs index)))
      acc)))
(def array (into-array Integer/TYPE (range 100)))
(bench (array-sum array))
;             Execution time mean : 161.939607 ns
;    Execution time std-deviation : 5.566530 ns

8.3. 将执行移至编译期

如果你在打高尔夫球, 杆数最少者胜, 你是愿意一杆进洞还是完全跳过这个洞? 假设这样做是合法的 (它不是), 并且你一开始就不喜欢玩这个游戏 (你的体验可能会有所不同), 零显然是比一更好的分数.

当涉及到维护时, 最好的代码是根本没有代码.5 最快的代码是在运行时不需要执行的代码. 在我们的例子中, 指令数最少者胜! 记住, 我们可以认为宏在运行时甚至不存在; 这是我们加速的一个明显机会.

需要注意的是, 为了将昂贵的计算移到编译时, 我们需要在编译时访问计算的所有输入. 所以需要用户输入的计算, 即使是间接的, 也是这类优化的一个糟糕的候选者. 对于任何输入值可能变化的计算也是如此: 当它们被要求在宏展开期间计算它们的值时, 它们还没有它们的输入值.

例如, 一个与 web 服务对话的函数不能真正被宏化, 因为输入在运行时函数被调用之前是不可用的:

;; performance/non_macroizable.clj
(defn calculate-estimate [project-id]
  (let [{:keys [optimistic realistic pessimistic]}
        (fetch-estimates-from-web-service project-id)
        weighted-mean (/ (+ optimistic (* realistic 4) pessimistic) 6)
        std-dev (/ (- pessimistic optimistic) 6)]
    (double (+ weighted-mean (* 2 std-dev)))))

但是如果我们能减少这个函数的职责, 让它只处理计算而不处理 web 服务调用 (把那个工作留给一些新的函数), 那将给我们一个更好的设计, 和一个关于我们如何可能从宏化这个调用中受益的想法:

;; performance/macroizable_1.clj
(defn calculate-estimate [{:keys [optimistic realistic pessimistic]}]
  (let [weighted-mean (/ (+ optimistic (* realistic 4) pessimistic) 6)
        std-dev (/ (- pessimistic optimistic) 6)]
    (double (+ weighted-mean (* 2 std-dev)))))

8.4. 在 ClojureScript 宏中使用 JVM

将求值从运行时转移到编译时的想法在我们将其应用于 ClojureScript 时特别有趣, 其中宏在 (JVM) Clojure 中编写和展开, 但运行时求值发生在 JavaScript 虚拟机上. 通过将昂贵的操作移到 JVM 上的编译时, 我们可以显著减少我们在运行时需要做的工作量.

去年在工作的一个项目上, 我们的目标是从文件中读入一个数据结构, 用来构建一些 HTML. 实现这个目标有几个障碍:

  • 从文件系统读取是一种昂贵的操作, 我们的文件在程序运行时永远不会改变.
  • 我们的 ClojureScript 在浏览器中运行 (而不是 Node.js 或类似的东西), 所以它甚至没有访问文件系统的能力来读取文件.

我们对这个问题的最初解决方案是使用 ClojureScript 宏系统, 这是可行的, 因为 JVM-land 的宏可以访问文件系统. 因为这感觉有点太聪明和神奇了, 我们最终从那个解决方案转向了另一个, 它把数据结构放到普通的 ClojureScript 函数中, 而不是它们自己的文件中. 但有趣的是, 我们能够将一个非常昂贵的操作 (事实上一个在运行时似乎不可能做的操作!) 提前到编译时来解决我们的问题. 所以, 长话短说.

8.5. 对速度的需求

在本章中, 你已经看到了一些宏可以帮助你编写快速系统, 同时保持代码简洁的方法, 包括隐藏难看的性能优化技巧和将执行转移到编译时. 你已经看到像 Criterium 这样的工具可以告诉你什么是慢的, 以及你随后的改变是否改善了性能. 加速软件可能真的很有趣, 但如果它不需要快, 你就不需要浪费时间在上面工作.

现在你已经看到了如何加速你代码的运行时性能, 我们将看看你如何能加速一个更重要的瓶颈: 你对代码的理解.

9. 第 6 章 构建名副其实的 API

当我们读到比需要更冗长的代码时, 它会减慢我们的速度. 理想情况下, 我们想要的代码只包含传达其目的所必需的内容. 在本章中, 我们将看看提供优美 API 的方法, 让开发者避免在他们的代码中包含不必要的样板代码. 我们这里不是在谈论 HTTP web 服务意义上的 API, 只是一个库或一段代码暴露出来让你使用它的接口.

这个目标可能看起来有点模糊, 因为 “美” 是主观的, 而且易于使用的纯函数 API 已经很普遍了. 好消息是, 提供一个简洁的宏 API 并不妨碍你同时提供一个带有函数的 API. 我强烈推荐这种做法, 以帮助那些想要避免我们在第 3 章, 明智地使用你的力量, 在第 27 页谈到的问题的用户. 通常的工作流程是先写函数式 API, 然后在需要的地方在上面添加一个薄的宏层. 当然, 你做这些事情的顺序远不如最终结果重要: 提供一个灵活的函数 API 和一个可能更愉快的宏 API.

在本章中, 我们将看几个你可能已经与之交互过的 Clojure API, 看看它们的宏如何影响它们呈现的接口.

9.1. Compojure

像 James Reeves 的大多数库一样, Compojure1 激励我们在自己的工作中追求优雅. 如果你用 Clojure 做过任何 web 工作, 你可能在某个时候使用过 Compojure, 在 Ring2 之上处理你的 HTTP 路由. 一个使用 Compojure 的典型应用开始时是这样的:

9.2. Clojure Koans

一个对 Clojure 新手有帮助的练习集, Clojure Koans,5 其代码结构基于一个名为 meditations 的宏, 它提供了一个无样板的接口. 也许你已经像我在第 ix 页的 引言 中建议的那样尝试过这些, 并想知道它在底层是如何工作的.

koans 要求你填空以使每个测试通过:

;; apis/koans_1.clj
(meditations
  "We shall contemplate truth by testing reality, via equality"
  (= __ true)

  "To understand reality, we must compare our expectations against reality"
  (= __ (+ 1 1)))

但在 meditations 宏呈现的接口中, 没有测试! 我们只看到一些 (或多或少有帮助的) 文本, 模糊地描述了我们想要使其为真的表达式. 然而, 在底层, meditations 创建了断言 (使用另一个命名空间中一个名为 fancy-assert 的辅助函数, 其细节我们不需要看), 让用户可以看到他们的输入是否正确:

;; apis/koans_2.clj
(ns koan-engine.core
  (:require [koan-engine.util :as u]))

(def __ :fill-in-the-blank)
(def ___ (fn [& args] __))

(defmacro meditations [& forms]
  (let [pairs (partition 2 forms)
        tests (map (fn [[doc# code#]]
                     `(u/fancy-assert ~code# ~doc#))
                   pairs)]
    `(do ~@tests)))

9.3. 将宏与函数解耦

当为 API 目的使用宏时, 记住宏是锦上添花, 而不是整个蛋糕. 如果我们选择提供一个宏 API 层, 通过函数提供对底层机器的访问是一个很好的解耦策略. 这使得我们的代码对于可能有不同需求和审美考虑的消费者更方便, 但也对我们阅读, 测试和维护我们的代码更方便. 所有其他条件相同的情况下, 围绕做一件事的函数和宏来包装我们的思想比理解做许多事情的要容易得多.

现在你已经看到了如何制作简洁的 API, 在你的领域逻辑函数之上给用户一个很好的接口, 我们将深入到语言的更低层次, 看看你如何能在更低层次的构造之上构建控制流机制.

10. 第 7 章 随心所欲地控制流程

因为函数在它们的函数体有机会执行之前会求值它们的参数, 所以当你想创建新的控制流构造时, 宏是你的首选. 在本章中, 你将看到如何用宏构建像 whiledo-while 这样的基本控制流机制.

然后我们将深入探讨更高级的控制流特性, 比如定界延续, 并惊叹于我们不需要游说任何语言委员会就能把这些澄清结构引入我们的程序.

10.1. 循环, 循环, 再循环…

在许多语言中, 在你接触该语言的头 24 小时内, 你会学到你将要使用的为数不多的几个循环构造. 通常, 这些构造包括 while, do-while, 和 for.

Clojure 确实带有一些内置的控制流工具, 比如 whilefor (尽管 Clojure 的 for 是一个列表推导, 比命令式语言中通常用来迭代索引元素的 for 循环要花哨得多).

更重要的是, 在 Clojure 的情况下, 这些构造几乎总是宏. 我们可以自己写它们, 并在我们的程序中使用它们. 我不知道你怎么想, 但这给了我一种巨大的力量感.

让我们看看自己构建这些是多么容易, 从 while 开始:

;; control_flow/while.clj
(defmacro while
  "Repeatedly executes body while test expression is true. Presumes
  some side-effect will cause test to become false/nil. Returns nil"
  {:added "1.0"}
  [test & body]
  `(loop []
     (when ~test
       ~@body
       (recur))))

这个在 loop 特殊形式之上的简短宏给了我们 Clojure 自带的熟悉的 while 循环! 在这里, 我们利用了我们在第 31 页的 写对宏可能很棘手 中看到的多次求值的潜在危险, 并把它用作我们的优势.

每次通过循环, 我们都会重新求值 test 和传递给 while 的表达式主体, 直到 test 返回 falsenil. 正如文档字符串所暗示的, 我们最好在使用 while 时在某个点改变一些可变状态——否则我们就会有一个无限循环:

;; control_flow/while_example.clj
(def counter (atom 0))

(while (< @counter 3)
  (println @counter)
  (swap! counter inc))
; 0
; 1
; 2
;=> nil

如果我们要用原始的 `loop/recur` 机制来写类似的计数器代码, 它就不会有 `while` 版本那么清晰:

;; control_flow/while_as_loop_recur.clj
(def counter (atom 0))

(loop []
  (when (< @counter 3)
    (println @counter)
    (swap! counter inc)
    (recur)))
; 0
; 1
; 2
;=> nil

许多控制流宏都建立在 loop/recur 之上——~while~ 不仅仅是一个孤立的例子. 你可以把它看作是一种低级操作, 几乎就像 Clojure 控制流的汇编语言. Clojure 没有内置的 do-while, 但我们可以很容易地添加一个.

如果我们需要像 while 一样的东西, 但保证表达式的主体至少执行一次, 这可能很有用:

;; control_flow/do_while.clj
(defmacro do-while [test & body]
  `(loop []
     ~@body
     (when ~test (recur))))

(defn play-game [secret]
  (let [guess (atom nil)]
    (do-while (not= (str secret) (str @guess))
      (print "Guess the secret I'm thinking: ")
      (flush)
      (reset! guess (read-line)))
    (println "You got it!")))

(play-game "zebra")
; Guess the secret I'm thinking: lion
; Guess the secret I'm thinking: zebra
; You got it!
;=> nil

如果我们只玩一次这个游戏, 一个普通的 while 循环就可以了, 因为猜测开始时是 nil. 但如果我们用一个狡猾的 "" 秘密来玩 (并且用 while 而不是 do-while), 玩家甚至不用输入答案就会赢.

所以 do-while 循环解决了那个问题. 我不会再进一步试图说服你应该寻找使用 do-while 循环的机会. 这里重要的事情是, 你可以用几行简短的代码自己创建这个控制流特性.

10.2. 使用线程宏反转表达式

Clojure 中一些最有趣的内置宏是那些允许你以一种更接近其执行顺序的方式重写深度嵌套表达式的宏. 在本节中, 我们将看看如何使用这些 线程宏 来简化我们的代码, 以及那些宏是如何施展它们的魔法的.

为了避免混淆: 这个上下文中的 threading 与 JVM/OS 线程无关; 这两个概念只是有一个不幸的命名冲突.

这些宏中最常见的是 ->, 它将每个表达式的结果作为下一个表达式的第一个参数 (在下一个表达式不是列表的情况下将符号转换为列表). 也许你以前见过这个宏, 当组合 Ring1 中间件时:

;; control_flow/threading_ring_middleware.clj
(def app-1
  (wrap-head
    (wrap-file-info
      (wrap-resource (wrap-session
                       (wrap-flash
                         (wrap-params app-handler)))
                     "public"))))

10.3. 定界延续

如果你用一种命令式语言编程过, 你很可能用过一个 守卫子句 在某些情况下从一个方法或函数中提前返回, 就像下面这个近似于在 Twitter 上关注某人的 Ruby 代码:

# control_flow/guard_clause.rb
def follow_user(user, user_to_follow)
  if user_to_follow.blocked?(user)
    puts "#{user_to_follow.name} has blocked #{user.name}"
    return
  end
  user.following << user_to_follow
  user_to_follow.followers << user
end

如果我们到处使用提前返回, 它们可能会令人困惑, 但它们对于描述主方法体执行的要求很有用. 通常在 Clojure 中, 我们需要写类似这样的东西:

;; control_flow/guard_clause_1.clj
(defn follow-user [user user-to_follow]
  (if (contains? @(:blocked user-to_follow) (:name user))
    (println (:name user-to-follow) "has blocked" (:name user))
    (do
      (println "Adding follow relationship...")
      (swap! (:following user) conj (:name user-to-follow))
      (swap! (:followers user-to-follow) conj (:name user)))))

这种嵌套在 Clojure 中并不罕见, 但它并不是真正的提前返回. 在前面的例子中, 检测一个用户是否被屏蔽并不真的像函数的前置条件——它更像是函数体其余部分的同级.

事实证明, 我们可以通过使用 定界延续 在 Clojure 中模拟提前返回. 思考延续的一个好方法是, 它们让你暂停你的代码的执行, 捕获它的环境, 并保存一个对那个环境的引用以便稍后使用. 再说一遍, 它们让你捕获环境: 像局部变量甚至调用堆栈的当前状态这样的东西!

如果你以前没有见过延续, 这可能听起来很疯狂. 对我来说, 它仍然有点听起来那样. Oleg Kiselyov, 他在数量和质量上都做了一些惊人的研究3, 对于像 Scheme 的 call/cc4 这样的无定界延续有一些很好的论点, 但定界变体的问题较少.

delimc5 库以宏的形式提供了定界延续, 所以如果你在你的 project.clj 中添加 [delimc "0.1.0"], 你可以在你的示例项目中尝试这个:

;; control_flow/guard_clause_2.clj
(use 'delimc.core) ;; gives us `shift` and `reset`

(defn make-user [name]
  {:name name
   :blocked (atom #{})
   :following (atom #{})
   :followers (atom #{})})

(def colin (make-user "Colin"))
(def owen (make-user "Owen"))

(defn follow-user [user user-to-follow]
  (reset
    (shift k
      (if (contains? @(:blocked user-to-follow) (:name user))
        (println (:name user-to-follow) "has blocked" (:name user))
        (k :ok)))
    (println "Adding follow relationship...")
    (swap! (:following user) conj (:name user-to-follow))
    (swap! (:followers user-to-follow) conj (:name user))))

(swap! (:blocked owen) conj (:name colin))
(follow-user colin owen)
; Owen has blocked Colin
;=> nil

10.4. 控制流: 语言特性的一个特例

在本章中, 你已经看到了一些宏赋予你创造力量的惊人控制流机制. 现在你已经看到了一种特定的方式来扩展语言, 这种方式是你在其他语言中做不到的, 我们将看看更一般的情况. 在下一章, 你将学习如何将其他语言特性实现为宏.

11. 第 8 章 实现新的语言特性

在我用过的每一种编程语言中, 我偶尔会渴望来自其他语言的特性. 当我在 Java 中时, 我想要像 Scheme 中那样的一等函数. 在 Ruby 中, 我想要像 Java 中那样的显式依赖. 不管你的语言有多好, 其他语言中都有足够多的伟大工作正在进行, 你很可能会最终看到一个来自另一种语言的特性, 它让你着迷. 值得庆幸的是, 在 Clojure 中, 我们可以自己实现许多这些缺失的特性.

11.1. 实现模式匹配

如果你曾在 Clojure 之外以函数式风格编程, 也许使用 Haskell, Erlang, 或 Scala, 你几乎肯定见过 模式匹配. 你可以把模式匹配看作与 Clojure 的 解构 特性相似. 它允许你分解输入表达式并将值绑定到你想要的任何地方的名称上. 但模式匹配实际上比解构更进一步, 因为它让你提供几个潜在的模式来匹配, 连同如果匹配成功将执行的子句.

所以如果你是一个来到 Clojure 的模式匹配粉丝, 你会怎么做? 如果你立刻想到, “我可以写一个宏!”, 你比我刚学到模式匹配时对你的宏技能更乐观——太棒了! 而且你也完全正确! 无论你对模式匹配的样子是否有点模糊, 让我们决定我们可能想在我们的模式匹配器中看到什么. 我们将从一些简单的东西开始, 只是为了确保我们能控制执行流, 类似于我们在上一章在第 65 页做的事情:

;; language_features/pattern_matching_1.clj
;; desired API (using vectors so we can match multiple things later on):
(match [x]
  :zero
  :one
  :else :foo)

;; desired macroexpansion:
(cond (= [x]) :zero
      (= [x]) :one
      :else :foo)

;; expected outcomes
(describe "pattern matching"
  (it "matches an int"
    ;; will only compile once we've written `match`
    (let [match-simple-int-input (fn [n]
                                   (match [n]
                                     :zero
                                     :one
                                     :two
                                     :else :other))]
      (should= :zero (match-simple-int-input 0))
      (should= :one (match-simple-int-input 1))
      (should= :two (match-simple-int-input 2))
      (should= :other (match-simple-int-input 3)))))

这里的预期结果是使用一个名为 Speclj1 的单元测试库编码的, 其 Leiningen 坐标是 [speclj 3.0.2].

你会如何把 match 写成一个宏? 一种方法是在 cond 的基础上构建:

;; language_features/pattern_matching_1a.clj
(defmacro match [input & more]
  (let [clauses (partition 2 more)]
    `(cond
       ~@(mapcat (fn [[match-expression result]]
                   (if (= :else match-expression)
                     [:else result]
                     [`(= ~input ~match-expression)
                      result]))
                 clauses))))

这个版本, 尽管很小, 足以通过我们的第一组测试. 花点时间确保你看到它是如何扩展成 cond 表达式的, 因为事情会变得更棘手. 我们之前说过, 在可能的情况下, 宏应该保持小巧, 并将大部分工作委托给辅助函数, 所以让我们采纳我们自己的建议, 提取我们传递给 mapcat 的这个函数:

11.2. 宏中的错误处理

大多数宏, 就像我们在上一节中的模式匹配例子一样, 都有非常特定的输入集, 它们接受, 当那些输入期望不被满足时, 会抛出看起来很奇怪的错误. 例如, 如果我们没有意识到 snore.match 只处理向量作为输入, 我们可能会尝试匹配一个关键字, 这会给我们这个错误:

;; language_features/error_handling_1.clj
(match :foo
  :oh "hi"
  :foo "bar"
  :else "else")

UnsupportedOperationException count not supported on this type: Keyword
  clojure.lang.RT.countFrom (RT.java:556)
java.lang.UnsupportedOperationException: count not supported on this type: Keyword
        RT.java:556 clojure.lang.RT.countFrom
        RT.java:530 clojure.lang.RT.count
 match.clj:13 snore.match/create-test-expression
 match.clj:33 snore.match/match-clause
  AFn.java:156 clojure.lang.AFn.applyToHelper
  AFn.java:144 clojure.lang.AFn.applyTo
   core.clj:626 clojure.core/apply
  core.clj:2468 clojure.core/partial [fn]
RestFn.java:408 clojure.lang.RestFn.invoke
  core.clj:2559 clojure.core/map [fn]
LazySeq.java:40 clojure.lang.LazySeq.sval
             :         :
             :         :
Compiler.java:6666 clojure.lang.Compiler.eval
   core.clj:2927 clojure.core/eval
   main.clj:239 clojure.main/repl [fn]
   main.clj:239 clojure.main/repl [fn]
   main.clj:257 clojure.main/repl [fn]
   main.clj:257 clojure.main/repl

这个堆栈跟踪给了我们所有需要追踪代码路径的信息, 从调用一直到抛出异常的地方, 但它没有告诉宏的用户, 他们只是用了不期望的值来使用宏. 如果你不习惯阅读编译器堆栈跟踪或不记得尝试宏展开表达式, 那个长堆栈跟踪可能会有点不知所措. 处理这个问题有几种方法, 有些比其他的更常见, 每种都有利弊:

什么都不做

  • 优点: • 对宏作者来说没有额外的工作
  • 缺点: • 用户需要阅读源代码或请求他人帮助调试

书面文档

  • 优点: • 英语语言的全部力量
  • 缺点: • 容易过时 • 当事情出错时, 上下文很少/反馈循环慢

让宏更聪明

  • 优点: • 对新手更友好
  • 缺点: • 很难知道用户会犯什么样的错误 • 很难弄清楚用户犯错时的 意思 • 可能会增加显著的代码复杂性 • 不可能完全通用

手动构建异常消息

  • 优点: • 容易解释问题 (当我们知道它是什么时)
  • 缺点: • 需要手动决定: 容易漏掉情况 • 并不总是容易弄清楚用户在哪个层次上出错了

自动构建异常消息

  • 优点: • 没有手动工作; 覆盖了语法可以覆盖的所有情况 • 非常好的错误消息 (虽然可能不如已知问题的定制消息好)
  • 缺点: • 可能需要宏编写实践的重大改变 • 在实践中相当罕见

前两个选项, 什么都不做和写文档, 都相当容易理解. 写好文档是困难的, 但这是我们作为程序员习惯于看到, 并希望做的事情. 其他选项, 另一方面, 可能需要一些讨论.

让宏更聪明, 接受更多种类的输入, 乍一看似乎是一个伟大的目标. 它基本上是 Postel 定律4 应用于宏. 这对于简单和明显的错误可能是一个很好的 hack. 但“用户可能犯的错误”的问题空间是巨大的, 接近无限, 对于许多错误, 甚至很难通过推理它们为什么被犯下. 不幸的是, 能够检测和修复任何用户错误不是一个现实的目标. 所以即使我们能够给用户一些帮助, 并且我们不介意增加一点代码复杂性, 它也不是一个完整的解决方案.

如果我们想为我们在第 82 页的错误模式匹配调用改进错误消息, 我们可以从添加一个断言, 即匹配输入是一个向量开始, 要么用显式的 assert 调用, 要么用 Clojure 前置条件:

;; language_features/error_handling_2.clj
;; Assertions, using custom error messages
(defmacro match [input & more]
  (assert (vector? input) "Match input must be a vector")
  (let [clauses (partition 2 more)]
    `(cond ~@(mapcat (partial match-clause input)
                     clauses))))

;; Clojure preconditions
(defmacro match [input & more]
  {:pre [(vector? input)]}
  (let [clauses (partition 2 more)]
    `(cond ~@(mapcat (partial match-clause input)
                     clauses))))

11.3. Illuminated Macros

据我所知, illuminated macros 的想法是在 2013 年 Clojure/Conj5 的同名演讲中由 Jonathan Claggett 和 Chris Houser 介绍的, 他们讨论了学习使用复杂且文档不足的宏的痛苦 , 以及一个潜在的解决方案, 部分基于 Culpepper & Felleison6 在 Scheme 中的研究.

他们介绍了一个名为 seqex7 的库, 旨在帮助 自动 创建好的文档和好的错误消息, 用于使用其工具编写的宏.

你还记得当你还在学习 defn 的语法时, 文档字符串相对于参数列表和任何前置条件的位置吗? 内置的 defn 宏在提供有用的错误消息方面做得相当好, 但它经过了许多小时的思考, 而且它并不完美:

tools.macro 除了 `name-with-attributes` 辅助函数外, Clojure contrib 库 `tools.macro` 还提供了许多强大的工具, 你在继续提升你的宏技能时可能会感兴趣:

  • 带有 `macrolet` 的局部作用域宏, 语法与 `letfn` 类似.
;; language_features/macrolet.clj
(macrolet [(do-twice [form] `(do ~form ~form))]
  (do-twice (println "hi")))
; hi
; hi
;=> nil
  • 符号宏, 允许用表达式替换特定的符号:
;; language_features/symbol_macros.clj
(let [counter (atom 0)]
  (symbol-macrolet [bump! (swap! counter inc)]
    bump!
    bump!
    bump!
    @counter))
;=> 3
  • 扩展常规宏和符号宏 (但不是局部作用域的, 因为它们在运行时不可用) 的 `macroexpand-1` 和 `macroexpand` 的类似物.
  • `clojure.walk/macroexpand-all` 的一个更健壮的类似物.

即使你不打算使用这些高级特性, 它也值得一看——代码读起来很有趣!

;; language_features/defn_errors.clj
(defn "Squares a number" square [x] (* x x))
; IllegalArgumentException First argument to defn must be a symbol
;       clojure.core/defn (core.clj:277)
; java.lang.IllegalArgumentException: First argument to defn must be a symbol
;       core.clj:277 clojure.core/defn

;; Not bad, huh? Let's try to remember how destructuring works:
(defn square-pair [(x y)]
  (list (* x x) (* y y)))
; CompilerException java.lang.Exception: Unsupported binding form: (x y),
;       compiling: [...]

;; Still pretty darn good, but doesn't tell us what binding forms *are* valid.

一个基于 seqexdefn 实现甚至更进一步, 提供了围绕错误的额外上下文和生成的语法文档:

;; language_features/seqex_defn_usage.clj
;; lein try org.clojars.trptcolin/seqex "2.0.1.1"
(require '[n01se.syntax.repl :as syntax])

(syntax/defn "Squares a number" square [x] (* x x))
; Bad value: "Squares a number"
;  Expected var-name
;=> nil

(syntax/syndoc syntax/defn)
;        defn => (defn var-name doc-string? attr-map? (sig-body | (sig-body)+))
;    sig-body => binding-vec prepost-map? form*
; binding-vec => [binding-form* (& symbol)? (:as symbol)?]
;binding-form => symbol | binding-vec | binding-map
;binding-map => {((binding-form form) | (:as symbol) | keys | defaults)*}
;        keys => (:keys | :strs | :syms) [symbol*]
;    defaults => :or {(symbol form)*}
;=> nil
;; The above is even better in the terminal, with its ANSI color output.

这个宏的 syndoc 语法文档看起来就像一个 BNF8 语法, 因为它是从提供错误消息的相同代码自动生成的, 所以即使允许的宏输入改变了, 它也注定会保持同步!

对于任何与宏可用性差作斗争的人来说, 这是一个相当大的胜利. 不过, 这不是没有代价的: syntax/defn 的实现需要工作, 即使它恰好是建立在 clojure.core/defn 之上的. seqex, 正如你可能根据它的名字或问题领域猜到的那样, 使用了作者称之为 “序列表达式” 的工具, 它对序列的作用就像正则表达式对字符串的作用一样. 忽略 syntax/defn 最复杂的部分, 解构逻辑, 仍然有很多思考被投入到构建序列表达式中:

;; language_features/seqex_defn_implementation.clj
(ns n01se.syntax.repl
  (:require [n01se.seqex :refer [cap recap]]
            [n01se.syntax :refer [defterminal defrule defsyntax
                                  cat opt alt rep* rep+
                                  vec-form, map-form, map-pair, list-form
                                  rule sym form string]])
  (:refer-clojure :exclude [let defn]))
(alias 'clj 'clojure.core)

(defterminal prepost-map map?)
(defterminal attr-map map?)
(defterminal doc-string string?)

(declare binding-form)
(defrule binding-vec
  (vec-form (cat (rep* (delay binding-form))
                 (opt (cat '& sym))
                 (opt (cat :as sym)))))

(defrule binding-map
  ;; [complex logic mostly because of destructuring]
  )

(defrule binding-form
  (alt sym binding-vec binding-map))

(defrule sig-body
  (cat binding-vec (opt prepost-map) (rep* form)))

(defterminal var-name symbol?)

(defsyntax defn
  (cap (cat var-name
            (opt doc-string)
            (opt attr-map)
            (alt sig-body
                 (rep+ (list-form sig-body))))
       (fn [forms] `(clj/defn ~@forms))))

11.4. 代码遍历宏

假设你以前是 Ruby 开发者, 你真的很怀念 Ruby 允许任何方法, 类或模块定义充当隐式 begin (Ruby 版本的 try) 的特性. 换句话说, 你希望能够在 Clojure 中写这样的代码:

;; language_features/implicit_try_1.clj
(defn delete-file [path]
  (clojure.java.io/delete-file path)
  (catch java.io.IOException e false))

这段代码在 Clojure 中当然行不通——如果你试图在你的 REPL 中输入它, 你会得到一个编译器异常. 你需要把一个 try 表达式包装在参数列表之后的所有东西周围, 才能让它按你想要的方式工作. 花点时间思考一下让这段代码工作需要什么. 一个选择是为语言提交一个增强请求, 以允许这样做, 但像这样的重大语言改变不太可能在没有经过大量思考的情况下被接受, 当然也不会很快. 但如果我们在我们的代码中包装一个宏调用, 我们肯定可以写一个允许这段代码工作的宏.

由于我们只有一个例子, 我们可能首先尝试一个天真的方法:

;; language_features/implicit_try_1a.clj
(defmacro with-implicit-try [& defns]
  `(do
     ~@(map
         (fn [defn-expression]
           (let [initial-args (take 3 defn-expression)
                 body (drop 3 defn-expression)]
             `(~@initial-args (try ~@body))))
         defns)))

(with-implicit-try
  (defn delete-file [path]
    (clojure.java.io/delete-file path)
    (catch java.io.IOException e false)))

事实上, 这个版本对于这种输入的 defn 来说会很好用. 不过, 它很脆弱: 如果我们做一些像添加文档字符串这样简单的事情, 我们的宏就会因为 initial-args 的数量需要改变而坏掉.

我们可以使用 clojure.tools.macro/name-with-attributes 来解决文档字符串问题, 但正如我们在上一节中看到的, defn 允许的输入还有很多其他的变体.

此外, 我们真的希望我们的 with-implicit-try 也能为 defn 之外的其他东西工作: do, fn, let, loop, when, 和 letfn 都感觉像是让我们的隐式 try 到位的地方.

为了正确地覆盖所有这些情况, 我们不仅需要看每个顶层表达式, 还要看所有的内部表达式. 像 when 这样的宏最终会扩展到我们命中列表上的其他表达式, 这又如何呢?

为了处理这类事情, 我们需要一个宏, 它实际上会遍历给它的代码, 在每一步进行宏展开, 并生成我们最终想要的代码.

有很多代码遍历工具. 对于最简单的任务, 我们可以使用内置的 clojure.walk, 但它的宏展开设施没有意识到绑定, 这是 Riddley10 克服的一个缺点:

;; language_features/clojure_walk.clj
(require '[clojure.walk :as cw])
(cw/macroexpand-all '(let [when :now] (when {:now "Go!"})))
;=> (let* [when :now] (if {:now "Go!"} (do)))

;; lein try org.clojars.trptcolin/riddley "0.1.7.1"
(require '[riddley.walk :as rw])
(rw/macroexpand-all '(let [when :now] (when {:now "Go!"})))
;=> (let* [when :now] (when {:now "Go!"}))

Riddley 还提供了对 &env 的访问, 并负责展开任何 :inline 函数定义. 除了 macroexpand-all, 回到我们的目的, Riddley 暴露了一个方便的函数, riddley.walk/walk-exprs, 它允许我们用我们选择的表达式替换输入表达式.

;; language_features/riddley_basics.clj
(require '[riddley.walk :as walk])

(defn malkovichify [expression]
  (walk/walk-exprs
    symbol?                 ;; predicate: should we run the handler on this?
    (constantly 'malkovich) ;; handler: does any desired replacements
    expression))

(malkovichify '(println a b))
;=> (malkovich malkovich malkovich)

这正是我们隐式 `try` 所需的那种东西. 在一个代码遍历宏中正确处理所有情况通常类似于递归地思考, 因为宏展开. 基本情况是什么? 好吧, 宏展开的基本情况正是特殊形式, 所以它们是一个很好的开始:

11.5. 宏并[不]是魔法

在本书中, 我们在短时间内涵盖了很多内容. 你已经看到了如何使用正常的 Clojure 编程技术构建表达式, 如何从各种引用机制中得到你想要的, 以及如何找出行为不端的宏出了什么问题. 你已经了解了宏的一些潜在缺点, 并看到了一些你可以使用普通旧函数来完成宏可以做的一些工作的方法. 我们也探索了宏的一些最常见的用例, 从性能到新的语言特性.

你通往 Clojure 宏掌握的旅程并不在这里结束, 但你已经走在了正确的道路上. 现在是时候看看你的新技能会带你去哪里了. 深入你最喜欢的库中宏的代码. 看看它们是如何组合在一起的, 以及它们为什么要做它们做的事情. 宏对某些人来说可能充满了魔法, 但正如你所知, 它们只是像其他一切一样的编程. 最重要的是, 玩得开心!

12. 参考文献

[ECG12] Chas Emerick, Brian Carper, and Christophe Grand. Clojure Programming. O’Reilly & Associates, Inc., Sebastopol, CA, 2012. [FH11] Michael Fogus and Chris Houser. The Joy of Clojure. Manning Publications Co., Greenwich, CT, 2011. [Hal09] Stuart Halloway. Programming Clojure. The Pragmatic Bookshelf, Raleigh, NC and Dallas, TX, 2009. [Hoy08] Doug Hoyte. Let Over Lambda: 50 Years of LISP. Doug Hoyte/HCSW, http://www.hcsw.org/, 2008. [Knu74] Donald E. Knuth. Structured Programming with go to Statements. ACM Comput. Surv. 6:261–301, 1974. [Kum13] Shantanu Kumar. Clojure High Performance Programming. Packt Publishing, Birmingham, UK, 2013.

Footnotes:

1

https://github.com/slagyr/speclj

;; language_features/pattern_matching_1b.clj
(defn match-clause [input [match-expression result]]
  (if (= :else match-expression)
    [:else result]
    [`(= ~input ~match-expression)
     result]))

(defmacro match [input & more]
  (let [clauses (partition 2 more)]
    `(cond ~@(mapcat (partial match-clause input)
                     clauses))))

啊, 清晰多了. 下一步是什么? 好吧, 模式匹配的主要特性之一是它允许你将值绑定到我们可以在结果表达式中使用的名称上, 所以让我们确保我们能做到这一点. 这是我们期望能够做到的:

;; language_features/pattern_matching_2.clj
(it "matches and binds"
  (let [match-and-bind (fn [[a b]]
                         (match [a b]
                           [0 y] {:axis "Y" :y y}
                           [x 0] {:axis "X" :x x}
                           [x y] {:x x :y y}))]
    (should= {:axis "Y" :y 5} (match-and-bind [0 5]))
    (should= {:axis "Y" :y 3} (match-and-bind [0 3]))
    (should= {:axis "X" :x 1} (match-and-bind [1 0]))
    (should= {:axis "X" :x 2} (match-and-bind [2 0]))
    (should= {:x 1 :y 2} (match-and-bind [1 2]))
    (should= {:x 2 :y 1} (match-and-bind [2 1]))))

注意在某些情况下, 比如 [0 y] 子句, 我们想检查第一个元素 a 是否等于 0, 如果是, 就把 y 绑定到 b 的值. 这不是一个那么直接的问题, 因为我们必须使用检查和绑定的某种组合: 在 Clojure 中没有直接的函数或宏可以作为我们宏展开的目标.

尽管如此, 这不是一个不可逾越的任务: 我花了一两个小时为一个解决方案工作以通过这个测试用例, 这是出来的结果:

;; language_features/pattern_matching_2a.clj
;; runtime helper
(defn match-item? [matchable-item input]
  (if (symbol? matchable-item)
    true
    (= input matchable-item)))

;; macroexpansion helpers
(defn create-test-expression [input match-expression]
  `(and (= (count ~input) ~(count match-expression))
        (every? identity
                (map match-item? '~match-expression ~input))))

(defn create-bindings-map [input match-expression]
  (let [pairs (map vector match-expression input)]
    (into {} (filter (comp symbol? first) pairs))))

(defn create-result-with-bindings [input match-expression result]
  (let [bindings-map (create-bindings-map input match-expression)]
    `(let [~@(mapcat identity bindings-map)]
       ~result)))

(defn match-clause [input [match-expression result]]
  (if (= :else match-expression)
    [:else result]
    [(create-test-expression input match-expression)
     (create-result-with-bindings input match-expression result)]))

(defmacro match [input & more]
  (let [clauses (partition 2 more)]
    `(cond ~@(mapcat (partial match-clause input)
                     clauses))))

现在, 我们将进入一些细节, 但在这里重要的是要注意, 这个解决方案并不是从我的指尖完全形成的. 当我写宏时, 我倾向于不时地遇到编译错误, 在这一点上我深吸一口气, 意识到编译错误正在尽力帮助我, 然后继续前进. 通常, 检查正在生成的代码是什么很有帮助, 要么直接从 macroexpand-1 要么通过用我期望传递的中间值调用辅助函数. 我们在这里做一些非常复杂的元编程——代码不多并不意味着不需要花时间把它全部加载到你的脑海里.

match 宏在这次迭代中保持不变, match-clause 辅助函数保留了它的基本结构, 但匹配逻辑需要变得更聪明. 因为现在测试表达式需要适应符号绑定或某些字面值, create-test-expression 确保输入和匹配表达式中有相同数量的元素. 你注意到我们把输入表达式倾倒到宏展开中的方式有点草率了吗? 每当你多次反引用相同的值时, 确保没有像我们在第 31 页的 写对宏可能很棘手 中看到的那样有多次求值的可能性——或者至少确保你意识到这种可能性并愿意接受它. 为了这个例子的目的, 我将接受它.

不过, 有一个有点烦人的问题需要处理. 我们正在宏展开成 let 表达式, 但我们没有利用解构来允许在嵌套结构中进行模式匹配. 让我们在一个测试中捕捉到这一点:

;; language_features/pattern_matching_3.clj
(it "handles vector destructuring"
  (let [match-and-bind (fn [[a b]]
                         (match [a b]
                           [0 [y & more]] {:axis "Y" :y y :more more}
                           [[x & more] 0] {:axis "X" :x x :more more}
                           [x y] {:x x :y y}))]
    (should= {:axis "Y" :y 5 :more [6 7]} (match-and-bind [0 [5 6 7]]))
    (should= {:axis "X" :x 1 :more [2 3]} (match-and-bind [[1 2 3] 0]))
    (should= {:x 1 :y 2} (match-and-bind [1 2]))))

这个问题只需要调整几行代码:

;; language_features/pattern_matching_3a.clj
(defn match-item? [matchable-item input]
  (cond (symbol? matchable-item) true
        (vector? matchable-item)
        (and (sequential? input)
             (every? identity (map match-item? matchable-item input)))
        :else (= input matchable-item)))

(defn create-bindings-map [input match-expression]
  (let [pairs (map vector match-expression (concat input (repeat nil)))]
    (into {} (filter (fn [[k v]]
                       (not (or (keyword? k)
                                (number? k)
                                (nil? k))))
                     pairs))))

有一些重复可以清理, 但这给了我们一个好得多的模式匹配器. 我们可以用它来实现一个简洁且足够快的合并函数, 作为归并排序的一部分, 我们有两个来自递归调用的已排序集合的一半, 需要将它们合并在一起:

;; language_features/pattern_matching_4.clj
(defn merge [xs ys]
  (loop [acc [] xs xs ys ys]
    (match [(seq xs) (seq ys)]
      [nil b] (concat acc b)
      [a nil] (concat acc a)
      [[x & x-rest] [y & y-rest]]
      (if (< x y)
        (recur (conj acc x) x-rest ys)
        (recur (conj acc y) xs y-rest)))))

(it "implements merge (from merge-sort)"
  (should= [1 2 3] (merge [1 2 3] []))
  (should= [1 2 3] (merge [1 2 3] nil))
  (should= [1 2 3 4] (merge [1 2 3] [4]))
  (should= [1 2 3] (merge [] [1 2 3]))
  (should= [1 2 3] (merge nil [1 2 3]))
  (should= [1 2 3 4 5 6 7] (merge [1 3 4 7] [2 5 6])))

我们将给这个模式匹配器起一个戏谑的名字 snore.match ——作为一个社区, 我们可以做得比这好得多. 考虑一下 David Nolen 写的 core.match,2 一个也是用宏构建的模式匹配库, 但投入了更多的思考和关心.

它是为速度而构建的, 基于 Luc Maranget 的一种算法, 可以更快地消除搜索树的分支 (但保持匹配子句的顺序 cond 式排序). 除了提供比我们在这里做的简单得多的更多类型的模式匹配之外, 它甚至允许扩展模式匹配语言本身! 看看 core.match wiki3 了解详情.

请记住, 模式匹配被认为是许多编程语言的一个关键区别特征, 和一个值得骄傲的标志. 尽管 Clojure 本身没有附带模式匹配器, 我们能够自己作为一个库创建这个特性, 这样其他人就可以在不改变核心语言的情况下使用它.

4

http://en.wikipedia.org/wiki/Robustness_principle

但我们如何知道要添加哪些断言呢? 我们可能偶然发现了那个烦人的错误消息, 因为一个同事被卡了半个小时试图弄清楚, 但下次会发生什么呢? 我们为某人可能做错的所有事情都提供了好的错误消息吗? 如果有人假设我们的 matchcase 而不是 cond, 因为它的最后一个子句不需要 :else 前缀呢? 就目前情况来看, 他们可能会大吃一惊:

;; language_features/error_handling_3.clj
(match [1 2 3]
  [0 y z] :yx-plane
  [x 0 z] :xz-plane
  [x y 0] :xy-plane
  :other)
;=> nil

所以要为这个错误提供一个好的错误消息, 我们需要一个额外的前置条件, 检查匹配输入后有偶数个表达式:

;; language_features/error_handling_4.clj
(defmacro match [input & more]
  {:pre [(vector? input)
         (even? (count more))]}
  (let [clauses (partition 2 more)]
    `(cond ~@(mapcat (partial match-clause input)
                     clauses))))

我们现在完成了吗? 也许是的. match (或者至少是我们的版本) 是一个非常简单的宏——我们没有提供很多行为分歧的地方. 但对于具有更复杂输入约束的宏, 完成的问题就更模糊了, 比如这个, 它像 defn 一样行事, 但在没有给出文档字符串的情况下提供一个默认的:

;; language_features/default_docstring.clj
;; lein try [org.clojure/tools.macro "0.1.5"]
(require '[clojure.tools.macro :as m])

(defn- default-docstring [name]
  (str "The author carelessly forgot to provide a docstring for `"
       name
       "`. Sorry!"))

(defmacro my-defn [name & body]
  (let [[name args] (m/name-with-attributes name body)
        name (if (:doc (meta name))
               name
               (vary-meta name assoc :doc (default-docstring name)))]
    `(defn ~name ~@args)))

(my-defn foo [])
;=> #'user/foo
(doc foo)
; -------------------------
; user/foo
; ([])
; The author carelessly forgot to provide a docstring for `foo`. Sorry!
;=> nil

列举用户可能出错的所有可能的事情将是耗时的. 用户可能在函数名, 文档字符串, 函数参数, 前置和后置条件, 以及元数据 map 上出错, 有一个组合数量的事情.

你可能还记得当你第一次学习 Clojure 时, 你在 defnns 宏允许的方面犯了一些令人困惑的错误. 在这样的情况下, illuminated macros (发光宏) 的想法特别有吸引力, 我们接下来会看.

5

https://github.com/swannodette/delimc

shift 捕获当前的延续, 由包含的 reset 定界. 这个延续 (shift 把它绑定到名字 k) 可以像一个函数一样被调用, 当我们这样做时, 执行会从 shift 在代码中出现的地方恢复.

所以当我们调用 (k :ok) 时, 我们是在说: 在 shift 在代码中出现的地方插入值 :ok. 如果我们不调用 k, 我们就不会在 shift 的位置插入任何值, 事实上 reset 在那时就完成了!

如果这看起来有点令人困惑, 你不是一个人: 延续是一个棘手的概念, 很难掌握. 这些例子的目的是展示什么是可能的, 而不是掌握 shift/reset 编程模型.

我们需要在 delimc 版本中添加另一层嵌套, 但现在守卫条件独立存在, 独立于函数体的其余部分. 我们可以更进一步, 提取守卫子句来清理事情:

;; control_flow/guard_clause_3.clj
(defmacro require-user-not-blocked [user user-to-follow]
  `(shift k#
     (if (contains? @(:blocked ~user-to-follow) (:name ~user))
       (println (:name ~user-to-follow) "has blocked" (:name ~user))
       (k# :ok))))

(defn follow-user [user user-to-follow]
  (reset
    (require-user-not-blocked user user-to-follow)
    (println "Adding follow relationship...")
    (swap! (:following user) conj (:name user-to-follow))
    (swap! (:followers user-to-follow) conj (:name user))))

(swap! (:blocked owen) conj (:name colin))
(follow-user colin owen)
; Owen has blocked Colin
;=> nil

所以在 reset 的上下文中, 每个 shift 表达式都能决定是否继续执行 reset 的主体. 更重要的是, shift 甚至可以决定多次继续执行! 我们可以用这个技巧来实现更有趣的控制流操作, 比如不确定性选择操作符.

这里的不确定性并不意味着任何关于随机性的东西: 这是一个编程范式的术语, 其中一个表达式可能有多个可能的值. 我们将在第 8 章, 实现新的语言特性, 在第 77 页看逻辑编程时看到这个想法的更多含义. 但就目前而言, 这就是用定界延续进行不确定性编程的样子:

;; control_flow/choice.clj
(defmacro choice [xs]
  `(shift k# (mapcat k# ~xs)))

(def return list)

(defmacro insist [p]
  `(when-not ~p (choice [])))

(let [numbers (range 1 20)
      square (fn [x] (* x x))]
  (reset
    (return
      (let [a (choice numbers)
            b (choice numbers)
            c (choice numbers)]
        (insist (< a b c))
        (insist (= (square c) (+ (square a) (square b))))
        [a b c]))))
;=> ([3 4 5] [5 12 13] [6 8 10] [8 15 17] [9 12 15])

注意, 用 choice, 我们实际上是多次调用延续, 而不仅仅是像我们在提前返回的例子中那样在 0 次和 1 次之间选择. 我们用 insist 来修剪搜索空间, 它基本上做了和我们之前的守卫子句同样的工作, 但它一次只停止搜索的一个分支, 而不是整个执行.

那么所有这些 shift/reset 魔法是如何工作的呢? 显然没有函数能如此深刻地扭曲控制流. 正如你所期望的, 底层有一个宏允许这些事情发生. delimc 使用一种叫做 延续传递风格 (CPS) 的技术来将 reset 的输入表达式重写成实现了我们一直看到的这些语义的表达式.

我们没有足够的空间来深入探讨所有的细节, 但足以说 reset 宏是我们的入口点, 它通过设置一些上下文并调用一个函数来把它启动起来, 这个函数把它的一系列表达式转换成延续传递风格:

;; control_flow/shift_reset_implementation.clj
(ns delimc.core)

«snip»
(defmulti transform (fn [[op & forms] k-expr] (keyword op)))
«snip»

(defn shift* [cc]
  (throw (Exception.
           "Please ensure shift is called from within the reset macro.")))
(defmacro shift [k & body]
  `(shift* (fn [~k] ~@body)))

(defmethod transform :shift* [cons k-expr]
  (when-not (= (count cons) 2)
    (throw (Exception. "Please ensure shift has one argument.")))
  `(~(first (rest cons)) ~k-expr))

«snip»
(defmacro reset [& body]
  (binding [*ctx* (Context. nil)]
    (expr-sequence->cps body identity)))

这里省略了相当多的复杂性, 但如你所见, shift 只是 shift* 的一个薄包装, 它总是会抛出一个异常. 怎么回事? shift* 纯粹是 expr-sequence->cps 转换使用的一个标记, 而 transform 是关键之一.

我们将在最后一章更详细地看其他像这样的代码遍历的例子, 但首先让我们看一些 shift/reset 宏展开的例子:

;; control_flow/shift_reset_expansion_1.clj
(macroexpand '(reset 1))
;=> (#<core$identity clojure.core$identity@750a6c68> 1)

(reset 1)
;=> 1

(macroexpand '(reset 1 2))
;=> ((clojure.core/fn [r__1247__auto__ & rest-args__1248__auto__]
;     (#<core$identity clojure.core$identity@750a6c68> 2))
;   1)

(reset 1 2)
;=> 2

(macroexpand '(reset (shift k (k 1))))
;=> ((clojure.core/fn [k] (k 1))
;   #<core$identity clojure.core$identity@750a6c68>)

(reset (shift k (k 1)))
;=> 1

在第一个例子中, (reset 1), 我们用 1 作为参数调用 identity 函数. 一点也不差. (reset 1 2) 变得更复杂: 我们构建一个匿名函数, 它用 2 作为参数调用 identity 函数 (忽略它的参数, 也就是 1).

在第三个例子中, (reset (shift k (k 1))), 我们在一个匿名函数内部捕获延续 k 并用 1 作为参数调用它. 在这个小例子中, 延续 k 就是 identity 函数.

你可能注意到 clojure.core/identity 函数是直接注入到宏展开中而不是被引用的. 这个技巧起初可能令人惊讶, 因为这是否合法并不明显.

事实证明 Clojure 编译器为我们处理了这种情况, 但一般来说, 在宏中引用名称比直接向其中发射函数要好. 不过, 这是代码中最不令人费解的部分之一. 一旦更复杂的表达式被传入它们, 这些转换就会变得更加复杂, 特别是当我们给 shift 一些东西来实际捕获时:

;; control_flow/shift_reset_expansion_2.clj
(macroexpand '(reset (+ 1 (shift k (k 1)))))
;=> ((clojure.core/fn [G__2268 & rest-args__1225__auto__]
;     ((clojure.core/fn [G__2269 & rest-args__1225__auto__]
;        ((clojure.core/fn [k] (k 1))
;         (clojure.core/fn [G__2270 & rest-args__1225__auto__]
;           (delimc.core/funcall-cc
;             G__2268
;             #<core$identity clojure.core$identity@750a6c68>
;             G__2269
;             G__2270))))
;      1))
;   (delimc.core/function +))

(reset (+ 1 (shift k (k 1))))
;=> 2

如你所见, 一旦 CPS 转换需要跟踪计算的待定状态, 事情就会很快变得复杂. 这是计算 (+ 1 1) 的一种糟糕的方式, 但正如你之前看到的, 当你决定调用延续 0 次或多次时, 所有这些复杂性都是值得的.

关于这个库, 也许最有趣的是, 这个语法转换所需的所有代码只有几百行长. reset 宏下面的函数, expr-sequence->cps 使用的那些, 有一个很大的工作要做, 而且它们不能处理每一种情况.

reset 是所谓的 代码遍历宏, 因为它遍历它的输入表达式, 将它们转换成与 delimc 的世界观相符的形式. 关于代码遍历宏要知道的第二件事 (在它们能做什么之后) 是它们很难写对.

为了像用户可能期望的那样工作, delimc 不仅要能转换你已经看到的简单表达式, 还要能处理局部变量绑定, 匿名函数, 条件语句, 以及谁知道还有什么! 它在许多输入下工作得很好, 但在写这篇文章的时候, 一些输入表达式还不能被处理, 比如 loop.

如果你对深入研究定界延续感兴趣, delimc 的 README6 列出了一些论文, 对这个编程模型及其实现细节有更多的详细介绍.

8

http://en.wikipedia.org/wiki/Backus%E2%80%93Naur_Form

这种宏编写风格还没有在 Clojure 社区中风靡, 但这可能会因为文档和错误生成的好处而改变. 最近, 序列表达式领域出现了另一个参与者: Christophe Grand 的优雅而强大的 seqexp.9 在 0.2.1 版本中, 它更像是一个单一用途的工具, 目前只处理问题的序列表达式部分, 而不是生成错误消息或文档. 我不会惊讶地看到未来在 seqexp 之上构建这类特性. 因为大多数宏倾向于相当专注, 而且转换到 illuminated 方法的成本很高, 我们很可能在可预见的未来继续以正常的方式编写宏. 但作为复杂宏的用户, 我当然希望拥有 illuminated 方法带来的好处.

10

https://github.com/ztellman/riddley

;; language_features/special_forms.clj
(source special-symbol?)
; (defn special-symbol?
;   "Returns true if s names a special form"
;   {:added "1.0"
;    :static true}
;   [s]
;   (contains? (. clojure.lang.Compiler specials) s))

user=> (sort (keys clojure.lang.Compiler/specials))
;=> (& . case* catch def deftype* do finally fn* if let* letfn* loop*
;    monitor-enter monitor-exit new quote recur reify* set! throw try var
;    clojure.core/import*)

沿着我们的递归编程思路继续, 如果我们正确地处理了基本情况 (特殊形式如 let*, fn*, 等), 并且我们正确地处理了宏展开, 那就意味着通过归纳, 我们将能够正确地处理包含宏的代码.

with-implicit-try 的第一次尝试只需要大约 60 行代码, 但它确实需要大量的试错, 以及大量的测试用例:

;; language_features/trymplicit_1.clj
(ns trptcolin.trymplicit
  (:require [riddley.walk :as walk]))

(declare add-try)

(defn should-transform? [x]
  (and (seq? x)
       (#{'fn* 'do 'loop* 'let* 'letfn* 'reify*} (first x))))

(defn- wrap-fn-body [[bindings & body]]
  (list bindings (cons 'try body)))

(defn- wrap-bindings [bindings]
  (->> bindings
       (partition-all 2)
       (mapcat
         (fn [[k v]]
           (let [[k v] [k (add-try v)]]
             [k v])))
       vec))

(defn- wrap-fn-decl [clauses]
  (let [[name? args? fn-bodies]
        (if (symbol? (first clauses))
          (if (vector? (second clauses))
            [(first clauses) (second clauses) (drop 2 clauses)]
            [(first clauses) nil (doall (map wrap-fn-body (rest clauses)))])
          [nil nil (doall (map wrap-fn-body clauses))])]
    (cond->> fn-bodies
      (and name? args?) (#(list (cons 'try %)))
      (not (and name? args?)) (map add-try)
      args? (cons args?)
      name? (cons name?))))

(defn- wrap-let-like [expression]
  (let [[verb bindings & body] expression]
    `(~verb ~(wrap-bindings bindings) (try ~@(add-try body)))))

(defn transform [x]
  (condp = (first x)
    'do (let [[_ & body] x]
          (cons 'try (add-try body)))
    'loop* (wrap-let-like x)
    'let* (wrap-let-like x)
    'letfn* (wrap-let-like x)
    'fn* (let [[verb & fn-decl] x]
           `(fn* ~@(wrap-fn-decl fn-decl)))
    'reify* (let [[verb options & fn-decls] x]
              `(~verb ~options ~@(map wrap-fn-decl fn-decls)))
    x))

(defn add-try [expression]
  (walk/walk-exprs should-transform? transform expression))

(defmacro with-implicit-try [& body]
  (cons 'try (map add-try body)))

这里显然有一些复杂性, 特别是围绕着提取各种风格的 fn* 表达式. 如果只有一种基本级别的形式就好了, 但由于向后兼容性, 我们可能会一直被这种事情困住.

然而, 更重要的是, 这种方法在包装每个这些特殊形式内部的 try 方面有一个关键的缺陷: 不可能跨越一个 tryrecur. 这意味着任何可以作为 recur 目标的特殊形式都不能以如此轻率的方式改变.

所以我们需要一种方法来避免我们对 fn*, loop*, 和 reify* 的包装, 至少在它们被用于 recur 的地方. 简单地总是跳过那些表达式会更好吗? 也许吧, 但这远没有那么有趣, 而且, 包装一个函数定义才是我们真正的主要用例!

什么是 `:inline` 函数, 无论如何? 我们说过 Riddley 的一个很好的特性是它扩展了 `:inline` 函数, 但那些是什么呢? 如果你读过 `clojure.core` 命名空间的源代码, 你可能已经注意到像 `>` 这样的与数学相关的函数似乎有多个相同代码的实现:

;; language_features/less_than.clj
(defn >
  "Returns non-nil if nums are in monotonically decreasing order,
  otherwise false."
  {:inline (fn [x y] `(. clojure.lang.Numbers (gt ~x ~y)))
   :inline-arities #{2}
   :added "1.0"}
  ([x] true)
  ([x y] (. clojure.lang.Numbers (gt x y)))
  ([x y & more]
   (if (> x y)
     (if (next more)
       (recur y (first more) (next more))
       (> y (first more)))
     false)))

你可能会从这个特性的名字猜到 `:inline` 是做什么的: 它告诉 Clojure 编译器, 无论它在哪里找到对 `>` 的调用, 编译器都可以继续并使用元数据中的扩展函数内联定义. 这些调用需要在动词位置, 即开括号后的第一件事, 以便被内联. 听起来熟悉吗? 内联函数实际上与宏非常相似! 但有一个关键的区别: 内联函数也可以被当作值来对待, 在这种情况下, 非内联实现被使用. 这就是为什么我们可以说 `(sort > [1 2 3 4 5])` 并得到 `(5 4 3 2 1)` 的原因.

那么你应该什么时候使用 `:inline` 函数呢? 基本上只有当你需要挤出一点性能时. 一个函数根据它是作为值传递还是不传递而表现不同会令人困惑, 你不想让你的用户感到困惑. 你可能会注意到 Clojure 的 `with-redefs` 不能替换 `:inline` 版本的函数, 因为在 `with-redefs` 得到它们之前, 它们已经被展开了:

;; language_features/inline_with_redefs.clj
(with-redefs [< +] (< 1 2))
;=> true

(with-redefs [< +] (apply < [1 2]))
;=> 3

但如果你想消除函数分派的运行时开销, 同时保持函数的灵活性, `:inline` 不是一个坏方法.

为了让事情具体化, 我们如何能避免在 fn*recur 时为其插入一个 try? 因为我们无论如何都在进行代码遍历, 一个有趣的策略是扩展现有的代码遍历器, 为 recur 添加一个情况, 并让 fn* 跟踪在其内容被遍历时是否找到了一个 recur:

;; language_features/trymplicit_finding_recur.clj
(def recur-found (atom false))

(defn should-transform? [x]
  (and (seq? x)
       ;; NOTE: we've added 'recur - easy to forget
       (#{'recur 'fn* 'do 'loop* 'let* 'letfn* 'reify*} (first x))))

(defn transform [x]
  (condp = (first x)
    'recur (let [[verb & args] x]
             (reset! recur-found true)
             x)
    ;; ...
    'fn* (let [[verb & fn-decl] x
               _ (reset! recur-found false)
               result `(fn* ~@(doall (wrap-fn-decl fn-decl)))]
           (if @recur-found
             x
             result))
    ;; ...
    ))

这里有一个主要问题: 我们正在寻找函数内部的 任何 recur 表达式, 而不仅仅是可能受影响的那些. 例如, 在这个无用的表达式 (fn* [] (loop* [] (recur))) 中, recur 只影响 loop* 表达式, 而不影响外部的 fn*. 就好像我们需要为我们遍历的每个潜在的 recur 目标推一个新的上下文到堆栈上, 并在我们完成遍历时弹出它. 好消息——Clojure 的动态绑定对此完美地工作, 是以一种合理的方式解决这个困境的关键.

这里还有很多其他的细节: 例如, 我们在很多地方添加了 doall 来实现任何懒惰序列, 因为我们在代码遍历时依赖于副作用. 而且在代码质量和功能方面都还有改进的空间. 例如, 我们将如何处理剩下的每个特殊形式?

;; language_features/trymplicit/src/trptcolin/trymplicit.clj
(ns trptcolin.trymplicit
  (:require [riddley.walk :as walk]))

(def ^:dynamic *recur-search-tracker*
  (atom false))

(declare add-try)

(defn should-transform? [x]
  (and (seq? x)
       (#{'fn* 'do 'loop* 'let* 'letfn* 'reify* 'recur} (first x))))

(defn- wrap-fn-body [wrapper-fn [bindings & body]]
  (if (nil? wrapper-fn)
    (cons bindings body)
    (list bindings (wrapper-fn body))))

(defn- wrap-bindings [bindings]
  (->> bindings
       (partition-all 2)
       (mapcat
         (fn [[k v]]
           (let [[k v] [k (add-try v)]]
             [k v])))
       vec))

(defn- wrap-fn-decl [wrapper-fn clauses]
  (let [[name? args? fn-bodies]
        (cond (symbol? (first clauses))
              (if (vector? (second clauses))
                [(first clauses) (second clauses) (drop 2 clauses)]
                [(first clauses) nil
                 (doall (map (partial wrap-fn-body wrapper-fn)
                             (rest clauses)))])
              (vector? (first clauses))
              [nil (first clauses) (rest clauses)]
              :else
              [nil nil (doall (map (partial wrap-fn-body wrapper-fn)
                                   clauses))])]
    (cond->> fn-bodies
      (and name? args?) (#(if (nil? wrapper-fn)
                            (list `(do ~@(doall (map add-try %))))
                            (list (wrapper-fn (doall (map add-try %))))))
      (not (and name? args?)) (#(let [not-both-result (map add-try %)]
                                  not-both-result))
      args? (cons args?)
      name? (cons name?))))

(defn- wrap-let-like [expression]
  (let [[verb bindings & body] expression
        result `(~verb ~(wrap-bindings bindings) (try ~@(doall (add-try body))))]
    (if @*recur-search-tracker*
      `(~verb ~(wrap-bindings bindings) ~@(add-try body))
      result)))

(defn transform [x]
  (condp = (first x)
    'recur (let [[verb & args] x]
             (reset! *recur-search-tracker* true)
             x)
    'do (let [[_ & body] x
              result (cons 'try (add-try body))]
          (if @*recur-search-tracker*
            (cons 'do (add-try body))
            (cons 'try (add-try body))))
    'loop* (binding [*recur-search-tracker* (atom false)]
             (wrap-let-like x))
    'let* (wrap-let-like x)
    'letfn* (wrap-let-like x)
    'fn* (binding [*recur-search-tracker* (atom false)]
           (let [[verb & fn-decl] x
                 result `(fn* ~@(doall (wrap-fn-decl #(cons 'try %) fn-decl)))]
             (if @*recur-search-tracker*
               `(fn* ~@(doall (wrap-fn-decl nil fn-decl)))
               result)))
    'reify* (let [[verb options & fn-decls] x
                  wrap-reify-fn (fn [expression]
                                  (binding [*recur-search-tracker* (atom false)]
                                    (let [result (doall (wrap-fn-decl #(cons 'try %)
                                                                        expression))]
                                      (if @*recur-search-tracker*
                                        (wrap-fn-decl nil expression)
                                        result))))]
              `(~verb ~options ~@(doall (map wrap-reify-fn fn-decls))))
    x))

(defn add-try [expression]
  (walk/walk-exprs should-transform? transform expression))

(defmacro with-implicit-try [& body]
  (cons 'try (map #(binding [*recur-search-tracker* (atom false)] (add-try %))
                  body)))

我想在这里说明的是, 代码遍历宏不容易: 它们需要大量的努力才能做好. 没有很多健壮的代码遍历宏的例子, 但存在的那些是相当有趣的:

  • Proteus11 创建局部可变变量… 好的, 我意识到在 Clojure 书中这听起来像个笑话, 但它可能没问题, 对吧? Proteus, 像我们的 trymplicit 一样, 使用 Riddley 来做它的代码遍历.
  • Clojure-TCO12 重写 Clojure 表达式以提供尾调用优化, 这是 JVM 缺乏完全支持的一个特性, 但我们可以通过宏得到. Clojure-TCO 做它自己的自定义代码遍历, 像 delimc 一样, 不处理所有的 Clojure.
  • core.async13 从命令式风格的代码生成一个状态机, 允许库异步地调度代码的执行, 无论是在 JVM 上使用线程还是在 JavaScript VM 上, 并使用 Go 风格的通道 (通信顺序进程). 它使用 tools.analyzer.jvm14 作为 Clojure 实现的代码遍历, 和内置的 ClojureScript 分析器作为 ClojureScript 实现.

我写过或研究过的每一个非平凡的代码遍历宏都有一些你需要知道底层机器如何工作的警告. 但我研究过的每一个有趣的语言特性也是如此.

抽象, 在某个点上, 会崩溃. 这些库可能都看起来在做神奇的事情, 但作为一个代码遍历宏的用户, 了解抽象可能泄漏的地方对你来说是很好的. 通常这类宏支持 Clojure 的一个子集, 对库作者来说记录那个子集, 连同任何语言语义的改变, 比以往任何时候都更重要.

正如 Douglas Hoyte 在 Let Over Lambda [Hoy08] 中指出的那样, 写一个健壮的代码遍历器是困难的. 如果我们能避免它, 我们通常最好不要自己尝试, 这就是为什么像 riddleytools.analyzer.jvm 这样的库如此有用的原因.

即使是写一个像我们刚才写的简单地使用代码遍历器的代码遍历宏, 本身也是一个严肃的 undertakings. 很容易不断地发现自己已经完成了 90% 的工作, 还有 90% 的工作要做.

但如果你想做 core.async, Clojure-TCO, Proteus, 和我们自己的 Trymplicit 库能够做的那种深度转换, 卷起你的袖子, 做艰苦的工作是值得的.

Author: Colin Jones(青岛红创翻译)

Created: 2025-11-11 Tue 11:38