如何使用Python編寫一個Lisp直譯器

發表於2013-09-09

本文有兩個目的: 一是講述實現計算機語言直譯器的通用方法,另外一點,著重展示如何使用Python來實現Lisp方言Scheme的一個子集。我將我的直譯器稱之為Lispy (lis.py)。幾年前,我介紹過如何使用Java編寫一個Scheme直譯器,同時我還使用Common Lisp語言編寫過一個版本。這一次,我的目的是儘可能簡單明瞭地演示一下Alan Kay所說的軟體的麥克斯韋方程組” (Maxwell’s Equations of Software)[1]

 

Lispy支援的Scheme子集的語法和語義

大多數計算機語言都有許多語法規約 (例如關鍵字、中綴操作符、括號、操作符優先順序、點標記、分號等等),但是,作為Lisp語言家族中的一員,Scheme所有的語法都是基於包含在括號中的、採用字首表示的列表的。這種表示看起來似乎有些陌生,但是它具有簡單一致的優點。 (一些人戲稱”Lisp”是”Lots of Irritating Silly Parentheses“——“大量惱人、愚蠢的括號“——的縮寫;我認為它是”Lisp Is Syntactically Pure“——“Lisp語法純粹”的縮寫。) 考慮下面這個例子:

注意上面的驚歎號在Scheme中並不是一個特殊字元;它只是”set!“這個名字的一部分。(在Scheme中)只有括號是特殊字元。類似於(set! x y)這樣以特殊關鍵字開頭的列表在Scheme中被稱為一個特殊形式 (special form);Scheme的優美之處就在於我們只需要六種特殊形式,以及另外的三種語法構造——變數、常量和過程呼叫。

形式 (Form) 語法 語義和示例
變數引用 var 一個符號,被解釋為一個變數名;其值就是這個變數的值。
示例: x
常量字面值 number 數字的求值結果為其本身
示例: 12 或者-3.45e+6
引用 (quote exp) 返回exp的字面值;不對它進行求值。
示例:(quote (a b c)) ⇒ (a b c)
條件測試 (if test conseq alt) test進行求值;如果結果為真,那麼對conseq進行求值並返回結果;否則對alt求值並返回結果。
示例:(if (< 10 20) (+ 1 1) (+ 3 3)) ⇒ 2
賦值 (set! varexp) exp進行求值並將結果賦給varvar必須已經進行過定義 (使用define進行定義或者作為一個封閉過程的引數)。
示例:(set! x2 (* x x))
定義 (define varexp) 在最內層環境 (environment) 中定義一個新的變數並將對exp表示式求值所得的結果賦給該變數。
示例:(define r 3) 或者 (define square (lambda (x) (* x x)))
過程 (lambda(var…)exp) 建立一個過程,其引數名字為var…,過程體為相應的表示式。
示例:(lambda (r) (* 3.141592653 (* r r)))
(表示式) 序列 (beginexp…) 按從左到右的順序對錶達式進行求值,並返回最終的結果。
示例:(begin (set! x 1) (set! x (+ x 1)) (* x 2)) ⇒ 4
過程呼叫 (proc exp…) 如果proc是除了if, set!, define, lambda, begin,或者quote之外的其它符號的話,那麼它會被視作一個過程。它的求值規則如下:所有的表示式exp都將被求值,然後這些求值結果作為過程的實際引數來呼叫該相應的過程。
示例:(square 12) ⇒ 144

在該表中,var必須是一個符號——一個類似於x或者square這樣的識別符號——number必須是一個整型或者浮點型數字,其餘用斜體標識的單詞可以是任何表示式。exp…表示exp的0次或者多次重複。

更多關於Scheme的內容,可以參考一些優秀的書籍 (如Friedman和FelleseinDybvig,QueinnecHarvey和Wright或者Sussman和Abelson)、視訊 (Abelson和Sussman)、教程 (DoraiPLT或者Neller)、或者參考手冊

