Lisp 的本質

Alpha(奧法)發表於2012-11-22

簡介

最初在web的某些角落偶然看到有人讚美Lisp時, 我那時已經是一個頗有經驗的程式設計師。在我的履歷上, 掌握的語言範圍相當廣泛, 象C++, Java, C#主流語言等等都不在話下,我覺得我差不多知道所有的有關程式語言的事情。對待程式語言的問題上, 我覺得自己不太會遇到什麼大問題。其實我大錯特錯了。

我試著學了一下Lisp, 結果馬上就撞了牆。我被那些範例程式碼嚇壞了。我想很多初次接觸Lisp語言的人, 一定也有過類似的感受。Lisp的語法太次了。一個語言的發明人, 居然不肯用心弄出一套漂亮的語法, 那誰還會願意學它。反正, 我是確確實實被那些難看的無數的括號搞蒙了。

Lisp 的本質

回過神來之後, 我和Lisp社群的那夥人交談, 訴說我的沮喪心情。結果, 立馬就有一大套理論砸過來, 這套理論在Lisp社群處處可見, 幾成慣例。比如說: Lisp的括號只是表面現象; Lisp的程式碼和資料的表達方式沒有差別, 而且比XML語法高明許多, 所以有無窮的好處; Lisp有強大無比的元語言能力, 程式設計師可以寫出自我維護的程式碼; Lisp可以創造出針對特定應用的語言子集; Lisp的執行時和編譯時沒有明確的分界; 等等, 等等, 等等。這麼長的讚美詞雖然看起來相當動人, 不過對我毫無意義。沒人能給我演示這些東西是如何應用的, 因為這些東西一般來說只有在大型系統才會用到。我爭辯說, 這些東西傳統語言一樣辦得到。在和別人爭論了數個小時之後, 我最終還是放棄了學Lisp的念頭。為什麼要花費幾個月的時間學習語法這麼難看的語言呢? 這種語言的概念這麼晦澀, 又沒什麼好懂的例子。也許這語言不是該我這樣的人學的。

幾個月來, 我承受著這些Lisp辯護士對我心靈的重壓。我一度陷入了困惑。我認識一些絕頂聰明的人, 我對他們相當尊敬, 我看到他們對Lisp的讚美達到了宗教般的高度。這就是說, Lisp中一定有某種神祕的東西存在, 我不能忍受自己對此的無知, 好奇心和求知慾最終不可遏制。我於是咬緊牙關埋頭學習Lisp, 經過幾個月的時間費勁心力的練習, 終於,我看到了那無窮無盡的泉水的源頭。在經過脫胎換骨的磨練之後, 在經過七重地獄的煎熬之後, 終於, 我明白了。

頓悟在突然之間來臨。曾經許多次, 我聽到別人引用雷蒙德(譯者注: 論文<<大教堂和市集>>的作者, 著名的黑客社群理論家)的話: “Lisp語言值得學習。當你學會Lisp之後, 你會擁有深刻的體驗。就算你平常並不用Lisp程式設計, 它也會使你成為更加優秀的程式設計師”。過去, 我根本不懂這些話的含義, 我也不相信這是真的。可是現在我懂得了。這些話蘊含的真理遠遠超過我過去的想像。我內心體會到一種神聖的情感, 一瞬間的頓悟, 幾乎使我對電腦科學的觀念發生了根本的改變。

頓悟的那一刻, 我成了Lisp的崇拜者。我體驗到了宗教大師的感受: 一定要把我的知識傳佈開來, 至少要讓10個迷失的靈魂得到拯救。按照通常的辦法, 我把這些道理(就是剛開始別人砸過來的那一套, 不過現在我明白了真實的含義)告訴旁人。結果太令人失望了,只有少數幾個人在我堅持之下, 發生了一點興趣, 但是僅僅看了幾眼Lisp程式碼, 他們就退卻了。照這樣的辦法, 也許費數年功夫能造就了幾個Lisp迷, 但我覺得這樣的結果太差強人意了, 我得想一套有更好的辦法。

我深入地思考了這個問題。是不是Lisp有什麼很艱深的東西, 令得那麼多老練的程式設計師都不能領會? 不是, 沒有任何絕對艱深的東西。因為我能弄懂, 我相信其他人也一定能。那麼問題出在那裡? 後來我終於找到了答案。我的結論就是, 凡是教人學高階概念, 一定要從他已經懂得的東西開始。如果學習過程很有趣, 學習的內容表達得很恰當, 新概念就會變得相當直觀。這就是我的答案。所謂超程式設計, 所謂資料和程式碼形式合一, 所謂自修改程式碼, 所謂特定應用的子語言, 所有這些概念根本就是同族概念, 彼此互為解釋, 肯定越講越不明白。還是從實際的例子出發最有用。

我把我的想法說給Lisp程式設計師聽, 遭到了他們的反對。”這些東西本身當然不可能用熟悉的知識來解釋, 這些概念完全與眾不同, 你不可能在別人已有的經驗裡找到類似的東西”,可是我認為這些都是遁詞。他們又反問我, “你自己為啥不試一下?” 好吧, 我來試一下。這篇文章就是我嘗試的結果。我要用熟悉的直觀的方法來解釋Lisp, 我希望有勇氣的人讀完它, 拿杯飲料, 深呼吸一下, 準備被搞得暈頭轉向。來吧, 願你獲得大能。

