前一章解釋了 Lisp 和 Lisp 程序兩者是如何由單一的原材料函數(shù),建造起來的。和任何建筑材料一樣,它的特質(zhì)既影響了我們所建造事物的種類,也影響著我們建造它們的方式。
本章描述 Lisp 世界里較常用的一類編程方法。這些方法十分精妙,讓我們能夠嘗試編寫更有挑戰(zhàn)的程序。
下一章將介紹一種尤其重要的編程方法,是 Lisp 讓我們得以運用這種方法:即通過進化的方式開發(fā)程序,而非遵循先計劃再實現(xiàn)的老辦法。
事物的特征會受其原材料的影響。例如,一座木結(jié)構(gòu)建筑和石結(jié)構(gòu)建筑看起來就會感覺不一樣。甚至當(dāng)離得很遠(yuǎn),看不清原材料究竟是木頭還是石頭,你也可以大體說出它是用什么造的。與之相似,Lisp 函數(shù)的特征也影響著 Lisp 程序的結(jié)構(gòu)。
函數(shù)式編程意味著利用返回值而不是副作用來寫程序。副作用包括破壞性修改對象(例如通過rplaca) 以及變量賦值(例如通過 setq)。如果副作用很少并且局部化,程序就會容易閱讀,測試和調(diào)試。Lisp 并非從一開始就是這種風(fēng)格的,但隨著時間的推移,Lisp 和函數(shù)式編程之間的關(guān)系變得越來越密不可分。
這里有個例子,它可以說明函數(shù)式編程和你在用其他語言編程時的做法到底有什么不一樣。假設(shè)由于某種原因我們想把列表里的元素順序顛倒一下。這次的函數(shù)不再顛倒參數(shù)列表中的元素順序,而是接受一個列表作為參數(shù),返回列表中的元素與之相同但是排列次序相反。
圖3.1 中的函數(shù)能對列表求逆。它把列表看作數(shù)組,按位置取反;其返回值是無意義的:
> (setq lst '(a b c))
(A B C)
> (bad-reverse lst)
NIL
> lst
(C B A)
函數(shù)如其名,bad-reverse 與好的 Lisp 風(fēng)格相去甚遠(yuǎn)。更糟糕的是,它還有其它丑陋之處:
因為其正常工作有賴于副作用,所以它使調(diào)用者離函數(shù)式編程的理想漸行漸遠(yuǎn)。
(defun bad-reverse (lst)
(let* ((len (length lst))
(ilimit (truncate (/ len 2))))
(do ((i 0 (1+ i))
(j (1- len) (1- j)))
((>= i ilimit))
(rotatef (nth i lst) (nth j lst)))))
圖3.1: 一個對列表求逆的函數(shù)
盡管是個反派角色, bad-reverse 仍有其可取之處:
它展示了 Common Lisp 交換兩個值的習(xí)慣用法。
rotatef 宏可以輪轉(zhuǎn)任何普通變量的值。所謂普通變量是指那些可以作為setf 第一個參數(shù)的變量。當(dāng)它只應(yīng)用于兩個參數(shù)時,效果就是交換它們。
與之相對,圖 3.2 中的函數(shù)能返回順序相反的列表。通過使用 good-reverse ,我們得到的返回值是顛倒順序后的列表,而原始列表原封不動:
;; 代碼 3.2: 一個返回相反順序列表的函數(shù)
> (setq lst '(a b c)
(A B C)
> (good-reverse lst)
(C B A)
> lst
(A B C)
(defun good-reverse (lst)
(labels ((rev (lst acc)
(if (null lst)
acc
(rev (cdr lst) (cons (car lst) acc)))))
(rev lst nil)))
過去常認(rèn)為可以根據(jù)外貌來判斷一個人的性格。不管這個說法對于人來說是否靈驗,但是對于 Lisp 來說, 這一般是可行的。函數(shù)式程序有著和命令式程序不同的外形。函數(shù)式程序的結(jié)構(gòu)完全是由表達(dá)式里參數(shù)的組合表現(xiàn)出來的,并且由于參數(shù)是縮進的,函數(shù)式代碼看起來在縮進方面顯得更為靈動。函數(shù)式代碼看起來如同紙面上的行云流水; 命令式代碼則看起來堅固駑鈍,Basic 語言就是一例。
即使遠(yuǎn)遠(yuǎn)的看上去,從bad- 和good-reverse 兩個函數(shù)的形狀也能分清孰優(yōu)孰劣。另外,good-reverse 不僅短些,也更加高效: 而不是因為 Common Lisp 已經(jīng)有了內(nèi)置的 reverse ,所以我們可以不用自己實現(xiàn)它。不過還是有必要簡單了解一下這個函數(shù),因為它經(jīng)常能暴露出一些函數(shù)式編程中的錯誤觀念。和 good-reverse 一樣,內(nèi)置的 reverse 通過返回值工作,而沒有修改它的參數(shù)。但學(xué)習(xí) Lisp 的人們可能會誤以為它像 bad-reverse 那樣依賴于 副作用。如果這些學(xué)習(xí)者想在程序里的某個地方顛倒一個列表的順序,他們可能會寫
(reverse lst)
結(jié)果還很奇怪為什么函數(shù)調(diào)用沒有效果。事實上,如果我們希望利用那種函數(shù)提供的效果,就必須在調(diào)用代碼里自己處理。也就是需要把程序改成這樣
(setq lst (reverse lst))
調(diào)用 reverse 這類操作符的本意就是取返回值,而非利用其副作用。你自己的程序也應(yīng)該用這種風(fēng)格編寫, 不僅因為它固有的好處,而是因為,如果你不這樣寫,就等于在跟語言過不去。
在比較 bad-reverse 和 good-reverse 時我們還忽略了一點,那就是 bad-reverse 里沒有 cons 。它對原始列表進行操作,但卻不構(gòu)造新的列表。這樣是比較危險的,因為有可能在程序的其他地方還會用到原始列表,但為了效率,有時可能必須這樣做。為滿足這種需要,Common Lisp 還提供了一個稱為 nreverse 的求逆函數(shù)的破壞性版本。
所謂破壞性函數(shù),是指那類能改變傳給它的參數(shù)的函數(shù)。即便如此,破壞性函數(shù)通常也通過取返回值的方式工作:你必須假定 nreverse 將會回收利用你作為參數(shù)傳給它的列表,但不能認(rèn)為它幫你在原地把原來的列表掉了個。和以前一樣,逆序后的列表只能通過返回值拿到。你仍然不能把
(nreverse lst)
寫在函數(shù)中間,然后假定從那以后lst 的順序就是相反的了。在大多數(shù)實現(xiàn)里可以看到下面的現(xiàn)象:
> (setq lst '(a b c))
(A B C)
第 164 頁有一個很典型的例子。
> (nreverse lst)
(C B A)
> lst
(A)
要想真正求逆一個列表,你就不得不把 lst 賦給返回值,這和使用原來的 reverse 是一樣的。
如果我們知道某個函數(shù)有破壞性,這并不是說:調(diào)用它就是為了利用其副作用。危險之處在于,有的破壞性函數(shù)給人留下了破壞性的印象。例如:
(nconc x y)
幾乎總是和:
(setq x (nconc x y))
效果相同。如果你寫的代碼依賴于前一個用法,有時它可以正常工作。然而當(dāng) x 為 nil 時,結(jié)果就會出人意料。
只有少數(shù) Lisp 操作符的本意就是為了副作用。一般而言,內(nèi)置操作符本來是為了調(diào)用后取返回值的。不要被sort,remove 或者 substitute 這樣的名字所誤導(dǎo)。如果你需要副作用,那就對返回值使用setq。
這個規(guī)則主張某些副作用其實是難免的。堅持函數(shù)式的編程思想并沒有提倡杜絕副作用。而是說除非必要最好不要有。
養(yǎng)成這個習(xí)慣可能要花些時間。不妨開始時先盡量少用下列的操作符:
set setq setf psetf psetq incf decf push
pop pushnew rplaca rplacd rotatef shiftf
remf remprop remhash
還包括 let* ,命令式程序經(jīng)常藏匿其中。在這里要求有節(jié)制地使用這些操作符的目的只是希望倡導(dǎo)良好的Lisp 風(fēng)格,而不是想制定清規(guī)戒律。然而,僅此一項就可讓你受益匪淺了。
在其他語言里,導(dǎo)致副作用的最普遍原因就是讓一個函數(shù)返回多個值的需求。如果函數(shù)只能返回一個值,那它就不得不通過改變參數(shù)來 "返回" 其余的值。幸運的是,在 Common Lisp 里不必這樣做,因為任何函數(shù)都可以返回多值。
舉例來說,內(nèi)置函數(shù) truncate 返回兩個值,被截斷的整數(shù),以及原來數(shù)字的小數(shù)部分。在典型的實現(xiàn)中,在最外層調(diào)用這個函數(shù)時兩個值都會返回:
> (truncate 26.21875)
26
0.21875
當(dāng)調(diào)用方只需要一個值時,被使用的就是第一個值:
> (= (truncate 26.21875) 26)
T
通過使用 multiple-value-bind ,調(diào)用方代碼可以捕捉到兩個值。該操作符接受一個變量列表、一個調(diào)用,以及一段程序體。變量將被綁定到函數(shù)調(diào)用的對應(yīng)返回值,而這段程序體會依照綁定后的變量求值:
> (multiple-value-bind (int frac) (truncate 26.21875)
(list int frac))
(26 0.21875)
最后,為了返回多值,我們使用 values 操作符:
> (defun powers (x)
(values x (sqrt x) (expt x 2)))
POWERS
> (multiple-value-bind (base root square) (powers 4)
(list base root square))
(4 2.0 16)
一般來說,函數(shù)式編程不失為上策。對于 Lisp 來說尤其如此,因為 Lisp 在演化過程中已經(jīng)支持了這種編程方式。諸如 reverse 和 nreverse 這樣的內(nèi)置操作符的本意就是以這種方式被使用的。其他操作符,例如 values 和 multiple-value-bind,是為了便于進行函數(shù)式編程而專門提供的。
函數(shù)式程序代碼的用意和那些更常見的方法,即命令式程序相比可能顯得更加明確一些。函數(shù)式程序告訴你它想要什么;而命令式程序告訴你它要做什么。函數(shù)式程序說 "返回一個由 a 和 x 的第一個元素的平方所組成的列表:"
(defun fun (x)
(list 'a (expt (car x) 2)))
而命令式程序則會說 "取得 x 的第一個元素,把它平方,然后返回由 a 及其平方組成的列表":
(defun imp (x)
(let (y sqr)
(setq y (car x))
(setq sqr (expt y 2))
(list 'a sqr)))
Lisp 程序員有幸可以同時用這兩種方式來寫程序。某些語言只適合于命令式編程 尤其是 Basic,以及大多數(shù)機器語言。事實上,imp 的定義和多數(shù) Lisp 編譯器從 fun 生成的機器語言代碼在形式上很相似。
既然編譯器能為你做,為什么還要自己寫這樣的代碼呢?對于許多程序員來說,他們甚至從沒想過這個問題。語言給我們的思想打上烙?。阂恍┝?xí)慣于命令式語言編程的人或許已經(jīng)開始用命令式的術(shù)語思考問題,而且會覺得寫命令式程序比寫函數(shù)式程序更容易。如果有一種語言可以助你一臂之力,這種思維定勢是值得克服的。
對于其他語言的同行來說,剛開始使用 Lisp 可能像初次踏入溜冰場那樣。事實上在冰上比在干地面上更容易行走 如果使用溜冰鞋的話。然后你對這項運動的看法就會徹底改觀。
溜冰鞋對于冰的意義,和函數(shù)式編程對 Lisp 的意義是一樣的。這兩樣?xùn)|西在一起讓你更優(yōu)雅地移動,事半功倍。但如果你已經(jīng)習(xí)慣于另一種行走模式,那么開始的時候你就無法體會到這一點。把 Lisp 作為第二語言學(xué)習(xí)的一個障礙就是學(xué)會如何用函數(shù)式的風(fēng)格來編程。
幸運的是,有一種把命令式程序轉(zhuǎn)換成函數(shù)式程序的訣竅。開始時你可以把這一訣竅用到寫好的代碼里。
不久以后你就可以預(yù)想到這個過程,一邊寫代碼,一邊做轉(zhuǎn)換了。而在這之后一段時間,你就有能力從一開始就用函數(shù)式的思想構(gòu)思你的程序。
這個訣竅就是認(rèn)識到命令式程序其實是一個從里到外翻過來的函數(shù)式程序。要想找出藏在命令式程序中的函數(shù)式程序,也只要把它從外到里翻一下。讓我們在 imp 上實踐一下這個技術(shù)。
我們首先注意到的是初始 let 里 y 和 sqr 的創(chuàng)建。這預(yù)示著接下來會出問題。就像運行期的 eval ,需要未初始化變量的情況很罕見,它們因而被看作程序染病的癥狀。這些變量就像插在程序上,用來固定的圖釘,它們被用來防止程序自己卷回到原形。
不過我們暫時先不考慮它們,直接看函數(shù)的結(jié)尾。命令式程序里最后發(fā)生的事情,也就是函數(shù)式程序在最外層發(fā)生的事情。所以第一步是抓住最后對 list 的調(diào)用,然后把程序的其余部分塞進去就好像把一件襯衫從里到外翻過來。我們繼續(xù)重復(fù)做相同的轉(zhuǎn)換,就好像我們先翻襯衫的袖子,然后再翻袖口那樣。
從結(jié)尾處開始,我們將 sqr 替換成 (expt y 2),得到:
(list 'a (expt y 2))
然后我們將y 替換成 (car x):
(list 'a (expt (car x) 2))
現(xiàn)在我們可以把其余代碼扔掉了,因為之前已經(jīng)把所有內(nèi)容都填到了最后一個表達(dá)式里。在這個過程中我們擺脫了對變量 y 和 sqr 的依賴,因而也得以把 let 一起扔進垃圾堆。
最終的結(jié)果比開始的時候要短小,而且更好懂。在原先的代碼里,我們面對最終的表達(dá)式 (list 'a sqr), 卻無法一眼看出 sqr 的值的出處。現(xiàn)在,返回值的來歷則像交通指示圖一樣一覽無余。
本章的這個例子很短,但這里的技術(shù)是可以推廣的。事實上,它對于大型函數(shù)應(yīng)該更有價值。即使存在一些有副作用的函數(shù),也可以把其中沒有副作用的那部分清理得干凈一些。
某些副作用比其他的更糟糕。例如,盡管下面的函數(shù)調(diào)用了 nconc
(defun qualify (expr)
(nconc (copy-list expr) (list 'maybe)))
但它沒有破壞引用透明。如果你每次都傳給它一個確定的參數(shù),那它的返回值將總是相同(equal) 的。
從調(diào)用者的角度來看,qualify 就和純函數(shù)型代碼一樣。但我們不能對bad-reverse (第19頁) 下同樣的評語,這個函數(shù)事實上修改了它的參數(shù)。
如果不把所有副作用的有害程度都劃上等號,而是有方法能把這些情況分出個高下,那樣將會對我們有很大的幫助。可以非正式地說,如果一個函數(shù)修改的是其他函數(shù)都不擁有的東西,那么它就是無害的。例如, qualify 里的 nconc 就是無害的,因為作為第一個參數(shù)的列表是新生成的。它不屬于任何其他函數(shù)。
通常,在我們提到擁有者關(guān)系時,不能說變量的擁有者是某某函數(shù),而應(yīng)該說其擁有者是函數(shù)的某個調(diào)用。
盡管這里并沒有其他函數(shù)擁有變量 x :
(let ((x 0))
(defun total (y)
(incf x y)))
但一次調(diào)用的效果會在接下來的調(diào)用中看到。所以規(guī)則應(yīng)當(dāng)是:一個給定的調(diào)用 (invocation) 可以安全地修改它唯一擁有的東西。
究竟誰是參數(shù)和返回值的擁有者 依照Lisp 的習(xí)慣,是函數(shù)的調(diào)用擁有那些作為返回值得到的對象,但它并不擁有那些作為參數(shù)傳給它的對象。凡是修改參數(shù)的函數(shù)都應(yīng)該打上"破壞性" 的標(biāo)簽,以示區(qū)別,但如果函數(shù)修改的只是返回給它們的對象,那我們沒有準(zhǔn)備什么特別的稱號給這些函數(shù)。
譬如,下面的函數(shù)就聽從了這個提議:
(defun ok (x)
(nconc (list 'a x) (list 'c)))
但它調(diào)用的nconc 卻置若罔聞。由于nconc 拼出來的列表總是重新生成的,而沒有使用原來傳給ok 作為參數(shù)的那個列表,所以ok 總的來說是ok 的。
如果稍微改一點兒,例如:
(defun not-ok (x)
(nconc (list 'a) x (list 'c)))
那么對nconc 的調(diào)用就會修改傳給not-ok 的參數(shù)了。
許多Lisp 程序沒有遵守這個慣例,至少在局部上是這樣。盡管如此,正如我們從 ok 那里看到的,局部的違背并不會讓主調(diào)函數(shù)變質(zhì)。而且那些與上述情況相符的函數(shù)仍會保留很多純函數(shù)式代碼的優(yōu)點。
要想寫出真正意義上的函數(shù)式代碼,還要再加個條件。函數(shù)不能和不遵守這些規(guī)則的代碼共享對象。例如,盡管這個函數(shù)沒有副作用:
(defun anything (x)
(+ x *anything*))
但它的返回值依賴于全局變量?anything。因此,如果任何其他函數(shù)可以改變這個變量的值,那么anything 就可能返回任意值。
關(guān)于引用透明的定義見135頁。
要是把代碼寫成讓每次調(diào)用都只修改它自己擁有的東西的話,那這樣的代碼就基本上就可以和純函數(shù)式代碼媲美了。從外界看來,一個滿足上述所有條件的函數(shù)至少會擁有有函數(shù)式的接口:如果用同一參數(shù)調(diào)用它兩次,你應(yīng)當(dāng)會得到同樣的結(jié)果。正如下一章所展示的那樣,這也是自底向上程序設(shè)計最重要的組成部分。
破壞性的操作符還有個問題,就是它和全局變量一樣會破壞程序的局部性。當(dāng)你寫函數(shù)式代碼時,可以集中精力:只要考慮調(diào)用正在編寫的函數(shù)的調(diào)用方,或者被調(diào)用方就行了。要是你想要破壞性地修改某些數(shù)據(jù),這個好處就不復(fù)存在了。你修改的數(shù)據(jù)可能在任何一個地方用到。
上面的條件不能保證你能得到和純粹的函數(shù)式代碼一樣的局部性,盡管它們確實在某種程度上有所改進。
例如,假設(shè)f 調(diào)用了g ,如下:
(defun f (x)
(let ((val (g x)))
; safe to modify val here?
))
在f 里把某些東西nconc 到val 上面安全嗎 如果g 是identity 的話就不安全:這樣我們就修改了某些原本作為參數(shù)傳給 f 本身的東西。
所以,就算要修改那些按照這個規(guī)定寫就的程序,還是不得不看看f 之外的東西。雖然要多操心一些,但也用不著看得太多:現(xiàn)在我們不用復(fù)查程序的所有代碼,只消考慮從f 開始的那棵子樹就行了。
推論之一是函數(shù)不該返回任何不能安全修改的東西。如此說來,就應(yīng)當(dāng)避免寫那些返回包含引用對象的函數(shù)。如果我們這樣定義exclaim ,讓它的返回值包含一個引用列表,
(defun exclaim (expression)
(append expression '(oh my)))
那么任何后續(xù)的對返回值的破壞性修改
> (exclaim '(lions and tigers and bears))
(LIONS AND TIGERS AND BEARS OH MY)
> (nconc * '(goodness))
(LIONS AND TIGERS AND BEARS OH MY GOODNESS)
將替換函數(shù)里的列表:
> (exclaim '(fixnums and bignums and floats))
(FIXNUMS AND BIGNUMS AND FLOATS OH MY GOODNESS)
為了避免exclaim 的這個問題,它應(yīng)該寫成:
(defun exclaim (expression)
(append expression (list 'oh 'my)))
雖說函數(shù)不應(yīng)返回引用列表,但是這個常理也有例外,即生成宏展開的函數(shù)。宏展開器可以安全地在它們的展開式里包含引用列表,只要這些展開式是直接送到編譯器那里的。
其他時候,還是應(yīng)該審慎地對待引用列表。除了上面的例外情況,如果發(fā)現(xiàn)用到了引用列表,很多情況,這些代碼是完全可以用類似in (103頁) 這樣的宏來完成的。
前一章說明了函數(shù)式的編程風(fēng)格是一種組織程序的好辦法。但它的好處還不止于此。Lisp 程序員并非完全是從美感出發(fā)才采納函數(shù)式風(fēng)格的。他們采用這種風(fēng)格是因為它讓工作更輕松。在 Lisp 的動態(tài)環(huán)境里, 函數(shù)式程序能以非同尋常的速度寫就,與此同時,寫出的程序也非同尋常的可靠。
在 Lisp 里調(diào)試程序相對簡單。很多信息在運行期是可見的,可以幫助追查錯誤的根源。但更重要的是你
可以輕易地測試程序。你不需要編譯一個程序然后一次性測試所有東西。你可以在toplevel 循環(huán)里通過逐個地調(diào)用每個函數(shù)來測試它們。
增量測試非常有用,為了更好地利用它,Lisp 風(fēng)格也隨之改進。用函數(shù)式風(fēng)格寫出的程序可以逐個函數(shù)地
理解它,從讀者的觀點來看,這是它的主要優(yōu)點。此外,函數(shù)式風(fēng)格也極其適合增量測試:以這種風(fēng)格寫出的程序可以逐個函數(shù)地進行測試。當(dāng)一個函數(shù)既不檢查也不改變外部狀態(tài)時,任何bug 都會立即現(xiàn)形。這樣,函數(shù)影響外面世界的唯一渠道是它的返回值。只要返回值是你期望的,你就完全可以信任返回它的代碼。
事實上有經(jīng)驗的Lisp 程序員會盡量讓他們的程序易于測試:
他們試圖把副作用分離到個別函數(shù)里,以便程序中更多的部分可以寫成純函數(shù)式風(fēng)格。
如果一個函數(shù)必須產(chǎn)生副作用,他們至少會想辦法給它設(shè)計一個函數(shù)式的接口。
一旦函數(shù)按照這種辦法寫成,程序員們就可以用一組有代表性的情況對它測試,測試好了,就使用另一組情況測試。如果每一塊磚都各司其職,那么圍墻就會屹立不倒。
在Lisp 里,一樣可以更好地設(shè)計圍墻。先假想一下,如果談話的時候,和對方距離很遠(yuǎn),聲音的延遲甚至有一分鐘,會有什么樣的一番感受。要是換成和隔壁房間的人說話,會有怎樣的改觀。這樣,將進行的對話不僅僅是速度比原來快,而是一個完全不同的對話。在Lisp 中,開發(fā)軟件就像是面對面的交流。你可以邊寫代碼邊做測試。和對話相似,即時的回應(yīng)對于開發(fā)來說一樣有戲劇化的效果。你不只是把原先的程序?qū)懙酶?,而是會寫出另一種程序。
這是什么道理 當(dāng)測試更便捷時,你就可以更頻繁地進行測試。對于Lisp,和其他語言一樣,開發(fā)是由編碼和測試構(gòu)成的循環(huán)往復(fù)的周期性過程。但在Lisp 的周期更短:單個函數(shù),甚至函數(shù)的一部分都可以成為一個開發(fā)周期。并且如果一邊寫代碼一邊測試的話,當(dāng)錯誤發(fā)生時你就知道該檢查哪里:應(yīng)該看看最后寫的那部分。正如聽起來那樣簡單,這一原則極大地提高了自底向上編程的可行性。它帶來了額外的信賴感, 使得Lisp 程序員至少在一定程度上從舊式的計劃–實現(xiàn)的軟件開發(fā)風(fēng)格中解脫了出來。
第 1.1 節(jié)強調(diào)了自底向上的設(shè)計是一個進化的過程。在這個過程中,你在寫程序的同時也就是在構(gòu)造一門語言。這一方法只有當(dāng)你信賴底層代碼時才可行。如果你真的想把這一層作為語言使用,你就必須假設(shè), 如同使用其他語言時那樣,任何遇到的bug 都是你程序里的bug,而不是語言本身的。
難道你的新抽象有能力承擔(dān)這一重任,同時還能按照新的需求隨機應(yīng)變?沒錯,在Lisp 里你可以兩不誤。
當(dāng)以函數(shù)式風(fēng)格編寫程序,并且進行增量測試時,你可以得到隨心所欲的靈活性,加上人們認(rèn)為只有仔細(xì)計劃才能確保的可靠性。
更多建議: