第二章:歡迎來到 Lisp

本章的目的是儘快讓你開始寫程式。本章結束時,你會掌握足夠的 Common Lisp 知識來撰寫程式。

2.1 形式 (Form)

你可以經由使用 Lisp 而學習它,這是千真萬確的事實,因為 Lisp 是互動式語言。任何 Lisp 系統都包含一個互動式的前台叫做 頂層 (toplevel)。你在頂層輸入 Lisp 表達式 (expression),然後系統顯示它們的值。

Lisp 通常顯示一個符號告訴你,它正在等待你的輸入。許多 Common Lisp 的實現用 > 作為頂層提示符 (prompt)。我們在這也用這符號。

最簡單的 Lisp 表達式之一是一個整數 (integer)。如果我們在提示符後面輸入 1

1
2
3
> 1
1
>

系統會印出它的值,伴隨著另一個提示符,告訴你它在等待更多的輸入。

這種情況下,顯示的值和我們輸入的值一樣。一個數字 1 稱之為對自身求值。當我們輸入需要做某些計算來求值的表達式時,生活變得更加有趣了。舉例來說,如果我們想把兩個數相加,我們輸入類似:

1
2
> (+ 2 3)
5

在表達式 (+ 2 3) 中, + 稱作運算元 (operator),而數字 2 跟 3 稱之為參數 (arguments)。

在日常生活中,我們會把此表達式寫作 2 + 3 ,但在 Lisp 我們把 + 運算元寫在前面,後面跟著參數,把整個表達式用一對括號包起來: (+ 2 3) 。這稱之為 前序 表示法 ( prefix notation)。一開始可能覺得這樣寫表達式有點怪,但事實上這種表示法是 Lisp 最好的東西之一。

舉例來說,我們想要把三個數加起來,用通常的表示法我們要寫兩次 +

1
2 + 3 + 4

然而在 Lisp 中我們只需增加一個參數:

1
(+ 2 3 4)

平常我們用 + ,它必須有兩個參數,一個在左,一個在右。前序表示法的彈性意味者,在 Lisp 中, + 可以接受任意數目的參數,包括沒有參數:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
> (+)
0
> (+ 2)
2
> (+ 2 3)
5
> (+ 2 3 4)
9
> (+ 2 3 4 5)
14

因為運算元可以接受不同數目的參數,我們需要用括號,來註明表達式的開始和結束。

可以是巢狀 (nested)表達式。即表達式中的參數,可以是另一個複雜的表達式:

1
2
> (/ (- 7 1) (- 4 2))
3

用中文來說, (七減一) 除以 (四減二) 。

另一個 Lisp 表示法美麗的地方是:它就是這麼簡單。所有 Lisp 表達式要嘛是 1 這樣的原子 (atom),或是包在括號中,由零個或多個表達式組成的列表 (lists)。下面是合法的 Lisp 表達式:

1
2     (+ 2 3)     (+ 2 3 4)     (/ (- 7 1) (- 4 2))

我們將看到,所有的 Lisp 程式都採用這種形式。像 C 這種語言有更複雜的語法:算數表達式採用中序表示法 (infix notation); 函數呼叫採用某種前序表示法,參數用逗號隔開; 表達式用分號隔開; 而一段程式用大括號隔開。

在 Lisp 中,我們用單一的表示法來表達所有的概念。

2.2 求值 (Evaluation)

上一小節中,我們在頂層輸入表達式,然後 Lisp 顯示它們的值。在這節裡我們深入理解一下表達式是如何被求值的。

在 Lisp 中, + 是一個函數,然而一個表達式如 (+ 2 3) 是一個函數呼叫 (function call)。

當 Lisp 對函數呼叫求值時,它做這兩個步驟:

  1. 首先先對參數從左至右求值。在這個情況是,每一個參數對自身求值,所以參數的值分別是 23
  2. 參數的值傳入以運算元命名的函數。在這個情況是,即 + 函數,返回 5

如果任何參數本身是函數呼叫,它們遵循上述規則。所以當 (/ (- 7 1) (- 4 2)) 被求值時所發生的情況:

  1. Lisp 對 (- 7 1) 求值: 7 求值為 7, 1 求值為 1,它們被傳給函數 - ,返回 6。
  2. Lisp 對 (- 4 2) 求值: 4 求值為 4, 2 求值為 2,它們被傳給函數 - ,返回 2。
  3. 數值 6 與 2 被傳入函數 / ,返回 3。

不是所有的 Common Lisp 運算元都是函數,但大部分是。而函數呼叫都是照這樣來求值的。對參數從左至右求值,然後將它們的數值傳入函數,再返回整個表達式的值。這稱為 Common Lisp 的求值規則。

Note

逃離麻煩

如果你試著輸入 Lisp 不能理解的東西,它會顯示一個錯誤訊息,然後把你帶到 中斷迴圈 (break loop)。 中斷迴圈給予有經驗的程式設計師一個機會來找出錯誤的原因,不過最初你只會想知道如何從中斷迴圈中跳出。 如何返回頂層取決於你所使用的 Common Lisp 實現。在這個假設的實現環境中,輸入 :abort 跳出:

> (/ 1 0)
Error: Division by zero
      Options: :abort, :backtrace
>> :abort
>

附錄A 告訴你如何對 Lisp 程式除錯,以及給出一些常見的錯誤例子。

一個運算元不遵守 Common Lisp 求值規則是 quote 。這 quote 叫做特殊運算元 (special operator),意味者他有自己特別的求值規則。而這個規則是:什麼也不做。這 quote 運算元接受一個參數,然後原封不動地返回它。

1
2
> (quote (+ 3 5))
(+ 3 5)

方便起見,Common Lisp 定義 ' 作為 quote 的縮寫。你可以在任何表達式前貼上一個 ' 得到與呼叫 quote 同樣的效果:

1
2
> '(+ 3 5)
(+ 3 5)

使用縮寫 'quote 來得普遍。Lisp 提供 quote 作為一種 保護 表達式被求值的方式。下一節會解釋為什麼這種保護很有用。

2.3 資料 (Data)

Lisp 提供我們所有其他語言有的資料型態 (data types),和一些其他語言所沒有的。有一個我們已經使用的型態是 整數 (integer),它用一系列的數字來表示: 256 。另一種與別的語言一樣的資料型態是 字串 (string),它用一系列被雙引號夾住的字元表示: ora et labora [3] 。整數與字串都是對自身求值的。

[3]是拉丁文,意思是禱告與工作。

我們通常在別的語言找不到的兩個 Lisp 資料型態是 符號 (symbol) 與 列表 (lists), 符號 是單字 (words)。無論你怎麼輸入,通常它們被轉換成大寫:

1
2
> 'Artichoke
ARTICHOKE

符號(通常)不對自身求值,因此若你想引用一個符號,你應該像上例那樣 ' 引用它。

