【轉】Lisp 已死,Lisp 萬歲!

weixin_33896726發表於2018-03-09

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# 等語言借鑑。

想不到吧,現代語言的很多優點,其實都是來自於 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 最令人頭痛的地方。好在現在的大部分語言其實已經吸取了這個教訓,所以你不再會遇到這種讓人發瘋的痛苦。不管是 Scheme, Common Lisp, Haskell, OCaml, Python, JavaScript…… 都不使用 dynamic scoping。

那現在也許你瞭解了,什麼是讓人深惡痛絕的 dynamic scoping。如果我告訴你,Lisp Machine 所使用的語言 Lisp Machine Lisp 使用的也是 dynamic scoping,你也許就明白了為什麼 Lisp Machine 會失敗。因為它跟現在的 Common Lisp 和 Scheme,真的是天壤之別。我寧願寫 C++,Java 或者 Python,也不願意寫 Lisp Machine Lisp 或者 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 已死”,其實是說 Lisp Machine Lisp 這樣的 Lisp,由於嚴重的設計問題,已經死去。而“Lisp 萬歲”,是說像 Scheme,Common Lisp 這樣的 Lisp,還會繼續存在。它們先進於其它語言的地方,也會更多的被借鑑,被髮揚廣大。

(其實老 Lisp 的死去還有另外一個重要的原因,那就是因為早期的 Lisp 編譯器生成的程式碼效率非常低下。這個問題我留到下一篇博文再講。)

相關文章