語言直譯器的職責
一個語言直譯器包括兩部分:

1、解析 (Parsing):解析部分接受一個使用字元序列表示的輸入程式,根據語言的語法規則對輸入程式進行驗證,然後將程式翻譯成一種中間表示。在一個簡單的直譯器中,中間表示是一種樹結構,緊密地反映了源程式中語句或表示式的巢狀結構。在一種稱為編譯器的語言翻譯器中,內部表示是一系列可以直接由計算機 (作者的原意是想說執行時系統——譯者注) 執行的指令。正如Steve Yegge所說,“如果你不明白編譯器的工作方式,那麼你不會明白計算機的工作方式。”Yegge介紹了編譯器可以解決的8種問題 (或者直譯器,又或者採用Yegge的典型的反諷式的解決方案)。 Lispy的解析器由parse函式實現。

2、執行:程式的內部表示 (由直譯器) 根據語言的語義規則進行進一步處理,進而執行源程式的實際運算。(Lispy的)執行部分由eval函式實現 (注意,該函式覆蓋了Python內建的同名函式)。

下面的圖片描述瞭直譯器的解釋流程,(圖片後的) 互動會話展示了parseeval函式對一個小程式的操作方式:
20130909160449

這裡,我們採用了一種儘可能簡單的內部表示,其中Scheme的列表、數字和符號分別使用Python的列表、數字和字串來表示。

執行: eval
下面是eval函式的定義。對於上面表中列出的九種情況,每一種都有一至三行程式碼,eval函式的定義只需要這九種情況:

eval函式的定義就是這麼多…當然,除了environments。Environments (環境) 只是從符號到符號所代表的值的對映而已。一個新的符號/值繫結由一個define語句或者一個過程定義 (lambda表示式) 新增。

讓我們通過一個例子來觀察定義然後呼叫一個Scheme過程的時候所發生的事情 (lis.py>提示符表示我們正在與Lisp直譯器進行互動,而不是Python):

當我們對(lambda (r) (* 3.141592653 (* r r)))進行求值時,我們在eval函式中執行elif x[0] == 'lambda'分支,將(_, vars, exp)三個變數分別賦值為列表x的對應元素 (如果x的長度不是3,就丟擲一個錯誤)。然後,我們建立一個新的過程,當該過程被呼叫的時候,將會對錶達式['*', 3.141592653 ['*', 'r', 'r']]進行求值,該求值過程的環境 (environment) 是通過將過程的形式引數 (該例中只有一個引數,r) 繫結為過程呼叫時所提供的實際引數,外加當前環境中所有不在引數列表 (例如,變數*) 的變數組成的。新建立的過程被賦值給global_env中的area變數。

那麼,當我們對(area 3)求值的時候發生了什麼呢?因為area並不是任何表示特殊形式的符號之一,它必定是一個過程呼叫 (eval函式的最後一個else:分支),因此整個表示式列表都將會被求值,每次求值其中的一個。對area進行求值將會獲得我們剛剛建立的過程;對3進行求值所得的結果就是3。然後我們 (根據eval函式的最後一行) 使用引數列表[3]來呼叫這個新建立的過程。也就是說,對exp(也就是['*', 3.141592653 ['*', 'r', 'r']])進行求值,並且求值所在的環境中r的值是3,並且外部環境是全域性環境,因此*是乘法過程。

現在,我們可以解釋一下Env類的細節了:

注意Envdict的一個子類,也就是說,通常的字典操作也適用於Env類。除此之外,該類還有兩個方法,建構函式__init__find函式,後者用來為一個變數查詢正確的環境。理解這個類的關鍵 (以及我們需要一個類,而不是僅僅使用dict的根本原因) 在於外部環境 (outer environment) 這個概念。考慮下面這個程式:

每個矩形框都代表了一個環境,並且矩形框的顏色與環境中最新定義的變數的顏色相對應。在程式的最後兩行我們定義了a1並且呼叫了(a1 -20.00);這表示建立一個開戶金額為100美元的銀行賬戶,然後是取款20美元。在對(a1 -20.00)求值的過程中,我們將會對黃色高亮表示式進行求值,該表示式中具有三個變數。amt可以在最內層 (綠色) 環境中直接找到。但是balance在該環境中沒有定義:我們需要檢視綠色環境的外層環境,也就是藍色環境。最後,+代表的變數在這兩個環境中都沒有定義;我們需要進一步檢視外層環境,也就是全域性 (紅色) 環境。先查詢內層環境,然後依次查詢外部的環境,我們把這一過程稱之為詞法定界 (lexical scoping)。Procedure.find負責根據詞法定界規則查詢正確的環境。

剩下的就是要定義全域性環境。該環境需要包含+過程以及所有其它Scheme的內建過程。我們並不打算實現所有的內建過程,但是,通過匯入Python的math模組,我們可以獲得一部分這些過程,然後我們可以顯式地新增20種常用的過程:

PS1: 對麥克斯韋方程組的一種評價是“一般地,宇宙間任何的電磁現象,皆可由此方程組解釋”。Alan Kay所要表達的,大致就是Lisp語言使用自身定義自身 (Lisp was “defined in terms of Lisp”) 這種自底向上的設計對軟體設計而言具有普遍的參考價值。——譯者注

解析 (Parsing): readparse

接下來是parse函式。解析通常分成兩個部分:詞法分析語法分析。前者將輸入字串分解成一系列的詞法單元 (token);後者將詞法單元組織成一種中間表示。Lispy支援的詞法單元包括括號、符號 (如set!或者x) 以及數字 (如2)。它的工作形式如下:

有許多工具可以進行詞法分析 (例如Mike Lesk和Eric Schmidt的lex)。但是我們將會使用一個非常簡單的工具:Python的str.split。我們只是在 (源程式中) 括號的兩邊新增空格,然後呼叫str.split來獲得一個詞法單元的列表。

接下來是語法分析。我們已經看到,Lisp的語法很簡單。但是,一些Lisp直譯器允許接受表示列表的任何字串作為一個程式,從而使得語法分析的工作更加簡單。換句話說,字串(set! 1 2)可以被接受為是一個語法上有效的程式,只有當執行的時候直譯器才會抱怨set!的第一個引數應該是一個符號,而不是數字。在Java或者Python中,與之等價的語句1 = 2將會在編譯時被認定是錯誤。另一方面,Java和Python並不需要在編譯時檢測出表示式x/0是一個錯誤,因此,如你所見,一個錯誤應該何時被識別並沒有嚴格的規定。Lispy使用read函式來實現parse函式,前者用以讀取任何的表示式 (數字、符號或者巢狀列表)。

tokenize函式獲取一系列詞法單元,read通過在這些詞法單元上呼叫read_from函式來進行工作。給定一個詞法單元的列表,我們首先檢視第一個詞法單元;如果它是一個’)’,那麼這是一個語法錯誤。如果它是一個’(‘,那麼我們開始構建一個表示式列表,直到我們讀取一個匹配的’)’。所有其它的 (詞法單元) 必須是符號或者數字,它們自身構成了一個完整的列表。剩下的需要注意的就是要了解’2‘代表一個整數,2.0代表一個浮點數,而x代表一個符號。我們將區分這些情況的工作交給Python去完成:對於每一個不是括號也不是引用 (quote) 的詞法單元,我們首先嚐試將它解釋為一個int,然後嘗試float,最後嘗試將它解釋為一個符號。根據這些規則,我們得到了如下程式:

最後,我們將要新增一個函式to_string,用來將一個表示式重新轉換成Lisp可讀的字串;以及一個函式repl,該函式表示read-eval-print-loop (讀取-求值-列印迴圈),用以構成一個互動式的Lisp直譯器:

下面是函式工作的一個例子:

