第6章 構建軟件工具
所謂人,就是能夠使用工具的動物。沒有工具就無從著力,有了工具則所向披靡。
——托馬斯?卡萊爾(1795-1881)
在第4章和第5章,我們主要是構建了兩個特定程序,GPS和ELIZA。在本章,我們來復習一下這兩個程序,來找找看一些普遍的模式。這些從可重用軟件工具中抽象出來模式將會在后續章節中起到一些幫助作用。
6.1 一個交互式的解釋器工具
這個函數結構是eliza的一個通用版本,這里再提點一下:
(defun eliza ()
?“Respond to user input using pattern matching rules.”
?(loop
? ?(print ‘eliza>)
? ?(print (flatten (use-eliza-rules (read))))))
其他很多應用都是用了這種模式,包括Lisp本身。Lisp的頂層表現可以定義成這樣子:
(defun lisp ()
?(loop
? ?(print ‘>)
? ?(print (eval (read)))))
一個Lisp系統的頂層表現歷史上一般就稱作“讀取-求值-打印 循環”這樣的過程。大部分Lisp會在讀取輸入之前打印一個提示符,所以實際上應該是“提示符-讀取-求值-打印 循環”才對,但是在一些早期的系統中,比如MacLisp就是沒有提示符的。如果我們不考慮提示符的話,也可以僅用四個符號就寫出一個完整的Lisp解釋器:
(loop (print (eval (read))))
僅僅用這四個符號和八個括號就像構建一個Lisp解釋器,聽上去是像是在開玩笑。那這一行代碼,到底是寫出了什么意思呢?這個問題的一個答案就是去想象,如果用Pascal語言來寫一個Lisp或者Pascal的解釋器,我們究竟要做什么。需要一個詞法分析器和一個符號表管理器。這些都是在工作的范圍內,但是read可以處理這些東西。還需要語法分析器來聚合詞法分隔符形成語句。Read也把這活兒干了,但只是因為Lisp的語句的語法是任意的,也就是列表和原子的語法。因此read在Lisp中扮演了一個很好的語法分析器的角色,但是在Pascal中是不行的。接下來,就是解釋器的求值或者解釋部分;eval完成了這項功能,并且也可以像處理Lisp表達式那樣處理好Pascal的語句。Print所做的要比read和eval少得多,但是仍然是很有必要的。
重點并不是在于一行代碼就可以被看做是一個Lisp的實現,而是要看做是計算過程的一般模式。ELIZA和Lisp都可以看做是交互式的解釋器,讀取一個輸入,以某種方式轉化或者求值輸入,打印結果,之后返回等待更多的輸入。我們從中可以提取出如下的一般模式:
(defun program ()
?(loop
? ? (print prompt)
? ? (print (transform (read)))))
有兩種方式來利用這些遞歸模式:正規路子和野路子。先說野路子,將模式看做一個模板或是一種泛型,在程序設計過程中根據應用的不同屢次使用。當我們要寫一個新程序,我們回想起寫過的或者看到過的相似的程序,回頭看看那個程序,吧相關的部分留下,之后修改為新程序做些修改就可以了。如果借用的程序部分比較多的話,在新程序中用注釋標記一下原始程序的部分是一個比較好的做法,但是在原始程序和導出程序之間,是沒有什么“官方”的連接的。
正規路子就是創建一種抽象,以函數的形式或者以數據結構的形式,顯式地指向每一個新的應用——就是說,以一個可用軟件工具的形式來適應抽象。解釋器模式可以被抽象成一個如下的函數:
(defun interactive-interpreter (prompt transformer)
?“Read an expression, transform it, and print the result.”
? (loop
? ? (print pronmpt)
? ? (print (funcall transformer (read)))))
這個函數可以用來寫每一個新的解釋器:
(defun lisp ()
(interactive-interpreter ‘> #’eval))
(defun eliza ()
(interactive-interpreter ‘eliza>
#’(lambda (x) (flatten (use-eliza-rules x)))))
或者,可以借用高階函數compose:
(defun compose (f g)
“Return the function that computes (f (g x)).”
#’(lambda (x) (funcall f (funcall g x))))
(defun eliza ()
(interactive-interpreter ‘eliza>
(compose #’flatten #’use-eliza-rules)))
在正規路子和野路子之間有兩個主要的區別。首先,他們看上去不一樣。如果是一個簡單的抽象,就像上面那個,讀取一個有顯式循環輸入發熱表達式要比讀取一個調用interactive-interpreter的表達式簡單得多,因為后者需要找到interactive-interpreter的定義,還要理解定義才可以。
另一個區別在維護性上體現出來。假設我們在交互式解釋器的定義中遺漏了一個特性。比如說疏忽了Loop的出口。我們就需要假定,用戶可以用一些中斷信息按鍵來結束循環。一個比較干凈的實現是允許用戶給解釋器一個顯式的結束命令。另一個有用的特性就是在解釋器內除處理錯誤。如果我們使用野路子,給程序添加一個這樣的特性就不會影響其他程序了。但是如果我們使用正規路子,之后對interactive-interpreter的所有改動將會自動給所有使用它的程序帶來新的特性。
后面的interactive-interpreter版本增加了兩個新的特性。首先,他使用宏handler-case來處理錯誤。這個宏會先求值第一個參數,然后返回第一個參數的值。但是如果有錯誤發生的話,后面的參數就會根據已發生的錯誤進行錯誤條件檢查。這么用的話,error會匹配所有的錯誤,才去的行動就是打印錯誤條件之后繼續。
這個版本也允許提示字符串或者一個沒有參數的函數,函數會被調用打印提舒服。函數prompt-generator,會返回一個函數來打印形式1,2等等的提示符。
(defun interactive-interpreter (prompt transformer)
?“Read an expression, transform it, and print the result.”
?(loop
? ?(handler-case
? ? ?(progn
? ? ? ?(if (string prompt)
? ? ? ? ?(print prompt)
? ? ? ? ?(funcall prompt))
? ? ? ?(print (funcall transformer (read))))
? ? ?;; In case of error, do this:
? ? ?(error (condition)
? ? ? ?(format t “~&;; Error ~a ignored, back to top level.”
? ? ? ? ?condition)))))
(defun prompt-generator (&optional (num 0) (ctl-string “[~d] ”))
“Return a function that prints prompts like [1], [2], etc.”
#’(lambda () (format t ctl-string (incf num))))