现在是时候开始编写自己的宏了。上一章中介绍的标准宏暗示了您可以使用宏做的一些事情,但这只是开始。 Common Lisp 不支持宏,因此每个 Lisp 程序员都可以创建他们自己的标准控制结构变体,就像 C 支持函数一样,因此每个 C 程序员都可以编写 C 标准库中函数的琐碎变体。宏是语言的一部分,允许您在核心语言和标准库之上创建抽象,使您更接近能够直接表达您想要表达的东西。具有讽刺意味的是,正确理解宏的最大障碍也许是它们很好地集成到语言中。在许多方面,它们看起来只是一种有趣的函数——它们是用 Lisp 重写的,它们接受参数并返回结果,并且它们允许您抽象掉分散注意力的细节。然而,尽管有这些相似之处,宏在与函数不同的层次上运行,并创建了一种完全不同的抽象。一旦你理解了宏和函数之间的区别,宏在语言中的紧密集成将是一个巨大的好处。但同时,它也是 newLispers 经常混淆的来源。下面的故事虽然在历史或技术意义上并不真实,但试图通过让您思考宏如何工作来减轻混淆。从前,很久以前,有一个 Lisp 程序员公司。事实上,很久以前 Lisp 没有宏。任何不能用函数定义或用特殊运算符完成的东西,每次都必须完整地编写,这相当拖累。可惜的是,这家公司的程序员虽然很聪明,但也很懒惰。通常在他们的程序中间——当编写一堆代码变得过于乏味时——他们会写一个注释来描述他们需要在程序中的那个地方编写的代码。更不幸的是,由于懒惰,程序员们也讨厌回去实际编写注释描述的代码。很快,公司就有了一大堆没人能运行的程序,因为它们充满了关于仍然需要编写的代码的注释。无奈之下,大老板聘请了一名初级程序员Mac,他的工作是找到笔记,编写所需的代码,并将其插入程序中代替笔记。 Mac 从来没有运行过这些程序——当然,它们还没有完成,所以他不能。但是,即使它们已经完成,Mac 也不知道要为它们提供哪些输入。所以他只是根据笔记的内容写了他的代码,然后把它发回给原来的程序员。在 Mac 的帮助下,所有程序很快就完成了,公司通过出售这些程序赚了一大笔钱——这么多钱,公司可以将其编程人员的人数增加一倍。但出于某种原因,没有人想到聘请任何人来帮助 Mac;很快,他就单枪匹马地协助了几十名程序员。为了避免花费所有时间在源代码中搜索注释,Mac 对程序员使用的编译器进行了小幅修改。此后,每当编译器遇到注释时,它都会通过电子邮件将注释发送给他,并等待他通过电子邮件发送替换代码。不幸的是,即使有了这种变化,Mac 也很难跟上程序员的步伐。他尽可能小心地工作,但有时——尤其是当笔记不清楚时——他会犯错误。然而,程序员注意到,他们写的笔记越精确,Mac 发回正确代码的可能性就越大。有一天,一位程序员很难用文字描述他想要的代码,在他的一个笔记中包含了一个 Lisp 程序,它可以生成他想要的代码。 Mac 没问题;他只是运行程序并将结果发送给编译器。
下一个创新出现时,程序员在他的一个包含函数定义和注释的程序的顶部放了一条注释,上面写着:“Mac,不要在这里写任何代码,但保留此函数以备后用;我将使用它在我的其他一些笔记中。”同一程序中的其他注释说诸如“Mac,用符号 x 和 y 作为参数运行其他函数的结果替换 thisnote”。这种技术很快流行起来,以至于在几天之内,大多数程序都包含了几十个定义函数的注释,这些注释只能由其他注释中的代码使用。为了让 Mac 能够轻松挑选出只包含不需要任何立即响应的定义的注释,程序员用标准序言标记它们:“Mac 定义,只读”。这——因为程序员仍然很懒惰——很快被缩写为“DEF.MAC.R/O”,然后是“DEFMACRO”。很快,Mac 的注释中就没有实际的英语了。他一整天所做的就是阅读和回复来自编译器的电子邮件,其中包含 DEFMACRO 注释和对 DEFMACRO 中定义的函数的调用。由于笔记中的 Lisp 程序完成了所有实际工作,因此跟上电子邮件没有问题。麦克突然有很多时间在他的办公室里,他会坐在办公室里幻想着白色的沙滩、湛蓝的海水和带小纸伞的饮料。几个月后,程序员意识到没有人见过 Mac 很长时间了。当他们来到他的办公室时,他们发现所有东西都覆盖着一层薄薄的灰尘,一张桌子上散落着各种热带地区的旅游手册,电脑也关机了。但是编译器仍然可以工作——怎么会呢?事实证明,Mac 对编译器进行了最后一次更改:编译器现在保存了 DEFMACRO 注释定义的函数,并在其他注释调用时运行它们,而不是通过电子邮件向 Mac 发送注释。程序员们决定没有理由告诉大老板 Mac 不再来办公室了。所以直到今天,Mac 拿一份薪水,不时给程序员寄一张来自一个热带地区或另一个地方的明信片。理解宏的关键是非常清楚生成代码的代码(宏)和最终构成程序的代码(其他一切)之间的区别。当您编写宏时,您正在编写程序,编译器将使用这些程序来生成随后将被编译的代码。只有在宏完全展开并编译生成的代码之后,程序才能真正运行。宏运行的时间称为宏展开时间;这与运行时不同,当常规代码(包括宏生成的代码)运行时。牢记这种区别很重要,因为在宏扩展时运行的代码与运行时运行的代码在非常不同的环境中运行。即,在宏扩展时,无法访问运行时将存在的数据。像 Mac 一样,因为他不知道正确的输入是什么而无法运行他正在开发的程序,在宏扩展时运行的代码只能处理源代码中固有的数据。例如,假设以下源代码出现在程序中的某处: 通常您会认为 x 是一个变量,它将保存在对 foo 的调用中传递的参数。但是在宏扩展时,例如编译器运行 WHEN 宏时,唯一可用的数据是源代码。由于程序尚未运行,因此没有调用 foo,因此没有与 x 关联的值。相反,编译器传递给 WHEN 的值是表示源代码的 Lisp 列表,即 (> x 10) 和 (print 'big)。假设 WHEN 定义为,如您在前一章中看到的,使用类似于以下宏的内容:
编译 foo 中的代码时, WHEN 宏将以这两种形式作为参数运行。参数condition 将绑定到表单(> x 10),表单(print'big) 将被收集到一个列表中,该列表将成为&rest body 参数的值。然后反引号表达式将生成以下代码:通过插入条件值并将 body 值拼接到 PROGN 中。当 Lisp 被解释而不是编译时,宏扩展时间和运行时之间的区别不太清楚,因为它们在时间上是交织在一起的。此外,语言标准并没有具体规定解释器必须如何处理宏——它可以在被解释的表单中展开所有宏,然后解释结果代码,或者它可以在解释表单时立即开始并在遇到宏时展开宏。在任何一种情况下,宏总是传递代表宏形式的子形式的未计算的 Lisp 对象,并且宏的工作仍然是生成可以做某事而不是直接做任何事情的代码。正如你在第 3 章中看到的,宏确实是用 DEFMACROforms 定义的,尽管它代表——当然——代表 DEFine MACRO,而不是 Definitionfor Mac。 DEFMACRO 的基本骨架与 DEFUN 的骨架非常相似。与函数一样,宏由名称、参数列表、可选的文档字符串和 Lisp 表达式主体组成。 1然而,正如我刚才所讨论的,宏的工作不是直接做任何事情——它的工作是生成稍后会做你想做的事情的代码。宏可以使用 Lisp 的全部功能来生成它们的扩展,这意味着在本章中我只能触及宏可以做的事情的皮毛。然而,我可以描述一个编写宏的一般过程,它适用于从最简单到最复杂的所有宏。宏的工作是将宏形式——换言之,其第一个元素是宏名称的 Lisp 形式——翻译成执行特定操作的代码。有时您会从您希望能够编写的代码开始编写宏,即使用示例宏形式。其他时候,您在多次编写相同模式的代码后决定编写宏,并意识到可以通过抽象模式使代码更清晰。
无论您从哪一端开始,在开始编写宏之前,您都需要弄清楚另一端:您需要知道自己从哪里来和要去哪里,然后才能希望编写代码来自动执行它。因此,编写宏的第一步是编写至少一个宏调用示例以及该调用应扩展到的代码。一旦您有了一个示例调用和所需的扩展,您就可以进行第二步了:编写实际的宏代码。对于简单的宏,这将是编写反引号模板并将宏参数插入正确位置的微不足道的事情。复杂的宏本身就是重要的程序,具有辅助函数和数据结构。在编写代码将示例调用转换为适当的扩展之后,您需要确保宏提供的抽象不会“泄漏”其实现的细节。 Leakymacro 抽象对于某些参数可以正常工作,但不能用于其他参数,或者会以不受欢迎的方式与调用环境中的代码交互。事实证明,宏可能以少数几种方式泄漏,只要您知道检查它们,所有这些都很容易避免。我将在“堵漏”一节中讨论如何做。编写对宏及其应扩展到的代码的示例调用,反之亦然。要了解这个三步过程是如何工作的,您将编写一个宏 do-primes,它提供类似于 DOTIMES 和 DOLIST 的循环构造,不同之处在于它不是迭代整数或列表元素,而是迭代连续的素数。这并不是一个特别有用的宏的例子——它只是一个演示过程的工具。首先,您需要两个实用程序函数,一个用于测试给定数字是否为素数,另一个返回下一个大于或等于其参数的素数。在这两种情况下,您都可以使用简单但效率低下的蛮力方法。 (defun primep (number) (when (> number 1) (loop for fac from 2 to (isqrt number) never (zerop (mod number fac)))))(defun next-prime (number) (loop for n from number)当 (primep n) 返回 n))
现在您可以编写宏。按照前面概述的过程,您至少需要一个宏调用示例以及宏应该扩展到的代码。假设您从希望能够这样写的想法开始:表达一个循环,该循环对每个大于或等于 0 且小于或等于 19 的质数执行一次循环体,变量 p 保存质数。以标准 DOLIST 和 DOTIMES 宏的形式对这个宏进行建模是有意义的;遵循现有宏模式的宏比无缘无故引入新语法的宏更容易理解和使用。如果没有 do-primes 宏,您可以使用 DO(以及之前定义的两个实用程序函数)编写这样一个循环,如下所示: (do ((p (next-prime 0) (next-prime (1+ p)))) ((> p 19)) (format t "~d " p)) 现在您已准备好开始编写将从前者转换为后者的宏代码。由于传递给宏的参数是表示宏调用源代码的 Lisp 对象,因此任何宏的第一步都是提取这些对象中计算扩展所需的任何部分。对于简单地将其参数直接插入到模板中的宏,这一步是微不足道的:只需定义正确的参数来保存不同的参数就足够了。但是这种方法似乎对 do-primes 不够用。do-primes 调用的第一个参数是一个包含循环变量名称 p 的列表;下限为 0;上限为 19。但是如果您查看展开式,整个列表并未出现在展开式中;三个元素被分开并放置在不同的地方。
您可以使用两个参数定义 do-primes,一个用于保存列表,一个 &rest 参数用于保存主体形式,然后手动拆分列表,如下所示:(defmacro do-primes (var-and-range &rest body) (let ((var (first var-and-range)) (start (second var-and-range)) (end (third var-and-range))) `(do ((,var (next-prime ,start) ) (next-prime (1+ ,var)))) ((> ,var ,end)) ,@body))) 稍后我将解释主体如何产生正确的扩展;现在你可以注意到变量 var、start 和 end 每个都包含一个值,从 var-and-range 中提取,然后插入到反引号表达式中,生成 do-primes 的扩展。但是,您不需要“手动”拆分 var-and-range,因为宏参数列表就是所谓的解构参数列表。顾名思义,解构涉及分解一个结构——在这种情况下是传递给宏的表单的列表结构。在解构参数列表中,可以用嵌套参数列表替换简单的参数名称。嵌套参数列表中的参数将从已绑定到列表替换的参数的表达式的元素中获取它们的值。例如,您可以将 var-and-range 替换为 alist (var start end),列表中的三个元素将自动解构为这三个参数。宏参数列表的另一个特点是您可以使用 &body 作为 &rest 的同义词。在语义上 &body 和 &rest 是等价的,但许多开发环境将使用 &body 参数的存在来修改它们如何缩进使用宏——通常 &body 参数用于保存组成宏主体的表单列表。因此,您可以简化 do-primes 的定义,并通过如下定义向人类读者和您的开发工具提供有关其预期用途的提示:
(defmacro do-primes ((var start end) &body body) `(do ((,var (next-prime ,start) (next-prime (1+ ,var)))) ((> ,var ,end)) ,@body)) 除了更简洁之外,解构参数列表还为您提供自动错误检查——通过这种方式定义的 do-primes,Lisp 将能够检测到第一个参数不是三元素列表的调用,并且会给出您会收到一条有意义的错误消息,就像您调用了一个参数太少或太多的函数一样。此外,在 SLIME 等开发环境中,只要您输入函数或宏的名称,就会指示预期的参数,如果您使用解构参数列表,环境将能够更具体地告诉你宏调用的语法。使用原始定义,SLIME 会告诉您 do-primes 是这样调用的: 但是使用新定义,它可以告诉您调用应该是这样的: 解构参数列表可以包含 &optional、&key 和 &rest 参数,并且可以包含嵌套解构列表。但是,您不需要任何这些选项来编写 do-primes。因为 do-primes 是一个相当简单的宏,在您解构参数之后,剩下的就是将 themin 插入到模板中以获得扩展。对于像 do-primes 这样的简单宏,特殊的反引号语法是完美的。回顾一下,反引号表达式类似于带引号的表达式,但您可以通过在特定子表达式前面加上逗号来“取消引用”它们,可能后跟 at (@) 符号。如果没有 at 符号,逗号会导致包含子表达式的值照原样。带有 at 符号的值——必须是 alist——被“拼接”到封闭列表中。考虑反引号语法的另一种有用方法是编写生成列表的代码的特别简洁的方法。这种思考方式的好处是几乎可以准确了解幕后发生的事情——当读者阅读反引号表达式时,它会将其转换为生成适当列表结构的代码。例如,`(,ab) 可能读作 (list a 'b)。语言标准并没有具体指定读者必须生成什么代码,只要它生成右表结构即可。
表 8-1 显示了一些反引号表达式的示例以及等效的列表构建代码,以及计算反引号表达式或等效代码时得到的结果。 2 需要注意的是,反引号只是一种方便。但这是一个很大的方便。要了解有多大,请将 do-primes 的反引号版本与以下版本进行比较,该版本使用显式列表构建代码: (defmacro do-primes-a ((var start end) &body body) (append '(do) (list (list) (list var (list 'next-prime start) (list 'next-prime (list '1+ var))))) (list (list (list '> var end)))) body))片刻, do-primes 的当前实现不能正确处理某些边缘情况。但是首先您应该验证它至少适用于原始示例。您可以通过两种方式对其进行测试。您可以通过简单地使用它来间接测试它——大概,如果结果行为是正确的,则扩展是正确的。例如,您可以在 REPL 中输入原始示例对 do-primes 的使用,并看到它确实打印了正确的素数系列。 CL-USER> (do-primes (p 0 19) (format t "~d " p))2 3 5 7 11 13 17 19NIL 或者直接查看特定调用的展开查看宏。函数 MACROEXPAND-1 将任何 Lisp 表达式作为参数并返回执行一级宏扩展的结果。 3 因为 MACROEXPAND-1 是一个函数,要传递给它一个文字宏形式,你必须引用它。你可以用它来查看预......