小 200 行 JS 程式碼打造 lambda 演算直譯器

發表於2016-06-28

最近,我發了一條推特,我喜歡上 lambda 演算了,它簡單、強大。

我當然聽說過 lambda 演算,但直到我讀了這本書 《型別和程式語言》(Types and Programming Languages) 我才體會到其中美妙。

已經有許多編譯器/解析器/直譯器(compiler / parser / interpreter)的教程,但大多數不會引導你完整實現一種語言,因為實現完全的語言語義,通常需要很多工作。不過在本文中, lambda 演算(譯者注:又寫作“λ 演算”,為統一行文,下文一律作 “lambda 演算”)是如此簡單,我們可以搞定一切!

首先,什麼是 lambda 演算呢?維基百科是這樣描述的:

lambda 演算(又寫作 “λ 演算”)是表達基於功能抽象和使用變數繫結和替代的應用計算數學邏輯形式系統。這是一個通用的計算模型,可以用來模擬單帶圖靈機,在 20 世紀 30 年代,由數學家奧隆索·喬奇第一次引入,作為數學基礎的調查的一部分。

這是一個非常簡單的 lambda 演算程式的模樣:

lambda 演算中只有兩個結構,函式抽象(也就是函式宣告)和應用(即函式呼叫),然而可以拿它做任何計算。

1. 語法

編寫解析器之前,我們需要知道的第一件事是我們將要解析的語言的語法是什麼,這是 BNF(譯者注:Backus–Naur Form,巴科斯正規化, 上下文無關的語法的標記技術) 表示式:

語法告訴我們如何在分析過程中尋找 token 。但是等一下,token 是什麼鬼?

2. Tokens

正如你可能已經知道的,解析器不會操作原始碼。在開始解析之前,先通過 詞法分析器(lexer)執行原始碼,這會將原始碼打散成 token(語法中全大寫的部分)。我們可以從上面的語法中提取的如下的 token :

我們來建一個可以包含 type (以上的任意一種)的 Token 類,以及一個可選的 value (比如LCID 的字串)。

3. 詞法分析器( Lexer )

現在我們可以拿上面定義的 token 來寫 詞法分析器(Lexer) 了, 為解析器解析程式提供一個很棒的 API

詞法分析器的 token 生成的部分不是很好玩:這是一個大的 switch 語句,用來檢查原始碼中的下一個字元:

下面這些方法是處理 token 的輔助方法:

  • next(Token): 返回下一個 token 是否匹配 Token
  • skip(Token): 和 next 一樣, 但如果匹配的話會跳過
  • match(Token): 斷言 next 方法返回 true 並 skip
  • token(Token): 斷言 next 方法返回 true 並返回 token

OK,現在來看 “解析器”!

4. 解析器

解析器基本上是語法的一個副本。我們基於每個 production 規則的名稱(::= 的左側)為其建立一個方法,再來看右側內容 —— 如果是全大寫的單詞,說明它是一個 終止符 (即一個 token ),詞法分析器會用到它。如果是一個大寫字母開頭的單詞,這是另外一段,所以同樣為其呼叫 production 規則的方法。遇到 “/” (讀作 “或”)的時候,要決定使用那一側,這取決於基於哪一側匹配我們的 token。

這個語法有點棘手的地方是:手寫的解析器通常是遞迴下降(recursive descent)的(我們的就是),它們無法處理左側遞迴。你可能已經注意到了, Application 的右側開頭包含 Application 本身。所以如果我們只是遵循前面段落說到的流程,呼叫我們找到的所有 production,會導致無限遞迴。

幸運的是左遞迴可以用一個簡單的技巧移除掉:

4.1. 抽象語法樹 (AST)

進行分析時,需要以儲存分析出的資訊,為此要建立 抽象語法樹 ( AST ) 。lambda 演算的 AST 非常簡單,因為我們只有 3 種節點: Abstraction (抽象), Application (應用)以及 Identifier (識別符號)(譯者注: 為方便理解,這三個單詞不譯)。

