实用zhlisp编程07:标准控制构造
Category:中文学习第7章 宏:标准控制构造
尽管起源于Lisp 的许多编程思想(从条件表达式到垃圾收集)都已经被吸取进其他语言,但 Lisp 的宏系统却始终使它保持了在语言风格上的独特性。不幸的是,宏这个字虽然在计算领域可以描述很多东西,但和 Common Lisp的宏相比,它们仅具有模糊和大致的相似性。当 Lisp 程序员们试图向非 Lisp 程序员解释宏这种特性的伟大之处时,这种相似性导致了无休止的误解。要想理解 Lisp 的宏,就真的需要重新看待它,不能带有任何基于其他碰巧叫做宏的概念所带来的成见。现在先退一步,从观察各种语言支持扩展的不同方式讨论 Lisp 宏。
所有的程序员应该都熟知这么一种观点:“编程语言” 的定义包括一个使用 “核心” 语言实现的标准功能库——如果某些功能没有定义在标准库中,那么它们可能已经被程序员实现在语言中了。只要它还没有被定义成标准库的一部分。例如,C 的标准库就差不多可以完全用可移植的 C 来实现。类似地,Java 的标准开发包(JDK)中所提供的不断改进的类和接口集合也是用 “纯” Java 编写的。
使用核心加上标准库的方式来定义语言的优势在于易于理解和实现。但真正的好处在于其可表达性——由于所认为的 “该语言” 很大程度上其实是一个库,因此很容易对其进行扩展。如果 C 语言中不含有所需的用来做某件事的一个函数,那就可以写出这个函数,然后就得到了一个特性稍微丰富一点的 C 版本。类似地,在诸如 Java 或 Smalltalk 这类几乎全部的有趣部分都是由类来定义的语言里,通过定义新的类就可以扩展该语言,使其更适用于编写你正试图编写的无论什么程序。
尽管 Common Lisp 支持所有这些扩展语言的方法,宏还提供了另一种方式。如同第 4 章所概述的那样,每个宏都定义了自己的语法,它们能够决定那些被传递的 S-表达式如何被转换成 Lisp 形式。核心语言有了宏,就有可能构造出新的语法——诸如 如果真、列表循环 和 循环 这样的控制构造以及 函数 和 全局变量 这样的定义形式,从而作为 “标准库” 的一部分而不是将其硬编码到语言核心。这已经牵涉到语言本身是如何实现的,但作为一个 Lisp 程序员你更关心的将是它所提供的另一种语言扩展式,使这些新语法可以使 Common Lisp 成为更好的用于表达特定编程问题解决方案的语言。
那么,利用另一种方式来拓展语言的好处似乎是显而易见的。但出于某些原因,大量没有实际使用过 Lisp 宏的人,他们可以为了解决编程问题而日复一日地创建新的函数型抽象或定义类的层次体系,但却被这种可以定义新的句法抽象的思想给吓到了。通常,这种宏恐惧症的原因多半是来自学习其他“宏”系统时的不良经历。简单地对未知事物的恐惧无疑也是其中一部分原因。为了避免触发任何宏恐惧症反应,讨论将从 Common Lisp 所定义的几种标准控制构造宏开始,既而缓慢进入该主题。它们都是那些如果 Lisp 没有宏,就必须构造在语言核心里的东西。尽管在使用时不必关心它们是一种作为宏的实现,但它们的确可以很好地展示出宏的一些功用。下一章将说明如何定义你自己的宏。
7.1 如果真和如果假
如前所述,最基本的条件执行形式是由 判断 特殊操作符提供的,其基本形式是:如果 x
成立,那么执行 y
;否则执行 z
。
(判断 条件 为真形式 [其他形式])
条件
被求值,如果其值非 NIL,那么 为真形式 会被求值并返回其结果。否则,如果有 其他形式 的话,它将被求值并返回其结果。如果 条件是 NIL 并且没有 其他形式,那么IF返回 NIL。
(判断 (> 2 3) "Yup" "Nope") ==> "Nope" (判断 (> 2 3) "Yup") ==> NIL (判断 (> 3 2) "Yup" "Nope") ==> "Yup"
尽管如此,判断 事实上并不是什么伟大的句法构造,因为每个 为真形式 和 其他形式 都被限制在必须是单一的 Lisp 形式上。这意味着如果想在每个子句中执行一系列操作,则必须将其用其他一些语法形式进行封装。举个例子,假如在一个垃圾过滤程序中,当一个消息是垃圾时,你想要在将其标记为垃圾的同时更新垃圾数据库,那么你不能这样写:
(判断 (垃圾邮件 当前的消息) (垃圾邮件文件夹 当前的消息) (更新垃圾邮件数据 当前的消息))
因为对 更新垃圾邮件数据 的调用将被作为 其他形式 子句来看待,而不是 为真形式 子句的一部分。另一个特殊操作符 依序求值 可以按顺序执行任意数量的形式并返回最后一个形式的值。因此可以通过写成下面这样来得到预想的行为:
(判断 (垃圾邮件 当前的消息) (依序求值 (垃圾邮件文件夹 当前的消息) (更新垃圾邮件数据 当前的消息)))
这样做并不算太坏。但假如不得不多次使用这样的写法,不难想象你将在一段时间以后开始厌倦它。你可能会自问:“为什么 Lisp 没有提供一种方式来做我真正想做的事,也就是说,‘当 x
为真时,做这个、那个以及其他一些事情’?” 换句话说,很快你将注意到这种由 判断 加上 依序求值 所组成的模式,并且希望可以有一种方式来抽象掉所有细节而不是每次都将它们写出来。
这正是宏所能够提供的功能。在这个案例中,Common Lisp 提供了一个标准宏 如果真,可以让你写成这样:
(如果真 (垃圾邮件 当前的消息) (垃圾邮件文件夹 当前的消息) (更新垃圾邮件数据 当前的消息))
但如果它没有被内置到标准库中,你也可以像下面这样用一个宏来自己定义 如果真,这里用到了第 3 章中讨论过的反引号:
(宏 如果真 (条件 &rest body) `(判断 ,条件 (依序求值 ,@body)))
与 如果真 宏同系列的另一个宏是 如果假,它取相反的条件,只有当条件为假时才求值其形式体。换句话说:
(宏 如果假 (条件 &rest body) `(判断 (非 ,条件) (依序求值 ,@body)))
必须承认,这些都是相当简单的宏。这里没有什么高深的道理,它们只是抽象掉了一些语言层面约定俗成的细节,从而允许你更加清晰地表达你的真实意图。但是它们的极度简单性却产生了一个重要的观点:由于宏系统是直接构建在语言之中的,所以可以写出像如果真 和 如果假 这样简单的宏来获得虽小但却重要的清晰性,并随后通过不断地使用而无限放大。第 24、26 和 31 章将展现宏是如何被更大规模地用于创建完整的特定领域的嵌入式语言。但首先来介绍一下标准控制构造宏。
7.2 如果
当遇到多重分支的条件语句时,原始的 判断 表达式再一次变得丑陋不堪:如果 a
成立那么执行 x,否则如果 b
成立那么执行 y;否则执行 z。只用 判断 来写这样的条件表达式链并没有逻辑问题,只是不太好看。
(判断 a (判断循环-x) (判断 b (判断循环-y) (判断循环-z)))
并且如果需要在 then 子句中包括多个形式,那就需要用到 依序求值,而那样事情就会变得更糟。因此毫不奇怪地,Common Lisp 提供了一个用于表达多重分支条件的宏 如果。下面是它的基本结构:
(如果 (test-1 form*) . . . (test-N form*))
主体中的每个元素都代表一个条件分支,并由一个列表所构成,列表中含有一个条件形式,以及零或多个当该分支被选择时将被求值的形式。这些条件形式按照分支在主体中出现的顺序被依次求值,直到它们中的一个求值为真。这时,该分支中的其余形式将被求值,且分支中最后一个形式的值将被作为整个 如果 的返回值。如果该分支在条件形式之后不再含有其他形式,那么就将 返回该条件形式的值。习惯上,那个用来表示 判断/其他形式 链中最后一个 其他形式 子句的分支将被写成带有条件 T。虽然任何非 NIL 的值都可以使用,但在阅读代码时,T 标记确实有用。这样就可以像下面这样用 如果 来写出前面的嵌套 判断 表达式:
(如果 (a (判断循环-x)) (b (判断循环-y)) (t (判断循环-z)))
7.3 与、或和非
在使用 判断,如果真,如果假,和 如果 形式编写条件语句时,经常用到的三个操作符是布尔逻辑操作符 与,或,和非。
严格来讲,非 这个函数并不属于本章讨论范畴,但它跟 与 和 或 紧密相关。它接受单一参数并对其真值取反,当参数为 NIL 时返回 T,否则返回 NIL。
而 与 和 或 则是宏。它们实现了对任意数量子表达式的逻辑合取和析取操作,并被定义成宏以便支持 “短路” 特性。也就是说,它们仅以从左到右的顺序对用于检测整体真值的必要数量的子表达式进行求值。这样,只要 或 的一个子表达式求值为 NIL,它就立即停止并返回 NIL。如果所有子表达式都求值到非 NIL,那么它将返回最后一个子表达式的值。而对于 或 来说只要一个子表达式求值到非 NIL,它就立即停止并返回当前子表达式的值。如果没有子表达式求值到真,或 返回 NIL。下面是一些例子:
(非 nil) ==> T (非 (= 1 1)) ==> NIL (与 (= 1 2) (= 3 3)) ==> NIL (或 (= 1 2) (= 3 3)) ==> T
7.4 循环
循环结构是另外一类主要的控制结构。Common Lisp 的循环机制,除了更加强大和灵活以外,还是一门关于宏所提供的 “鱼和熊掌兼得” 的编程风格的有趣课程。
初看起来,Lisp 的 25 个特殊操作符中没有一个能够直接支持结构化循环,所有的 Lisp 循环控制构造都是构建在一对提供原生 goto
机制的特殊操作符之上的宏。和许多好的抽象或句法等一样,Lisp 的循环宏构建在以那两个特殊操作符为基础的一组分层抽象之上。
最底层(不考虑特殊操作符)是一个非常通用的循环构造 判断循环。尽管非常强大,但 判断循环 和许多其他的通用抽象一样,在应用于简单情形时显得过于复杂。因此 Lisp 还提供了另外两个宏,列表循环 和 计数循环。它们不像 判断循环 那样灵活,但却提供了对于常见的在列表元素上循环和计数循环的便利支持。尽管一个实现可以用任何方式来实现这些宏,但它们被典型实现为展开到等价 判断循环 循环的宏。因此,在由 Common Lisp 特殊操作符所提供的底层原语之上,判断循环 提供了一种基本的结构化循环构造,而 列表循环 和 计数循环则提供了两种易用却不那么通用的构造。并且如同在下一章将看到的那样,对于那些 列表循环 和 计数循环 无法满足需要的情形,还可以在 判断循环 之上构建自定义的循环构造。
最后,循环 宏提供了一种成熟的微型语言,它用一种非 Lisp 的类似英语(或至少类似 Algol)的语言来表达循环构造。一些 Lisp 黑客热爱 循环,其他人则讨厌它。循环 爱好者们喜欢它是因为它用了一种简洁的方式来表达特定的常用循环构造。而贬低者们不喜欢它则是因为它不太像 Lisp。但无论你倾向于哪一方,循环 本身都是一个为语言增加新构造的宏展示其强大威力的突出示例。
7.5 列表循环和计数循环
先从易于使用的 列表循环 和 计数循环 宏开始。
列表循环 在一个列表的元素上循环操作,使用一个依次持有列表中所有后继元素的变量来执行循环体。下面是其基本形式(去掉了一些比较难懂的选项):
(列表循环 (临时变量 列表形式) 主体形式*)
当循环开始时,列表形式 被求值一次以产生一个列表。然后循环体在列表的每一项上求值一次,同时用变量 临时变量 保存当前项的值。例如:
CL-USER> (列表循环 (x '(1 2 3)) (打印 x))123NIL
在这种方式下,列表循环 这种形式本身求值为 NIL。
如果想在列表结束之前中断一个 列表循环 循环,则可以使用 返回。
CL-USER> (列表循环 (x '(1 2 3)) (打印 x) (判断 (偶数 x) (返回))) 1 2 NIL
计数循环 是用于循环计数的高级循环构造。其基本模板和 列表循环 非常相似。
(计数循环 (临时变量 计数-形式) 主体形式*)
其中的 计数-形式 必须要能求值为一个整数。通过每次循环,临时变量 所持有的整数依次为从 0 到比那个数小 1 的每一个后继整数。例如:
CL-USER> (计数循环 (i 4) (打印 i)) 0 1 2 3 NIL
和 列表循环 一样,也可以使用 返回 来提前中断循环。
由于 列表循环 和 计数循环 的循环体中可以包含任何类型的表达式,因此也可以使用嵌套循环。例如,为了打印出从 1 × 1 = 1 到 20 × 20 = 400 的乘法表,可以写出下面这对嵌套的 DOTIMES 循环:
(计数循环 (x 20) (计数循环 (y 20) (格式 t "~3d " (* (1+ x) (1+ y)))) (格式 t "~%"))
7.6 判断循环
尽管 列表循环 和 计数循环 方便且易于使用,但却没有灵活到可用于所有循环。例如,如果想要并行循环多个变量该怎样做?或是使用任意表达式来测试循环的末尾呢?如果 列表循环 和 计数循环 都不能满足需求,那还可以用更通用的 判断循环 循环。
与 列表循环 和 计数循环 只提供一个循环变量有所不同的是,判断循环 允许绑定任意数量的变量,也可以定义测试条件来决定何时终止循环,并可以提供一个形式,在循环结束时进行求值来为 判断循环 表达式整体生成一个返回值。基本模板如下所示:
(判断循环 (变量定义*) (最终测试形式 结果形式*) 主体形式*)
每个 变量定义 引入了一个将存在于循环体作用域之内的变量。单个变量定义的完整形式是一个含有三个元素的列表。
(临时变量 初始化-形式 步进-形式)
上述 初始化-形式 在循环开始时被求值并将结果值绑定到变量 临时变量 上。在循环的每一个后续迭代开始之前,步进-形式 将被求值并把新值分配给 临时变量。步进-形式 是可选的,如果它没有给出,那么变量将在迭代过程中保持其值不变,除非在循环体中显式地为其赋予新值。和LET 中的变量定义一样,如果 初始化-形式 没有给出,那么变量将被绑定到 NIL。另外和 LET 的情形一样的是,你可以将一个只含有名字的列表简化成一个简单的变量名来使用。
在每次迭代开始时以及所有循环变量都被指定新值后,end-test-form
会被求值。只要其值为 NIL,迭代过程就会继续,依次求值所有的 statement
。
在每次迭代开始时,在为所有循环变量赋予其新值之后,将评估最终测试形式。只要它评估为NIL,迭代就会继续,按顺序评估语句。
当 最终测试形式 求值为真时,结果形式 将被求值,且最后一个结果形式的值将被作为 判断循环 表达式的值返回。
在迭代的每一步里,所有变量的 步进形式 将在分配任何值给变量之前被求值。这意味着可以在步长形式里引用其他任何循环变量。 比如在下列循环中:
(判断循环 ((n 0 (1+ n)) (cur 0 next) (next 1 (+ cur next))) ((= 10 n) cur))
其步长形式 (1+ n)
、next
和 (+ cur next)
均使用 n
、cur
和 next
的旧值来求值。只有当所有步长形式都被求值以后,这些变量才被指定其新的值。(有数学天赋的读者可能会注意到,这其实是一种计算第 11 个斐波那契数的特别有效的方式。)
这个例子还阐述了 判断循环 的另一种特征——由于可以同时推进多个变量所以往往根本不需要一个循环体。其他时候,尤其在只是把循环用作控制构造时,则可能会省略结果形式。尽管如此,这种灵活性正是 判断循环 表达式有点儿晦涩难懂的原因。所有这些括号都该放在哪里?理解一个 判断循环 表达式的最佳方式是记住其基本模板:
(判断循环 (变量定义*) (最终测试形式 结果形式*) 主体形式*)
该模板中的六个括号是 判断循环 结构本身所必需的。一对括号来围住变量声明,一对用来围住终止测试形式和结果形式,以及一对用来围住整个表达式。判断循环 中的其他形式可能需要它们自己的括号——例如变量定义总是以列表形式存在,而测试形式则通常是一个函数调用。不过 判断循环 循环的框架将总是一致的。下面是一些框架用黑体表示的 判断循环 循环的例子。
(判断循环 ((i 0 (1+ i))) ((>= i 4)) (打印 i))
注意到本例的结果被忽略了。不过这种用法对 判断循环 来说没有特别意义,因为用 计数循环 来写这个循环会更简单。
(计数循环 (i 4) (打印 i))
另一个例子是一个没有循环体的斐波那契数计算循环:
(判断循环 ((n 0 (1+ n)) (cur 0 next) (next 1 (+ cur next))) ((= 10 n) cur))
最后,下面循环演示了一个不绑定变量的 判断循环 循环。在当前时间小于一个全局变量值的时候,它保持循环,每分钟打印一个 “Waiting”。注意,就算没有循环变量,仍然需要有那个空变量列表。
(判断循环 () ((> (当前时间) *some-future-date*)) (格式 t "等候~%") (延时 60))
7.7 强大的循环
简单的情形可以使用 列表循环 和 计数循环。但如果它们不符合需要,就需要退而使用完全通用的 判断循环。不然还能怎样?
然而,结果是有少量的循环用法一次又一次地产生出来,例如在多种数据结构上的循环:列表、向量、哈希表和包,或是在循环时以多种方式来集聚值:收集、计数、求和、最小化和最大化。如果需要用宏来做其中的一件事(或同时几件),那么 循环 宏可以提供一种更容易表达的方式。
循环 宏事实上有两大类——简化的和扩展的。简化的版本极其简单,就是一个不绑定任何变量的无限循环。其框架看起来像这样::
(循环 主体形式*)
主体形式在每次通过循环时都将被求值,整个循环将不停地迭代,直到使用 返回 来进行中止。例如,可以使用一个简化的 循环 来写出前面的 判断循环 循环:
(循环 (如果真 (> (当前时间) *some-future-date*) (返回)) (格式 t "等候~%") (延时 60))
而扩展的 循环 则是一个完全不同的庞然大物。它以使用某些循环关键字来实现一种用于表达循环用法的通用。值得注意的是,并非所有的 Lisp 程序员都喜爱扩展的 循环 语言。至少一位 Common Lisp 的最初设计者就很讨厌它。循环 的贬低者们抱怨它的语法是完全非 Lisp 化的(换句话说,没有足够的括号)。循环 的爱好者们则反驳说,问题在于复杂的循环构造,如果不将它们用 判断循环 那晦涩语法包装起来,它们将难于被人理解。所以他们认为最好用一种稍显冗长的语法来提供某些关于你正在做的事情的线索。
例如,下面是一个地道的 判断循环 循环,它将把从 1 到 10 的数字收集到一个列表中:
(判断循环 ((nums nil) (i 1 (1+ i))) ((> i 10) (nreverse nums)) (添加 i nums)) ==> (1 2 3 4 5 6 7 8 9 10)
一个经验丰富的 Lisp 程序员将毫不费力地理解这些代码——只要理解一个 判断循环 循环的基本形式并且认识用于构建列表的 添加/NREVERSE 用法就可以了。但它并不是很直观。而它的 循环 版本理解起来就几乎可以像一个英语句子那样简单。
(循环 for i from 1 to 10 collecting i) ==> (1 2 3 4 5 6 7 8 9 10)
接下来是一些关于 循环 简单用法的例子。下例可以对前十个平方数求和:
(循环 for x from 1 to 10 summing (expt x 2)) ==> 385
这个用来统计一个字符串中元音字母的个数:
(循环 for x across "敏捷的棕色狐狸跳过了懒狗" counting (find x "aeiou")) ==> 11
下面这个例子用来计算第 11 个斐婆那契数,它类似于前面使用 判断循环 循环的版本:
(循环 for i below 10 and a = 0 then b and b = 1 then (+ b a) finally (返回 a))
符号 across
、and
、below
、collecting
、counting
、finally
、for
、from
、summing
、then
和 to
都是一些循环关键字,它们的存在表明当前正在使用扩展的 循环。
第 22 章将介绍 循环 的细节,但目前值得注意的是,我们通过它可以再次看到,宏是如何被用于扩展基本语言的。尽管 循环 提供了它自己的语言用来表达循环构造,但它并没有抹杀 Lisp 的其他优势。虽然循环关键字是按照循环的语法来解析的,但一个循环 中的其余代码都是正常的 Lisp 代码。
另外,值得再次指出的是,尽管 循环 宏相比诸如 如果真 或者 如果假 这样的宏复杂了许多,但它也只是另外一个宏而已。如果它没有被包括在标准库之中,你也可以自己实现它或是借助一个第三方库来实现它。
以上就是我们对基本控制构造宏的介绍。现在可以进一步了解如何定义自己的宏了。
1要查看这个误解是什么样的,请在comp.lang.lisp和任何其他comp.lang。*组之间找到任意长的Usenet线程,并在主题中使用宏。粗略的解释是这样的:
Lispnik:“Lisp是最好的,因为它的宏!”;
Othernik:“你认为Lisp是好的,因为宏?!但宏是可怕的和邪恶的; Lisp一定是可怕和邪恶的。”
2另一类重要的是使用宏是所有定义性的结构,如定义的语言结构的 函数,全局变量,空值全局变量,等。在第24章中,您将定义自己的定义宏,这将允许您简明地编写用于读取和写入二进制数据的代码。
3您实际上无法将此定义提供给Lisp,因为重新定义COMMON-LISP包中的名称是非法的 如果真。如果您真的想尝试编写这样的宏,则需要将名称更改为其他名称,例如my-如果真。
4如果你必须知道,特殊操作员是TAGBODY和GO。现在没有必要讨论它们,但我将在第20章中介绍它们。
5列表循环类似于Perl foreach或Python for。Java添加了一种类似的循环结构,带有“增强”for循环使用Java 1.5,作为JSR-201的一部分。注意宏有什么区别。一个Lisp程序员在他们的代码中注意到一个共同的模式,可以编写一个宏来给自己提供该模式的源级抽象。注意到相同模式的Java程序员必须说服Sun,这种特殊的抽象值得添加到该语言中。然后Sun必须发布一个JSR并召集一个行业范围的“专家组”来解决所有问题。根据Sun的说法,这个过程平均需要18个月。之后,编译器编写者都必须升级他们的编译器以支持新功能。甚至一旦Java程序员最喜欢的编译器支持新版本的Java,它们可能仍然存在在被允许破坏与旧版Java的源兼容性之前,不能使用新功能。因此,Common Lisp程序员可以在五分钟内自行解决的烦恼困扰着Java程序员多年。
6的一个变体判断循环,判断循环*在评估后续变量的步进形式之前,为每个变量赋值。有关更多详细信息,请参阅您最喜欢的Common Lisp参考。
7这计数循环也是首选,因为宏扩展可能包含允许编译器生成更高效代码的声明。
8 循环关键字有点用词不当,因为它们不是关键字符号。实际上,循环不关心符号来自哪个包。当循环宏解析它的主体时,它会认为任何适当命名的符号等价。你甚至可以使用真正的关键字,如果你wanted– :for, :across等-因为他们也有正确的名称。但大多数人只使用普通符号。因为循环关键字仅用作语法标记,所以它们是否用于其他目的 – 作为函数或变量名称无关紧要。
http://mip.i3geek.com