列表 是由被括號包住的零個或多個元素來表示。元素可以是任何型態,包括列表。你必須引用表( ' ),不然 Lisp 會以為這是一個函數呼叫:

1
2
3
4
> '(my 3 "Sons")
(MY 3 SONS)
> '(the list (a b c) has 3 elements)
(THE LIST (A B C) HAS 3 ELEMENTS)

注意一個引號,保護整個表達式以及裡面的表達式被求值。

你可以呼叫 list 來創建列表。因為 list 是一個函數,它的參數會被求值。這裡我們看一個在函數 list 呼叫裡面呼叫 + 函數的例子。

1
2
> (list 'my (+ 2 1) "Sons")
(MY 3 "Sons")

我們現在來到領悟 Lisp 最卓越的特性之一的地方。 Lisp 的程式用列表來表示 ( Lisp programs are expressed by lists )。如果參數的優雅與彈性不能說服你 Lisp 表示法是一個無價的工具,這裡應該能使你信服。這意味著 Lisp 程式可以寫出 Lisp 程式。 Lisp 程式設計師能(並且經常)寫出能為自己寫程式的程式。

到第 10 章我們才來考慮這種程式,但在現在了解列表和表達式的關係是非常重要的,而不是被它們搞混。這也就是為什麼我們需要 quote 。如果一個列表被引用了,則求值規則對列表自身來求值; 如果沒有被引用,則列表被視為是程式,依求值規則對列表求值後,回傳它的值。

1
2
> (list '(+ 2 1) (+ 2 1))
((+ 2 1) (3))

這裡第一個參數被引用了,所以產生一個列表。第二個參數沒有被引用,視為函數呼叫,經求值後得到一個數字。

在 Common Lisp 中有兩種方法來表示空的列表。你可以用一對不包括任何東西的括號來表示,或用符號 nil 來表示空表。你用哪種表示法來表示空表都沒關係,但它會被顯示為 nil

1
2
3
4
> ()
NIL
> nil
NIL

你不需要引用 nil (但引用也無妨),因為 nil 是對自身求值的。

2.4 列表運算 (List Operations)

用函數 cons 來創建列表。如果傳入的第二個參數是一個列表,則返回一個由第二個參數所組成的新列表,其中新列表的第一個元素是傳入的第一個參數:

1
2
> (cons 'a '(b c d))
(A B C D)

我們可以把新元素建立在空表之上來創建新列表。上一節所看到的函數 list 只是一個把幾個元素加到 nil 上的快捷方式:

1
2
3
4
> (cons 'a (cons 'b nil))
(A B)
> (list a b)
(A B)

來取出列表元素的基本函數是 carcdr 。列表的 car 是第一個元素,而列表的 cdr 是第一個元素之後的所有元素:

1
2
3
4
> (car '(a b c))
A
> (cdr '(a b c))
(B C)

你可以把 carcdr 混合使用來取得列表中的任何元素。如果我們想要取得第三個元素,我們可以:

1
2
> (car (cdr (cdr '(a b c d))))
C

不過,你可以用更簡單的 third 來做到同樣的事情:

1
2
> (third '(a b c d))
C

2.5 真與假 (Truth)

在 Common Lisp 中,符號 t 是表示 的預設值。和 nil 一樣, t 也是對自身求值的。如果參數是一個列表,則函數 listp 返回

1
2
> (listp '(a b c))
T

一個函數的回傳值被解釋成 ,則此函數被稱為判斷式 ( predicate )。 Common Lisp 中,判斷式的名字通常以 p 結尾。

在 Common Lisp 中,用 nil ,空表來表示。如果我們傳給 listp 的參數不是列表,則回傳 nil

1
2
> (listp 27)
NIL

因為 nil 在 Common Lisp 中扮演兩個角色,如果參數是一個空表,則函數 null 回傳

1
2
> (null nil)
T

而如果參數是 ,則函數 not 回傳

1
2
> (not nil)
T

nullnot 做的是一樣的事情。

在 Common Lisp 中,最簡單的條件式 (conditional)是 if 。它通常接受三個參數:一個 test 表達式,一個 then 表達式和一個 else 表達式。 test 表達式被求值。若為 ,則 then 表達式被求值,並回傳這個值。若 test 表達式為 ,則 else 表達式被求值,並回傳這個值:

1
2
3
4
5
6
7
8
> (if (listp '(a b c))
      (+ 1 2)
      (+ 5 6))
3
> (if (listp 27)
      (+ 1 2)
      (+ 5 6))
11

quote 一樣, if 是特殊運算元。不能用一個函數來實現,因為函數呼叫的參數永遠會被求值,而 if 的特點是只有最後兩個參數的其中一個會被求值。 if 的最後一個參數是選擇性的。如果你忽略它,預設是 nil

1
2
3
> (if (listp 27)
      (+ 1 2))
NIL

雖然 t 的預設表示法,任何不是 nil 的東西,在邏輯的語意中被認為是

1
2
> (if 27 1 2)
1

邏輯運算元 andor 與條件式 (conditionals)類似。兩者都接受任意數目的參數,但只對能夠決定回傳值的那幾個參數來作求值。如果所有的參數都為 (即不為 nil ),那麼 and 會返回最後一個參數的值:

1
2
> (and t (+ 1 2))
3

如果其中一個參數為 ,那麼之後的所有參數都不會被求值。 or 也是如此,只要碰到一個是 的參數,就停止對之後的所有的參數求值。

這兩個運算元稱之為 巨集 (macro)。跟特殊運算元一樣,巨集可以繞過一般的求值規則。第十章解釋了如何撰寫你自己的巨集。

2.6 函數 (Functions)

你可以用 defun 來定義新函數。它通常接受三個以上的參數:一個名字,一列參數 (a list of parameters),及組成函數主體 (body)的一個或多個表達式。我們可能會這樣定義 third

1
2
3
> (defun our-third (x)
    (car (cdr (cdr x))))
OUR-THIRD

第一個參數說明此函數的名稱將是 our-third。第二個參數,一個列表 (x),說明這個函數會接受一個參數 (parameter): x 。這樣使用的占位符 (placeholder) 符號叫做 變量 。當變量代表了傳入函數的參數,如這裡的 x ,又被叫做 參數 ( parameter )。

定義的其它部分, (car (cdr (cdr x))) ,即所謂的函數主體 (the body of the function)。它告訴 Lisp 怎麼計算此函數的回傳值。所以,呼叫一個 our-third 函數,對於我們作為參數傳入的任何 x,會回傳 (car (cdr (cdr x)))

1
2
> (our-third '(a b c d))
C

既然我們已經看過了變量,就更簡單來了解什麼是符號了。它們是變量的名字,它們本身就是以物件的方式存在。這也是為什麼符號,像列表一樣必須被引用。一個列表必須被引用,不然會被當做程式。一個符號必須要被引用,不然會被當做變量。

你可以把函數定義想成廣義版的 Lisp 表達式。下面的表達式測試 1 和 4 的和是否大於 3 :

1
2
> (> (+ 1 4) 3)
T

藉由替換這些數字為變量,我們可以寫一個函數,測試任兩數之和是否大於第三個數:

1
2
3
4
5
> (defun sum-greater (x y z)
    (> (+ x y) z))
SUM-GREATER
> (sum-greater 1 4 3)
T

Lisp 不對程式、過程 (procedure)及函數來作區別。函數作了所有的事情(事實上,函數是語言的主要部分)。如果你想要把你的函數之一當作是主函數 ( main function),可以這麼做,但你平常就能在頂層中調用任何一個函數。這表示當你寫程式時,你可以把程式分成一小塊一小塊地來作測試。

2.7 遞迴 (Recursion)

上一節我們定義的函數,呼叫了別的函數來幫它們做事。比如 sum-greater 呼叫了 +> 。函數可以呼叫任何函數,包括自己。自己呼叫自己的函數叫做 遞迴 (recursive)。 Common Lisp 函數 member 測試某個東西是否為一個列表的元素。下面是定義成遞迴函數的簡化版:

1
2
3
4
5
6
7
> (defun our-member (obj lst)
    (if (null lst)
      nil
    (if (eql (car lst) obj)
      lst
      (our-member obj (cdr lst)))))
OUR-MEMBER

判斷式 eql 測試它的兩個參數是否相同; 此外,這個定義的所有東西我們之前都學過。下面是它的執行情況:

1
2
3
4
> (our-member 'b '(a b c))
(B C)
> (our-member 'z '(a b c))
NIL

下面是 our-member 的定義對應到英語的描述。為了測試一個物件 obj 是否是一個列表 lst 的成員,我們

  1. 首先檢查 lst 列表是否為空列表。如果是空列表,那 obj 一定不是它的成員,結束。
  2. 否則,若 obj 是列表的第一個元素時,它是列表的一個成員。
  3. 不然,只有當 obj 是列表其餘部分的元素時,它是列表的一個成員。

當你想要了解遞迴函數是怎麼工作時,把它翻成這樣的敘述會幫助你理解。

起初,許多人覺得遞迴函數很難理解。大部分的理解困難來自對函數使用了一個錯誤的比喻。人們傾向於把函數理解為某種機器。原物料像參數一樣抵達; 某些工作委派給其它函數; 最後組裝起來的成品,被作為一個回傳值運送出去。如果我們用這種比喻來理解函數,那遞迴就自相矛盾了。機器怎可以把工作委派給自己?它已經在忙碌中了。

較好的比喻是,把函數想成一個處理的過程。在過程中,遞迴是在自然不過的事情了。我們經常在日常生活中,看到遞迴的過程。舉例來說,假設一個歷史學家,對歐洲歷史上的人口變化感興趣。研究文獻的過程很可能是:

  1. 取得一個文獻的複本
  2. 尋找關於人口變化的資訊
  3. 如果這份文獻提到其它可能有用的文獻,研究它們。

這個過程是很容易理解的,而且它是遞迴的,因為第三個步驟可能帶出一個或多個同樣的過程。

所以,別把 our-member 想成是一種測試某個東西是否在一個列表的機器。而是把它想成是,決定某個東西是否在一個列表的規則。如果我們從這個角度來考慮函數,那遞迴的矛盾就不復存在了。

2.8 閱讀Lisp (Reading Lisp)

上一節我們定義的 our-member 以五個括號結尾。更複雜的函數定義可能以七、八個括號結尾。剛學 Lisp 的人看到這麼多括號會感到氣餒。這叫人怎麼讀這樣的程式,更不用說寫了?這叫人怎麼知道哪個括號該跟哪個匹配?

答案是,你不需要這麼做。 Lisp 程式設計師用縮排來閱讀及撰寫程式,而不是括號。當他們在寫程式時,他們讓文字編輯器顯示哪個括號該與哪個匹配。任一個好的文字編輯器,特別是 Lisp 系統原生的,都應該能做到括號匹配 (paren-matching)。在這種編輯器中,當你輸入一個括號時,編輯器指出與其匹配的那一個。如果你的編輯器不能匹配括號,別用了,想想如何讓它做到,因為沒有這個功能,你根本不可能寫 Lisp 程式 [1]

[1]在 vi,你可以用 :set sm 來啟用括號匹配。在 Emacs,M-x lisp-mode 是一個啟用的好方法。

有了好的編輯器,括號匹配不再是個問題。而且因為 Lisp 縮排有通用的慣例,閱讀程式也不是個問題。因為所有人都使用一樣的習慣,你可以忽略那些括號,通過縮排來閱讀程式。

任何有經驗的 Lisp 黑客,會發現如果是這樣的 our-member 的定義很難閱讀:

1
2
(defun our-member (obj lst) (if (null lst) nil (if
(eql (car lst) obj) lst (our-member obj (cdr lst)))))

但如果程式適當地縮排時,他就沒有問題了。你可以忽略大部分的括號而仍能讀懂它:

1
2
3
4
5
6
defun our-member (obj lst)
  if null lst
     nil
     if eql (car lst) obj
        lst
        our-member obj (cdr lst)

事實上,這是一個當你在紙上寫 Lisp 程式的實用方法。等你輸入的時候,可以利用編輯器匹配括號的功能。

2.9 輸入輸出 (Input and Output)

到目前為止,我們已經利用頂層偷偷使用了 I/O。對實際的互動程式來說,這似乎還是不太夠。在這一節,我們來看看幾個輸入輸出的函數。

最普遍的 Common Lisp 輸出函數是 format 。它接受兩個或兩個以上的參數,第一個參數表示,輸出要在哪裡被印出,第二個參數是字串模版 (String Template),而剩下的參數,通常是要插入到字串模版物件的列印表示法 (printed representation)。下面是一個典型的例子:

1
2
3
> (format t "~A plus ~A equals ~A. ~%" 2 3 (+ 2 3))
2 PLUS 3 EQUALS 5
NIL

注意到有兩個東西被顯示出來。第一行是 format 印出來的。第二行是 呼叫 format 函數的回傳值,就像平常頂層會印出來的一樣。通常像 format 這種函數不會直接在頂層呼叫,而在程式內部中使用,所以回傳值不會被看到。

format 的第一個參數 t 表示輸出被送到預設的地方去。通常這會是頂層。第二個參數是一個當作輸出模版的字串。在這字串裡,每一個 ~A 表示了被填入的位置,而 ~% 表示一個換行。 這些被填入的位置依序被後面的參數替換。

標準的輸入函數是 read 。當沒有參數時,它讀取預設的位置,通常是頂層。下面這一個函數,提示使用者輸入,並回傳任何輸入的東西:

1
2
3
4
5
6
7
8
9
(defun askem (string)
  (format t "~A" string)
  (read))

它的行為如下:

> (askem "How old are you?")
How old are you? 29
29

記住 read 會一直永遠等在這裡,直到輸入某些東西並(通常要)按下確定 (hit return)。因此,不印出明確的提示訊息是很不明智的,否則你的程式會給人已經當掉的印象,但其實它在等待輸入。

第二件關於 read 需要知道的事是它很強大: read 是一個完整的 Lisp 解析器。不僅是讀入字元,然後當作字串回傳它們。它解析它讀入的東西,並回傳產生的 Lisp 物件。在上述的例子,它回傳一個數字。

askem 的定義雖然很短,但它顯示了一些我們在之前的函數沒看過的東西。它的函數主體可以有不只一個表達式。函數主體可以有任意數量的表達式。當函數被呼叫時,他們會依序求值,然後函數會回傳最後一個的值。

在之前的每一節中,我們堅持所謂的 “純粹的” Lisp─即沒有副作用的 Lisp 。一個副作用是指,一個表達式被求值的後果,對外部世界的狀態作了某些改變。當我們對一個如 (+ 1 2) 這樣純粹的 Lisp 表達式求值,沒有產生副作用。它只回傳一個值。但當我們呼叫 format 時,它不僅回傳值,還印出了某些東西。這是一種副作用。

當我們想要寫沒有副作用的程式,那麼定義多個表達式的函數主體就沒有意義了。最後一個表達式的值,會被當成函數的回傳值,而之前表達式的值都被捨棄了。如果這些表達式沒有副作用,你沒有任何理由告訴 lisp ,為什麼要去對它們求值。

2.10 變數 (Variables)

let 是一個最常用的 Common Lisp 的運算元之一,它讓你引入新的區域變數 (local variable):

1
2
3
> (let ((x 1) (y 2))
     (+ x y))
3

一個 let 表達式有兩個部分。第一個部分是一系列創造新變數的指令,每個的形式為 (variable expression) 。 每一個變數會被賦予相對應表達式的值。上述的例子中,我們創造了兩個變數, xy ,它們分別被賦予初始值 12 。這些變數只在 let 的主體內有效。

一列變數與數值後面是一個有表達式的主體,它們依序被求值。在這個例子中,只有一個表達式,呼叫 + 函數。最後一個表達式的求值作為 let 的回傳值。以下是一個用 let 所寫的,更有選擇性的 askem 函數:

1
2
3
4
5
6
(defun ask-number ()
  (format t "Please enter a number. ")
  (let ((val (read)))
    (if (numberp val)
        val
        (ask-number))))

這個函數創造了變數 val 來儲存 read 所回傳的物件。因為它已知道該怎麼處理這個物件,函數可以先觀察你的輸入,再決定是否回傳它。你可能猜到了, numberp 是一個判斷式,測試它傳入的參數是否為數字。

如果使用者輸入的數字,不是一個數字, ask-number 呼叫它自己。結果是我們有一個堅持要得到數字的函數:

1
2
3
4
5
> (ask-number)
Please enter a number. a
Please enter a number. (ho hum)
Please enter a number. 52
52

像這些我們已經看過的變數都叫做區域變數。它們只在特定的上下文 (context)中有效的。還有另外一種變數叫做全域變數 (global variable),是在任何地方都可見的。 [2]

[2]真正的區別是詞法 (lexical)與特殊變數 (special variable),但我們到第六章才討論這個主題。

你可以給 defparameter 傳入一個符號和一個值,來創造一個全域變數:

1
2
> (defparameter *glob* 99)
*GLOB*

像這樣的變數在任何地方都可以存取,除了有表達式定義了相同名字的區域變數。為了避免這種情形發生,通常我們在給全域變數命名時,以星號作開始與結束。剛才我們創造的變數可以唸作 “星-glob-星” (star-glob-star)。

你也可以用 defconstant 來定義一個全域的常數:

1
(defconstant limit (+ *glob* 1))

這裡我們不需要給常數一個獨特的名字,因為如果有相同的名字,就會有錯誤產生 (error)。如果你想要檢查某些符號,是否是一個全域變數或常數,用 boundp

1
2
> (boundp '*glob*)
T

2.11 賦值 (Assignment)

在 Common Lisp 中,最普遍的賦值運算元 (assignment operator)是 setf 。我們可以用它來全域或區域變數作賦值:

1
2
3
4
5
6
> (setf *glob* 98)
98
> (let ((n 10))
    (setf n 2)
    n)
2

如果 setf 的第一個參數是一個符號(symbol),且這個符號不是某個區域變數的名字,那麼 setf 將把這個符號設為全域變數:

1
2
> (setf x (list 'a 'b 'c))
(A B C)

也就是說你可以僅透過賦值,隱式地創造全域變數。明確地使用 defparameter 會是較好的風格,最起碼在源文件(source files)裡是這樣。

你不僅可以給變數賦值。傳入 setf 的第一個參數,還可以是一個表達式或一個變數名。在這種情況下,第二個參數的值被插入至第一個參數所參照的地方 (place referred):

1
2
3
4
> (setf (car x) 'n)
N
> x
(N B C)

setf 的第一個參數幾乎可以是任何參照到特定位置的表達式。所有這樣的運算元在 附錄D 中被標註為 “可設置的” (“settable”)。你可以給任何(偶數)數目的參數至 setf 。一個這樣的表達式

1
2
3
(setf a b
      c d
      e f)

等同於依序呼叫三個單獨的 setf 函數:

1
2
3
(setf a b)
(setf c d)
(setf e f)

2.12 函數式程式設計 (Functional Programming)

函數式程式設計意味著使用具有回傳值的可工作程式,而不是修改東西。它是 Lisp 的主導思維。大部分 Lisp 的內建函數被呼叫是為了得到它們的回傳值,而不是得到它們的副作用。

舉例來說,函數 remove 接受一個物件和一個列表,並回傳一個不含這個物件的新列表:

1
2
3
4
> (setf lst '(c a r a t))
(C A R A T)
> (remove 'a lst)
(C R T)

為什麼不乾脆說 remove 從列表中移除一個物件?因為它不是這麼做的。原來的表沒有被改變:

1
2
> lst
(C A R A T)

若你真的想從列表中移除某些東西怎麼辦?在 Lisp 通常你這麼做,把這個列表當作參數,傳入某些函數,並使用 setf 處理回傳值。要移除所有在列表 xa ,我們這麼做:

1
(setf x (remove 'a x))

函數式程式設計本質上意味者避免使用如 setf 的函數。起初可能連想這怎麼可能都很困難,更遑論去做了。怎麼可以只憑回傳值來建立程式?

完全不用到副作用是很不方便的。然而,隨著你進一步閱讀,你會驚訝地發現需要副作用的地方很少。你副作用用得越少,你就更上一層樓。

函數式程式設計最重要的優點之一是,它允許互動式測試 (interactive testing)。在純函數化程式中,你可以測試每個你寫的函數。如果它回傳你預期的值,你可以確信它是對的。這額外的信心,集合起來,會產生巨大的差別。當你改動了程式中的任何一個地方,你會得到即時的轉變。而這種即時的轉變使我們有一種新的程式設計風格。對比於電話與信件,讓我們有一種新的通訊方式。

2.13 迭代 (Iteration)

當我們想作一些重複的事情時,用迭代比用遞迴更來得自然。典型的例子是用迭代來產生某種表格。這個函數

1
2
3
4
(defun show-squares (start end)
   (do ((i start (+ i 1)))
       ((> i end) 'done)
     (format t "~A ~A~%" i (* i i))))

列印從 start 到 end 之間的整數的平方:

1
2
3
4
5
6
> (show-squares 2 5)
2 4
3 9
4 16
5 25
DONE

這個 do 巨集是 Common Lisp 中最基本的迭代運算元。跟 let 一樣, do 可以創造變數,而且第一個參數是一個變數的規格說明列表。每一個在這個列表的元素可以是以下的形式

1
(variable  initial  update)

其中 variable 是一個符號, initialupdate 是表達式。最初每個變數會被賦予相應的 initial 的值; 每一次迭代中,它會被賦予相應的 update 的值。在 show-squares 中, do 只創造了一個變數 i 。在第一次迭代中, i 被賦與 start 的值,在之後的迭代中,它的值會被增加 1 。

第二個傳給 do 的參數包含了一個或多個表達式。第一個表達式用來測試迭代是否停止。在上面的例子中,測試表達式是 (> i end) 。剩下來在列表中的表達式會依序被求值,直到迭代停止,而最後一個值會被當作 do 的回傳值來回傳。所以 show-squares 總是回傳 done

do 剩下來的參數組成了循環的主體。它們會在每次迭代中依序被求值。在每一次迭代裡,變數被更新,檢查終止測試條件,然後(若測試失敗)主體被求值。

作為比較,以下是遞迴版本的show-squares:

1
2
3
4
5
6
(defun show-squares (i end)
    (if (> i end)
      'done
      (progn
        (format t "~A ~A~%" i (* i i))
        (show-squares (+ i 1) end))))

在這函數中唯一的新東西是 progn 。它接受任意數目個表達式,對它們依序求值,然後回傳最後一個值。

為了某些特殊情況, Common Lisp 有更簡單的迭代運算元。舉例來說,要走訪一個列表的元素,你可能會使用 dolist 。以下是一個回傳列表長度的函數:

1
2
3
4
5
(defun our-length (lst)
  (let ((len 0))
    (dolist (obj lst)
      (setf len (+ len 1)))
    len))

這裡 dolist 接受這樣形式的參數 (variable expression) ,跟著一個具有表達式的主體。主體會被求值,而變數相繼與由表達式所回傳的列表元素綁定。因此上面的循環說,對每一個列表 lst 中的 objlen 增加 1 。很顯然的這個函數的遞迴版本是:

1
2
3
4
(defun our-length (lst)
  (if (null lst)
      0
      (+ (our-length (cdr lst)) 1)))

也就是說,如果這個列表是空表,它的長度是 0 ; 否則它的長度就是 cdr 的長度加一。遞迴版本的 our-length 比較易懂,但因為它不是尾遞迴 (tail-recursive)的形式 ( 13.2 節),它的效率不那麼高。

2.14 作為物件的函數 (Functions as Objects)

函數在 Lisp 中就是一般的物件,像是符號或字串或列表。如果我們把一個函數的名字傳給 function ,它會回傳相關連的物件。跟 quote 一樣, function 是一個特殊運算元,所以我們不用引用 (quote)它的參數:

1
2
> (function +)
#<Compiled-Function + 17BA4E>

這看起來很奇怪的回傳值,是在典型的 Common Lisp 實現中,可能的顯示方法。

到目前為止,我們僅討論過 Lisp 顯示它們與我們輸入它們,看起來是一樣的物件。這個慣例對函數不適用。一個內建函數像是 + ,在內部可能是一段機械語言程式 (machine language code)。一個 Common Lisp 實現可能選擇任何它所喜歡的外部表示法。

就如同我們可以用 ' 作為 quote 的縮寫,我們可以用 #' 作為 function 的縮寫:

1
2
> #'+
#<Compiled-Function + 17BA4E>

這個縮寫稱之為 升引號 (sharp-quote)。

和別種物件一樣,我們可以把函數當作參數傳入。一個接受函數作為參數的函數是 apply 。它接受一個函數和一個參數列表,然後回傳把傳入函數應用在傳入參數的結果:

1
2
3
4
> (apply #'+ '(1 2 3))
6
> (+ 1 2 3)
6

它可以接受任意數目的參數,只要最後一個是列表:

1
2
> (apply #'+ 1 2 '(3 4 5))
15

函數 funcall 做一樣的事情但參數不需要包裝成列表。

1
2
> (funcall #'+ 1 2 3)
6

Note

什麼是 lambda?

lambda 表達式中的 lambda 不是運算元。它只是個符號。 在早期的 Lisp 方言裡有一個目的:函數在內部用列表來代表, 因此辨別列表與函數的方法, 是檢查第一個元素是否為符號 lambda 。

在 Common Lisp 中,你可以用列表來表達函數, 但在內部被表示成獨特的函數物件。 因此不再需要 lambda 。

函數記為

((x) (+ x 100))

而不是

(lambda (x) (+ x 100))

也是可以的。

但 Lisp 程式設計師習慣用符號 lambda , 來開始寫函數,因此 Common Lisp 因為這個傳統而保留了 lambda 。

這個 defun 巨集創造一個函數並替它命名。但函數不需要有名字,而且我們不需要 defun 來定義他們。像大多數的 Lisp 物件一樣,我們可以直接參照函數。

要直接參照一個整數,我們使用一系列的數字; 要直接參照一個函數,我們使用所謂的 lambda 表達式 。一個 lambda 表達式是一個列表,包含符號 lambda ,伴隨著參數列表,與一個由零個或多個表達式所組成的主體。

下面的 lambda 表達式代表一個接受兩個數字,並回傳它們的和的函數:

1
2
(lambda (x y)
  (+ x y))

列表 (x y) 是參數列表,跟在它後面的是函數主體。

一個 lambda 表達式可以被當成是函數的名字。就像普通的函數名稱, lambda 表達式可以是函數呼叫的第一個元素,

1
2
> ((lambda (x) (+ x 100)) 1)
101

而透過在 lambda 表達式前面貼上 #' ,我們得到對應的函數,

1
2
> (funcall #'(lambda (x) (+ x 100))
           1)

lambda 表示法除前述用途外,還允許我們使用匿名函數。

2.15 型態 (Types)

Lisp用非常靈活的方法來處理型態。在很多語言裡,變數是有型態的,而你得宣告變數的型態才能使用它。在 Common Lisp 裡,數值才有型態,而不是變數。你可以想像每一個物件都貼有一個,標明它的型態的標籤。這種方法叫做 顯式型態 ( manifest typing )。你不需要宣告變數的型態,因為任何變數可以存放任何型態的物件。

雖然從來不需要宣告型態,為了效率的原因你可能想要用到它們。型態宣告在第 13.3 節中討論。

Common Lisp 的內建型態組成了一個父子關係的結構 (a hierarchy of subtypes and supertypes)。一個物件總有不止一個型態。舉例來說,數字 27 的型態依普遍性的增加,依序是 fixnum , integer , rational , real , number , atomt 型態。 (數值型態在第9章討論。)型態 t 是所有型態的超集 (supertype)。所以每個物件都是 t 型態。

函數 typep 接受一個物件和一個型態指定,然後若物件是指定的那種型態就回傳真:

1
2
> (typep 27 'integer)
T

當我們遇到各式內建型態時,我們會討論它們。

2.16 展望 (Looking Forward)

本章僅談到 Lisp 的表面。然而一種非比尋常的語言的形象開始出現了。首先,這語言用一種語法表達所有的程式結構。這種語法是基於列表,列表是一種 Lisp 物件。函數,它本身也是 Lisp 物件,能用列表來表示。而且 Lisp 本身就是 Lisp 程式。幾乎所有你定義的函數與內建的 Lisp 函數沒有任何區別。

不用擔心如果你對這些概念還不太了解。 Lisp 介紹了這麼多新穎的概念,在你能使用它們之前,你得花時間去熟悉它們。不過至少要了解一件事:在這些概念當中,有優雅到令人吃驚的概念。

Richard Gabriel 曾經半開玩笑地描述說 C 是拿來寫 Unix 的語言。我們也可以說 Lisp 是拿來寫 Lisp 的語言。但這是兩種不同的論述。一個可以用自己編寫的語言和一種適合編寫某些特定類型的應用的語言,是根本上不同的。 它開啟了新的程式設計方法:你不但在語言當中寫程式,你還把語言改善成適合你程式的語言。如果你想了解Lisp程式設計的本質,這個概念是一個好的開始。

Chapter 2 總結 (Summary)

  1. Lisp 是一種互動式語言。如果你在頂層輸入一個表達式, Lisp 會顯示它的值。
  2. Lisp 程式由表達式組成。一個表達式可以是原子,或一個由運算元跟著零個或多個參數的列表。前序表示法意味著運算元可以有任意數目的參數。
  3. Common Lisp 函數呼叫的求值規則: 對參數從左至右求值,然後把它們的值傳入由運算元表示的函數。 quote 運算元有自己的求值規則,它逐字不變地返回參數。
  4. 除了平常的資料型態, Lisp 有符號與列表。因為 Lisp 程式是用列表來表示的,很簡單寫出能寫程式的程式。
  5. 三個基本的列表函數是 cons ,它創建一個列表; car ,它返回列表的第一個元素; 和 cdr ,它返回第一個元素之後的所有東西。
  6. 在 Common Lisp 中, t 表示 ,而 nil 表示 。在邏輯的語意中,任何不為 nil 的東西都視為 。基本的條件式是 ifandor 是相似的條件式。
  7. Lisp 主要由函數所組成。你可以用 defun 來定義新的函數。
  8. 一個呼叫自己的函數是遞迴的。一個遞迴函數應該要被視為過程,而不是機器。
  9. 括號不是問題,因為程式設計師藉由縮排來閱讀與撰寫 Lisp 程式。
  10. 基本的 I/O 函數是 read ,它包含了一個完整的 Lisp 解析器,以及 format ,它基由模版來產生輸出。
  11. 你可以用 let 來創造新的區域變數,用 defparameter 來創造全域變數。
  12. 賦值運算元是 setf 。它的第一個參數可以是一個表達式。
  13. 函數式程式設計,意味著避免產生副作用,是 Lisp 的主導思維。
  14. 基本的迭代運算元是 do
  15. 作為一般的 Lisp 物件的函數。它們可以被當成參數傳入,並可以用 lambda 表達式來表示。
  16. 在 Lisp 中,數值有型態,而不是變數。

Chapter 2 練習 (Exercises)

  1. 描述下列表達式求值後的結果:
1
2
3
4
5
6
7
(a)  (+ (- 5 1) (+ 3 7))

(b)  (list 1 (+ 2 3))

(c)  (if (listp 1) (+ 1 2) (+ 3 4))

(d)  (list (and (listp 3) t) (+ 1 2))
  1. 給出3種不同表示 (a b c)cons 表達式
  2. 使用 carcdr ,定義一個函數,它回傳一個列表的第四個元素。
  3. 定義一個函數,接受兩個參數,回傳兩者當中較大的那個。
  4. 這些函數做了什麼?
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
(a) (defun enigma (x)
      (and (not (null x))
           (or (null (car x))
               (enigma (cdr x)))))

(b) (defun mystery (x y)
      (if (null y)
          nil
          (if (eql (car y) x)
              0
              (let ((z (mystery x (cdr y))))
                (and z (+ z 1))))))
  1. 下列表達式, x 該是什麼,會得到相同的結果?
1
2
3
4
5
6
(a) > (car (x (cdr '(a (b c) d))))
    B
(b) > (x 13 (/ 1 0))
    13
(c) > (x #'list 1 nil)
    (1)
  1. 只使用本章所介紹的運算元,定義一個函數,它接受一個列表作為參數,如果有一個元素是列表就回傳真。
  2. 給出函數的迭代與遞迴版本:
  1. 接受一個正整數,並印出這麼多數目的點。
  2. 接受一個列表,並回傳 a 在列表中出現的次數。
  1. 一位朋友想寫一個函數,它回傳列表中所有非 nil 元素的和。他寫了此函數的兩個版本,但兩個都不能工作。請解釋每一個的錯誤在哪裡,並給出正確的版本。
1
2
3
4
5
6
7
8
9
(a) (defun summit (lst)
      (remove nil lst)
      (apply #'+ lst))

(b) (defun summit (lst)
      (let ((x (car lst)))
        (if (null x)
            (summit (cdr lst))
            (+ x (summit (cdr lst))))))
comments powered by Disqus