Lispy有多小、多快、多完備、多優秀?

我們使用如下幾個標準來評價Lispy:

*小巧:Lispy非常小巧:不包括註釋和空白行,其原始碼只有90行,並且體積小於4K。(比第一個版本的體積要小,第一個版本有96行——根據Eric Cooper的建議,我刪除了Procedure的類定義,轉而使用Python的lambda。) 我用Java編寫的Scheme直譯器Jscheme最小的版本,其原始碼也有1664行、57K。Jscheme最初被稱為SILK (Scheme in Fifty Kilobytes——50KB的Scheme直譯器),但是隻有計算位元組碼而不是原始碼的時候,我才能保證 (其體積) 小於該最小值。Lispy做的要好得多;我認為它滿足了Alan Kay在1972年的斷言:他聲稱我們可以使用“一頁程式碼”來定義“世界上最強大的語言”

*高效:Lispy計算(fact 100)只需要0.004秒。對我來說,這已經足夠快了 (雖然相比起其它的計算方式來說要慢很多)。

*完備:相比起Scheme標準來說,Lispy不是非常完備。主要的缺陷有:
(1) 語法:缺少註釋、引用 (quote) / 反引用 (quasiquote) 標記 (即'`——譯者注)、#字面值 (例如#\a——譯者注)、衍生表示式型別 (例如從if衍生而來的cond,或者從lambda衍生而來的let),以及點列表 (dotted list)。
(2) 語義:缺少call/cc以及尾遞迴。
(3) 資料型別:缺少字串、字元、布林值、埠 (ports)、向量、精確/非精確數字。事實上,相比起Scheme的pairs和列表,Python的列表更加類似於Scheme的向量。
(4) 過程:缺少100多個基本過程:與缺失資料型別相關的所有過程,以及一些其它的過程 (如set-car!set-cdr!,因為使用Python的列表,我們無法完整實現set-cdr!)。
(5) 錯誤恢復:Lispy沒有嘗試檢測錯誤、合理地報告錯誤以及從錯誤中恢復。Lispy希望程式設計師是完美的。

*優秀:這一點需要讀者自己確定。我覺得,相對於我解釋Lisp直譯器這一目標而言,它已經足夠優秀。

真實的故事

瞭解直譯器的工作方式會很有幫助,有一個故事可以支援這一觀點。1984年的時候,我在撰寫我的博士論文。當時還沒有LaTeX和Microsoft Word——我們使用的是troff。遺憾的是,troff中沒有針對符號標籤的前向引用機制:我想要能夠撰寫“正如我們將要在@theoremx頁面看到的”,隨後在合適的位置撰寫”@(set theoremx \n%)” (troff暫存器\n%儲存了頁號)。我的同伴,研究生Tony DeRose也有著同樣的需求,我們一起實現了一個簡單的Lisp程式,使用這個程式作為一個前處理器來解決我們的問題。然而,事實證明,當時我們用的Lisp善於讀取Lisp表示式,但在採用一次一個字元的方式讀取非Lisp表示式時效率過低,以至於我們的這個程式很難使用。

在這個問題上,Tony和我持有不同的觀點。他認為 (實現) 表示式的直譯器是困難的部分;他需要Lisp為他解決這一問題,但是他知道如何編寫一個短小的C過程來處理非Lisp字元,並知道如何將其連結進Lisp程式。我不知道如何進行這種連結,但是我認為為這種簡單的語言編寫一個直譯器 (其所具有的只是設定變數、獲取變數值和字串連線) 很容易,於是我使用C語言編寫了一個直譯器。因此,戲劇性的是,Tony編寫了一個Lisp程式,因為他是一個C程式設計師;我編寫了一個C程式,因為我是一個Lisp程式設計師。

最終,我們都完成了我們的論文。

整個直譯器

重新總結一下,下面就是Lispy的所有程式碼 (也可以從lis.py下載):

相關文章