Abstraction 持有其引數(param) 和主體(body); Application 則持有語句的左右側; Identifier 是一個葉節點,只有持有該識別符號本身的字串表示形式。

這是一個簡單的程式及其 AST:

5. 求值(Evaluation)

現在,我們可以用 AST 來給程式求值了。不過想知道我們的直譯器長什麼樣子,還得先看看 lambda 的求值規則。

5.1. 求值規則

首先,我們需要定義,什麼是形式(terms)(從語法可以推斷),什麼是值(values)。

我們的 term 是:

是的,這些幾乎和我們的 AST 節點一模一樣!但這其中哪些是 value?

value 是最終的形式,也就是說,它們不能再被求值了。在這個例子中,唯一的既是 term 又是 value 的是 abstraction(不能對函式求值,除非它被呼叫)。

實際的求值規則如下:

我們可以這樣解讀每一條規則:

  1. 如果 t1 是值為 t1' 的項, t1 t2 求值為 t1' t2。即一個 application 的左側先被求值。
  2. 如果 t2 是值為 t2' 的項, v1 t2 求值為 v1 t2'。注意這裡左側的是 v1 而非 t1, 這意味著它是 value,不能再一步被求值,也就是說,只有左側的完成之後,才會對右側求值。
  3. application (λx. t12) v2 的結果,和 t12 中出現的所有 x 被有效替換之後是一樣的。注意在對 application 求值之前,兩側必須都是 value。

5.2. 直譯器

直譯器遵循求值規則,將一個程式歸化為 value。現在我們將上面的規則用 JavaScript 寫出來:

首先定義一個工具,當某個節點是 value 的時候告訴我們:

好了,如果 node 是 abstraction,它就是 value;否則就不是。

接下來是直譯器起作用的地方:

程式碼有點密,但睜大眼睛好好看下,可以看到編碼後的規則:

  • 首先檢測其是否為 application,如果是,則對其求值:
    • 若 abstraction 的兩側都是值,只要將所有出現的 x 用給出的值替換掉; (3)
    • 否則,若左側為值,給右側求值;(2)
    • 如果上面都不行,只對左側求值;(1)
  • 現在,如果下一個節點是 identifier,我們只需將它替換為它所表示的變數繫結的值。
  • 最後,如果沒有規則適用於AST,這意味著它已經是一個 value,我們將它返回。

另外一個值得提出的是上下文(context)。上下文持有從名字到值(AST節點)的繫結,舉例來說,呼叫一個函式時,就說你說傳的引數繫結到函式需要的變數上,然後再對函式體求值。

克隆上下文能保證一旦我們完成對右側的求值,繫結的變數會從作用域出來,因為我們還持有原來的上下文。

如果不克隆上下文, application 右側引入的繫結可能洩露並可以在左側獲取到 —— 這是不應當的。考慮下面的程式碼:

這顯然是無效程式: 最左側 abstraction 中的識別符號 y沒有被繫結。來看下如果不克隆上下文,求值最後變成什麼樣。

左側已經是一個 value,所以對右側求值。這是個 application,所以會將 (λx .x)y 繫結,然後對 (λy. y) 求值,而這就是 y 本身。所以最後的求值就成了 (λx. x)

到目前,我們完成了右側,它是 value,而 y 超出了作用域,因為我們退出了 (λy. y), 如果求值的時候不克隆上下文,我們會得到一個變化過的的上下文,繫結就會洩漏,y 的值就是 (λx. x),最後得到錯誤的結果。

6. Printing

OK, 現在差不多完成了:已經可以將一個程式歸化為 value,我們要做的就是想辦法將這個 value 表示出來。

一個簡單的 辦法是為每個AST節點新增 toString方法

現在我們可以在結果的根節點上呼叫 toString方法,它會遞迴列印所有子節點, 以生成字串表示形式。

7. 組合起來

我們需要一個指令碼,將所有這些部分連線在一起,程式碼看起來是這樣的:

原始碼

完整實現可以在 Github 上找到: github.com/tadeuzagallo/lc-js

完成了!

感謝閱讀,一如既往地歡迎你的反饋!

相關文章