第三章:列表

2018-02-24 15:51 更新

列表是 Lisp 的基本数据结构之一。在最早的 Lisp 方言里,列表是唯一的数据结构: “Lisp” 这个名字起初是 “LISt Processor” 的缩写。但 Lisp 已经超越这个缩写很久了。 Common Lisp 是一个有着各式各样数据结构的通用性程序语言。

Lisp 程序开发通常呼应着开发 Lisp 语言自身。在最初版本的 Lisp 程序,你可能使用很多列表。然而之后的版本,你可能换到快速、特定的数据结构。本章描述了你可以用列表所做的很多事情,以及使用它们来演示一些普遍的 Lisp 概念。

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)))

我们可以借由检查两件事情,来确信这个函数是正确的:

  1. 对长度为 0 的列表是有效的。
  2. 给定它对于长度为 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)

  1. 一个 Cons 是一个含两部分的数据结构。列表用链结在一起的 Cons 组成。
  2. 判断式 equal 比 eql 来得不严谨。基本上,如果传入参数印出来的值一样时
以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号