重新審視XML

千里之行始於足下。讓我們的第一步從XML開始。可是XML已經說得更多的了, 還能有什麼新意思可說呢? 有的。XML自身雖然談談不上有趣, 但是XML和Lisp的關係卻相當有趣。XML和Lisp的概念有著驚人的相似之處。XML是我們通向理解Lisp的橋樑。好吧, 我們且把XML當作活馬醫。讓我們拿好手杖, 對XML的無人涉及的荒原地帶作一番探險。我們要從一個全新的視角來考察這個題目。

表面上看, XML是一種標準化語法, 它以適合人閱讀的格式來表達任意的層次化資料(hirearchical data)。象任務表(to-do list), 網頁, 病歷, 汽車保險單, 配置檔案等等, 都是XML用武的地方。比如我們拿任務表做例子:

解析這段資料時會發生什麼情況? 解析之後的資料在記憶體中怎樣表示? 顯然, 用樹來表示這種層次化資料是很恰當的。說到底, XML這種比較容易閱讀的資料格式, 就是樹型結構資料經過序列化之後的結果。任何可以用樹來表示的資料, 同樣可以用XML來表示, 反之亦然。希望你能懂得這一點, 這對下面的內容極其重要。

再進一步。還有什麼型別的資料也常用樹來表示? 無疑列表(list)也是一種。上過編譯課吧? 還模模糊糊記得一點吧? 原始碼在解析之後也是用樹結構來存放的, 任何編譯程式都會把原始碼解析成一棵抽象語法樹, 這樣的表示法很恰當, 因為原始碼就是層次結構的:函式包含引數和程式碼塊, 程式碼快包含表示式和語句, 語句包含變數和運算子等等。

我們已經知道, 任何樹結構都可以輕而易舉的寫成XML, 而任何程式碼都會解析成樹, 因此,任何程式碼都可以轉換成XML, 對不對? 我舉個例子, 請看下面的函式:

能把這個函式變成對等的XML格式嗎? 當然可以。我們可以用很多種方式做到, 下面是其中的一種, 十分簡單:

這個例子非常簡單, 用哪種語言來做都不會有太大問題。我們可以把任何程式碼轉成XML,也可以把XML轉回到原來的程式碼。我們可以寫一個轉換器, 把Java程式碼轉成XML, 另一個轉換器把XML轉回到Java。一樣的道理, 這種手段也可以用來對付C++(這樣做跟發瘋差不多麼。可是的確有人在做, 看看GCC-XML(http://www.gccxml.org)就知道了)。進一步說,凡是有相同語言特性而語法不同的語言, 都可以把XML當作中介來互相轉換程式碼。實際上幾乎所有的主流語言都在一定程度上滿足這個條件。我們可以把XML作為一種中間表示法,在兩種語言之間互相譯碼。比方說, 我們可以用Java2XML把Java程式碼轉換成XML, 然後用XML2CPP再把XML轉換成C++程式碼, 運氣好的話, 就是說, 如果我們小心避免使用那些C++不具備的Java特性的話, 我們可以得到完好的C++程式。這辦法怎麼樣, 漂亮吧?

這一切充分說明, 我們可以把XML作為原始碼的通用儲存方式, 其實我們能夠產生一整套使用統一語法的程式語言, 也能寫出轉換器, 把已有程式碼轉換成XML格式。如果真的採納這種辦法, 各種語言的編譯器就用不著自己寫語法解析了, 它們可以直接用XML的語法解析來直接生成抽象語法樹。

說到這裡你該問了, 我們研究了這半天XML, 這和Lisp有什麼關係呢? 畢竟XML出來之時,Lisp早已經問世三十年了。這裡我可以保證, 你馬上就會明白。不過在繼續解釋之前, 我們先做一個小小的思維練習。看一下上面這個XML版本的add函式例子, 你怎樣給它分類,是程式碼還是資料? 不用太多考慮都能明白, 把它分到哪一類都講得通。它是XML, 它是標準格式的資料。我們也知道, 它可以通過記憶體中的樹結構來生成(GCC-XML做的就是這個事情)。它儲存在不可執行的檔案中。我們可以把它解析成樹節點, 然後做任意的轉換。顯而易見, 它是資料。不過且慢, 雖然它語法有點陌生, 可它又確確實實是一個add函式,對吧?  一旦經過解析, 它就可以拿給編譯器編譯執行。我們可以輕而易舉寫出這個XML程式碼直譯器, 並且直接執行它。或者我們也可以把它譯成Java或C++程式碼, 然後再編譯執行。所以說, 它也是程式碼。

我們說到那裡了? 不錯, 我們已經發現了一個有趣的關鍵之點。過去被認為很難解的概念已經非常直觀非常簡單的顯現出來。程式碼也是資料, 並且從來都是如此。這聽起來瘋瘋癲癲的, 實際上卻是必然之事。我許諾過會以一種全新的方式來解釋Lisp, 我要重申我的許諾。但是我們此刻還沒有到預定的地方, 所以還是先繼續上邊的討論。

剛才我說過, 我們可以非常簡單地實現XML版的add函式直譯器, 這聽起來好像不過是說說而已。誰真的會動手做一下呢? 未必有多少人會認真對待這件事。隨便說說, 並不打算真的去做, 這樣的事情你在生活中恐怕也遇到吧。你明白我這樣說的意思吧, 我說的有沒有打動你? 有哇, 那好, 我們繼續。

重新審視Ant

我們現在已經來到了月亮背光的那一面, 先別忙著離開。再探索一下, 看看我們還能發現什麼東西。閉上眼睛, 想一想2000年冬天的那個雨夜, 一個名叫James Duncan Davidson的傑出的程式設計師正在研究Tomcat的servlet容器。那時, 他正小心地儲存好剛修改過的檔案, 然後執行make。結果冒出了一大堆錯誤, 顯然有什麼東西搞錯了。經過仔細檢查, 他想, 難道是因為tab前面加了個空格而導致命令不能執行嗎? 確實如此。老是這樣, 他真的受夠了。烏雲背後的月亮給了他啟示, 他建立了一個新的Java專案, 然後寫了一個簡單但是十分有用的工具, 這個工具巧妙地利用了Java屬性檔案中的資訊來構造工程, 現在James可以寫makefile的替代品, 它能起到相同的作用, 而形式更加優美, 也不用擔心有makefile那樣可恨的空格問題。這個工具能夠自動解釋屬性檔案, 然後採取正確的動作來編譯工程。真是簡單而優美。

(作者注: 我不認識James, James也不認識我, 這個故事是根據網上關於Ant歷史的帖子虛構的)

使用Ant構造Tomcat之後幾個月, 他越來越感到Java的屬性檔案不足以表達複雜的構造指令。檔案需要檢出, 拷貝, 編譯, 發到另外一臺機器, 進行單元測試。要是出錯, 就發郵件給相關人員, 要是成功, 就繼續在儘可能高層的卷(volumn)上執行構造。追蹤到最後,卷要回復到最初的水平上。確實, Java的屬性檔案不夠用了, James需要更有彈性的解決方案。他不想自己寫解析器(因為他更希望有一個具有工業標準的方案)。XML看起來是個不錯的選擇。他花了幾天工夫把Ant移植到XML,於是,一件偉大的工具誕生了。

Ant是怎樣工作的?原理非常簡單。Ant把包含有構造命令的XML檔案(算程式碼還是算資料,你自己想吧),交給一個Java程式來解析每一個元素,實際情況比我說的還要簡單得多。一個簡單的XML指令會導致具有相同名字的Java類裝入,並執行其程式碼。

這段文字的含義是把源目錄複製到目標目錄,Ant會找到一個”copy”任務(實際上就是一個Java類), 通過呼叫Java的方法來設定適當引數(todir和fileset),然後執行這個任務。Ant帶有一組核心類, 可以由使用者任意擴充套件, 只要遵守若干約定就可以。Ant找到這些類,每當遇到XML元素有同樣的名字, 就執行相應的程式碼。過程非常簡單。Ant做到了我們前面所說的東西: 它是一個語言直譯器, 以XML作為語法, 把XML元素轉譯為適當的Java指令。我們可以寫一個”add”任務, 然後, 當發現XML中有add描述的時候, 就執行這個add任務。由於Ant是非常流行的專案, 前面展示的策略就顯得更為明智。畢竟, 這個工具每天差不多有幾千家公司在使用。

到目前為之, 我還沒有說Ant在解析XML時所遇到困難。你也不用麻煩去它的網站上去找答案了, 不會找到有價值的東西。至少對我們這個論題來說是如此。我們還是繼續下一步討論吧。我們答案就在那裡。

為什麼是XML

有時候正確的決策並非完全出於深思熟慮。我不知道James選擇XML是否出於深思熟慮。也許僅僅是個下意識的決定。至少從James在Ant網站上發表的文章看起來, 他所說的理由完全是似是而非。他的主要理由是移植性和擴充套件性, 在Ant案例上, 我看不出這兩條有什麼幫助。使用XML而不是Java程式碼, 到底有什麼好處? 為什麼不寫一組Java類, 提供api來滿足基本任務(拷貝目錄, 編譯等等), 然後在Java裡直接呼叫這些程式碼? 這樣做仍然可以保證移植性, 擴充套件性也是毫無疑問的。而且語法也更為熟悉, 看著順眼。那為什麼要用 XML呢? 有什麼更好的理由嗎?

有的。雖然我不確定James是否確實意識到了。在語義的可構造性方面, XML的彈性是Java望塵莫及的。我不想用高深莫測的名詞來嚇唬你, 其中的道理相當簡單, 解釋起來並不費很多功夫。好, 做好預備動作, 我們馬上就要朝向頓悟的時刻做奮力一躍。

上面的那個copy的例子, 用Java程式碼怎樣實現呢? 我們可以這樣做:

這個程式碼看起來和XML的那個很相似, 只是稍微長一點。差別在那裡? 差別在於XML構造了一個特殊的copy動詞, 如果我們硬要用Java來寫的話, 應該是這個樣子:

看到差別了嗎? 以上程式碼(如果可以在Java中用的化), 是一個特殊的copy算符, 有點像for迴圈或者Java5中的foreach迴圈。如果我們有一個轉換器, 可以把XML轉換到Java, 大概就會得到上面這段事實上不可以執行的程式碼。因為Java的技術規範是定死的, 我們沒有辦法在程式裡改變它。我們可以增加包, 增加類, 增加方法, 但是我們沒辦法增加算符,而對於XML, 我們顯然可以任由自己增加這樣的東西。對於XML的語法樹來說, 只要原意,我們可以任意增加任何元素, 因此等於我們可以任意增加算符。如果你還不太明白的話,看下面這個例子, 加入我們要給Java引入一個unless算符:

在上面的兩個例子中, 我們打算給Java語法擴充套件兩個算符, 成組拷貝檔案算符和條件算符unless, 我們要想做到這一點, 就必須修改Java編譯器能夠接受的抽象語法樹, 顯然我們無法用Java標準的功能來實現它。但是在XML中我們可以輕而易舉地做到。我們的解析器根據 XML元素, 生成抽象語法樹, 由此生成算符, 所以, 我們可以任意引入任何算符。

對於複雜的算符來說, 這樣做的好處顯而易見。比如, 用特定的算符來做檢出原始碼, 編譯檔案, 單元測試, 傳送郵件等任務, 想想看有多麼美妙。對於特定的題目, 比如說構造軟體專案, 這些算符的使用可以大幅減低少程式碼的數量。增加程式碼的清晰程度和可重用性。解釋性的XML可以很容易的達到這個目標。XML是儲存層次化資料的簡單資料檔案, 而在Java中, 由於層次結構是定死的(你很快就會看到, Lisp的情況與此截然不同), 我們就沒法達到上述目標。也許這正是Ant的成功之處呢。

你可以注意一下最近Java和C#的變化(尤其是C#3.0的技術規範), C#把常用的功能抽象出來, 作為算符增加到C#中。C#新增加的query算符就是一個例子。它用的還是傳統的作法:C#的設計者修改抽象語法樹, 然後增加對應的實現。如果程式設計師自己也能修改抽象語法樹該有多好! 那樣我們就可以構造用於特定問題的子語言(比如說就像Ant這種用於構造專案的語言), 你能想到別的例子嗎? 再思考一下這個概念。不過也不必思考太甚, 我們待會還會回到這個題目。那時候就會更加清晰。

離Lisp越來越近

我們先把算符的事情放一放, 考慮一下Ant設計侷限之外的東西。我早先說過, Ant可以通過寫Java類來擴充套件。Ant解析器會根據名字來匹配XML元素和Java類, 一旦找到匹配, 就執行相應任務。為什麼不用Ant自己來擴充套件Ant呢? 畢竟核心任務要包含很多傳統語言的結構(例如”if”), 如果Ant自身就能提供構造任務的能力(而不是依賴java類), 我們就可以得到更高的移植性。我們將會依賴一組核心任務(如果你原意, 也不妨把它稱作標準庫), 而不用管有沒有Java 環境了。這組核心任務可以用任何方式來實現, 而其他任務建築在這組核心任務之上, 那樣的話, Ant就會成為通用的, 可擴充套件的, 基於XML的程式語言。考慮下面這種程式碼的可能性:

如果XML支援”task”的建立, 上面這段程式碼就會輸出”Hello World!”. 實際上, 我們可以用Java寫個”task”任務, 然後用Ant-XML來擴充套件它。Ant可以在簡單原語的基礎上寫出更復雜的原語, 就像其他程式語言常用的作法一樣。這也就是我們一開始提到的基於XML的程式語言。這樣做用處不大(你知道為甚麼嗎?), 但是真的很酷。

再看一回我們剛才說的Task任務。祝賀你呀, 你在看Lisp程式碼!!! 我說什麼? 一點都不像Lisp嗎? 沒關係, 我們再給它收拾一下。

比XML更好

前面一節說過, Ant自我擴充套件沒什麼大用, 原因在於XML很煩瑣。對於資料來說, 這個問題還不太大, 但如果程式碼很煩瑣的話, 光是打字上的麻煩就足以抵消它的好處。你寫過Ant的指令碼嗎? 我寫過, 當指令碼達到一定複雜度的時候, XML非常讓人厭煩。想想看吧, 為了寫結束標籤, 每個詞都得打兩遍, 不發瘋算好的!

為了解決這個問題, 我們應當簡化寫法。須知, XML僅僅是一種表達層次化資料的方式。我們並不是一定要使用尖括號才能得到樹的序列化結果。我們完全可以採用其他的格式。其中的一種(剛好就是Lisp所採用的)格式, 叫做s表示式。s表示式要做的和XML一樣, 但它的好處是寫法更簡單, 簡單的寫法更適合程式碼輸入。後面我會詳細講s表示式。這之前我要清理一下XML的東西。考慮一下關於拷貝檔案的例子:

想想看在記憶體裡面, 這段程式碼的解析樹在記憶體會是什麼樣子? 會有一個”copy”節點, 其下有一個 “fileset”節點, 但是屬性在哪裡呢? 它怎樣表達呢? 如果你以前用過XML, 並且弄不清楚該用元素還是該用屬性, 你不用感到孤單, 別人一樣糊塗著呢。沒人真的搞得清楚。這個選擇與其說是基於技術的理由, 還不如說是閉著眼瞎摸。從概念上來講, 屬性也是一種元素, 任何屬效能做的, 元素一樣做得到。XML引入屬性的理由, 其實就是為了讓XML寫法不那麼冗長。比如我們看個例子:

兩下比較, 內容的資訊量完全一樣, 用屬性可以減少打字數量。如果XML沒有屬性的話,光是打字就夠把人搞瘋掉。

說完了屬性的問題, 我們再來看一看s表示式。之所以繞這麼個彎, 是因為s表示式沒有屬性的概念。因為s表示式非常簡練, 根本沒有必要引入屬性。我們在把XML轉換成s表示式的時候, 心裡應該記住這一點。看個例子, 上面的程式碼譯成s表示式是這樣的:

仔細看看這個例子, 差別在哪裡? 尖括號改成了圓括號, 每個元素原來是有一對括號標記包圍的, 現在取消了後一個(就是帶斜槓的那個)括號標記。表示元素的結束只需要一個”)”就可以了。不錯, 差別就是這些。這兩種表達方式的轉換, 非常自然, 也非常簡單。s表示式打起字來, 也省事得多。第一次看s表示式(Lisp)時, 括號很煩人是吧? 現在我們明白了背後的道理, 一下子就變得容易多了。至少, 比XML要好的多。用s表示式寫程式碼, 不單是實用, 而且也很讓人愉快。s表示式具有XML的一切好處, 這些好處是我們剛剛探討過的。現在我們看看更加Lisp風格的task例子:

用Lisp的行話來講, s表示式稱為表(list)。對於上面的例子, 如果我們寫的時候不加換行, 用逗號來代替空格, 那麼這個表示式看起來就非常像一個元素列表, 其中又巢狀著其他標記。

XML自然也可以用這樣的風格來寫。當然上面這句並不是一般意義上的元素表。它實際上是一個樹。這和XML的作用是一樣的。稱它為列表, 希望你不會感到迷惑, 因為巢狀表和樹實際上是一碼事。Lisp的字面意思就是表處理(list processing), 其實也可以稱為樹處理, 這和處理XML節點沒有什麼不同。

經受這一番折磨以後, 現在我們終於相當接近Lisp了, Lisp的括號的神祕本質(就像許多Lisp狂熱分子認為的)逐漸顯現出來。現在我們繼續研究其他內容。

重新審視C語言的巨集

到了這裡, 對XML的討論你大概都聽累了, 我都講累了。我們先停一停, 把樹, s表示式,Ant這些東西先放一放, 我們來說說C的前處理器。一定有人問了, 我們的話題和C有什麼關係? 我們已經知道了很多關於超程式設計的事情, 也探討過專門寫程式碼的程式碼。理解這問題有一定難度, 因為相關討論文章所使用的程式語言, 都是你們不熟悉的。但是如果只論概念的話, 就相對要簡單一些。我相信, 如果以C語言做例子來討論超程式設計, 理解起來一定會容易得多。好, 我們接著看。

一個問題是, 為什麼要用程式碼來寫程式碼呢? 在實際的程式設計中, 怎樣做到這一點呢? 到底超程式設計是什麼意思? 你大概已經聽說過這些問題的答案, 但是並不懂得其中緣由。為了揭示背後的真理, 我們來看一下一個簡單的資料庫查詢問題。這種題目我們都做過。比方說,直接在程式碼裡到處寫SQL語句來修改表(table)裡的資料, 寫多了就非常煩人。即便用C#3.0的LINQ, 仍然不減其痛苦。寫一個完整的SQL查詢(儘管語法很優美)來修改某人的地址, 或者查詢某人的名字, 絕對是件令程式設計師倍感乏味的事情, 那麼我們該怎樣來解決這個問題? 答案就是: 使用資料訪問層。

概念挺簡單, 其要點是把資料訪問的內容(至少是那些比較瑣碎的部分)抽象出來, 用類來對映資料庫的表, 然後用訪問物件屬性訪問器(accessor)的辦法來間接實現查詢。這樣就極大地簡化了開發工作量。我們用訪問物件的方法(或者屬性賦值, 這要視你選用的語言而定)來代替寫SQL查詢語句。凡是用過這種方法的人, 都知道這很節省時間。當然, 如果你要親自寫這樣一個抽象層, 那可是要花非常多的時間的–你要寫一組類來對映表, 把屬性訪問轉換為SQL查詢, 這個活相當耗費精力。用手工來做顯然是很不明智的。但是一旦你有了方案和模板, 實際上就沒有多少東西需要思考的。你只需要按照同樣的模板一次又一次重複編寫相似程式碼就可以了。事實上很多人已經發現了更好的方法, 有一些工具可以幫助你連線資料庫, 抓取資料庫結構定義(schema), 按照預定義的或者使用者定製的模板來自動編寫程式碼。

如果你用過這種工具, 你肯定會對它的神奇效果深為折服。往往只需要滑鼠點選數次, 就可以連線到資料庫, 產生資料訪問原始碼, 然後把檔案加入到你的工程裡面, 十幾分鐘的工作, 按照往常手工方式來作的話, 也許需要數百個小時人工(man-hours)才能完成。可是,如果你的資料庫結構定義後來改變了怎麼辦? 那樣的話, 你只需把這個過程重複一遍就可以了。甚至有一些工具能自動完成這項變動工作。你只要把它作為工程構造的一部分, 每次編譯工程的時候, 資料庫部分也會自動地重新構造。這真的太棒了。你要做的事情基本上減到了0。如果資料庫結構定義發生了改變, 並在編譯時自動更新了資料訪問層的程式碼,那麼程式中任何使用過時的舊程式碼的地方, 都會引發編譯錯誤。

資料訪問層是個很好的例子, 這樣的例子還有好多。從GUI樣板程式碼, WEB程式碼, COM和CORBA存根, 以及MFC和ATL等等。在這些地方, 都是有好多相似程式碼多次重複。既然這些程式碼有可能自動編寫, 而程式設計師時間又遠遠比CPU時間昂貴, 當然就產生了好多工具來自動生成樣板程式碼。這些工具的本質是什麼呢? 它們實際上就是製造程式的程式。它們有一個神祕的名字, 叫做超程式設計。所謂超程式設計的本義, 就是如此。

超程式設計本來可以用到無數多的地方, 但實際上使用的次數卻沒有那麼多。歸根結底, 我們心裡還是在盤算, 假設重複程式碼用拷貝貼上的話, 大概要重複6,7次, 對於這樣的工作量,值得專門建立一套生成工具嗎? 當然不值得。資料訪問層和COM存根往往需要重用數百次,甚至上千次, 所以用工具生成是最好的辦法。而那些僅僅是重複幾次十幾次的程式碼, 是沒有必要專門做工具的。不必要的時候也去開發程式碼生成工具, 那就顯然過度估計了程式碼生成的好處。當然, 如果建立這類工具足夠簡單的話, 還是應當儘量多用, 因為這樣做必然會節省時間。現在來看一下有沒有合理的辦法來達到這個目的。

現在, C前處理器要派上用場了。我們都用過C/C++的前處理器, 我們用它執行簡單的編譯指令, 來產生簡單的程式碼變換(比方說, 設定除錯程式碼開關), 看一個例子:

這一行的作用是什麼? 這是一個簡單的預編譯指令, 它把程式中的triple(X)替換稱為X+X+X。例如, 把所有的triple(5)都換成5+5+5, 然後再交給編譯器編譯。這就是一個簡單的程式碼生成的例子。要是C的前處理器再強大一點, 要是能夠允許連線資料庫, 要是能多一些其他簡單的機制, 我們就可以在我們程式的內部開發自己的資料訪問層。下面這個例子, 是一個假想的對C巨集的擴充套件:

我們連線資料庫結構定義, 遍歷資料表, 然後對每個表建立一個類, 只消幾行程式碼就完成了這個工作。這樣每次編譯工程的時候, 這些類都會根據資料庫的定義同步更新。顯而易見, 我們不費吹灰之力就在程式內部建立了一個完整的資料訪問層, 根本用不著任何外部工具。當然這種作法有一個缺點, 那就是我們得學習一套新的”編譯時語言”, 另一個缺點就是根本不存在這麼一個高階版的C前處理器。需要做複雜程式碼生成的時候, 這個語言(譯者注: 這裡指預處理指令, 即作者所說的”編譯時語言”)本身也一定會變得相當複雜。它必須支援足夠多的庫和語言結構。比如說我們想要生成的程式碼要依賴某些ftp伺服器上的檔案, 前處理器就得支援ftp訪問, 僅僅因為這個任務而不得不創造和學習一門新的語言,真是有點讓人噁心(事實上已經存在著有此能力的語言, 這樣做就更顯荒謬)。我們不妨再靈活一點, 為什麼不直接用 C/C++自己作為自己的預處理語言呢?  這樣子的話, 我們可以發揮語言的強大能力, 要學的新東西也只不過是幾個簡單的指示字 , 這些指示字用來區別編譯時程式碼和執行時程式碼。

你明白了嗎? 在<%和%>標記之間的程式碼是在編譯時執行的, 標記之外的其他程式碼都是普通程式碼。編譯程式時, 系統會提示你輸入一個數, 這個數在後面的迴圈中會用到。而for迴圈的程式碼會被編譯。假定你在編譯時輸入5, for迴圈的程式碼將會是:

又簡單又有效率, 也不需要另外的預處理語言。我們可以在編譯時就充分發揮宿主語言(此處是C/C++)的強大能力, 我們可以很容易地在編譯時連線資料庫, 建立資料訪問層, 就像JSP或者ASP建立網頁那樣。我們也用不著專門的視窗工具來另外建立工程。我們可以在程式碼中立即加入必要的工具。我們也用不著顧慮建立這種工具是不是值得, 因為這太容易了, 太簡單了。這樣子不知可以節省多少時間啊。

你好, Lisp

到此刻為止, 我們所知的關於Lisp的指示可以總結為一句話: Lisp是一個可執行的語法更優美的XML, 但我們還沒有說Lisp是怎樣做到這一點的, 現在開始補上這個話題。

Lisp有豐富的內建資料型別, 其中的整數和字串和其他語言沒什麼分別。像71或者”hello”這樣的值, 含義也和C++或者Java這樣的語言大體相同。真正有意思的三種型別是符號(symbol), 表和函式。這一章的剩餘部分, 我都會用來介紹這幾種型別, 還要介紹Lisp環境是怎樣編譯和執行原始碼的。這個過程用Lisp的術語來說通常叫做求值。通讀這一節內容, 對於透徹理解超程式設計的真正潛力, 以及程式碼和資料的同一性, 和麵向領域語言的觀念, 都極其重要。萬勿等閒視之。我會盡量講得生動有趣一些, 也希望你能獲得一些啟發。那好, 我們先講符號。

大體上, 符號相當於C++或Java語言中的標誌符, 它的名字可以用來訪問變數值(例如currentTime, arrayCount, n, 等等), 差別在於, Lisp中的符號更加基本。在C++或Java裡面, 變數名只能用字母和下劃線的組合, 而Lisp的符號則非常有包容性, 比如, 加號(+)就是一個合法的符號, 其他的像-, =, hello-world, *等等都可以是符號名。符號名的命名規則可以在網上查到。你可以給這些符號任意賦值, 我們這裡先用偽碼來說明這一點。假定函式set是給變數賦值(就像等號=在C++和Java裡的作用), 下面是我們的例子:

好像有什麼不對的地方? 假定我們對*賦給整數或者字串值, 那做乘法時怎麼辦? 不管怎麼說, *總是乘法呀? 答案簡單極了。Lisp中函式的角色十分特殊, 函式也是一種資料型別, 就像整數和字串一樣, 因此可以把它賦值給符號。乘法函式Lisp的內建函式, 預設賦給*, 你可以把其他函式賦值給*, 那樣*就不代表乘法了。你也可以把這函式的值存到另外的變數裡。我們再用偽碼來說明一下:

再古怪一點, 把減號的值賦給加號:

這只是舉例子, 我還沒有詳細講函式。Lisp中的函式是一種資料型別, 和整數, 字串,符號等等一樣。一個函式並不必然有一個名字, 這和C++或者Java語言的情形很不相同。在這裡函式自己代表自己。事實上它是一個指向程式碼塊的指標, 附帶有一些其他資訊(例如一組引數變數)。只有在把函式賦予其他符號時, 它才具有了名字, 就像把一個數值或字串賦予變數一樣的道理。你可以用一個內建的專門用於建立函式的函式來建立函式,然後把它賦值給符號fn, 用偽碼來表示就是:

這段程式碼返回一個具有一個引數的函式, 函式的功能是計算引數乘2的結果。這個函式還沒有名字, 你可以把此函式賦值給別的符號:

我們現在可以這樣呼叫這個函式:

我們先跳過符號和函式, 講一講表。什麼是表? 你也許已經聽過好多相關的說法。表, 一言以蔽之, 就是把類似XML那樣的資料塊, 用s表示式來表示。表用一對括號括住, 表中元素以空格分隔, 表可以巢狀。例如(這回我們用真正的Lisp語法, 注意用分號表示註釋):

當Lisp系統遇到這樣的表時, 它所做的, 和Ant處理XML資料所做的, 非常相似, 那就是試圖執行它們。其實, Lisp原始碼就是特定的一種表, 好比Ant原始碼是一種特定的XML一樣。Lisp執行表的順序是這樣的, 表的第一個元素當作函式, 其他元素當作函式的引數。如果其中某個引數也是表, 那就按照同樣的原則對這個表求值, 結果再傳遞給最初的函式作為引數。這就是基本原則。我們看一下真正的程式碼:

上述的例子中, 所有的表都是當作程式碼來處理的。怎樣把表當作資料來處理呢? 同樣的,設想一下, Ant是把XML資料當作自己的引數。在Lisp中, 我們給表加一個字首’來表示資料。

我們可以用一個內建的函式head來返回表的第一個元素, tail函式來返回剩餘元素組成的表。

你可以把Lisp的內建函式想像成Ant的任務。差別在於, 我們不用在另外的語言中擴充套件Lisp(雖然完全可以做得到), 我們可以用Lisp自己來擴充套件自己, 就像上面舉的times-two函式的例子。Lisp的內建函式集十分精簡, 只包含了十分必要的部分。剩下的函式都是作為標準庫來實現的。

Lisp巨集

我們已經看到, 超程式設計在一個類似jsp的模板引擎方面的應用。我們通過簡單的字串處理來生成程式碼。但是我們可以做的更好。我們先提一個問題, 怎樣寫一個工具, 通過查詢目錄結構中的原始檔來自動生成Ant指令碼。

用字串處理的方式生成Ant指令碼是一種簡單的方式。當然, 還有一種更加抽象, 表達能力更強, 擴充套件性更好的方式, 就是利用XML庫在記憶體中直接生成XML節點, 這樣的話記憶體中的節點就可以自動序列化成為字串。不僅如此, 我們的工具還可以分析這些節點, 對已有的XML檔案做變換。通過直接處理XML節點。我們可以超越字串處理, 使用更高層次的概念, 因此我們的工作就會做的更快更好。

我們當然可以直接用Ant自身來處理XML變換和製作程式碼生成工具。或者我們也可以用Lisp來做這項工作。正像我們以前所知的, 表是Lisp內建的資料結構, Lisp含有大量的工具來快速有效的操作表(head和tail是最簡單的兩個)。而且, Lisp沒有語義約束, 你可以構造任何資料結構, 只要你原意。

Lisp通過巨集(macro)來做超程式設計。我們寫一組巨集來把任務列表(to-do list)轉換為專用領域語言。

回想一下上面to-do list的例子, 其XML的資料格式是這樣的:

相應的s表示式是這樣的:

假設我們要寫一個任務表的管理程式, 把任務表資料存到一組檔案裡, 當程式啟動時, 從檔案讀取這些資料並顯示給使用者。在別的語言裡(比如說Java), 這個任務該怎麼做? 我們會解析XML檔案, 從中得出任務表資料, 然後寫程式碼遍歷XML樹, 再轉換為Java的資料結構(老實講, 在Java裡解析XML真不是件輕鬆的事情), 最後再把資料展示給使用者。現在如果用Lisp, 該怎麼做?

假定要用同樣思路的化, 我們大概會用Lisp庫來解析XML。XML對我們來說就是一個Lisp的表(s表示式), 我們可以遍歷這個表, 然後把相關資料提交給使用者。可是, 既然我們用Lisp, 就根本沒有必要再用XML格式儲存資料, 直接用s表示式就好了, 這樣就沒有必要做轉換了。我們也用不著專門的解析庫, Lisp可以直接在記憶體裡處理s表示式。注意, Lisp編譯器和.net編譯器一樣, 對Lisp程式來說, 在執行時總是隨時可用的。

但是還有更好的辦法。我們甚至不用寫表示式來儲存資料, 我們可以寫巨集, 把資料當作程式碼來處理。那該怎麼做呢? 真的簡單。回想一下, Lisp的函式呼叫格式:

其中每個引數都是s表示式, 求值以後, 傳遞給函式。如果我們用(+ 4 5)來代替arg1,那麼, 程式會先求出結果, 就是9, 然後把9傳遞給函式。巨集的工作方式和函式類似。主要的差別是, 巨集的引數在代入時不求值。

這裡, (+ 4 5)作為一個表傳遞給巨集, 然後巨集就可以任意處理這個表, 當然也可以對它求值。巨集的返回值是一個表, 然後有程式作為程式碼來執行。巨集所佔的位置, 就被替換為這個結果程式碼。我們可以定義一個巨集把資料替換為任意程式碼, 比方說, 替換為顯示資料給使用者的程式碼。

這和超程式設計, 以及我們要做的任務表程式有什麼關係呢? 實際上, 編譯器會替我們工作,呼叫相應的巨集。我們所要做的, 僅僅是建立一個把資料轉換為適當程式碼的巨集。

例如, 上面曾經將過的C的求三次方的巨集, 用Lisp來寫是這樣子:

(譯註: 在Common Lisp中, 此處的單引號應當是反單引號, 意思是對錶不求值, 但可以對錶中某元素求值, 記號~表示對元素x求值, 這個求值記號在Common Lisp中應當是逗號。反單引號和單引號的區別是, 單引號標識的表, 其中的元素都不求值。這裡作者所用的記號是自己發明的一種Lisp方言Blaise, 和common lisp略有不同, 事實上, 發明方言是lisp高手獨有的樂趣, 很多狂熱分子都熱衷這樣做。比如Paul Graham就發明了ARC, 許多記號比傳統的Lisp簡潔得多, 顯得比較現代)

單引號的用處是禁止對錶求值。每次程式中出現triple的時候,

都會被替換成:

我們可以為任務表程式寫一個巨集, 把任務資料轉換為可執行碼, 然後執行。假定我們的輸出是在控制檯:

我們創造了一個非常小的有限的語言來管理嵌在Lisp中的任務表。這個語言只用來解決特定領域的問題, 通常稱之為DSLs(特定領域語言, 或專用領域語言)。

特定領域語言

本文談到了兩個特定領域語言, 一個是Ant, 處理軟體構造。一個是沒起名字的, 用於處理任務表。兩者的差別在於, Ant是用XML, XML解析器, 以及Java語言合在一起構造出來的。而我們的迷你語言則完全內嵌在Lisp中, 只消幾分鐘就做出來了。

我們已經說過了DSL的好處, 這也就是Ant用XML而不直接用Java的原因。如果使用Lisp,我們可以任意建立DSL, 只要我們需要。我們可以建立用於網站程式的DSL, 可以寫多使用者遊戲, 做固定收益貿易(fixed income trade), 解決蛋白質摺疊問題, 處理事務問題, 等等。我們可以把這些疊放在一起, 造出一個語言, 專門解決基於網路的貿易程式, 既有網路語言的優勢, 又有貿易語言的好處。每天我們都會收穫這種方法帶給我們的益處, 遠遠超過Ant所能給予我們的。

用DSL解決問題, 做出的程式精簡, 易於維護, 富有彈性。在Java裡面, 我們可以用類來處理問題。這兩種方法的差別在於, Lisp使我們達到了一個更高層次的抽象, 我們不再受語言解析器本身的限制, 比較一下用Java庫直接寫的構造指令碼和用Ant寫的構造指令碼其間的差別。同樣的, 比較一下你以前所做的工作, 你就會明白Lisp帶來的好處。

接下來

學習Lisp就像戰爭中爭奪山頭。儘管在電腦科學領域, Lisp已經算是一門古老的語言, 直到現在仍然很少有人真的明白該怎樣給初學者講授Lisp。儘管Lisp老手們盡了很大努力,今天新手學習Lisp仍然是困難重重。好在現在事情正在發生變化, Lisp的資源正在迅速增加, 隨著時間推移, Lisp將會越來越受關注。

Lisp使人超越平庸, 走到前沿。學會Lisp意味著你能找到更好的工作, 因為聰明的僱主會被你與眾不同的洞察力所打動。學會Lisp也可能意味著明天你可能會被解僱, 因為你總是強調, 如果公司所有軟體都用Lisp寫, 公司將會如何卓越, 而這些話你的同事會聽煩的。Lisp值得努力學習嗎? 那些已經學會Lisp的人都說值得, 當然, 這取決於你的判斷。

你的看法呢?

這篇文章寫寫停停, 用了幾個月才最終完成。如果你覺得有趣, 或者有什麼問題, 意見或建議, 請給我發郵件coffeemug@gmail.com, 我會很高興收到你的反饋。

相關文章