14.5 Loop 宏 (The Loop Facility)
loop
宏最初是设计来帮助无经验的 Lisp 用户来写出迭代的代码。与其撰写 Lisp 代码,你用一种更接近英语的形式来表达你的程序,然后这个形式被翻译成 Lisp。不幸的是, loop
比原先设计者预期的更接近英语:你可以在简单的情况下使用它,而不需了解它是如何工作的,但想在抽象层面上理解它几乎是不可能的。
如果你是曾经计划某天要理解 loop
怎么工作的许多 Lisp 程序员之一,有一些好消息与坏消息。好消息是你并不孤单:几乎没有人理解它。坏消息是你永远不会理解它,因为 ANSI 标准实际上并没有给出它行为的正式规范。
这个宏唯一的实际定义是它的实现方式,而唯一可以理解它(如果有人可以理解的话)的方法是通过实例。ANSI 标准讨论 loop
的章节大部分由例子组成,而我们将会使用同样的方式来介绍相关的基础概念。
第一个关于 loop
宏我们要注意到的是语法 ( syntax )。一个 loop
表达式不是包含子表达式而是子句 (clauses)。這些子句不是由括号分隔出来;而是每种都有一个不同的语法。在这个方面上, loop
与传统的 Algol-like 语言相似。但其它 loop
独特的特性,使得它与 Algol 不同,也就是在 loop
宏里调换子句的顺序与会发生的事情没有太大的关联。
一个 loop
表达式的求值分为三个阶段,而一个给定的子句可以替多于一个的阶段贡献代码。这些阶段如下:
- 序幕 (Prologue)。 被求值一次来做为迭代过程的序幕。包括了将变量设至它们的初始值。
- 主体 (Body) 每一次迭代时都会被求值。
- 闭幕 (Epilogue) 当迭代结束时被求值。决定了
loop
表达式的返回值(可能返回多个值)。
我们会看几个 loop
子句的例子,并考虑何种代码会贡献至何个阶段。
举例来说,最简单的 loop
表达式,我们可能会看到像是下列的代码:
> (loop for x from 0 to 9
do (princ x))
0123456789
NIL
这个 loop
表达式印出从 0
至 9
的整数,并返回 nil
。第一个子句,
for x from 0 to 9
贡献代码至前两个阶段,导致 x
在序幕中被设为 0
,在主体开头与 9
来做比较,在主体结尾被递增。第二个子句,
do (princ x)
贡献代码给主体。
一个更通用的 for
子句说明了起始与更新的形式 (initial and update form)。停止迭代可以被像是 while
或 until
子句来控制。
> (loop for x = 8 then (/ x 2)
until (< x 1)
do (princ x))
8421
NIL
你可以使用 and
来创建复合的 for
子句,同时初始及更新两个变量:
> (loop for x from 1 to 4
and y from 1 to 4
do (princ (list x y)))
(1 1)(2 2)(3 3)(4 4)
NIL
要不然有多重 for
子句时,变量会被循序更新。
另一件在迭代代码通常会做的事是累积某种值。举例来说:
> (loop for x in '(1 2 3 4)
collect (1+ x))
(2 3 4 5)
在 for
子句使用 in
而不是 from
,导致变量被设为一个列表的后续元素,而不是连续的整数。
在这个情况里, collect
子句贡献代码至三个阶段。在序幕,一個匿名累加器 (anonymous accumulator)設為 nil
;在主体裡, (1+ x)
被累加至這個累加器,而在闭幕时返回累加器的值。
这是返回一个特定值的第一个例子。有用来明确指定返回值的子句,但没有这些子句时,一个 collect
子句决定了返回值。所以我们在这里所做的其实是重复了 mapcar
。
loop
最常见的用途大概是蒐集调用一个函数数次的结果:
> (loop for x from 1 to 5
collect (random 10))
(3 8 6 5 0)
这里我们获得了一个含五个随机数的列表。这跟我们定义过的 map-int
情况类似 (105 页「译注: 6.4 小节。」)。如果我们有了 loop
,为什么还需要 map-int
?另一个人也可以说,如果我们有了 map-int
,为什么还需要 loop
?
一个 collect
子句也可以累积值到一个有名字的变量上。下面的函数接受一个数字的列表并返回偶数与奇数列表:
(defun even/odd (ns)
(loop for n in ns
if (evenp n)
collect n into evens
else collect n into odds
finally (return (values evens odds))))
一个 finally
子句贡献代码至闭幕。在这个情况它指定了返回值。
一个 sum
子句和一个 collect
子句类似,但 sum
子句累积一个数字,而不是一个列表。要获得 1
至 n
的和,我们可以写:
(defun sum (n)
(loop for x from 1 to n
sum x))
loop
更进一步的细节在附录 D 讨论,从 325 页开始。举个例子,图 14.1 包含了先前章节的两个迭代函数,而图 14.2 演示了将同样的函数翻译成 loop
。
(defun most (fn lst)
(if (null lst)
(values nil nil)
(let* ((wins (car lst))
(max (funcall fn wins)))
(dolist (obj (cdr lst))
(let ((score (funcall fn obj)))
(when (> score max)
(setf wins obj
max score))))
(values wins max))))
(defun num-year (n)
(if (< n 0)
(do* ((y (- yzero 1) (- y 1))
(d (- (year-days y)) (- d (year-days y))))
((<= d n) (values y (- n d))))
(do* ((y yzero (+ y 1))
(prev 0 d)
(d (year-days y) (+ d (year-days y))))
((> d n) (values y (- n prev))))))
图 14.1 不使用 loop 的迭代函数
(defun most (fn lst)
(if (null lst)
(values nil nil)
(loop with wins = (car lst)
with max = (funcall fn wins)
for obj in (cdr lst)
for score = (funcall fn obj)
when (> score max)
(do (setf wins obj
max score)
finally (return (values wins max))))))
(defun num-year (n)
(if (< n 0)
(loop for y downfrom (- yzero 1)
until (<= d n)
sum (- (year-days y)) into d
finally (return (values (+ y 1) (- n d))))
(loop with prev = 0
for y from yzero
until (> d n)
do (setf prev d)
sum (year-days y) into d
finally (return (values (- y 1)
(- n prev))))))
图 14.2 使用 loop 的迭代函数
一个 loop
的子句可以参照到由另一个子句所设置的变量。举例来说,在 even/odd
的定义里面, finally
子句参照到由两个 collect
子句所创建的变量。这些变量之间的关系,是 loop
定义最含糊不清的地方。考虑下列两个表达式:
(loop for y = 0 then z
for x from 1 to 5
sum 1 into z
finally (return y z))
(loop for x from 1 to 5
for y = 0 then z
sum 1 into z
finally (return y z))
它们看起来够简单 ── 每一个有四个子句。但它们返回同样的值吗?它们返回的值多少?你若试着在标准中想找答案将徒劳无功。每一个 loop
子句本身是够简单的。但它们组合起来的方式是极为复杂的 ── 而最终,甚至标准里也没有明确定义。
由于这类原因,使用 loop
是不推荐的。推荐 loop
的理由,你最多可以说,在像是图 14.2 这般经典的例子中, loop
让代码看起来更容易理解。