這個附錄演示了如何調試 Lisp 程序,并給出你可能會遇到的常見錯誤。
如果你要求 Lisp 做些它不能做的事,求值過程會被一個錯誤訊息中斷,而你會發(fā)現你位于一個稱為中斷循環(huán)的地方。中斷循環(huán)工作的方式取決于不同的實現,但通常它至少會顯示三件事:一個錯誤信息,一組選項,以及一個特別的提示符。
在中斷循環(huán)里,你也可以像在頂層那樣給表達式求值。在中斷循環(huán)里,你或許能夠找出錯誤的起因,甚至是修正它,并繼續(xù)你程序的求值過程。然而,在一個中斷循環(huán)里,你想做的最常見的事是跳出去。多數的錯誤起因于打錯字或是小疏忽,所以通常你只會想終止程序并返回頂層。在下面這個假定的實現里,我們輸入?:abort
?來回到頂層。
> (/ 1 0)
Error: Division by zero.
Options: :abort, :backtrace
>> :abort
>
在這些情況里,實際上的輸入取決于實現。
當你在中斷循環(huán)里,如果一個錯誤發(fā)生的話,你會到另一個中斷循環(huán)。多數的 Lisp 會指出你是在第幾層的中斷循環(huán),要嘛通過印出多個提示符,不然就是在提示符前印出數字:
>> (/ 2 0)
Error: Division by zero.
Options: :abort, :backtrace, :previous
>>>
現在我們位于兩層深的中斷循環(huán)。此時我們可以選擇回到前一個中斷循環(huán),或是直接返回頂層。
當你的程序不如你預期的那樣工作時,有時候第一件該解決的事情是,它在做什么?如果你輸入?(trace?foo)
?,則 Lisp 會在每次調用或返回?foo
?時顯示一個信息,顯示傳給?foo
?的參數,或是?foo
?返回的值。你可以追蹤任何自己定義的 (user-defined)函數。
一個追蹤通常會根據調用樹來縮進。在一個做遍歷的函數,像下面這個函數,它給一個樹的每一個非空元素加上 1,
(defun tree1+ (tr)
(cond ((null tr) nil)
((atom tr) (1+ tr))
(t (cons (treel+ (car tr))
(treel+ (cdr tr))))))
一個樹的形狀會因此反映出它被遍歷時的數據結構:
> (trace tree1+)
(tree1+)
> (tree1+ '((1 . 3) 5 . 7))
1 Enter TREE1+ ((1 . 3) 5 . 7)
2 Enter TREE1+ (1.3)
3 Enter TREE1+ 1
3 Exit TREE1+ 2
3 Enter TREE1+ 3
3 Exit TREE1+ 4
2 Exit TREE1+ (2 . 4)
2 Enter TREE1+ (5 . 7)
3 Enter TREE1+ 5
3 Exit TREE1+ 6
3 Enter TREE1+ 7
3 Exit TREE1+ 8
2 Exit TREE1+ (6 . 8)
1 Exit TREE1+ ((2 . 4) 6 . 8)
((2 . 4) 6 . 8)
要關掉?foo
?的追蹤,輸入?(untrace?foo)
?;要關掉所有正在追蹤的函數,只要輸入?(untrace)
?就好。
一個更靈活的追蹤辦法是在你的代碼里插入診斷性的打印語句。如果已經知道結果了,這個經典的方法大概會與復雜的調適工具一樣被使用數十次。這也是為什么可以互動地重定義函數式多么有用的原因。
一個回溯 (backtrace)是一個當前存在棧的調用的列表,當一個錯誤中止求值時,會由一個中斷循環(huán)生成此列表。如果追蹤像是”讓我看看你在做什么”,一個回溯像是詢問”我們是怎么到達這里的?” 在某方面上,追蹤與回溯是互補的。一個追蹤會顯示在一個程序的調用樹里,選定函數的調用。一個回溯會顯示在一個程序部分的調用樹里,所有函數的調用(路徑為從頂層調用到發(fā)生錯誤的地方)。
在一個典型的實現里,我們可通過在中斷循環(huán)里輸入?:backtrace
?來獲得一個回溯,看起來可能像下面這樣:
> (tree1+ ' ( ( 1 . 3) 5 . A))
Error: A is not a valid argument to 1+.
Options: :abort, :backtrace
? :backtrace
(1+ A)
(TREE1+ A)
(TREE1+ (5 . A))
(TREE1+ ((1 . 3) 5 . A))
出現在回溯里的臭蟲較容易被發(fā)現。你可以僅往回檢查調用鏈,直到你找到第一個不該發(fā)生的事情。另一個函數式編程 (2.12 節(jié))的好處是所有的臭蟲都會在回溯里出現。在純函數式代碼里,每一個可能出錯的調用,在錯誤發(fā)生時,一定會在棧出現。
一個回溯每個實現所提供的信息量都不同。某些實現會完整顯示一個所有待調用的歷史,并顯示參數。其他實現可能僅顯示調用歷史。一般來說,追蹤與回溯解釋型的代碼會得到較多的信息,這也是為什么你要在確定你的程序可以工作之后,再來編譯。
傳統(tǒng)上我們在解釋器里調試代碼,且只在工作的情況下才編譯。但這個觀點也是可以改變的:至少有兩個 Common Lisp 實現沒有包含解釋器。
不是所有的 bug 都會打斷求值過程。另一個常見并可能更危險的情況是,當 Lisp 好像不鳥你一樣。通常這是程序進入無窮循環(huán)的徵兆。
如果你懷疑你進入了無窮循環(huán),解決方法是中止執(zhí)行,并跳出中斷循環(huán)。
如果循環(huán)是用迭代寫成的代碼,Lisp 會開心地執(zhí)行到天荒地老。但若是用遞歸寫成的代碼(沒有做尾遞歸優(yōu)化),你最終會獲得一個信息,信息說 Lisp 把棧的空間給用光了:
> (defun blow-stack () (1+ (blow-stack)))
BLOW-STACK
> (blow-stack)
Error: Stack Overflow
在這兩個情況里,如果你懷疑進入了無窮循環(huán),解決辦法是中斷執(zhí)行,并跳出由于中斷所產生的中斷循環(huán)。
有時候程序在處理一個非常龐大的問題時,就算沒有進入無窮循環(huán),也會把棧的空間用光。雖然這很少見。通常把棧空間用光是編程錯誤的徵兆。
遞歸函數最常見的錯誤是忘記了基本用例 (base case)。用英語來描述遞歸,通常會忽略基本用例。不嚴謹地說,我們可能說“obj 是列表的成員,如果它是列表的第一個元素,或是剩余列表的成員” 嚴格上來講,應該添加一句“若列表為空,則 obj 不是列表的成員”。不然我們描述的就是個無窮遞歸了。
在 Common Lisp 里,如果給入?nil
?作為參數,?car
?與?cdr
?皆返回?nil
?:
> (car nil)
NIL
> (cdr nil)
NIL
所以若我們在?member
?函數里忽略了基本用例:
(defun our-member (obj lst)
(if (eql (car lst) obj)
lst
(our-member obj (cdr lst))))
要是我們找的對象不在列表里的話,則會陷入無窮循環(huán)。當我們到達列表底端而無所獲時,遞歸調用會等價于:
(our-member obj nil)
在正確的定義中(第十六頁「譯注: 2.7 節(jié)」),基本用例在此時會停止遞歸,并返回?nil
?。但在上面錯誤的定義里,函數愚昧地尋找?nil
?的?car
?,是?nil
?,并將?nil
?拿去跟我們尋找的對象比較。除非我們要找的對象剛好是?nil
?,不然函數會繼續(xù)在?nil
?的?cdr
里尋找,剛好也是?nil
?── 整個過程又重來了。
如果一個無窮循環(huán)的起因不是那么直觀,可能可以通過看看追蹤或回溯來診斷出來。無窮循環(huán)有兩種。簡單發(fā)現的那種是依賴程序結構的那種。一個追蹤或回溯會即刻演示出,我們的?our-member
?究竟哪里出錯了。
比較難發(fā)現的那種,是因為數據結構有缺陷才發(fā)生的無窮循環(huán)。如果你無意中創(chuàng)建了環(huán)狀結構(見 199頁「12.3 節(jié)」,遍歷結構的代碼可能會掉入無窮循環(huán)里。這些 bug 很難發(fā)現,因為不在后面不會發(fā)生,看起來像沒有錯誤的代碼一樣。最佳的解決辦法是預防,如同 199 頁所描述的:避免使用破壞性操作,直到程序已經正常工作,且你已準備好要調優(yōu)代碼來獲得效率。
如果 Lisp 有不鳥你的傾向,也有可能是等待你完成輸入什么。在多數系統(tǒng)里,按下回車是沒有效果的,直到你輸入了一個完整的表達式。這個方法的好事是它允許你輸入多行的表達式。壞事是如果你無意中少了一個閉括號,或是一個閉引號,Lisp 會一直等你,直到你真正完成輸入完整的表達式:
> (format t "for example ~A~% 'this)
這里我們在控制字符串的最后忽略了閉引號。在此時按下回車是沒用的,因為 Lisp 認為我們還在輸入一個字符串。
在某些實現里,你可以回到上一行,并插入閉引號。在不允許你回到前行的系統(tǒng),最佳辦法通常是中斷執(zhí)行,并從中斷循環(huán)回到頂層。
一個你最常聽到 Lisp 的抱怨是一個符號沒有值或未綁定。數種不同的問題都用這種方式呈現。
局部變量,如?let
?與?defun
?設置的那些,只在創(chuàng)建它們的表達式主體里合法。所以要是我們試著在 創(chuàng)建變量的?let
?外部引用它,
> (progn
(let ((x 10))
(format t "Here x = ~A. ~%" x))
(format t "But now it's gone...~%")
x)
Here x = 10.
But now it's gone...
Error: X has no value.
我們獲得一個錯誤。當 Lisp 抱怨某些東西沒有值或未綁定時,它的意思通常是你無意間引用了一個不存在的變量。因為沒有叫做?x
的局部變量,Lisp 假定我們要引用一個有著這個名字的全局變量或常量。錯誤會發(fā)生是因為當 Lisp 試著要查找它的值的時候,卻發(fā)現根本沒有給值。打錯變量的名字通常會給出同樣的結果。
一個類似的問題發(fā)生在我們無意間將函數引用成變量。舉例來說:
> defun foo (x) (+ x 1))
Error: DEFUN has no value
這在第一次發(fā)生時可能會感到疑惑:?defun
?怎么可能會沒有值?問題的癥結點在于我們忽略了最初的左括號,導致 Lisp 把符號defun
?解讀錯誤,將它視為一個全局變量的引用。
有可能你真的忘記初始化某個全局變量。如果你沒有給?defvar
?第二個參數,你的全局變量會被宣告出來,但沒有初始化;這可能是問題的根源。
當函數抱怨傳入?nil
?作為參數時,通常是程序先前出錯的徵兆。數個內置操作符返回?nil
?來指出失敗。但由于?nil
?是一個合法的 Lisp 對象,問題可能之后才發(fā)生,在程序某部分試著要使用這個信以為真的返回值時。
舉例來說,返回一個月有多少天的函數有一個 bug;假設我們忘記十月份了:
(defun month-length (mon)
(case mon
((jan mar may jul aug dec) 31)
((apr jun sept nov) 30)
(feb (if (leap-year) 29 28))))
如果有另一個函數,企圖想計算出一個月當中有幾個禮拜,
(defun month-weeks (mon) (/ (month-length mon) 7.0))
則會發(fā)生下面的情形:
> (month-weeks 'oct)
Error: NIL is not a valud argument to /.
問題發(fā)生的原因是因為?month-length
?在?case
?找不到匹配 。當這個情形發(fā)生時,?case
?返回?nil
?。然后?month-weeks
?,認為獲得了一個數字,將值傳給?/
?,/
?就抱怨了。
在這里最起碼 bug 與 bug 的臨床表現是挨著發(fā)生的。這樣的 bug 在它們相距很遠時很難找到。要避免這個可能性,某些 Lisp 方言讓跑完?case
?或?cond
?又沒匹配的情形,產生一個錯誤。在 Common Lisp 里,在這種情況里可以做的是使用?ecase
?,如 14.6 節(jié)所描述的。
在某些場合里(但不是全部場合),有一種特別狡猾的 bug ,起因于重新命名函數或變量,。舉例來說,假設我們定義下列(低效的) 函數來找出雙重嵌套列表的深度:
(defun depth (x)
(if (atom x)
1
(1+ (apply #'max (mapcar #'depth x)))))
測試函數時,我們發(fā)現它給我們錯誤的答案(應該是 1):
> (depth '((a)))
3
起初的?1
?應該是?0
?才對。如果我們修好這個錯誤,并給這個函數一個較不模糊的名稱:
(defun nesting-depth (x)
(if (atom x)
0
(1+ (apply #'max (mapcar #'depth x)))))
當我們再測試上面的例子,它返回同樣的結果:
> (nesting-depth '((a)))
3
我們不是修好這個函數了嗎?沒錯,但答案不是來自我們修好的代碼。我們忘記也改掉遞歸調用中的名稱。在遞歸用例里,我們的新函數仍調用先前的?depth
?,這當然是不對的。
若函數同時接受關鍵字與選擇性參數,這通常是個錯誤,無心地提供了關鍵字作為選擇性參數。舉例來說,函數?read-from-string
有著下列的參數列表:
(read-from-string string &optional eof-error eof-value
&key start end preserve-whitespace)
這樣一個函數你需要依序提供值,給所有的選擇性參數,再來才是關鍵字參數。如果你忘記了選擇性參數,看看下面這個例子,
> (read-from-string "abcd" :start 2)
ABCD
4
則?:start
?與?2
?會成為前兩個選擇性參數的值。若我們想要?read
?從第二個字符開始讀取,我們應該這么說:
> (read-from-string "abcd" nil nil :start 2)
CD
4
第十三章解釋了如何給變量及數據結構做類型聲明。通過給變量做類型聲明,你保證變量只會包含某種類型的值。當產生代碼時,Lisp 編譯器會依賴這個假定。舉例來說,這個函數的兩個參數都聲明為?double-floats
?,
(defun df* (a b)
(declare (double-float a b))
(* a b))
因此編譯器在產生代碼時,被授權直接將浮點乘法直接硬連接 (hard-wire)到代碼里。
如果調用?df*
?的參數不是聲明的類型時,可能會捕捉一個錯誤,或單純地返回垃圾。在某個實現里,如果我們傳入兩個定長數,我們獲得一個硬體中斷:
> (df* 2 3)
Error: Interrupt.
如果獲得這樣嚴重的錯誤,通常是由于數值不是先前聲明的類型。
有些時候 Lisp 會抱怨一下,但不會中斷求值過程。許多這樣的警告是錯誤的警鐘。一種最常見的可能是由編譯器所產生的,關于未宣告或未使用的變量。舉例來說,在 66 頁「譯注: 6.4 節(jié)」,?map-int
?的第二個調用,有一個?x
?變量沒有使用到。如果想要編譯器在每次編譯程序時,停止通知你這些事,使用一個忽略聲明:
(map-int #'(lambda (x)
(declare (ignore x))
(random 100))
10)
更多建議: