第十二章:结构
3.3 节中介绍了 Lisp 如何使用指针允许我们将任何值放到任何地方。这种说法是完全有可能的,但这并不一定都是好事。
例如,一个对象可以是它自已的一个元素。这是好事还是坏事,取决于程序员是不是有意这样设计的。
- 12.1 共享结构 (Shared Structure)
- 12.2 修改 (Modification)
- 12.3 示例:队列 (Example: Queues)
- 12.4 破坏性函数 (Destructive Functions)
- 12.5 示例:二叉搜索树 (Example: Binary Search Trees)
- 12.6 示例:双向链表 (Example: Doubly-Linked Lists)
- 12.7 环状结构 (Circular Structure)
- 12.8 常量结构 (Constant Structure)
- Chapter 12 总结 (Summary)
- Chapter 12 练习 (Exercises)
12.1 共享结构 (Shared Structure)
多个列表可以共享 cons
。在最简单的情况下,一个列表可以是另一个列表的一部分。
> (setf part (list 'b 'c))
(B C)
> (setf whole (cons 'a part))
(A B C)
图 12.2 被共享的尾端
现在 whole1
和 whole2
共享结构,但是它们彼此都不是对方的一部分。
当存在嵌套列表时,重要的是要区分是列表共享了结构,还是列表的元素共享了结构。顶层列表结构指的是,直接构成列表的那些cons
,而不包含那些用于构造列表元素的 cons
。图 12.3 是一个嵌套列表的顶层列表结构 (译者注:图 12.3 中上面那三个有黑色阴影的 cons
即构成顶层列表结构的 cons
)。
Common Lisp 包含一些允许修改列表结构的函数。为了提高效率,这些函数是具有破坏性的。虽然它们可以回收利用作为参数传给它们的 cons
,但并不是因为想要它们的副作用而调用它们 (译者注:因为这些函数的副作用并没有任何保证,下面的例子将说明问题)。
比如, delete
是 remove
的一个具有破坏性的版本。虽然它可以破坏作为参数传给它的列表,但它并不保证什么。在大多数的 Common Lisp 的实现中,会出现下面的情况:
> (setf lst '(a r a b i a) )
(A R A B I A)
> (delete 'a lst )
(R B I)
> lst
(A R B I)
正如 remove
一样,如果你想要副作用,应该对返回值使用 setf
:
(setf lst (delete 'a lst))
破坏性函数是怎样回收利用传给它们的列表的呢?比如,可以考虑 nconc
—— append
的破坏性版本。[2]下面是两个参数版本的实现,其清楚地展示了两个已知列表是怎样被缝在一起的:
(defun nconc2 ( x y)
(if (consp x)
(progn
(setf (cdr (last x)) y)
x)
y))
我们找到第一个列表的最后一个 Cons 核 (cons cells),把它的 cdr
设置成指向第二个列表。一个正规的多参数的 nconc
可以被定义成像附录 B 中的那样。
函数 mapcan
类似 mapcar
,但它是用 nconc
把函数的返回值 (必须是列表) 拼接在一起的:
> (mapcan #'list
'(a b c)
'(1 2 3 4))
( A 1 B 2 C 3)
这个函数可以定义如下:
(defun our-mapcan (fn &rest lsts )
(apply #'nconc (apply #'mapcar fn lsts)))
使用 mapcan
时要谨慎,因为它具有破坏性。它用 nconc
拼接返回的列表,所以这些列表最好不要再在其它地方使用。
这类函数在处理某些问题的时候特别有用,比如,收集树在某层上的所有子结点。如果 children
函数返回一个节点的孩子节点的列表,那么我们可以定义一个函数返回某节点的孙子节点的列表如下:
(defun grandchildren (x)
(mapcan #'(lambda (c)
(copy-list (children c)))
(children x)))
这个函数调用 copy-list
时存在一个假设 —— chlidren
函数返回的是一个已经保存在某个地方的列表,而不是构建了一个新的列表。
一个 mapcan
的无损变体可以这样定义:
(defun mappend (fn &rest lsts )
(apply #'append (apply #'mapcar fn lsts)))
如果使用 mappend
函数,那么 grandchildren
的定义就可以省去 copy-list
:
(defun grandchildren (x)
(mappend #'children (children x)))
12.5 示例:二叉搜索树 (Example: Binary Search Trees)
在某些情况下,使用破坏性操作比使用非破坏性的显得更自然。第 4.7 节中展示了如何维护一个具有二分搜索格式的有序对象集 (或者说维护一个二叉搜索树 (BST))。第 4.7 节中给出的函数都是非破坏性的,但在我们真正使用BST的时候,这是一个不必要的保护措施。本节将展示如何定义更符合实际应用的具有破坏性的插入函数和删除函数。
图 12.8 展示了如何定义一个具有破坏性的 bst-insert
(第 72 页「译者注:第 4.7 节」)。相同的输入参数,能够得到相同返回值。唯一的区别是,它将修改作为第二个参数输入的 BST。 在第 2.12 节中说过,具有破坏性并不意味着一个函数调用具有副作用。的确如此,如果你想使用 bst-insert!
构造一个 BST,你必须像调用 bst-insert
那样调用它:
> (setf *bst* nil)
NIL
> (dolist (x '(7 2 9 8 4 1 5 12))
(setf *bst* (bst-insert! x *bst* #'<)))
NIL
(defun bst-insert! (obj bst <)
(if (null bst)
(make-node :elt obj)
(progn (bsti obj bst <)
bst)))
(defun bsti (obj bst <)
(let ((elt (node-elt bst)))
(if (eql obj elt)
bst
(if (funcall < obj elt)
(let ((l (node-l bst)))
(if l
(bsti obj l <)
(setf (node-l bst)
(make-node :elt obj))))
(let ((r (node-r bst)))
(if r
(bsti obj r <)
(setf (node-r bst)
(make-node :elt obj))))))))
图 12.8: 二叉搜索树:破坏性插入
你也可以为 BST 定义一个类似 push 的功能,但这超出了本书的范围。(好奇的话,可以参考第 409 页 「译者注:即备注 204 」 的宏定义。)
与 bst-remove
(第 74 页「译者注:第 4.7 节」) 对应,图 12.9 展示了一个破坏性版本的 bst-delete
。同 delete
一样,我们调用它并不是因为它的副作用。你应该像调用 bst-remove
那样调用 bst-delete
:
> (setf *bst* (bst-delete 2 *bst* #'<) )
#<7>
> (bst-find 2 *bst* #'<)
NIL
(defun bst-delete (obj bst <)
(if bst (bstd obj bst nil nil <))
bst)
(defun bstd (obj bst prev dir <)
(let ((elt (node-elt bst)))
(if (eql elt obj)
(let ((rest (percolate! bst)))
(case dir
(:l (setf (node-l prev) rest))
(:r (setf (node-r prev) rest))))
(if (funcall < obj elt)
(if (node-l bst)
(bstd obj (node-l bst) bst :l <))
(if (node-r bst)
(bstd obj (node-r bst) bst :r <))))))
(defun percolate! (bst)
(cond ((null (node-l bst))
(if (null (node-r bst))
nil
(rperc! bst)))
((null (node-r bst)) (lperc! bst))
(t (if (zerop (random 2))
(lperc! bst)
(rperc! bst)))))
(defun lperc! (bst)
(setf (node-elt bst) (node-elt (node-l bst)))
(percolate! (node-l bst)))
(defun rperc! (bst)
(setf (node-elt bst) (node-elt (node-r bst)))
(percolate! (node-r bst)))
图 12.9: 二叉搜索树:破坏性删除
译注: 此范例已被回报为错误的,一个修复的版本请造访这里。
12.6 示例:双向链表 (Example: Doubly-Linked Lists)
普通的 Lisp 列表是单向链表,这意味着其指针指向一个方向:我们可以获取下一个元素,但不能获取前一个。在双向链表中,指针指向两个方向,我们获取前一个元素和下一个元素都很容易。这一节将介绍如何创建和操作双向链表。
图 12.10 展示了如何用结构来实现双向链表。将 cons
看成一种结构,它有两个字段:指向数据的 car
和指向下一个元素的 cdr
。要实现一个双向链表,我们需要第三个字段,用来指向前一个元素。图 12.10 中的 defstruct
定义了一个含有三个字段的对象 dl
(用于“双向链接”),我们将用它来构造双向链表。dl
的 data
字段对应一个 cons
的 car
,next
字段对应 cdr
。 prev
字段就类似一个cdr
,指向另外一个方向。(图 12.11 是一个含有三个元素的双向链表。) 空的双向链表为 nil
,就像空的列表一样。
(defstruct (dl (:print-function print-dl))
prev data next)
(defun print-dl (dl stream depth)
(declare (ignore depth))
(format stream "#<DL ~A>" (dl->list dl)))
(defun dl->list (lst)
(if (dl-p lst)
(cons (dl-data lst) (dl->list (dl-next lst)))
lst))
(defun dl-insert (x lst)
(let ((elt (make-dl :data x :next lst)))
(when (dl-p lst)
(if (dl-prev lst)
(setf (dl-next (dl-prev lst)) elt
(dl-prev elt) (dl-prev lst)))
(setf (dl-prev lst) elt))
elt))
(defun dl-list (&rest args)
(reduce #'dl-insert args
:from-end t :initial-value nil))
(defun dl-remove (lst)
(if (dl-prev lst)
(setf (dl-next (dl-prev lst)) (dl-next lst)))
(if (dl-next lst)
(setf (dl-prev (dl-next lst)) (dl-prev lst)))
(dl-next lst))
图 12.10: 构造双向链表
因为常量实际上是程序代码的一部分,所以我们也不应该修改他们,或者是不经意地写了自重写的代码。一个通过 quote
引用的列表是一个常量,所以一定要小心,不要修改被引用的列表的任何 cons
。比如,如果我们用下面的代码,来测试一个符号是不是算术运算符:
(defun arith-op (x)
(member x '(+ - * /)))
如果被测试的符号是算术运算符,它的返回值将至少一个被引用列表的一部分。如果我们修改了其返回值,
> (nconc (arith-op '*) '(as i t were))
(* / AS IT WERE)
那么我就会修改 arith-op
函数中的一个列表,从而改变了这个函数的功能:
> (arith-op 'as )
(AS IT WERE)
写一个返回常量结构的函数,并不一定是错误的。但当你考虑使用一个破坏性的操作是否安全的时候,你必须考虑到这一点。
有几个其它方法来实现 arith-op
,使其不返回被引用列表的部分。一般地,我们可以通过将其中的所有引用( quote
) 替换成 list
来确保安全,这使得它每次被调用都将返回一个新的列表:
(defun arith-op (x)
(member x (list '+ '- '* '/)))
这里,使用 list
是一种低效的解决方案,我们应该使用 find
来替代 member
:
(defun arith-op (x)
(find x '(+ - * /)))
这一节讨论的问题似乎只与列表有关,但实际上,这个问题存在于任何复杂的对象中:数组,字符串,结构,实例等。你不应该逐字地去修改程序的代码段。
即使你想写自修改程序,通过修改常量来实现并不是个好办法。编译器将常量编译成了代码,破坏性的操作可能修改它们的参数,但这些都是没有任何保证的事情。如果你想写自修改程序,正确的方法是使用闭包 (见 6.5 节)。
Chapter 12 总结 (Summary)
- 两个列表可以共享一个尾端。多个列表可以以树的形式共享结构,而不是共享顶层列表结构。可通过拷贝方式来避免共用结构。
- 共享结构通常可以被忽略,但如果你要修改列表,则需要特别注意。因为修改一个含共享结构的列表可能修改所有共享该结构的列表。
- 队列可以被表示成一个
cons
,其的car
指向队列的第一个元素,cdr
指向队列的最后一个元素。 - 为了提高效率,破坏性函数允许修改其输入参数。
- 在某些应用中,破坏性的实现更适用。
- 列表可以是
car-circular
或cdr-circular
。 Lisp 可以表示圆形结构和共享结构。 - 不应该去修改的程序代码段中的常量形式。
Chapter 12 练习 (Exercises)
- 画三个不同的树,能够被打印成
((A) (A) (A))
。写一个表达式来生成它们。 - 假设
make-queue
,enqueue
和dequeue
是按照图 12.7 中的定义,用箱子表式法画出下面每一步所得到的队列的结构图:
> (setf q (make-queue))
(NIL)
> (enqueue 'a q)
(A)
> (enqueue 'b q)
(A B)
> (dequeue q)
A
- 定义一个函数
copy-queue
,可以返回一个 queue 的拷贝。 - 定义一个函数,接受两个输入参数
object
和queue
,能将object
插入到queue
的首端。 - 定义一个函数,接受两个输入参数
object
和queue
,能具有破坏性地将object
的第一个实例 (eql
等价地) 移到queue
的首端。 - 定义一个函数,接受两个输入参数
object
和lst
(lst
可能是cdr-circular
列表),如果object
是lst
的成员时返回真。 - 定义一个函数,如果它的参数是一个
cdr-circular
则返回真。 - 定义一个函数,如果它的参数是一个
car-circular
则返回真。
脚注
[1] | 比如,在 Common Lisp 中,修改一个被用作符号名的字符串被认为是一种错误,因为内部的定义并没声明它是从参数复制来的,所以必须假定修改传入内部的任何参数中的字符串来创建新的符号是错误的。
[2] | 函数名称中 n 的含义是 “non-consing”。一些具有破坏性的函数以 n 开头。