第三章:列表
列表是 Lisp 的基本数据结构之一。在最早的 Lisp 方言里,列表是唯一的数据结构: “Lisp” 这个名字起初是 “LISt Processor” 的缩写。但 Lisp 已经超越这个缩写很久了。 Common Lisp 是一个有着各式各样数据结构的通用性程序语言。
Lisp 程序开发通常呼应着开发 Lisp 语言自身。在最初版本的 Lisp 程序,你可能使用很多列表。然而之后的版本,你可能换到快速、特定的数据结构。本章描述了你可以用列表所做的很多事情,以及使用它们来演示一些普遍的 Lisp 概念。
- 3.1 构造 (Conses)
- 3.2 等式 (Equality)
- 3.3 为什么 Lisp 没有指针 (Why Lisp Has No Pointers)
- 3.4 建立列表 (Building Lists)
- 3.5 示例:压缩 (Example: Compression)
- 3.6 存取 (Access)
- 3.7 映射函数 (Mapping Functions)
- 3.8 树 (Trees)
- 3.9 理解递归 (Understanding Recursion)
- 3.10 集合 (Sets)
- 3.11 序列 (Sequences)
- 3.12 栈 (Stacks)
- 3.13 点状列表 (Dotted Lists)
- 3.14 关联列表 (Assoc-lists)
- 3.15 示例:最短路径 (Example: Shortest Path)
- 3.16 垃圾 (Garbages)
- Chapter 3 总结 (Summary)
- Chapter 3 习题 (Exercises)
3.1 构造 (Conses)
在 2.4 节我们介绍了 cons
, car
, 以及 cdr
,基本的 List 操作函数。 cons
真正所做的事情是,把两个对象结合成一个有两部分的对象,称之为 Cons 对象。概念上来说,一个 Cons 是一对指针;第一个是 car
,第二个是 cdr
。
Cons 对象提供了一个方便的表示法,来表示任何类型的对象。一个 Cons 对象里的一对指针,可以指向任何类型的对象,包括 Cons对象本身。它利用到我们之后可以用 cons
来构造列表的可能性。
我们往往不会把列表想成是成对的,但它们可以这样被定义。任何非空的列表,都可以被视为一对由列表第一个元素及列表其余元素所组成的列表。 Lisp 列表体现了这个概念。我们使用 Cons 的一半来指向列表的第一个元素,然后用另一半指向列表其余的元素(可能是别的 Cons 或 nil
)。 Lisp 的惯例是使用 car
代表列表的第一个元素,而用 cdr
代表列表的其余的元素。所以现在 car
是列表的第一个元素的同义词,而 cdr
是列表的其余的元素的同义词。列表不是不同的对象,而是像 Cons 这样的方式连结起来。
当我们想在 nil
上面建立东西时,
> (setf x (cons 'a nil))
(A)
图 3.2 三个元素的列表
> (cdr y)
(B C)
在一个有多个元素的列表中, car
指针让你取得元素,而 cdr
让你取得列表内其余的东西。
一个列表可以有任何类型的对象作为元素,包括另一个列表:
> (setf z (list 'a (list 'b 'c) 'd))
(A (B C) D)
当这种情况发生时,它的结构如图 3.3 所示;第二个 Cons 的 car
指针也指向一个列表:
> (car (cdr z))
(B C)
学生在学习递归时,有时候是被鼓励在纸上追踪 (trace)递归程序调用 (invocation)的过程。 (288页「译注:附录 A 追踪与回溯」可以看到一个递归函数的追踪过程。)但这种练习可能会误导你:一个程序员在定义一个递归函数时,通常不会特别地去想函数的调用顺序所导致的结果。
如果一个人总是需要这样子思考程序,递归会是艰难的、没有帮助的。递归的优点是它精确地让我们更抽象地来设计算法。你不需要考虑真正函数时所有的调用过程,就可以判断一个递归函数是否是正确的。
要知道一个递归函数是否做它该做的事,你只需要问,它包含了所有的情况吗?举例来说,下面是一个寻找列表长度的递归函数:
> (defun len (lst)
(if (null lst)
0
(+ (len (cdr lst)) 1)))
我们可以借由检查两件事情,来确信这个函数是正确的:
- 对长度为
0
的列表是有效的。 - 给定它对于长度为
n
的列表是有效的,它对长度是n+1
的列表也是有效的。
如果这两点是成立的,我们知道这个函数对于所有可能的列表都是正确的。
我们的定义显然地满足第一点:如果列表( lst
) 是空的( nil
),函数直接返回 0
。现在假定我们的函数对长度为 n
的列表是有效的。我们给它一个 n+1
长度的列表。这个定义说明了,函数会返回列表的 cdr
的长度再加上 1
。 cdr
是一个长度为 n
的列表。我们经由假定可知它的长度是 n
。所以整个列表的长度是 n+1
。
我们需要知道的就是这些。理解递归的秘密就像是处理括号一样。你怎么知道哪个括号对上哪个?你不需要这么做。你怎么想像那些调用过程?你不需要这么做。
更复杂的递归函数,可能会有更多的情况需要讨论,但是流程是一样的。举例来说, 41 页的 our-copy-tree
,我们需要讨论三个情况: 原子,单一的 Cons 对象, n+1
的 Cons 树。
第一个情况(长度零的列表)称之为基本用例( base case )。当一个递归函数不像你想的那样工作时,通常是处理基本用例就错了。下面这个不正确的 member
定义,是一个常见的错误,整个忽略了基本用例:
(defun our-member (obj lst)
(if (eql (car lst) obj)
lst
(our-member obj (cdr lst))))
我们需要初始一个 null
测试,确保在到达列表底部时,没有找到目标时要停止递归。如果我们要找的对象没有在列表里,这个版本的 member
会陷入无穷循环。附录 A 更详细地讨论了这种问题。
能够判断一个递归函数是否正确只不过是理解递归的上半场,下半场是能够写出一个做你想做的事情的递归函数。 6.9 节讨论了这个问题。
3.10 集合 (Sets)
列表是表示小集合的好方法。列表中的每个元素都代表了一个集合的成员:
> (member 'b '(a b c))
(B C)
当 member
要返回“真”时,与其仅仅返回 t
,它返回由寻找对象所开始的那部分。逻辑上来说,一个 Cons 扮演的角色和 t
一样,而经由这么做,函数返回了更多资讯。
一般情况下, member
使用 eql
来比较对象。你可以使用一种叫做关键字参数的东西来重写缺省的比较方法。多数的 Common Lisp 函数接受一个或多个关键字参数。这些关键字参数不同的地方是,他们不是把对应的参数放在特定的位置作匹配,而是在函数调用中用特殊标签,称为关键字,来作匹配。一个关键字是一个前面有冒号的符号。
一个 member
函数所接受的关键字参数是 :test
参数。
如果你在调用 member
时,传入某个函数作为 :test
参数,那么那个函数就会被用来比较是否相等,而不是用 eql
。所以如果我们想找到一个给定的对象与列表中的成员是否相等( equal
),我们可以:
> (member '(a) '((a) (z)) :test #'equal)
((A) (Z))
关键字参数总是选择性添加的。如果你在一个调用中包含了任何的关键字参数,他们要摆在最后; 如果使用了超过一个的关键字参数,摆放的顺序无关紧要。
另一个 member
接受的关键字参数是 :key
参数。借由提供这个参数,你可以在作比较之前,指定一个函数运用在每一个元素:
> (member 'a '((a b) (c d)) :key #'car)
((A B) (C D))
在这个例子里,我们询问是否有一个元素的 car
是 a
。
如果我们想要使用两个关键字参数,我们可以使用其中一个顺序。下面这两个调用是等价的:
> (member 2 '((1) (2)) :key #'car :test #'equal)
((2))
> (member 2 '((1) (2)) :test #'equal :key #'car)
((2))
两者都询问是否有一个元素的 car
等于( equal
) 2。
如果我们想要找到一个元素满足任意的判断式像是── oddp
,奇数返回真──我们可以使用相关的 member-if
:
> (member-if #'oddp '(2 3 4))
(3 4)
我们可以想像一个限制性的版本 member-if
是这样写成的:
(defun our-member-if (fn lst)
(and (consp lst)
(if (funcall fn (car lst))
lst
(our-member-if fn (cdr lst)))))
函数 adjoin
像是条件式的 cons
。它接受一个对象及一个列表,如果对象还不是列表的成员,才构造对象至列表上。
> (adjoin 'b '(a b c))
(A B C)
> (adjoin 'z '(a b c))
(Z A B C)
通常的情况下它接受与 member
函数同样的关键字参数。
集合论中的并集 (union)、交集 (intersection)以及补集 (complement)的实现,是由函数 union
、 intersection
以及 set-difference
。
这些函数期望两个(正好 2 个)列表(一样接受与 member
函数同样的关键字参数)。
> (union '(a b c) '(c b s))
(A C B S)
> (intersection '(a b c) '(b b c))
(B C)
> (set-difference '(a b c d e) '(b e))
(A C D)
因为集合中没有顺序的概念,这些函数不需要保留原本元素在列表被找到的顺序。举例来说,调用 set-difference
也有可能返回(d c a)
。
3.11 序列 (Sequences)
另一种考虑一个列表的方式是想成一系列有特定顺序的对象。在 Common Lisp 里,序列( sequences )包括了列表与向量 (vectors)。本节介绍了一些可以运用在列表上的序列函数。更深入的序列操作在 4.4 节讨论。
函数 length
返回序列中元素的数目。
> (length '(a b c))
3
我们在 24 页 (译注:2.13节 our-length
)写过这种函数的一个版本(仅可用于列表)。
要复制序列的一部分,我们使用 subseq
。第二个(需要的)参数是第一个开始引用进来的元素位置,第三个(选择性)参数是第一个不引用进来的元素位置。
> (subseq '(a b c d) 1 2)
(B)
>(subseq '(a b c d) 1)
(B C D)
如果省略了第三个参数,子序列会从第二个参数给定的位置引用到序列尾端。
函数 reverse
返回与其参数相同元素的一个序列,但顺序颠倒。
> (reverse '(a b c))
(C B A)
一个回文 (palindrome) 是一个正读反读都一样的序列 —— 举例来说, (abba)
。如果一个回文有偶数个元素,那么后半段会是前半段的镜射 (mirror)。使用 length
、 subseq
以及 reverse
,我们可以定义一个函数
(defun mirror? (s)
(let ((len (length s)))
(and (evenp len)
(let ((mid (/ len 2)))
(equal (subseq s 0 mid)
(reverse (subseq s mid)))))))
来检测是否是回文:
> (mirror? '(a b b a))
T
Common Lisp 有一个内置的排序函数叫做 sort
。它接受一个序列及一个比较两个参数的函数,返回一个有同样元素的序列,根据比较函数来排序:
> (sort '(0 2 1 3 8) #'>)
(8 3 2 1 0)
你要小心使用 sort
,因为它是破坏性的(destructive)。考虑到效率的因素, sort
被允许修改传入的序列。所以如果你不想你本来的序列被改动,传入一个副本。
使用 sort
及 nth
,我们可以写一个函数,接受一个整数 n
,返回列表中第 n
大的元素:
(defun nthmost (n lst)
(nth (- n 1)
(sort (copy-list lst) #'>)))
我们把整数减一因为 nth
是零索引的,但如果 nthmost
是这样的话,会变得很不直观。
(nthmost 2 '(0 2 1 3 8))
多努力一点,我们可以写出这个函数的一个更有效率的版本。
函数 every
和 some
接受一个判断式及一个或多个序列。当我们仅输入一个序列时,它们测试序列元素是否满足判断式:
> (every #'oddp '(1 3 5))
T
> (some #'evenp '(1 2 3))
T
如果它们输入多于一个序列时,判断式必须接受与序列一样多的元素作为参数,而参数从所有序列中一次提取一个:
> (every #'> '(1 3 5) '(0 2 4))
T
如果序列有不同的长度,最短的那个序列,决定需要测试的次数。
3.12 栈 (Stacks)
用 Cons 对象来表示的列表,很自然地我们可以拿来实现下推栈 (pushdown stack)。这太常见了,以致于 Common Lisp 提供了两个宏给堆使用: (push x y)
把 x
放入列表 y
的前端。而 (pop x)
则是将列表 x 的第一个元素移除,并返回这个元素。
两个函数都是由 setf
定义的。如果参数是常数或变量,很简单就可以翻译出对应的函数调用。
表达式
(push obj lst)
等同于
(setf lst (cons obj lst))
而表达式
(pop lst)
等同于
(let ((x (car lst)))
(setf lst (cdr lst))
x)
所以,举例来说:
> (setf x '(b))
(B)
> (push 'a x)
(A B)
> x
(A B)
> (setf y x)
(A B)
> (pop x)
(A)
> x
(B)
> y
(A B)
以上,全都遵循上述由 setf
所给出的相等式。图 3.9 展示了这些表达式被求值后的结构。
[4] ,以及一个关联列表来表示网络本身。
3.16 垃圾 (Garbages)
有很多原因可以使列表变慢。列表提供了顺序存取而不是随机存取,所以列表取出一个指定的元素比数组慢,同样的原因,录音带取出某些东西比在光盘上慢。电脑内部里, Cons 对象倾向于用指针表示,所以走访一个列表意味着走访一系列的指针,而不是简单地像数组一样增加索引值。但这两个所花的代价与配置及回收 Cons 核 (cons cells)比起来小多了。
自动内存管理(Automatic memory management)是 Lisp 最有价值的特色之一。 Lisp 系统维护着一段內存称之为堆(Heap)。系统持续追踪堆当中没有使用的内存,把这些内存发放给新产生的对象。举例来说,函数 cons
,返回一个新配置的 Cons 对象。从堆中配置内存有时候通称为 consing 。
如果内存永远没有释放, Lisp 会因为创建新对象把内存用完,而必须要关闭。所以系统必须周期性地通过搜索堆 (heap),寻找不需要再使用的内存。不需要再使用的内存称之为垃圾 (garbage),而清除垃圾的动作称为垃圾回收 (garbage collection或 GC)。
垃圾是从哪来的?让我们来创造一些垃圾:
> (setf lst (list 'a 'b 'c))
(A B C)
> (setf lst nil)
NIL
一开始我们调用 list
, list
调用 cons
,在堆上配置了一个新的 Cons 对象。在这个情况我们创出三个 Cons 对象。之后当我们把lst
设为 nil
,我们没有任何方法可以再存取 lst
,列表 (a b c)
。 [5]
因为我们没有任何方法再存取列表,它也有可能是不存在的。我们不再有任何方式可以存取的对象叫做垃圾。系统可以安全地重新使用这三个 Cons 核。
这种管理內存的方法,给程序員带来极大的便利性。你不用显式地配置 (allocate)或释放 (dellocate)內存。这也表示了你不需要处理因为这么做而可能产生的臭虫。內存泄漏 (Memory leaks)以及迷途指针 (dangling pointer)在 Lisp 中根本不可能发生。
但是像任何的科技进步,如果你不小心的话,自动內存管理也有可能对你不利。使用及回收堆所带来的代价有时可以看做 cons
的代价。这是有理的,除非一个程序从来不丢弃任何东西,不然所有的 Cons 对象终究要变成垃圾。 Consing 的问题是,配置空间与清除內存,与程序的常规运作比起来花费昂贵。近期的研究提出了大幅改善內存回收的演算法,但是 consing 总是需要代价的,在某些现有的 Lisp 系统中,代价是昂贵的。
除非你很小心,不然很容易写出过度显式创建 cons 对象的程序。举例来说, remove
需要复制所有的 cons
核,直到最后一个元素从列表中移除。你可以借由使用破坏性的函数避免某些 consing,它试着去重用列表的结构作为参数传给它们。破坏性函数会在 12.4 节讨论。
当写出 cons
很多的程序是如此简单时,我们还是可以写出不使用 cons
的程序。典型的方法是写出一个纯函数风格,使用很多列表的第一版程序。当程序进化时,你可以在代码的关键部分使用破坏性函数以及/或别种数据结构。但这很难给出通用的建议,因为有些 Lisp 实现,內存管理处理得相当好,以致于使用 cons
有时比不使用 cons
还快。这整个议题在 13.4 做更进一步的细部讨论。
无论如何 consing 在原型跟实验时是好的。而且如果你利用了列表给你带来的灵活性,你有较高的可能写出后期可存活下来的程序。
Chapter 3 总结 (Summary)
- 一个 Cons 是一个含两部分的数据结构。列表用链结在一起的 Cons 组成。
- 判断式
equal
比eql
来得不严谨。基本上,如果传入参数印出来的值一样时