Lisp的永恆之道

發表於2013-03-26

來源:weidagang

Lisp之魅

長久以來,Lisp一直被許多人視為史上最非凡的程式語言。它不僅在50多年前誕生的時候帶來了諸多革命性的創新並極大地影響了後來程式語言的發展,即使在一大批現代語言不斷湧現的今天,Lisp的諸多特性仍然未被超越。當各式各樣的程式語言擺在面前,我們可以從執行效率、學習曲線、社群活躍度、廠商支援等多種不同的角度進行評判和選擇,但我特別看中的一點在於語言能否有效地表達程式設計者的設計思想。學習C意味著學習如何用過程來表達設計思想,學習Java意味著學習如何用物件來表達設計思想,而雖然Lisp與函數語言程式設計有很大的關係,但學習Lisp絕不僅僅是學習如何用函式表達設計思想。實際上,函數語言程式設計並非Lisp的本質,在已經掌握了lambda、高階函式、閉包、惰性求值等函數語言程式設計概念之後,學習Lisp仍然大大加深了我對程式設計的理解。學習Lisp所收穫的是如何“自由地”表達你的思想,這正是Lisp最大的魅力所在,也是這門古老的語言仍然具有很強的生命力的根本原因。

 

Lisp之源

Lisp意為表處理(List Processing),源自設計者John McCarthy於1960年發表的一篇論文《符號表示式的遞迴函式及其機器計算》。McCarthy在這篇論文中向我們展示了用一種簡單的資料結構S表示式(S-expression)來表示程式碼和資料,並在此基礎上構建一種完整的語言。Lisp語言形式簡單、內涵深刻,Paul Graham在《Lisp之根源》中將其對程式設計的貢獻與歐幾里德對幾何的貢獻相提並論。

 

Lisp之形

然而,與數學世界中簡單易懂的歐氏幾何形成鮮明對比,程式世界中的Lisp卻一直是一種古老而又神祕的存在,真正理解其精妙的人還是少數。從表面上看,Lisp最明顯的特徵是它“古怪”的S表示式語法。S表示式是一個原子(atom),或者若干S表示式組成的列表(list),表示式之間用空格分開,放入一對括號中。“列表“這個術語可能會容易讓人聯想到資料結構中的連結串列之類的線形結構,實際上,Lisp的列表是一種可巢狀的樹形結構。下面是一些S表示式的例子:

據說,這個古怪的S表示式是McCarthy在發明Lisp時候所採用的一種臨時語法,他實際上是準備為Lisp加上一種被稱為M表示式(M-expression)的語法,然後再把M表示式編譯為S表示式。用一個通俗的類比,S表示式相當於是JVM的位元組碼,而M表示式相當於Java語言,但是後來Lisp的使用者都熟悉並喜歡上了直接用S表示式編寫程式,並且他們發現S表示式有許多獨特的優點,所以M表示式的引入也就被無限期延遲了。

許多Lisp的入門文章都比較強調Lisp的函式式特性,而我認為這是一種誤導。真正的Lisp之門不在函數語言程式設計,而在S表示式本身,Lisp最大的奧祕就藏在S表示式後面。S表示式是Lisp的語法基礎,語法是語義的載體,形式是實質的寄託。“S表示式”是程式的一種形,正如“七言”是詩的一種形,“微博”是資訊的一種形。正是形的不同,讓微博與部落格有了質的差異,同樣的道理,正是S表示式讓Lisp與C、Java、SQL等語言有了天壤之別。

 

Lisp之道

一門語言能否有效地表達程式設計者的設計思想取決於其抽象機制的語義表達能力。根據抽象機制的不同,語言的抽象機制形成了程式導向、物件導向、函式式、併發式等不同的正規化。當你採用某一種語言,基本上就表示你已經“面向XXX“了,你的思維方式和解決問題的手段就會依賴於語言所提供的抽象方式。比如,採用Java語言通常意味著採用物件導向分析設計;採用Erlang通常意味著按Actor模型對併發任務進行建模。

有經驗的程式設計師都知道,無論是面向XXX程式設計,程式設計都有一條“抽象原則“:What與How解耦。但是,普通語言的問題就在於表達What的手段非常有限,無非是過程、類、介面、函式等幾種方式,而諸多領域問題是無法直接抽象為函式或介面的。比如,你完全可以在C語言中定義若干函式來做到make file所做的事情,但C程式碼很難像make file那樣宣告式地體現出target、depends等語義,它們只會作為實現細節被淹沒在一個個的C函式之中。採用OOP或是FP等其它正規化也會遇到同樣的困難,也就是說make file語言所代表的抽象維度與程式導向、OOP以及FP的抽象維度是正交的,使得各種正規化無法直接表達出make file的語義。這就是普通語言的“剛性”特徵,它要求我們必須以語言的抽象維度去分析和解決問題,把問題對映到語言的基本語法和語義。

更進一步,如果仔細探究這種剛性的根源,我們會發現正是由於普通語言語法和語義的緊耦合造成了這種剛性。比如,C語言中printf(“hello %s”, name)符合函式呼叫語法,它表達了函式呼叫語義,除此之外別無他義;Java中interface IRunnable { … }符合介面定義語法,它表達了介面定義語義,除此之外別無他義。如果你認為“語法和語義緊耦合“是理所當然的,看不出這有什麼問題,那麼理解Lisp就會讓你對此產生更深的認識。

當你看到Lisp的(f a (b c))的時候,你會想到什麼?會不會馬上聯想到函式求值或是巨集擴充套件?就像在C語言裡看到gcd(10, 15)馬上想到函式呼叫,或者在Java裡看到class A馬上想到類定義一樣。如果真是這樣,那它就是你理解Lisp的一道障礙,因為你已經習慣了順著語言去思考,總是在想這一句話機器怎麼解釋執行?那一句話又對應語言的哪個特性?理解Lisp要反過來,讓語言順著你,Lisp的(f a (b c))可以是任何語義,完全由你來定,它可以是函式定義、類定義、資料庫查詢、檔案依賴關係,非同步任務的執行關係,業務規則 …

下面我準備先通過幾個具體的例子逐步展示Lisp的本質。需要說明的是,由於Lisp的S表示式和XML的語法形式都是一種樹形結構,在語義表達方面二者並無本質的差別。所以,為了理解方便,下面我暫且用多數人更為熟悉的XML來寫程式碼,請記住我們可以很輕易地把XML程式碼和Lisp程式碼相互轉換。

首先,我們可以輕易地用XML來定義一個求兩個數最大公約數的函式:

其次,我們可以用它來定義類:

還可以輕易地用它來編寫關係查詢:

還可以用它來實現類似make file的自動化構建(語法取自ant):

一口氣舉了這麼多個例子,目的在於用XML這種樹形結構來說明Lisp的S表示式所能夠描述的語義。不知道你是否發現了S表示式和XML這種樹形語法在語義構造方面有著特別的“柔性”?我們可以輕易地用它構造出函式、變數、條件判斷語義;類、屬性、方法語義;可以輕易地構造出關係模型的select、where語義;可以輕易地構造出make的target、depends語義,等等數不清的語義。在普通語言裡,你可以定義一個函式、一個類,但你無法為C語言增加匿名函式特性,也沒法給Java語言加上RAII語義,甚至連自己創造一個foreach迴圈都不行,而自定義語義意味著在Lisp之上你創造了一門語言!不管是程式導向,物件導向,函式式,還是關係模型,在Lisp裡統統都變成了一種DSL,而Lisp本身也就成了一種定義語言的語言,即元語言(Meta Language)。

 

lisp

Lisp的柔性與S表示式有著密切的關係。Lisp並不限制你用S表示式來表達什麼語義,同樣的S表示式語法可以表達各種不同領域的語義,這就是語法和語義解耦。如果說普通語言的剛性源於“語法和語義緊耦合”,那麼Lisp的柔性正是源於“語法和語義解耦”!“語法和語義解耦”使得Lisp可以隨意地構造各種領域的DSL,而不強制用某一種正規化或是領域視角去分析和解決問題。本質上,Lisp程式設計是一種超越了普通程式設計正規化的正規化,這就是Lisp之道:面向語言程式設計(LOP, Language Oriented Programming)。Wikipedia上是這樣描述LOP的:

Language oriented programming (LOP) is a style of computer programming in which, rather than solving problems in general-purpose programming languages, the programmer creates one or more domain-specific languages for the problem first, and solves the problem in those languages … The concept of Language Oriented Programming takes the approach to capture requirements in the user’s terms, and then to try to create an implementation language as isomorphic as possible to the user’s descriptions, so that the mapping between requirements and implementation is as direct as possible.

LOP正規化的基本思想是從問題出發,先建立一門描述領域模型的DSL,再用DSL去解決問題,它具有高度的宣告性和抽象性。SQL、make file、CSS等DSL都可以被認為是LOP的具體例項,下面我們再通過兩個常見的例子來理解LOP的優勢。

例1:在股票交易系統中,交易協議定義若干二進位制的訊息格式,交易所和客戶端需要對訊息進行編碼和解碼。

