小語種介紹:LISP/Scheme

自從裘宗燕教授翻譯了《計算機程序的構造和解釋》(Structure and Intepretation of Computer ProgramsSICP)第二版之後,這本MIT計算機系的編程入門教材開始越來越多地受到中國開發者的關注。同時受到關注的,還有它所介紹的函數式編程(Functional Programming),以及其中範例所使用的Scheme語言。

時光倒轉到30年前,1975年,Bill GatesPaul Allen寫出了那個傳奇的BASIC版本——他們後來賣給MITS、換來“第一桶金”的那個BASIC版本。同一年,Gerald Sussman——他正是SICP的作者——創造了Scheme語言。其實Scheme並不是一種新鮮的語言,準確地說,它只是LISP的一個變體、一種方言。早在1958年,John McCarthy就開始研究一種“用於處理列表數據”的語言——這也是LISP名字的由來(LISt Processing)。“列表處理”乍看上去是一個相當特定的問題,但實際上這類問題有着深遠而重要的內涵,稍後我們就會看到這裏的故事。

LISP的其他方言相比,Scheme最大的特點或許在於:它是可以被編譯成機器碼的。也就是說,它的運行效率更高。除此之外,Scheme在語言層面上可說是中規中矩。LISP素來奉仰的哲學思想是“微核心+高擴展性”,Scheme也將這一特點發揮到了極致。Scheme內置的關鍵字(keyword)少得可憐,就連大於小於、加減乘除等操作都是以函數的形式出現。甚至可以誇張一點說,只要有define關鍵字與括號,就可以寫出所有程序。不過,這種風格的一個副作用是會在程序中出現大量的括號,所以也有人把LISP戲稱爲“一大堆煩人的、教人看不懂的括號”(Lots of Irritating, Spurious Parentheses)。譬如說,下面的程序是用來求一個值的平方:

(define (square x)

      (* x x))

(display (square 3))

對於我們這些從C語言開始入門、習慣了過程式編程(相對於“函數式編程”而言)的程序員,初接觸LISP/Scheme之時,受到的第一個觸動大概就是:Scheme不區分數據與操作。仍然以“求平方”的例子,“x的平方”可以表述爲“以1爲基數,將‘乘以x’計算兩次”。如果用C++語言,這個邏輯可以這樣實現:

int square(int x) {

      return 1 * x * x;

}

而在Scheme中,我們還可以這樣實現:

(define (twice func base arg)

      (func base (func base arg)))

(define (square x)

      (twice * 1 x))

這種實現的特點在哪裏?最大的特點就是:一個操作(乘法運算)被當作參數傳遞。按照程序設計的“黑話”,如果一個程序單元可以被作爲參數和返回值傳遞,那麼這個單元就被稱爲“一等公民”(first class)。在C/C++/Java等語言中,雖然也可以通過函數指針、functor等形式傳遞“操作”,但畢竟是經過了包裝;而在Scheme中,可以將另一個函數直接作爲參數傳入函數,也可以作爲返回值傳回另一個函數,函數(也即“操作”)完全被作爲一等公民對待。

這樣做的好處是什麼?在上面的例子中,我們把“兩次執行某操作”的邏輯也抽象出來,得到了twice函數。如果我們想要實現“以0爲基數將加法操作執行兩遍”(也就是“乘以2”),只需要這樣寫:

(define (double x)

      (twice + 0 x))

這裏的twice函數是“對別的函數進行操作的函數”,它的結果取決於傳入什麼函數給它作爲參數。像這種“函數的函數”,在函數式編程的術語中被稱爲“高階函數”(high-order)。能夠很自然地實現高階函數,是Scheme的第二個重要特點。在前面已經提到過,LISP這個名字代表“列表處理”,其實處理列表數據的能力正是來自對高階函數的運用。譬如說,我們有下面這樣一個列表:

{1, 2, 3, 4, 5}

針對這個列表,我們要做兩件事:

<!--[if !supportLists]-->1.      <!--[endif]-->將每個元素翻倍,得到新的列表:{2, 4, 6, 8, 10}

<!--[if !supportLists]-->2.      <!--[endif]-->對每個元素求平方,得到新的列表:{1, 4, 9, 16, 25}

Java語言,我們可以這樣實現:

List<int> doubleList(List<int> src) {

      List<int> dist = new ArrayList<int>();

      for(int item : src) {

            dist.add(item * 2);<?XML:NAMESPACE PREFIX = O />

}

return dist;

}

List<int> squareList(List<int> src) {

      List<int> dist = new ArrayList<int>();

      for(int item : src) {

            dist.add(item * item);

}

return dist;

}

問題一目瞭然:除了加粗的兩行代碼之外,這兩個方法幾乎是完全重複的。細想之下,其實這兩個方法做的事情非常相似:遍歷一個列表,按照“某種規則”將原列表的每個元素映射到新的列表中。因爲這個“某種規則”是針對元素的映射操作,所以爲了抽象這種列表操作,我們必須實現一個高階函數,將實際的映射操作以參數的形式傳入。於是,在能夠方便實現高階函數的Scheme中,以上邏輯實現起來相當容易:

(define (double-list src)

      (map double src))

(define (square-list src)

      (map square src))

由於高階函數在操作邏輯抽象方面的強大與便利,很多人開始尋求在“主流”的過程式語言中將操作當作一等公民對待,進而實現高階函數。譬如說,C#delegate的形式允許將方法作爲參數或返回值傳遞,並且在List<T>類型中加入了find等高階操作;Java世界的FunctionalJhttp://functionalj.sourceforge.net/)則在Java5提供的泛型基礎上提供了filtermap等常用的列表操作。前面的例子如果用FunctionalJ來實現,就可以寫成:

// double and square are Function instances

List<int> doubleList(List<int> src) {

      return Functions.map(double, src);

}

List<int> squareList(List<int> src) {

return Functions.map(square, src);

}

“不區分數據與操作”這句話說起來很簡單,其實背後蘊含着一個重要的哲學問題,即“什麼是時間”的問題。按照過程式編程的理念,“時間”是操作內部的一個變量,程序以局部變量的形式記錄系統在各個時間點的瞬時狀態;而按照函數式編程的理念,“時間”是操作外部的變量,以參數的形式傳入函數,函數內部則沒有局部狀態,更沒有賦值操作。或者更簡單一點說,在任何時候用同樣的參數調用同一個函數,必定會得到同樣的結果。這種性質被稱爲“引用透明”。如果操作不具備引用透明性,就不能將它作爲參數或返回值傳遞,因爲調用環境和順序都可能改變高階函數的結果。

具備引用透明性的程序還有一項額外的好處:它們天生地具有線程安全性。不論有多少條線程、以什麼順序訪問,只要程序具有引用透明性,就不需要額外的線程同步機制來保證結果正確。在同時面向衆多用戶的服務器端應用、尤其是web應用中,這一點顯得特別重要。Rod Johnson在他的《J2EE Development without EJB》一書中提倡“無狀態的Java服務器端應用”,企業應用的開發者們也從函數式編程的思想中受益匪淺。

據說LISP當初的發明頗有些無心插柳的味道:McCarthy只實現了一個基於lambda運算的抽象語法就把它扔到一邊,而他的學生們卻發現在這樣極簡的語法中寫程序別有一番樂趣。有人說計算機科學家是一羣喜歡歸約的人,LISP的發明卻是從實踐的角度證明:基本上所有的程序結構都可以歸約爲lambda運算。按照Alonzo Church發明的丘奇代數(Church Calculus)理論,一切可以有效計算的函數——包括定值函數——都可以用lambda運算來定義。譬如說,數據“0”和操作“加1”可以用lambda運算定義如下:

(define zero (lambda (f) (lambda (x) x)))

(define (add-1 n)
  (lambda (f) (lambda (x) (f ((n f) x)))))

在此基礎上,就可以用lambda運算定義整個自然數系。這是一個極端的例子。在別的很多地方,LISP/Scheme也能夠以類似的方式剖析我們習以爲常的概念,讓我們獲得更加深入的洞見。譬如在SICP第二章“構造數據抽象”中,我們親眼看到:平時所說的“面向過程編程”與“面向對象編程”,在很大程度上無非是使用同一組lambda運算的不同語法糖而已。熟悉面向對象的程序員在學習Scheme時,常常會因爲跨越了“數據”與“操作”的鴻溝而獲得一些全新的理解。再加上Scheme的語法極其簡單,最常用的關鍵字大概不超過5個,所以作爲教學語言有着得天獨厚的優勢——不少學校採用Java作爲大學生的編程入門語言,當你看着這些可憐的學生在兩個月之後還在與“匿名內部類”之類詭異的語法和“IO流裝飾器”之類複雜的類庫糾纏不清時,你不難理解我的意思。

但這種簡單性也成爲Scheme在企業應用中推廣的最大障礙:企業應用需要的不是許多種優雅的可能性,而是一種可行的解決方案。雖然PLTScheme實現版本提供了XMLservlet等工具庫,但過於靈活的語法、最佳實踐的缺乏、以及沒有大廠商的支持,都讓Scheme終於無法成爲企業應用的主流。不過,儘管幾乎沒有真正的應用,來自函數式編程的理念還是啓發着企業應用的開發者們。譬如WebWork2.2引入的continuation特性,就是來自函數式編程的概念。

最後——但並非最不重要的——應該說明:雖然很少在企業應用中見到蹤影,但LISP/Scheme在科學計算、人工智能、數學建模等領域的運用非常廣泛,所以把它稱作“小語種”多少有些不太公平。整體而言,LISP/Scheme強於算法邏輯的編寫,而不善於I/O操作。我們當然可以說這是它失意於企業應用領域的原因,但又何嘗不可以是它的結果呢?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章