日期: 2010年10月14日
上週,《黑客與畫家》總算翻譯完成,已經交給出版社了。
翻譯完這本書,累得像生了一場大病。把書稿交出去的時候,心裡空蕩蕩的,也不知道自己得到了什麼,失去了什麼。
希望這個中譯本和我的努力,能得到讀者認同和肯定。
下面是此書中非常棒的一篇文章,原文寫於八年前,至今仍然具有啟發性,作者眼光之超前令人佩服。由於我不懂Lisp語言,所以田春同學幫忙校讀了一遍,糾正了一些翻譯不當之處,在此表示衷心感謝。
============================
為什麼Lisp語言如此先進?
作者:Paul Graham
譯者:阮一峰
英文原文:Revenge of the Nerds
(節選自即將出版的《黑客與畫家》中譯本)
一、
如果我們把流行的程式語言,以這樣的順序排列:Java、Perl、Python、Ruby。你會發現,排在越後面的語言,越像Lisp。
Python模仿Lisp,甚至把許多Lisp黑客認為屬於設計錯誤的功能,也一起模仿了。至於Ruby,如果回到1975年,你聲稱它是一種Lisp方言,沒有人會反對。
程式語言現在的發展,不過剛剛趕上1958年Lisp語言的水平。
二、
1958年,John McCarthy設計了Lisp語言。我認為,當前最新潮的程式語言,只是實現了他在1958年的設想而已。
這怎麼可能呢?計算機技術的發展,不是日新月異嗎?1958年的技術,怎麼可能超過今天的水平呢?
讓我告訴你原因。
這是因為John McCarthy本來沒打算把Lisp設計成程式語言,至少不是我們現在意義上的程式語言。他的原意只是想做一種理論演算,用更簡潔的方式定義圖靈機。
所以,為什麼上個世紀50年代的程式語言,到現在還沒有過時?簡單說,因為這種語言本質上不是一種技術,而是數學。數學是不會過時的。你不應該把Lisp語言與50年代的硬體聯絡在一起,而是應該把它與快速排序(Quicksort)演算法進行類比。這種演算法是1960年提出的,至今仍然是最快的通用排序方法。
三、
Fortran語言也是上個世紀50年代出現的,並且一直使用至今。它代表了語言設計的一種完全不同的方向。Lisp是無意中從純理論發展為程式語言,而Fortran從一開始就是作為程式語言設計出來的。但是,今天我們把Lisp看成高階語言,而把Fortran看成一種相當低層次的語言。
1956年,Fortran剛誕生的時候,叫做Fortran I,與今天的Fortran語言差別極大。Fortran I實際上是組合語言加上數學,在某些方面,還不如今天的組合語言強大。比如,它不支援子程式,只有分支跳轉結構(branch)。
Lisp和Fortran代表了程式語言發展的兩大方向。前者的基礎是數學,後者的基礎是硬體架構。從那時起,這兩大方向一直在互相靠攏。Lisp剛設計出來的時候,就很強大,接下來的二十年,它提高了自己的執行速度。而那些所謂的主流語言,把更快的執行速度作為設計的出發點,然後再用超過四十年的時間,一步步變得更強大。
直到今天,最高階的主流語言,也只是剛剛接近Lisp的水平。雖然已經很接近了,但還是沒有Lisp那樣強大。
四、
Lisp語言誕生的時候,就包含了9種新思想。其中一些我們今天已經習以為常,另一些則剛剛在其他高階語言中出現,至今還有2種是Lisp獨有的。按照被大眾接受的程度,這9種思想依次是:
1. 條件結構(即"if-then-else"結構)。現在大家都覺得這是理所當然的,但是Fortran I就沒有這個結構,它只有基於底層機器指令的goto結構。
2. 函式也是一種資料型別。在Lisp語言中,函式與整數或字串一樣,也屬於資料型別的一種。它有自己的字面表示形式(literal representation),能夠儲存在變數中,也能當作引數傳遞。一種資料型別應該有的功能,它都有。
3. 遞迴。Lisp是第一種支援遞迴函式的高階語言。
4. 變數的動態型別。在Lisp語言中,所有變數實際上都是指標,所指向的值有型別之分,而變數本身沒有。複製變數就相當於複製指標,而不是複製它們指向的資料。
5. 垃圾回收機制。
6. 程式由表示式(expression)組成。Lisp程式是一些表示式區塊的集合,每個表示式都返回一個值。這與Fortran和大多數後來的語言都截然不同,它們的程式由表示式和語句(statement)組成。
區分表示式和語句,在Fortran I中是很自然的,因為它不支援語句巢狀。所以,如果你需要用數學式子計算一個值,那就只有用表示式返回這個值,沒有其他語法結構可用,因為否則就無法處理這個值。
後來,新的程式語言支援區塊結構(block),這種限制當然也就不存在了。但是為時已晚,表示式和語句的區分已經根深蒂固。它從Fortran擴散到Algol語言,接著又擴散到它們兩者的後繼語言。
7. 符號(symbol)型別。符號實際上是一種指標,指向儲存在雜湊表中的字串。所以,比較兩個符號是否相等,只要看它們的指標是否一樣就行了,不用逐個字元地比較。
8. 程式碼使用符號和常量組成的樹形表示法(notation)。
9. 無論什麼時候,整個語言都是可用的。Lisp並不真正區分讀取期、編譯期和執行期。你可以在讀取期編譯或執行程式碼;也可以在編譯期讀取或執行程式碼;還可以在執行期讀取或者編譯程式碼。
在讀取期執行程式碼,使得使用者可以重新調整(reprogram)Lisp的語法;在編譯期執行程式碼,則是Lisp巨集的工作基礎;在執行期編譯程式碼,使得Lisp可以在Emacs這樣的程式中,充當擴充套件語言(extension language);在執行期讀取程式碼,使得程式之間可以用S-表示式(S-expression)通訊,近來XML格式的出現使得這個概念被重新"發明"出來了。
五、
Lisp語言剛出現的時候,它的思想與其他程式語言大相徑庭。後者的設計思想主要由50年代後期的硬體決定。隨著時間流逝,流行的程式語言不斷更新換代,語言設計思想逐漸向Lisp靠攏。
思想1到思想5已經被廣泛接受,思想6開始在主流程式語言中出現,思想7在Python語言中有所實現,不過似乎沒有專用的語法。
思想8可能是最有意思的一點。它與思想9只是由於偶然原因,才成為Lisp語言的一部分,因為它們不屬於John McCarthy的原始構想,是由他的學生Steve Russell自行新增的。它們從此使得Lisp看上去很古怪,但也成為了這種語言最獨一無二的特點。Lisp古怪的形式,倒不是因為它的語法很古怪,而是因為它根本沒有語法,程式直接以解析樹(parse tree)的形式表達出來。在其他語言中,這種形式只是經過解析在後臺產生,但是Lisp直接採用它作為表達形式。它由列表構成,而列表則是Lisp的基本資料結構。
用一門語言自己的資料結構來表達該語言,這被證明是非常強大的功能。思想8和思想9,意味著你可以寫出一種能夠自己程式設計的程式。這可能聽起來很怪異,但是對於Lisp語言卻是再普通不過。最常用的做法就是使用巨集。
術語"巨集"在Lisp語言中,與其他語言中的意思不一樣。Lisp巨集無所不包,它既可能是某樣表示式的縮略形式,也可能是一種新語言的編譯器。如果你想真正地理解Lisp語言,或者想拓寬你的程式設計視野,那麼你必須學習巨集。
就我所知,巨集(採用Lisp語言的定義)目前仍然是Lisp獨有的。一個原因是為了使用巨集,你大概不得不讓你的語言看上去像Lisp一樣古怪。另一個可能的原因是,如果你想為自己的語言添上這種終極武器,你從此就不能聲稱自己發明了新語言,只能說發明了一種Lisp的新方言。
我把這件事當作笑話說出來,但是事實就是如此。如果你創造了一種新語言,其中有car、cdr、cons、quote、cond、atom、eq這樣的功能,還有一種把函式寫成列表的表示方法,那麼在它們的基礎上,你完全可以推匯出Lisp語言的所有其他部分。事實上,Lisp語言就是這樣定義的,John McCarthy把語言設計成這個樣子,就是為了讓這種推導成為可能。
六、
就算Lisp確實代表了目前主流程式語言不斷靠近的一個方向,這是否意味著你就應該用它程式設計呢?
如果使用一種不那麼強大的語言,你又會有多少損失呢?有時不採用最尖端的技術,不也是一種明智的選擇嗎?這麼多人使用主流程式語言,這本身不也說明那些語言有可取之處嗎?
另一方面,選擇哪一種程式語言,許多專案是無所謂的,反正不同的語言都能完成工作。一般來說,條件越苛刻的專案,強大的程式語言就越能發揮作用。但是,無數的專案根本沒有苛刻條件的限制。大多數的程式設計任務,可能只要寫一些很小的程式,然後用膠水語言把這些小程式連起來就行了。你可以用自己熟悉的程式語言,或者用對於特定專案來說有著最強大函式庫的語言,來寫這些小程式。如果你只是需要在Windows應用程式之間傳遞資料,使用Visual Basic照樣能達到目的。
那麼,Lisp的程式設計優勢體現在哪裡呢?
七、
語言的程式設計能力越強大,寫出來的程式就越短(當然不是指字元數量,而是指獨立的語法單位)。
程式碼的數量很重要,因為開發一個程式耗費的時間,主要取決於程式的長度。如果同一個軟體,一種語言寫出來的程式碼比另一種語言長三倍,這意味著你開發它耗費的時間也會多三倍。而且即使你多僱傭人手,也無助於減少開發時間,因為當團隊規模超過某個門檻時,再增加人手只會帶來淨損失。Fred Brooks在他的名著《人月神話》(The Mythical Man-Month)中,描述了這種現象,我的所見所聞印證了他的說法。
如果使用Lisp語言,能讓程式變得多短?以Lisp和C的比較為例,我聽到的大多數說法是C程式碼的長度是Lisp的7倍到10倍。但是最近,New Architect雜誌上有一篇介紹ITA軟體公司的文章,裡面說"一行Lisp程式碼相當於20行C程式碼",因為此文都是引用ITA總裁的話,所以我想這個數字來自I他的程式設計實踐。 如果真是這樣,那麼我們可以相信這句話。I他的軟體,不僅使用Lisp語言,還同時大量使用C和C++,所以這是他們的經驗談。
根據上面的這個數字,如果你與ITA競爭,而且你使用C語言開發軟體,那麼I他的開發速度將比你快20倍。如果你需要一年時間實現某個功能,它只需要不到三星期。反過來說,如果某個新功能,它開發了三個月,那麼你需要五年才能做出來。
你知道嗎?上面的對比,還只是考慮到最好的情況。當我們只比較程式碼數量的時候,言下之意就是假設使用功能較弱的語言,也能開發出同樣的軟體。但是事實上,程式設計師使用某種語言能做到的事情,是有極限的。如果你想用一種低層次的語言,解決一個很難的問題,那麼你將會面臨各種情況極其複雜、乃至想不清楚的窘境。
所以,當我說假定你與ITA競爭,你用五年時間做出的東西,ITA在Lisp語言的幫助下只用三個月就完成了,我指的五年還是一切順利、沒有犯錯誤、也沒有遇到太大麻煩的五年。事實上,按照大多數公司的實際情況,計劃中五年完成的專案,很可能永遠都不會完成。
我承認,上面的例子太極端。ITA似乎有一批非常聰明的黑客,而C語言又是一種很低層次的語言。但是,在一個高度競爭的市場中,即使開發速度只相差兩三倍,也足以使得你永遠處在落後的位置。
附錄:程式設計能力
為了解釋我所說的語言程式設計能力不一樣,請考慮下面的問題。我們需要寫一個函式,它能夠生成累加器,即這個函式接受一個引數n,然後返回另一個函式,後者接受引數i,然後返回n增加(increment)了i後的值。
Common Lisp的寫法如下:
(defun foo (n)
(lambda (i) (incf n i)))
Ruby的寫法幾乎完全相同:
def foo (n)
lambda {|i| n += i } end
Perl 5的寫法則是:
sub foo {
my ($n) = @_;
sub {$n += shift}
}
這比Lisp和Ruby的版本,有更多的語法元素,因為在Perl語言中,你不得不手工提取引數。
Smalltalk的寫法稍微比Lisp和Ruby的長一點:
foo: n
|s|
s := n.
^[:i| s := s+i. ]
因為在Smalltalk中,區域性變數(lexical variable)是有效的,但是你無法給一個引數賦值,因此不得不設定了一個新變數,接受累加後的值。
Javascript的寫法也比Lisp和Ruby稍微長一點,因為Javascript依然區分語句和表示式,所以你需要明確指定return語句,來返回一個值:
function foo (n) {
return function (i) {
return n += i } }
(實事求是地說,Perl也保留了語句和表示式的區別,但是使用了典型的Perl方式處理,使你可以省略return。)
如果想把Lisp/Ruby/Perl/Smalltalk/Javascript的版本改成Python,你會遇到一些限制。因為Python並不完全支援區域性變數,你不得不創造一種資料結構,來接受n的值。而且儘管Python確實支援函式資料型別,但是沒有一種字面量的表示方式(literal representation)可以生成函式(除非函式體只有一個表示式),所以你需要創造一個命名函式,把它返回。最後的寫法如下:
def foo (n):
s = [n]
def bar (i):
s[0] += i
return s[0]
return bar
Python使用者完全可以合理地質疑,為什麼不能寫成下面這樣:
def foo (n):
return lambda i: return n += i
或者:
def foo (n):
lambda i: n += i
我猜想,Python有一天會支援這樣的寫法。(如果你不想等到Python慢慢進化到更像Lisp,你總是可以直接......)
在物件導向程式設計的語言中,你能夠在有限程度上模擬一個閉包(即一個函式,通過它可以引用由包含這個函式的程式碼所定義的變數)。你定義一個類(class),裡面有一個方法和一個屬性,用於替換封閉作用域(enclosing scope)中的所有變數。這有點類似於讓程式設計師自己做程式碼分析,本來這應該是由支援區域性作用域的編譯器完成的。如果有多個函式,同時指向相同的變數,那麼這種方法就會失效,但是在這個簡單的例子中,它已經足夠了。
Python高手看來也同意,這是解決這個問題的比較好的方法,寫法如下:
def foo (n):
class acc:
def _ _init_ _ (self, s):
self.s = s
def inc (self, i):
self.s += i
return self.s
return acc (n).inc
或者
class foo:
def _ _init_ _ (self, n):
self.n = n
def _ _call_ _ (self, i):
self.n += i
return self.n
我新增這一段,原因是想避免Python愛好者說我誤解這種語言。但是,在我看來,這兩種寫法好像都比第一個版本更復雜。你實際上就是在做同樣的事,只不過劃出了一個獨立的區域,儲存累加器函式,區別只是儲存在物件的一個屬性中,而不是儲存在列表(list)的頭(head)中。使用這些特殊的內部屬性名(尤其是__call__),看上去並不像常規的解法,更像是一種破解。
在Perl和Python的較量中,Python黑客的觀點似乎是認為Python比Perl更優雅,但是這個例子表明,最終來說,程式設計能力決定了優雅。Perl的寫法更簡單(包含更少的語法元素),儘管它的語法有一點醜陋。
其他語言怎麼樣?前文曾經提到過Fortran、C、C++、Java和Visual Basic,看上去使用它們,根本無法解決這個問題。Ken Anderson說,Java只能寫出一個近似的解法:
public interface Inttoint {
public int call (int i);
}public static Inttoint foo (final int n) {
return new Inttoint () {
int s = n;
public int call (int i) {
s = s + i;
return s;
}};
}
這種寫法不符合題目要求,因為它只對整數有效。
當然,我說使用其他語言無法解決這個問題,這句話並不完全正確。所有這些語言都是圖靈等價的,這意味著嚴格地說,你能使用它們之中的任何一種語言,寫出任何一個程式。那麼,怎樣才能做到這一點呢?就這個小小的例子而言,你可以使用這些不那麼強大的語言,寫一個Lisp直譯器就行了。
這樣做聽上去好像開玩笑,但是在大型程式設計專案中,卻不同程度地廣泛存在。因此,有人把它總結出來,起名為"格林斯潘第十定律"(Greenspun's Tenth Rule):
"任何C或Fortran程式複雜到一定程度之後,都會包含一個臨時開發的、只有一半功能的、不完全符合規格的、到處都是bug的、執行速度很慢的Common Lisp實現。"
如果你想解決一個困難的問題,關鍵不是你使用的語言是否強大,而是好幾個因素同時發揮作用(a)使用一種強大的語言,(b)為這個難題寫一個事實上的直譯器,或者(c)你自己變成這個難題的人肉編譯器。在Python的例子中,這樣的處理方法已經開始出現了,我們實際上就是自己寫程式碼,模擬出編譯器實現區域性變數的功能。
這種實踐不僅很普遍,而且已經制度化了。舉例來說,在物件導向程式設計的世界中,我們大量聽到"模式"(pattern)這個詞,我覺得那些"模式"就是現實中的因素(c),也就是人肉編譯器。 當我在自己的程式中,發現用到了模式,我覺得這就表明某個地方出錯了。程式的形式,應該僅僅反映它所要解決的問題。程式碼中其他任何外加的形式,都是一個訊號,(至少對我來說)表明我對問題的抽象還不夠深,也經常提醒我,自己正在手工完成的事情,本應該寫程式碼,通過巨集的擴充套件自動實現。