訊息格式是一種抽象的規範,本身不對語言做任何的限制,你可以用C,C++,Java,或者Python。普通的實現方式是按照訊息格式規範,在相應的語言中定義訊息結構,並編寫相應的編解碼函式。假設為一個訊息定義結構和實現編解碼函式的工作量為M,不同訊息型別的數量為N,這種方式的工作量大致為M*N。也就是說每增加一種訊息型別,就需要為該訊息定義結構,實現編解碼函式,引入bug的可能性當然也和M*N成正比。如果仔細觀察不難發現,各個訊息結構其實是高度類似的,編解碼函式也大同小異,但是普通語言卻找不到一種抽象機制能表達這種共性,比如,我們無法通過物件導向的方法定義一個基類把訊息結構的共性抽象出來,然後讓具體的訊息去繼承它,達到複用的目的。這正是由於普通語言的抽象維度限制所致,在普通語言中,你只能從函式、介面等維度對事物進行抽象,而恰好訊息格式共性所在的維度與這些抽象維度並不匹配。

其實,不同訊息型別的共性在於它們都具有相同的領域語義,比如:“某欄位內容是另一個欄位內容的md5碼”就是一種訊息格式的領域語義,這種領域語義是OOP的抽象機制無法描述的。LOP的思路是先建立一門訊息定義DSL,比如,類似Google的Protocol Buffer,Android的AIDL。然後,通過DSL編寫訊息定義檔案,直接宣告式地描述訊息的結構特徵,比如,我們可以宣告式地描述“某欄位內容是另一個欄位內容的md5碼”。我們還需要為DSL開發編譯器用於生成C、Java等通用語言的訊息定義和編解碼函式。

有了訊息定義DSL和編譯器之後,由於DSL編寫訊息定義是一種高度宣告式的程式設計方法,每增加一種訊息的只需要多編寫一個訊息定義檔案而已,工作量幾乎可以忽略不計。所有的工作量都集中在編譯器的開發上,工作量是一個常數C,與訊息的數量沒有關係;質量保證方面也只需要關注編譯器這一點,不會因為增加新的訊息型別而引入bug。

例2:在圖書管理系統中,需要支援在管理介面上對書籍、學生、班級等各種實體進行管理操作。

如果按傳統的三層架構,一般需要在後端程式中為每一種實體定義一個類,並定義相應的方法實現CRUD操作,與之相應的,還需要在前端頁面中為每一個實體編寫相應的管理頁面。這些實體類的CRUD操作都是大同小異的,但細節又各不相同,雖然我們很想複用某些共同的設計實現,但OOP所提供的封裝、繼承、多型等抽象機制不足以有效捕獲實體之間的共性,大量的程式碼還是必須放在子類中來完成。比如,Student和Book實體類的實現非常相似,但是如果要通過OOP的方式去抽象它們的共性,得出的結果多半是Entity這樣的大而空的基類,很難起到複用的效果。

其實,不同實體之間的共性還是在於它們具有相同的領域語義,比如:實體具有屬性,屬性具有型別,屬性具有取值範圍,屬性具有可讀取、可編輯等訪問屬性,實體之間有關聯關係等。LOP方法正是直接面向這種領域語義的。採用LOP方法,我們並不需要為每一個實體類單獨編寫CRUD方法,也不需要單獨編寫管理頁面,只需要定義一種DSL並實現其編譯器;然後,用DSL宣告式地編寫實體描述檔案,去描述實體的屬性列表,屬性的型別、取值範圍,屬性所支援的操作,屬性之間的關係和約束條件等;最後,通過這個實體描述檔案自動生成後端的實體類和前端管理頁面。採用LOP,不論前後端採用何種技術,Java也好,C#也好,JSP也好,ASP.NET也好,都可以自動生成它們的程式碼。採用LOP的工作量和質量都集中在DSL的設計和編譯器的開發,與實體的數量無關,也就是說,越是龐大的系統,實體類越多越是能體現LOP的優勢。

通過上面兩個小例子我們可以感受到,LOP是一種面向領域的,高度宣告式的程式設計方式,它的抽象維度與領域模型的維度完全一致。LOP能讓程式設計師從複雜的實現細節中解脫出來,把關注點集中在問題的本質上,從而提高程式設計的效率和質量。

接下來的問題是如果需要為某領域設計DSL,我們是應該發明一門類似SQL這樣的專用DSL呢,還是用XML或S表示式去定義DSL呢?它們各有何優缺點呢?

我認為採用XML或S表示式定義DSL的優點主要有:1) SQL、make file、CSS等專用DSL都只能面向各自的領域,而一個實際的領域問題通常是跨越多個領域的,有時我們需要將不同領域融合在一起,但是由於普通語言的剛性,多語言融合通常會是一件非常困難的事情,而XML和S表示式語法結構的單一性和“程式碼及資料”的特點使得跨領域融合毫無障礙。2) 在為DSL開發編譯器或直譯器的方面,二者難度不同。對XML和S表示式定義的DSL進行語法分析非常簡單,相比之下,對SQL這樣的專用DSL進行語法分析,雖然可以藉助Lex、Yacc、ANTLR等程式碼生成工具,但總的來講複雜度還是要明顯高一些。

