《ANSI Common Lisp》- 第三章:列表【笔记】

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

3.1 构造

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

产生的列表由一个 Cons 所组成,见图 3.1。这种表达 Cons 的方式叫做箱子表示法 (box notation),因为每一个 Cons 是用一个箱子表示,内含一个 car 和 cdr的指针。当我们调用 car 与 cdr 时,我们得到指针指向的地方:

> (car x)

A

> (cdr x)

NIL

当我们构造一个多元素的列表时,我们得到一串 Cons (a chain of conses):

> (setf y (list 'a 'b 'c))

(A B C)

产生的结构见图 3.2。现在当我们想得到列表的 cdr 时,它是一个两个元素的列表。

在一个有多个元素的列表中, car 指针让你取得元素,而 cdr 让你取得列表内其余的东西。

一个列表可以有任何类型的对象作为元素,包括另一个列表:

> (setf z (list 'a (list 'b 'c) 'd))

(A (B C) D)

当这种情况发生时,它的结构如图 3.3 所示;第二个 Cons 的 car 指针也指向一个列表:

>(car (cdr z))

(B C)

前两个我们构造的列表都有三个元素;只不过 z 列表的第二个元素也刚好是一个列表。像这样的列表称为嵌套列表,而像 y 这样的列表称之为平坦列表 (flatlist)。

如果参数是一个 Cons 对象,函数 consp 返回真。所以我们可以这样定义 listp :

>(defun our-listp (x)

  (or (null x) (consp x)))

因为所有不是 Cons 对象的东西,就是一个原子 (atom),判断式 atom 可以这样定义:

>(defun our-atom (x) (not (consp x)))

注意, nil 既是一个原子,也是一个列表。

3.2 等式 (Equality)

每一次你调用 cons 时, Lisp 会配置一块新的内存给两个指针。所以如果我们用同样的参数调用 cons 两次,我们得到两个数值看起来一样,但实际上是两个不同的对象:

CL-USER> (eql (cons 'a nil) (cons 'a nil))

NIL

如果我们也可以询问两个列表是否有相同元素,那就很方便了。 Common Lisp 提供了这种目的另一个判断式: equal 。而另一方面 eql 只有在它的参数是相同对象时才返回真,

CL-USER> (setf x (cons 'a nil))

(A)

CL-USER> (eql x x)

T

本质上 equal 若它的参数打印出的值相同时,返回真

CL-USER> (equal x (cons 'a nil))

T

这个判断式对非列表结构的别种对象也有效,但一种仅对列表有效的版本可以这样定义:

CL-USER> (defun our-equal (x y)

    (or (eql x y)

        (and (consp x)

            (consp y)

            (our-equal (car x) (car y))

            (our-equal (cdr x) (cdr y)))))

这个定义意味着,如果某个 x 和 y 相等( eql ),那么他们也相等( equal )。

勘误: 这个版本的 our-equal 可以用在符号的列表 (list of symbols),而不是列表 (list)。

3.3 为什么 Lisp 没有指针 (Why Lisp Has No Pointers)

一个理解 Lisp 的秘密之一是意识到变量是有值的,就像列表有元素一样。如同 Cons 对象有指针指向他们的元素,变量有指针指向他们的值。

你可能在别的语言中使用过显式指针 (explicitly pointer)。在 Lisp,你永远不用这么做,因为语言帮你处理好指针了。我们已经在列表看过这是怎么实现的。同样的事情发生在变量身上。举例来说,假设我们想要把两个变量设成同样的列表:

CL-USER> (setf x '(a b c))

(A B C)

CL-USER> (setf y x)

(A B C)

当我们把 x 的值赋给 y 时,究竟发生什么事呢?内存中与 x 有关的位置并没有包含这个列表,而是一个指针指向它。当我们给 y 赋一个相同的值时, Lisp 复制的是指针,而不是列表。(图 3.4 显式赋值 x 给 y 后的结果)无论何时,你将某个变量的值赋给另个变量时,两个变量的值将会是 eql 的:

CL-USER> (eql x y)

T

Lisp 没有指针的原因是因为每一个值,其实概念上来说都是一个指针。当你赋一个值给变量或将这个值存在数据结构中,其实被储存的是指向这个值的指针。当你要取得变量的值,或是存在数据结构中的内容时, Lisp 返回指向这个值的指针。但这都在台面下发生。你可以不加思索地把值放在结构里,或放“在”变量里。

为了效率的原因, Lisp 有时会选择一个折衷的表示法,而不是指针。举例来说,因为一个小整数所需的内存空间,少于一个指针所需的空间,一个 Lisp 实现可能会直接处理这个小整数,而不是用指针来处理。但基本要点是,程序员预设可以把任何东西放在任何地方。除非你声明你不愿这么做,不然你能够在任何的数据结构,存放任何类型的对象,包括结构本身。

3.4 建立列表 (Building Lists)

函数 copy-list 接受一个列表,然后返回此列表的复本。新的列表会有同样的元素,但是装在新的 Cons 对象里:

CL-USER> (setf x '(a b c)

        y (copy-list x))

(A B C)

图 3.5 展示出结果的结构; 返回值像是有着相同乘客的新公交。我们可以把 copy-list 想成是这么定义的:

CL-USER> (defun our-copy-list (lst)

(if (atom lst)

    lst

    (cons (car lst) (our-copy-list (cdr lst)))))

这个定义暗示着 x 与 (copy-list x) 会永远 equal ,并永远不 eql ,除非 x 是 NIL 。

最后,函数 append 返回任何数目的列表串接 (concatenation):

CL-USER> (append '(a b) '(c d) 'e)

(A B C D . E)

通过这么做,它复制所有的参数,除了最后一个

3.5 示例:压缩 (Example: Compression)

作为一个例子,这节将演示如何实现简单形式的列表压缩。这个算法有一个令人印象深刻的名字,游程编码(run-length encoding)。

在餐厅的情境下,这个算法的工作方式如下。一个女服务生走向有四个客人的桌子。“你们要什么?” 她问。“我要特餐,” 第一个客人说。 “我也是,” 第二个客人说。“听起来不错,” 第三个客人说。每个人看着第四个客人。 “我要一个 cilantro soufflé,” 他小声地说。 (译注:蛋奶酥上面洒香菜跟酱料)

瞬息之间,女服务生就转身踩着高跟鞋走回柜台去了。 “三个特餐,” 她大声对厨师说,“还有一个香菜蛋奶酥。”

图 3.6 展示了如何实现这个压缩列表演算法。函数 compress 接受一个由原子组成的列表,然后返回一个压缩的列表:

CL-USER> (compress '(1 1 1 0 1 0 0 0 0 1))

((3 1) 0 1 (4 0) 1)

当相同的元素连续出现好几次,这个连续出现的序列 (sequence)被一个列表取代,列表指明出现的次数及出现的元素。

主要的工作是由递归函数 compr 所完成。这个函数接受三个参数: elt , 上一个我们看过的元素; n ,连续出现的次数;以及 lst ,我们还没检查过的部分列表。如果没有东西需要检查了,我们调用 n-elts 来取得 n elts 的表示法。如果 lst 的第一个元素还是 elt ,我们增加出现的次数 n 并继续下去。否则我们得到,到目前为止的一个压缩的列表,然后 cons 这个列表在 compr 处理完剩下的列表所返回的东西之上。

要复原一个压缩的列表,我们调用 uncompress (图 3.7)

CL-USER> (uncompress '((3 1) 0 1 (4 0) 1))

(1 1 1 0 1 0 0 0 0 1)

这个函数递归地遍历这个压缩列表,逐字复制原子并调用 list-of ,展开成列表。

CL-USER> (list-of 3 'ho)

(HO HO HO)

我们其实不需要自己写 list-of 。内置的 make-list 可以办到一样的事情 ── 但它使用了我们还没介绍到的关键字参数 (keyword argument)。

图 3.6 跟 3.7 这种写法不是一个有经验的Lisp 程序员用的写法。它的效率很差,它没有尽可能的压缩,而且它只对由原子组成的列表有效。在几个章节内,我们会学到解决这些问题的技巧。

载入程序

在这节的程序是我们第一个实质的程序。

当我们想要写超过数行的函数时,

通常我们会把程序写在一个文件,

然后使用 load 让 Lisp 读取函数的定义。

如果我们把图 3.6 跟 3.7 的程序,

存在一个文件叫做,“compress.lisp”然后输入

(load "compress.lisp")

到顶层,或多或少的,

我们会像在直接输入顶层一样得到同样的效果。

注意:在某些实现中,Lisp 文件的扩展名会是“.lsp”而不是“.lisp”

3.6 存取 (Access)

Common Lisp 有额外的存取函数,它们是用 car 跟 cdr 所定义的。要找到列表特定位置的元素,我们可以调用 nth ,

CL-USER> (nth 0 '(a b c))

A

而要找到第 n 个 cdr ,我们调用 nthcdr :

CL-USER> (nthcdr 2 '(a b c d))

(C D)

nth 与 nthcdr 都是零索引的 (zero-indexed); 即元素从 0 开始编号,而不是从 1 开始。在 Common Lisp 里,无论何时你使用一个数字来参照一个数据结构中的元素时,都是从 0 开始编号的。

两个函数几乎做一样的事; nth 等同于取 nthcdr 的 car 。没有检查错误的情况下, nthcdr 可以这么定义:

(defun our-nthcdr (n lst)

  (if (zerop n)

      lst

      (our-nthcdr (- n 1) (cdr lst))))

函数 zerop 仅在参数为零时,才返回真。

函数 last 返回列表的最后一个 Cons 对象:

CL-USER> (last '(a b c))

(C)

这跟取得最后一个元素不一样。要取得列表的最后一个元素,你要取得 last 的 car 。

Common Lisp 定义了函数 first 直到 tenth 可以取得列表对应的元素。这些函数不是 零索引的 (zero-indexed):

(second x) 等同于 (nth 1 x) 。

此外, Common Lisp 定义了像是 caddr 这样的函数,它是 cdr 的 cdr 的 car 的缩写 ( car of cdr of cdr )。所有这样形式的函数 cxr ,其中 x 是一个字符串,最多四个 a 或 d ,在 Common Lisp 里都被定义好了。使用 cadr 可能会有异常 (exception)产生,在所有人都可能会读的代码里使用这样的函数,可能不是个好主意。

3.7 映射函数 (Mapping Functions)

Common Lisp 提供了数个函数来对一个列表的元素做函数调用。最常使用的是 mapcar ,接受一个函数以及一个或多个列表,并返回把函数应用至每个列表的元素的结果,直到有的列表没有元素为止:

CL-USER> (mapcar #'(lambda (x) (+ x 10))

          '(1 2 3))

(11 12 13)

CL-USER> (mapcar #'list

          '(a b c)

          '(1 2 3 4))

((A 1) (B 2) (C 3))

相关的 maplist 接受同样的参数,将列表的渐进的下一个 cdr 传入函数。

CL-USER> (maplist #'(lambda (x) x)

          '(a b c))

((A B C) (B C) (C))

其它的映射函数,包括 mapc 我们在 89 页讨论(译注:5.4 节最后),以及 mapcan 在 202 页(译注:12.4 节最后)讨论。

笔记:这一节不怎么看得懂 - -! 先过吧。

3.8 树 (Trees)

Cons 对象可以想成是二叉树, car 代表左子树,而 cdr 代表右子树。举例来说,列表

(a (b c) d) 也是一棵由图 3.8 所代表的树。 (如果你逆时针旋转 45 度,你会发现跟图 3.3 一模一样)

图 3.8 二叉树 (Binary Tree)

Common Lisp 有几个内置的操作树的函数。举例来说, copy-tree 接受一个树,并返回一份副本。它可以这么定义:

CL-USER> (defun our-copy-tree (tr)

  (if (atom tr)

      tr

      (cons (our-copy-tree (car tr))

            (our-copy-tree (cdr tr)))))

把这跟 36 页的 copy-list 比较一下; copy-tree 复制每一个 Cons 对象的 car 与 cdr ,而 copy-list 仅复制 cdr 。

没有内部节点的二叉树没有太大的用处。 Common Lisp 包含了操作树的函数,不只是因为我们需要树这个结构,而是因为我们需要一种方法,来操作列表及所有内部的列表。举例来说,假设我们有一个这样的列表:

(and (integerp x) (zerop (mod x 2)))

而我们想要把各处的 x 都换成 y 。调用 substitute 是不行的,它只能替换序列 (sequence)中的元素:

CL-USER> (substitute 'y 'x '(and (integerp x) (zerop (mod x 2))))

(AND (INTEGERP X) (ZEROP (MOD X 2)))

这个调用是无效的,因为列表有三个元素,没有一个元素是 x 。我们在这所需要的是 subst ,它替换树之中的元素。

如果我们定义一个 subst 的版本,它看起来跟 copy-tree 很相似:

CL-USER> (subst 'y 'x '(and (integerp x) (zerop (mod x 2))))

(AND (INTEGERP Y) (ZEROP (MOD Y 2)))

如果我们定义一个 subst 的版本,它看起来跟 copy-tree 很相似:

CL-USER> (defun our-subst (new old tree)

    (if (eql tree old)

        new

        (if (atom tree)

            tree

            (cons (our-subst new old (car tree))

                  (our-subst new old (cdr tree))))))

操作树的函数通常有这种形式, car 与 cdr 同时做递归。这种函数被称之为是 双重递归 (doubly recursive)

3.9 理解递归 (Understanding Recursion)

学生在学习递归时,有时候是被鼓励在纸上追踪 (trace)递归程序调用 (invocation)的过程。 (288页「译注:附录 A 追踪与回溯」可以看到一个递归函数的追踪过程。)但这种练习可能会误导你:一个程序员在定义一个递归函数时,通常不会特别地去想函数的调用顺序所导致的结果。

如果一个人总是需要这样子思考程序,递归会是艰难的、没有帮助的。递归的优点是它精确地让我们更抽象地来设计算法。你不需要考虑真正函数时所有的调用过程,就可以判断一个递归函数是否是正确的。

要知道一个递归函数是否做它该做的事,你只需要问,它包含了所有的情况吗?举例来说,下面是一个寻找列表长度的递归函数:

CL-USER> (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 树。

能够判断一个递归函数是否正确只不过是理解递归的上半场,下半场是能够写出一个做你想做的事情的递归函数。 6.9 节讨论了这个问题。

3.10 集合 (Sets)

列表是表示小集合的好方法。列表中的每个元素都代表了一个集合的成员:

CL-USER> (member 'b '(a b c))

(B C)

当 member 要返回“真”时,与其仅仅返回 t ,它返回由寻找对象所开始的那部分。逻辑上来说,一个 Cons 扮演的角色和 t 一样,而经由这么做,函数返回了更多资讯。

一般情况下, member 使用 eql 来比较对象。你可以使用一种叫做关键字参数的东西来重写缺省的比较方法。多数的 Common Lisp 函数接受一个或多个关键字参数。这些关键字参数不同的地方是,他们不是把对应的参数放在特定的位置作匹配,而是在函数调用中用特殊标签,称为关键字,来作匹配。一个关键字是一个前面有冒号的符号。

一个 member 函数所接受的关键字参数是 :test 参数。

如果你在调用 member 时,传入某个函数作为 :test 参数,那么那个函数就会被用来比较是否相等,而不是用 eql 。所以如果我们想找到一个给定的对象与列表中的成员是否相等( equal ),我们可以:

CL-USER> (member '(a) '((a) (z)) :test #'equal)

((A) (Z))

关键字参数总是选择性添加的。如果你在一个调用中包含了任何的关键字参数,他们要摆在最后; 如果使用了超过一个的关键字参数,摆放的顺序无关紧要。

另一个 member 接受的关键字参数是 :key 参数。借由提供这个参数,你可以在作比较之前,指定一个函数运用在每一个元素:

CL-USER> (member 'a '((a b) (c d)) :key #'car)

((A B) (C D))

在这个例子里,我们询问是否有一个元素的 car 是 a 。

如果我们想要使用两个关键字参数,我们可以使用其中一个顺序。下面这两个调用是等价的:

CL-USER> (member 2 '((1) (2)) :key #'car :test #'equal)

((2))

CL-USER> (member 2 '((1) (2)) :test #'equal :key #'car)

((2))

两者都询问是否有一个元素的 car 等于( equal ) 2。

如果我们想要找到一个元素满足任意的判断式像是── oddp ,奇数返回真──我们可以使用相关的 member-if :

CL-USER> (member-if #'oddp '(2 3 4))

(3 4)

我们可以想像一个限制性的版本 member-if 是这样写成的:

CL-USER> (defun our-member-if (fn lst)

  (and (consp lst)

      (if (funcall fn (car lst))

          lst

          (our-member-if fn (cdr lst)))))

函数 adjoin 像是条件式的 cons 。它接受一个对象及一个列表,如果对象还不是列表的成员,才构造对象至列表上。

CL-USER> (adjoin 'b '(a b c))

(A B C)

CL-USER> (adjoin 'z '(a b c))

(Z A B C)

通常的情况下它接受与 member 函数同样的关键字参数。

集合论中的并集 (union)、交集 (intersection)以及补集 (complement)的实现,是由函数 union 、 intersection 以及 set-difference 。

这些函数期望两个(正好 2 个)列表(一样接受与 member 函数同样的关键字参数)。

CL-USER> (union '(a b c) '(c b s))

(A C B S)

CL-USER> (intersection '(a b c) '(b b c))

(C B)

CL-USER> (set-difference '(a b c d e) '(b e))

(D C A)

3.11 序列 (Sequences)

另一种考虑一个列表的方式是想成一系列有特定顺序的对象。在 Common Lisp 里,序列sequences )包括了列表与向量 (vectors)。本节介绍了一些可以运用在列表上的序列函数。更深入的序列操作在 4.4 节讨论。

函数 length 返回序列中元素的数目。

CL-USER> (length '(a b c))

3

我们在 24 页 (译注:2.13节 our-length )写过这种函数的一个版本(仅可用于列表)。

要复制序列的一部分,我们使用 subseq 。第二个(需要的)参数是第一个开始引用进来的元素位置,第三个(选择性)参数是第一个不引用进来的元素位置。

CL-USER> (subseq '(a b c d) 1 2)

(B)

CL-USER> (subseq '(a b c d) 1)

(B C D)

如果省略了第三个参数,子序列会从第二个参数给定的位置引用到序列尾端。

函数 reverse 返回与其参数相同元素的一个序列,但顺序颠倒。

CL-USER> (reverse '(a b c))

(C B A)

一个回文 (palindrome) 是一个正读反读都一样的序列 —— 举例来说, (abba) 。如果一个回文有偶数个元素,那么后半段会是前半段的镜射 (mirror)。使用length 、 subseq 以及 reverse ,我们可以定义一个函数

CL-USER> (defun mirror? (s)

  (let ((len (length s)))

    (and (evenp len)

        (let ((mid (/ len 2)))

          (equal (subseq s 0 mid)

                  (reverse (subseq s mid)))))))

来检测是否是回文:

CL-USER> (mirror? '(a b b a))

T

CL-USER> (mirror? '(a b a))

NIL

Common Lisp 有一个内置的排序函数叫做 sort 。它接受一个序列及一个比较两个参数的函数,返回一个有同样元素的序列,根据比较函数来排序:

CL-USER> (sort '(0 2 1 3 8) #'>)

(8 3 2 1 0)

你要小心使用 sort ,因为它是破坏性的(destructive)。考虑到效率的因素, sort 被允许修改传入的序列。所以如果你不想你本来的序列被改动,传入一个副本。

使用 sort 及 nth ,我们可以写一个函数,接受一个整数 n ,返回列表中第 n 大的元素:

CL-USER> (defun nthmost (n lst)

  (nth (- n 1)

      (sort (copy-list lst) #'>)))

我们把整数减一因为 nth 是零索引的,但如果 nthmost 是这样的话,会变得很不直观。

CL-USER> (nthmost 2 '(0 2 1 3 8))

3

函数 every 和 some 接受一个判断式及一个或多个序列。当我们仅输入一个序列时,它们测试序列元素是否满足判断式:

CL-USER> (every #'oddp '(1 3 5))

T

CL-USER> (some #'evenp '(1 2 3))

T

如果它们输入多于一个序列时,判断式必须接受与序列一样多的元素作为参数,而参数从所有序列中一次提取一个:

CL-USER> (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)

等同于

CL-USER> (let ((x (car lst)))

  (setf lst (cdr lst))

  x)

所以,举例来说:

CL-USER> (setf x '(b))

(B)

CL-USER> (push 'a x)

(A B)

CL-USER> (setf y x )

(A B)

CL-USER> (pop x)

;Compiler warnings :

;  In an anonymous lambda form: Undeclared free variable X (3 references)

A

CL-USER> x

(B)

CL-USER> y

(A B)

以上,全都遵循上述由 setf 所给出的相等式。图 3.9 展示了这些表达式被求值后的结构。

图 3.9 push 及 pop 的效果

3.13 点状列表 (Dotted Lists)

调用 list 所构造的列表,这种列表精确地说称之为正规列表(properlist )。一个正规列表可以是 NIL 或是 cdr 是正规列表的 Cons 对象。也就是说,我们可以定义一个只对正规列表返回真的判断式:

CL-USER> (defun proper-list? (x)

  (or (null x)

      (and (consp x)

          (proper-list? (cdr x)))))

至目前为止,我们构造的列表都是正规列表。

然而, cons 不仅是构造列表。无论何时你需要一个具有两个字段 (field)的列表,你可以使用一个 Cons 对象。你能够使用 car 来参照第一个字段,用 cdr 来参照第二个字段。

CL-USER> (setf pair (cons 'a 'b))

(A . B)

因为这个 Cons 对象不是一个正规列表,它用点状表示法来显示。在点状表示法,每个 Cons 对象的 car 与 cdr 由一个句点隔开来表示。这个 Cons 对象的结构展示在图 3.10 。

图3.10 一个成对的 Cons 对象 (A cons used as a pair)

一个非正规列表的 Cons 对象称之为点状列表 (dotted list)。这不是个好名字,因为非正规列表的 Cons 对象通常不是用来表示列表:(a . b) 只是一个有两部分的数据结构。

你也可以用点状表示法表示正规列表,但当 Lisp 显示一个正规列表时,它会使用普通的列表表示法:

CL-USER> '(a . (b . (c . nil)))

(A B C)

顺道一提,注意列表由点状表示法与图 3.2 箱子表示法的关联性。

还有一个过渡形式 (intermediate form)的表示法,介于列表表示法及纯点状表示法之间,对于 cdr 是点状列表的 Cons 对象:

CL-USER> (cons 'a (cons 'b (cons 'c 'd)))

(A B C . D)

图 3.11 一个点状列表 (A dotted list)

这样的 Cons 对象看起来像正规列表,除了最后一个 cdr 前面有一个句点。这个列表的结构展示在图 3.11 ; 注意它跟图3.2 是多么的相似。

所以实际上你可以这么表示列表 (a b) ,

(a . (b . nil))

(a . (b))

(a b . nil)

(a b)

虽然 Lisp 总是使用后面的形式,来显示这个列表。

3.14 关联列表 (Assoc-lists)

用 Cons 对象来表示映射 (mapping)也是很自然的。一个由 Cons 对象组成的列表称之为关联列表(assoc-listor alist)。这样的列表可以表示一个翻译的集合,举例来说:

CL-USER> (setf trans '((+ . "add") (- . "subtract")))

((+ . "add") (- . "subtract"))

关联列表很慢,但是在初期的程序中很方便。 Common Lisp 有一个内置的函数 assoc ,用来取出在关联列表中,与给定的键值有关联的 Cons 对:

CL-USER> (assoc '+ trans)

(+ . "add")

CL-USER> (assoc '* trans)

NIL

如果 assoc 没有找到要找的东西时,返回 nil 。

我们可以定义一个受限版本的 assoc :

CL-USER> (defun our-assoc (key alist)

  (and (consp alist)

      (let ((pair (car alist)))

        (if (eql key (car pair))

            pair

            (our-assoc key (cdr alist))))))

和 member 一样,实际上的 assoc 接受关键字参数,包括 :test 和 :key 。 Common Lisp 也定义了一个 assoc-if 之于 assoc ,如同 member-if 之于 member一样。

3.15 示例:最短路径 (Example: Shortest Path)

图 3.12 包含一个搜索网络中最短路径的程序。函数 shortest-path 接受一个起始节点,目的节点以及一个网络,并返回最短路径,如果有的话。

图 3.12 广度优先搜索(breadth-first search)

在这个范例中,节点用符号表示,而网络用含以下元素形式的关联列表来表示:

(node . neighbors)

所以由图 3.13 展示的最小网络 (minimal network)可以这样来表示:

(setf min '((a b c) (b c) (c d)))

图 3.13 最小网络

要找到从节点 a 可以到达的节点,我们可以:

CL-USER> (cdr (assoc 'a min))

(B C)

图 3.12 程序使用广度优先的方式搜索网络。要使用广度优先搜索,你需要维护一个含有未探索节点的队列。每一次你到达一个节点,检查这个节点是否是你要的。如果不是,你把这个节点的子节点加入队列的尾端,并从队列起始选一个节点,从这继续搜索。借由总是把较深的节点放在队列尾端,我们确保网络一次被搜索一层。

图 3.12 中的代码较不复杂地表示这个概念。我们不仅想要找到节点,还想保有我们怎么到那的纪录。所以与其维护一个具有节点的队列 (queue),我们维护一个已知路径的队列,每个已知路径都是一列节点。当我们从队列取出一个元素继续搜索时,它是一个含有队列前端节点的列表,而不只是一个节点而已。

函数 bfs 负责搜索。起初队列只有一个元素,一个表示从起点开始的路径。所以 shortest-path 调用 bfs ,并传入 (list (list start)) 作为初始队列。

bfs 函数第一件要考虑的事是,是否还有节点需要探索。如果队列为空, bfs 返回 nil 指出没有找到路径。如果还有节点需要搜索, bfs 检查队列前端的节点。如果节点的 car 部分是我们要找的节点,我们返回这个找到的路径,并且为了可读性的原因我们反转它。如果我们没有找到我们要找的节点,它有可能在现在节点之后,所以我们把它的子节点(或是每一个子路径)加入队列尾端。然后我们递回地调用 bfs 来继续搜寻剩下的队列。

因为 bfs 广度优先地搜索,第一个找到的路径会是最短的,或是最短之一:

CL-USER> (shortest-path 'a 'd min)

(A C D)

这是队列在我们连续调用 bfs 看起来的样子:

((A))

((B A) (C A))

((C A) (C B A))

((C B A) (D C A))

((D C A) (D C B A))

在队列中的第二个元素变成下一个队列的第一个元素。队列的第一个元素变成下一个队列尾端元素的 cdr 部分。

在图 3.12 的代码不是搜索一个网络最快的方法,但它给出了列表具有多功能的概念。在这个简单的程序中,我们用三种不同的方式使用了列表:我们使用一个符号的列表来表示路径,一个路径的列表来表示在广度优先搜索中的队列 [4] ,以及一个关联列表来表示网络本身。

3.16 垃圾 (Garbages)

有很多原因可以使列表变慢。列表提供了顺序存取而不是随机存取,所以列表取出一个指定的元素比数组慢,同样的原因,录音带取出某些东西比在光盘上慢。电脑内部里, Cons 对象倾向于用指针表示,所以走访一个列表意味着走访一系列的指针,而不是简单地像数组一样增加索引值。但这两个所花的代价与配置及回收Cons 核 (cons cells)比起来小多了。

自动内存管理(Automatic memory management)是 Lisp 最有价值的特色之一。 Lisp 系统维护着一段內存称之为堆(Heap)。系统持续追踪堆当中没有使用的内存,把这些内存发放给新产生的对象。举例来说,函数 cons ,返回一个新配置的 Cons 对象。从堆中配置内存有时候通称为 consing 。

如果内存永远没有释放, Lisp 会因为创建新对象把内存用完,而必须要关闭。所以系统必须周期性地通过搜索堆 (heap),寻找不需要再使用的内存。不需要再使用的内存称之为垃圾 (garbage),而清除垃圾的动作称为垃圾回收 (garbage collection或 GC)。

垃圾是从哪来的?让我们来创造一些垃圾:

CL-USER> (setf lst (list 'a 'b 'c))

(A B C)

CL-USER> (setf lst nil)

NIL

一开始我们调用 list , list 调用 cons ,在堆上配置了一个新的 Cons 对象。在这个情况我们创出三个 Cons 对象。之后当我们把 lst 设为 nil ,我们没有任何方法可以再存取 lst 列表 (a b c) 。

因为我们没有任何方法再存取列表,它也有可能是不存在的。我们不再有任何方式可以存取的对象叫做垃圾。系统可以安全地重新使用这三个 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 来得不严谨。基本上,如果传入参数印出来的值一样时,返回真。

3)所有 Lisp 对象表现得像指针。你永远不需要显式操作指针。

4)你可以使用 copy-list 复制列表,并使用 append 来连接它们的元素。

5)游程编码是一个餐厅中使用的简单压缩演算法。

6)Common Lisp 有由 car 与 cdr 定义的多种存取函数。

7)映射函数将函数应用至逐项的元素,或逐项的列表尾端。

8)嵌套列表的操作有时被考虑为树的操作。

9)要判断一个递归函数是否正确,你只需要考虑是否包含了所有情况。

10)列表可以用来表示集合。数个内置函数把列表当作集合。

11)关键字参数是选择性的,并不是由位置所识别,是用符号前面的特殊标签来识别。

12)列表是序列的子类型。 Common Lisp 有大量的序列函数。

13)一个不是正规列表的 Cons 称之为点状列表。

14)用 cons 对象作为元素的列表,可以拿来表示对应关系。这样的列表称为关联列表(assoc-lists)。

15)自动内存管理拯救你处理内存配置的烦恼,但制造过多的垃圾会使程序变慢。

笔记:

一、常用函数函数

1)consp:如果参数是一个 Cons 对象,函数 consp 返回真

2)copy-list: 接受一个列表,然后返回此列表的复本。新的列表会有同样的元素,但是装在新的 Cons 对象里

3)append:函数 append 返回任何数目的列表串接 (concatenation)

4)nth: 找到列表特定位置的元素

5)nthcdr:找到第 n 个 cdr

    nth 与 nthcdr 都是零索引的 (zero-indexed); 即元素从 0 开始编号,而不是从 1 开始。在 Common Lisp 里,无论何时你使用一个数字来参照一个数据结构中的元素时,都是从 0 开始编号的。

6)zerop:仅在参数为零时,才返回真

7)last:返回列表的最后一个 Cons 对象

    Common Lisp 定义了函数 first 直到 tenth 可以取得列表对应的元素。这些函数不是 零索引的 (zero-indexed)

8)mapcar:接受一个函数以及一个或多个列表,并返回把函数应用至每个列表的元素的结果,直到有的列表没有元素为止

9)maplist:相关的 maplist 接受同样的参数,将列表的渐进的下一个 cdr 传入函数

10)atom:原子 (atom)判断式

11)copy-tree:接受一个树,并返回一份副本

12)substitute:替换序列 (sequence)中的元素

13)subst:替换树之中的元素

14)adjoin:函数 adjoin 像是条件式的 cons 。它接受一个对象及一个列表,如果对象还不是列表的成员,才构造对象至列表上

15)union:并集 (union)

16)intersection:交集 (intersection)

17)set-difference:补集 (complement)

18)length:返回序列中元素的数目

19)subseq:复制序列的一部分。第二个(需要的)参数是第一个开始引用进来的元素位置,第三个(选择性)参数是第一个不引用进来的元素位置。

20)reverse:返回与其参数相同元素的一个序列,但顺序颠倒。

21)sort:排序函数。它接受一个序列及一个比较两个参数的函数,返回一个有同样元素的序列,根据比较函数来排序

22)every: 接受一个判断式及一个或多个序列。当我们仅输入一个序列时,它们测试序列元素是否满足判断式

23)some: 接受一个判断式及一个或多个序列。当我们仅输入一个序列时,它们测试序列元素是否满足判断式

24)push:(push x y)把 x 放入列表 y 的前端。

25)pop:(pop x) 则是将列表 x 的第一个元素移除,并返回这个元素

26)pushnew:pushnew 宏是 push 的变种,使用了 adjoin 而不是 cons

27)assoc:取出在关联列表中,与给定的键值有关联的 Cons 对

二、操作符(好像也可以叫函数)

1)equal:本质上 equal 若它的参数打印出的值相同时,返回真

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容