《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?若它的參數打印出的值相同時,返回真

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容