當然,XML和S表示式的優點也正好是其缺點,由於XML和S表示式的語法形式是固定的,不能像專用DSL那樣自由地設計語法。所以,一般來講專用DSL的語法顯得更加簡潔。換句話說,XML和Lisp其實是在語法和語義間做了一個交換,用語法的限制換來了語義的靈活。

 

Lisp之器

接下來我們繼續探討DSL的解釋執行問題。DSL程式碼的解釋執行一般分為3種典型的方式:1) 通過專門的直譯器解釋執行;2) 編譯生成其他語言的程式碼,再通過其他語言的直譯器解釋執行(或編譯執行);3) 自解釋。比如,第1類的代表是SQL,上一節舉的兩個例子都屬於第2類,而第3類自解釋正是Lisp的特色。

為了理解自解釋,我們可以先從內部DSL的解釋執行說起。內部DSL是指嵌入在宿主語言中的DSL,比如,Google Test單元測試框架定義了一套基於流暢介面(Fluent Interface)的C++單元測試DSL。從語義構造的角度看,內部DSL直接借用宿主語言的語法定義了自己的領域語義,是一種語法和語義解耦;從解釋執行的角度看,內部DSL是隨宿主語言的直譯器而自動解釋的,不需要像外部DSL一樣開發專門的直譯器,因而實現的代價很低。當然,並不是說設計內部DSL不用關心任何的解釋實現,實際上,還是需要熟悉宿主語言的特性,並利用該特性使得DSL能隨著宿主語言的直譯器得到解釋執行。

Lisp擁有強大的自解釋特性,這得益於獨一無二的Lisp之器:巨集 (macro)。巨集使得Lisp編寫的DSL可以被Lisp直譯器直接解釋執行,這在原理上與內部DSL是相通的,只是內部DSL一般是利用宿主語言的鏈式呼叫等特性,通常形式簡陋,功能有限,而Lisp的巨集則要強大和靈活得多。

C語言中也有巨集的概念,不過Lisp的巨集與C語言的巨集完全不同,C語言的巨集是簡單的字串替換。比如,下面的巨集定義:

square(1+1)的期望結果是4,而實際上它會被替換成(1+1*1+1),結果是3。這個例子說明,C語言的巨集只在預編譯階段進行簡單的字串替換,對程式語法結構缺乏理解,非常脆弱。Lisp的巨集不是簡單的字串替換,而是一套完整的程式碼生成系統,它是在語法解析的基礎上把Lisp程式碼從一種形式轉換為另一種形式,本質上起到了普通語言編譯器的作用。不同的是,普通編譯器是把一種語言的程式碼轉換為另一種語言的程式碼,比如,Java編譯器把Java程式碼轉換成Java位元組碼;而Lisp巨集的輸入和輸出都是S表示式,它本質上是把一種DSL轉換為另一種DSL。下面的例子是巨集的一個典型用法。

例3:假設Lisp直譯器已經具備解釋執行程式導向DSL的能力,需要實現類似ant的自動化構建工具。

我們可以基於巨集構建一門類ant的DSL,巨集的作用是把類ant DSL通過巨集展開變成程式導向的DSL,最後被Lisp直譯器所解釋執行。這樣用Lisp編寫的ant DSL就不需要被編譯為其他語言,也不需要像XML的ant一樣依賴於專門的直譯器了。

當然,和開發專門的直譯器/編譯器相比,Lisp的巨集也並非沒有缺點,巨集難以理解,開發和除錯更加困難。到底是開發專門的直譯器/編譯器還是直接採用巨集應該視具體情況而定。

 

總結

Lisp採用單一的S表示式語法表達不同的語義,實現了語法和語義解耦。這使得Lisp具有強大的語義構造能力,擅長於構造DSL實現面向語言程式設計,而巨集使得Lisp具有自解釋能力,讓不同DSL之間的轉換遊刃有餘。進入Lisp的世界應當從理解面向語言程式設計入門,這是Lisp之道,而函數語言程式設計和巨集皆為Lisp之器,以道馭器方為正途。

 

後記

本文是我學習Lisp的一個總結,也是寫給有興趣學習Lisp的程式設計師的入門資料。必須說明,我還是一個標準的Lisp初學者,幾乎沒有寫過像樣的Lisp程式,文中的錯誤和不足在所難免,希望讀者批評指正,感謝!

 

參考

The Roots of Lisp

The Nature of Lisp

Why Lisp macros are cool, a Perl perspective

Wikipedia: Language-oriented programming

《實用Common Lisp程式設計》

《冒號課堂 – 程式設計正規化與OOP思想》

 

相關文章