Lisp 已死,Lisp 萬歲!
有一句古話,叫做“國王已死,國王萬歲!”它的意思是,老國王已經死去,國王的兒子現在繼位。這句話的幽默,就在于這兩個“國王”其實指的不是同一個人,而你咋一看還以為它自相矛盾。今天我的話題仿效了這句話,叫做“Lisp 已死,Lisp 萬歲!”希望到最后你會明白這是什么意思。
首先,我想總結一下 Lisp 的優點。你也許已經知道,Lisp 身上最重要的一些優點,其實已經“遺傳”到了幾乎每種流行的語言身上(Java,C#,JavaScript,Python, Ruby,Haskell,……)。由于我已經在其他博文里詳細的敘述過其中一些,所以現在只把這些 Lisp 的優點簡單列出來(關鍵部分加了鏈接):
- Lisp 的語法是世界上最精煉,最美觀,也是語法分析起來最高效的語法。這是 Lisp 獨一無二的,其他語言都沒有的優點。有些人喜歡設計看起來很炫的語法,其實都是自找麻煩。為什么這么說呢,請參考這篇《談語法》。
- Lisp 是第一個可以在程序的任何位置定義函數,并且可以把函數作為值傳遞的語言。這樣的設計使得它的表達能力非常強大。這種理念被 Python,JavaScript,Ruby 等語言所借鑒。
- Lisp 有世界上最強大的宏系統(macro system)。這種宏系統的表達力幾乎達到了理論所允許的極限。如果你只見過 C 語言的“宏”,那我可以告訴你它是完全沒法跟 Lisp 的宏系統相提并論的。
- Lisp 是世界上第一個使用垃圾回收(garbage collection)的語言。這種超前的理念,后來被 Java,C# 等語言借鑒。 </ul>
想不到吧,現代語言的很多優點,其實都是來自于 Lisp — 世界上第二古老的程序語言。所以有人才會說,每一種現代語言都在朝著 Lisp 的方向“進化”。如果你相信了這話,也許就會疑惑,為什么 Lisp 今天沒有成為主流,為什么 Lisp Machine 會被 Unix 打敗。其實除了商業原因之外,還有技術上的問題。
早期的 Lisp 其實普遍存在一個非常嚴重的問題:它使用 dynamic scoping。所謂 dynamic scoping 就是說,如果你的函數定義里面有“自由變量”,那么這個自由變量的值,會隨著函數的“調用位置”的不同而發生變化。
比如下面我定義一個函數 f,它接受一個參數 y,然后返回 x 和 y 的積。
(setq f (let ((x 1)) (lambda (y) (* x y))))
這里 x 對于函數 (lambda (y) (* x y)) 來說是個“自由變量”(free variable),因為它不是它的參數。
看著這段代碼,你會很自然的認為,因為 x 的值是 1,那么 f 被調用的時候,結果應該等于 (* 1 y),也就是說應該等于 y 的值。可是這在 dynamic scoping 的語言里結果如何呢?我們來看看吧。
(你可以在 emacs 里面試驗以下的結果,因為 Emacs Lisp 使用的就是 dynamic scoping。)
如果我們在函數調用的外層定義一個 x,值為 2:
(let ((x 2))
(funcall f 2))
因為這個 x 跟 f 定義處的 x 的作用域不同,所以它們不應該互相干擾。所以我們應該得到 2。可是,這段代碼返回的結果卻為 4。
再來。我們另外定義一個 x,值為 3:
(let ((x 3))
(funcall f 2))
我們的期望值還是 2,可是結果卻是 6。
再來。如果我們直接調用:
(funcall f 2)
你想這次總該得到 2 了吧?結果,出錯了:
Debugger entered--Lisp error: (void-variable x) (* x y) (lambda (y) (* x y))(2) funcall ((lambda (y) (* x y)) 2) eval_r((funcall f 2) nil) eval-last-sexp-1(nil) eval-last-sexp (nil) call-interactively (eval-last-sexp nil nil)
看到問題了嗎?f 的行為,隨著調用位置的一個“名叫 x”的變量的值而發生變化。而這個 x,跟 f 定義處的 x 其實根本就不是同一個變量,它們只不過名字相同而已。這會導致非常難以發現的錯誤,也就是早期的 Lisp 最令人頭痛的地方。我的老師 Dan Friedman 當年就為此痛苦了很多年,直到 Scheme 的出現,他才歡呼道:“終于有人把它給做對了!”
(附帶說一句,Scheme 不是 Dan Friedman 發明的,而是 Guy Steele 和 Gerald Sussman。然而,Friedman 對程序語言的本質理解,其實超越了 Lisp 的范疇,并且對 Scheme 的后期設計做出了重要的貢獻。以至于 Sussman 在 Friedman 的 60 大壽時發表演說,戲稱自己比起 Friedman 來,“只是 Scheme 的用戶”。)
好在現在的大部分語言其實已經吸取了這個教訓,所以你不再會遇到這種讓人發瘋的痛苦。不管是 Scheme, Common Lisp, Haskell, OCaml, Python, JavaScript…… 都不使用 dynamic scoping。
那現在也許你了解了,什么是讓人深惡痛絕的 dynamic scoping。如果我告訴你,Lisp Machine 所使用的語言 ZetaLisp(也叫 Lisp Machine Lisp)使用的也是 dynamic scoping,你也許就明白了為什么 Lisp Machine 會失敗。因為它跟現在的 Common Lisp 和 Scheme,真的是天壤之別。我寧愿寫 C++,Java 或者 Python,也不愿意寫 ZetaLisp 或者 Emacs Lisp。
話說回來,為什么早期的 Lisp 會使用 dynamic scoping 呢?其實這根本就不是一個有意的“設計”,而是一個無意的“巧合”。你幾乎什么都不用做,它就成那個樣子了。這不是開玩笑,如果你在 emacs 里面顯示 f 的值,它會打印出:
'(lambda (y) (* x y))
這說明 f 的值其實是一個 S 表達式,而不是像 Scheme 一樣的“閉包”(closure)。原來,Emacs Lisp 直接把函數定義處的 S 表達式 ‘(lambda (y) (* x y)) 作為了函數的“值”,這是一種很幼稚的做法。如果你是第一次實現函數式語言的新手,很有可能就會這樣做。Lisp 的設計者當年也是這樣的情況。
簡單倒是簡單,麻煩事接著就來了。調用 f 的時候,比如 (funcall f 2),y 的值當然來自參數 2,可是 x 的值是多少呢?答案是:不知道!不知道怎么辦?到“外層環境”去找唄,看到哪個就用哪個,看不到就報錯。所以你就看到了之前出現的現象,函數的行為隨著一個完全無關的變量而變化。如果你單獨調用 (funcall f 2) 就會因為找不到 x 的值而出錯。
那么正確的實現函數的做法是什么呢?是制造“閉包”(closure)。這也就是 Scheme,Common Lisp 以及 Python,C# 的做法。在函數定義被解釋或者編譯的時候,當時的自由變量(比如 x)的值,會跟函數的代碼綁在一起,被放進一種叫做“閉包”的結構里。比如上面的函數,就可以表示成這個樣子:(Closure '(lambda (y) (* x y)) '((x . 1)))。
在這里我用 (Closure ...) 表示一個“結構”(就像 C 語言的 struct)。它的第一個部分,是這個函數的定義。第二個部分是 '((x . 1)),它是一個“環境”,其實就是一個從變量到值的映射(map)。利用這個映射,我們記住函數定義處的那個 x 的值,而不是在調用的時候才去瞎找。
我不想在這里深入細節。如果你對實現語言感興趣的話,可以參考我的另一篇博文《怎樣寫一個解釋器》。它教你如何實現一個正確的,沒有以上毛病的解釋器。
與 dynamic scoping 相對的就是“lexical scoping”。我剛才告訴你的閉包,就是 lexical scoping 的實現方法。第一個實現 lexical scoping 的語言,其實不是 Lisp 家族的,而是 Algol 60。“Algol”之所以叫這名字,是因為它的設計初衷是用來實現算法(algorithm)。其實 Algol 比起 Lisp 有很多不足,但在 lexical scoping 這一點上它卻做對了。Scheme 從 Algol 60 身上學到了 lexical scoping,成為了第一個使用 lexical scoping 的“Lisp 方言”。9 年之后,Lisp 家族的“集大成者” Common Lisp 誕生了,它也采用了 lexical scoping。看來英雄所見略同。
你也許發現了,Lisp 其實不是一種語言,而是很多種語言。這些被人叫做“Lisp 家族”的語言,其實共同點只是它們的“語法”:它們都是基于 S 表達式。如果你因此對它們同樣贊美的話,那么你贊美的其實只是 S 表達式,而不是這些語言本身。因為一個語言的本質應該是由它的語義決定的,而跟語法沒有很大關系。你甚至可以給同一種語言設計多種不同的語法,而不改變這語言的本質。比如,我曾經給 TeX 設計了 Lisp 的語法,我把它叫做 SchTeX(Scheme + TeX)。SchTeX 的文件看起來是這個樣子:
(documentclass article (11pt)) (document (abstract (...)) (section (First Section) ... ) (section (Second Section) ... ) )
很明顯,雖然這看起來像是 Scheme,本質卻仍然是 TeX。
所以,因為 Scheme 的語法使用 S 表達式,就把 Scheme 叫做 Lisp 的“方言”,其實是不大準確的做法。Scheme 和 Emacs Lisp,Common Lisp 其實是三種不同的語言。Racket 曾經叫做 PLT Scheme,但是它跟 Scheme 的區別日益增加,以至于現在 PLT 把它改名叫 Racket。這是有他們的道理的。
所以,你也許明白了為什么這篇文章的標題叫做“Lisp 已死,Lisp 萬歲!” 因為這句話里面的兩個 “Lisp”其實是完全不同的語言。“Lisp 已死”,其實是說 ZetaLisp 這樣的 Lisp,由于嚴重的設計問題,已經死去。而“Lisp 萬歲”,是說像 Scheme,Common Lisp 這樣的 Lisp,還會繼續存在。它們先進于其它語言的地方,也會更多的被借鑒,被發揚廣大。