附錄 A:除錯

這個附錄示範了如何除錯 Lisp 程式,並給出你可能會遇到的常見錯誤。

中斷迴圈 (Breakloop)

如果你要求 Lisp 做些它不能做的事,求值過程會被一個錯誤訊息中斷,而你會發現你位於一個稱爲中斷迴圈的地方。中斷迴圈工作的方式取決於不同的實現,但通常它至少會顯示三件事:一個錯誤資訊,一組選項,以及一個特別的提示符。

在中斷迴圈裡,你也可以像在頂層那樣給表達式求值。在中斷迴圈裡,你或許能夠找出錯誤的起因,甚至是修正它,並繼續你程式的求值過程。然而,在一個中斷迴圈裡,你想做的最常見的事是跳出去。多數的錯誤起因於打錯字或是小疏忽,所以通常你只會想終止程式並返回頂層。在下面這個假定的實現裡,我們輸入 :abort 來回到頂層。

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

在這些情況裡,實際上的輸入取決於實現。

當你在中斷迴圈裡,如果一個錯誤發生的話,你會到另一個中斷迴圈。多數的 Lisp 會指出你是在第幾層的中斷迴圈,要嘛通過印出多個提示符,不然就是在提示符前印出數字:

>> (/ 2 0)
Error: Division by zero.
       Options: :abort, :backtrace, :previous
>>>

現在我們位於兩層深的中斷迴圈。此時我們可以選擇回到前一個中斷迴圈,或是直接返回頂層。

追蹤與回溯 (Traces and Backtraces)

當你的程式不如你預期的那樣工作時,有時候第一件該解決的事情是,它在做什麼?如果你輸入 (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)是一個當前存在棧的呼叫的列表,當一個錯誤中止求值時,會由一個中斷迴圈生成此列表。如果追蹤像是”讓我看看你在做什麼”,一個回溯像是詢問”我們是怎麼到達這裡的?” 在某方面上,追蹤與回溯是互補的。一個追蹤會顯示在一個程式的呼叫樹裡,選定函數的呼叫。一個回溯會顯示在一個程式部分的呼叫樹裡,所有函數的呼叫(路徑爲從頂層呼叫到發生錯誤的地方)。

在一個典型的實現裡,我們可通過在中斷迴圈裡輸入 :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))

出現在回溯裡的臭蟲較容易被發現。你可以僅往回檢視呼叫鏈,直到你找到第一個不該發生的事情。另一個函數式編程 (2.12 節)的好處是所有的臭蟲都會在回溯裡出現。在純函數式程式碼裡,每一個可能出錯的呼叫,在錯誤發生時,一定會在棧出現。

一個回溯每個實現所提供的資訊量都不同。某些實現會完整顯示一個所有待呼叫的歷史,並顯示參數。其他實現可能僅顯示呼叫歷史。一般來說,追蹤與回溯解釋型的程式碼會得到較多的資訊,這也是爲什麼你要在確定你的程式可以工作之後,再來編譯。

傳統上我們在解釋器裡除錯程式碼,且只在工作的情況下才編譯。但這個觀點也是可以改變的:至少有兩個 Common Lisp 實現沒有包含解釋器。

當什麼事都沒發生時 (When Noting Happens)

不是所有的 bug 都會打斷求值過程。另一個常見並可能更危險的情況是,當 Lisp 好像不鳥你一樣。通常這是程式進入無窮迴圈的徵兆。

如果你懷疑你進入了無窮迴圈,解決方法是中止執行,並跳出中斷迴圈。

如果迴圈是用迭代寫成的程式碼,Lisp 會開心地執行到天荒地老。但若是用遞迴寫成的程式碼(沒有做尾遞迴優化),你最終會獲得一個資訊,資訊說 Lisp 把棧的空間給用光了:

> (defun blow-stack () (1+ (blow-stack)))
BLOW-STACK
> (blow-stack)
Error: Stack Overflow

在這兩個情況裡,如果你懷疑進入了無窮迴圈,解決辦法是中斷執行,並跳出由於中斷所產生的中斷迴圈。

有時候程式在處理一個非常龐大的問題時,就算沒有進入無窮迴圈,也會把棧的空間用光。雖然這很少見。通常把棧空間用光是編程錯誤的徵兆。

遞迴函數最常見的錯誤是忘記了基本用例 (base case)。用英語來描述遞迴,通常會忽略基本用例。不嚴謹地說,我們可能說“obj 是列表的成員,如果它是列表的第一個元素,或是剩餘列表的成員” 嚴格上來講,應該添加一句“若列表爲空,則 obj 不是列表的成員”。不然我們描述的就是個無窮遞迴了。

在 Common Lisp 裡,如果給入 nil 作爲參數, carcdr 皆返回 nil

> (car nil)
NIL
> (cdr nil)
NIL

所以若我們在 member 函數裡忽略了基本用例:

(defun our-member (obj lst)
  (if (eql (car lst) obj)
      lst
      (our-member obj (cdr lst))))

要是我們找的物件不在列表裡的話,則會陷入無窮迴圈。當我們到達列表底端而無所獲時,遞迴呼叫會等價於:

(our-member obj nil)

在正確的定義中(第十六頁「譯註: 2.7 節」),基本用例在此時會停止遞迴,並返回 nil 。但在上面錯誤的定義裡,函數愚昧地尋找 nilcar ,是 nil ,並將 nil 拿去跟我們尋找的物件比較。除非我們要找的物件剛好是 nil ,不然函數會繼續在 nilcdr 裡尋找,剛好也是 nil ── 整個過程又重來了。

如果一個無窮迴圈的起因不是那麼直觀,可能可以通過看看追蹤或回溯來診斷出來。無窮迴圈有兩種。簡單發現的那種是依賴程式結構的那種。一個追蹤或回溯會即刻示範出,我們的 our-member 究竟哪裡出錯了。

比較難發現的那種,是因爲資料結構有缺陷才發生的無窮迴圈。如果你無意中創建了環狀結構(見 199頁「12.3 節」,遍歷結構的程式碼可能會掉入無窮迴圈裡。這些 bug 很難發現,因爲不在後面不會發生,看起來像沒有錯誤的程式碼一樣。最佳的解決辦法是預防,如同 199 頁所描述的:避免使用破壞性操作,直到程式已經正常工作,且你已準備好要調優程式碼來獲得效率。

如果 Lisp 有不鳥你的傾向,也有可能是等待你完成輸入什麼。在多數系統裡,按下 Enter 是沒有效果的,直到你輸入了一個完整的表達式。這個方法的好事是它允許你輸入多行的表達式。壞事是如果你無意中少了一個閉括號,或是一個閉引號,Lisp 會一直等你,直到你真正完成輸入完整的表達式:

> (format t "for example ~A~% 'this)

這裡我們在控制字串的最後忽略了閉引號。在此時按下回車是沒用的,因爲 Lisp 認爲我們還在輸入一個字串。

在某些實現裡,你可以回到上一行,並插入閉引號。在不允許你回到前行的系統,最佳辦法通常是中斷執行,並從中斷迴圈回到頂層。

沒有值或未綁定 (No Value/Unbound)

一個你最常聽到 Lisp 的抱怨是一個符號沒有值或未綁定。數種不同的問題都用這種方式呈現。

區域變數,如 letdefun 設置的那些,只在創建它們的表達式主體裡合法。所以要是我們試著在 創建變數的 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 假定我們要引用一個有著這個名字的全局變數或常數。錯誤會發生是因爲當 Lisp 試著要查找它的值的時候,卻發現根本沒有給值。打錯變數的名字通常會給出同樣的結果。

一個類似的問題發生在我們無意間將函數引用成變數。舉例來說:

> defun foo (x) (+ x 1))
Error: DEFUN has no value

這在第一次發生時可能會感到疑惑: defun 怎麼可能會沒有值?問題的癥結點在於我們忽略了最初的左括號,導致 Lisp 把符號 defun 解讀錯誤,將它視爲一個全局變數的引用。

有可能你真的忘記初始化某個全局變數。如果你沒有給 defvar 第二個參數,你的全局變數會被宣告出來,但沒有初始化;這可能是問題的根源。

意料之外的 Nil (Unexpected Nils)

當函數抱怨傳入 nil 作爲參數時,通常是程式先前出錯的徵兆。數個內建運算子返回 nil 來指出失敗。但由於 nil 是一個合法的 Lisp 物件,問題可能之後才發生,在程式某部分試著要使用這個信以爲真的返回值時。

舉例來說,返回一個月有多少天的函數有一個 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))

則會發生下面的情形:

> (month-weeks 'oct)
Error: NIL is not a valud argument to /.

問題發生的原因是因爲 month-lengthcase 找不到匹配 。當這個情形發生時, case 返回 nil 。然後 month-weeks ,認爲獲得了一個數字,將值傳給 // 就抱怨了。

在這裡最起碼 bug 與 bug 的臨牀表現是挨著發生的。這樣的 bug 在它們相距很遠時很難找到。要避免這個可能性,某些 Lisp 方言讓跑完 casecond 又沒匹配的情形,產生一個錯誤。在 Common Lisp 裡,在這種情況裡可以做的是使用 ecase ,如 14.6 節所描述的。

重新命名 (Renaming)

在某些場合裡(但不是全部場合),有一種特別狡猾的 bug ,起因於重新命名函數或變數,。舉例來說,假設我們定義下列(低效的) 函數來找出雙重巢狀列表的深度:

(defun depth (x)
  (if (atom x)
      1
      (1+ (apply #'max (mapcar #'depth x)))))

測試函數時,我們發現它給我們錯誤的答案(應該是 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 ,這當然是不對的。

作爲選擇性參數的關鍵字 (Keywords as Optional Parameters)

若函數同時接受關鍵字與選擇性參數,這通常是個錯誤,無心地提供了關鍵字作爲選擇性參數。舉例來說,函數 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

:start2 會成爲前兩個選擇性參數的值。若我們想要 read 從第二個字元開始讀取,我們應該這麼說:

> (read-from-string "abcd" nil nil :start 2)
CD
4

錯誤宣告 (Misdeclarations)

第十三章解釋了如何給變數及資料結構做型別宣告。通過給變數做型別宣告,你保證變數只會包含某種型別的值。當產生程式碼時,Lisp 編譯器會依賴這個假定。舉例來說,這個函數的兩個參數都宣告爲 double-floats

(defun df* (a b)
  (declare (double-float a b))
  (* a b))

因此編譯器在產生程式碼時,被授權直接將浮點乘法直接硬連接 (hard-wire)到程式碼裡。

如果呼叫 df* 的參數不是宣告的型別時,可能會捕捉一個錯誤,或單純地返回垃圾。在某個實現裡,如果我們傳入兩個定長數,我們獲得一個硬體中斷:

> (df* 2 3)
Error: Interrupt.

如果獲得這樣嚴重的錯誤,通常是由於數值不是先前宣告的型別。

警告 (Warnings)

有些時候 Lisp 會抱怨一下,但不會中斷求值過程。許多這樣的警告是錯誤的警鐘。一種最常見的可能是由編譯器所產生的,關於未宣告或未使用的變數。舉例來說,在 66 頁「譯註: 6.4 節」, map-int 的第二個呼叫,有一個 x 變數沒有使用到。如果想要編譯器在每次編譯程式時,停止通知你這些事,使用一個忽略宣告:

(map-int #'(lambda (x)
             (declare (ignore x))
             (random 100))
         10)

讨论

comments powered by Disqus