Lisa -- 一個Lisp風格的解釋器

說點題外話

第一次看到λ演算的時候,腦子裏只有一個詞來形容它:美感。

(相對來說,需要讀寫頭和紙帶的圖靈機,可能只算得上一個能work的方案。)

類似的感覺只在看到Lambert的Paxos以及中本聰的區塊鏈的時候出現過。

爲了表示對作者的敬仰之情,我特地把之前做的項目起名爲“丘奇”,也算是能力之內的一種致敬吧。

λ演算和程序語言的淵源,要從麥卡錫和他的Lisp說起,當然如果這麼別下去,大概就沒完沒了了,在這裏我想表達的是,自從那一顆小小的種子種下起,就一直期待着,能實現一個基於Lambda演算的解釋器,或者說一個Lisp的解釋器。藉此去理解一下,計算或者說編程的本質到底是什麼。

從麥卡錫開始,到格雷爾姆,到弗裏德曼,到王垠,實際上我看過好多個版本的Lisp解釋器的實現,但一直似懂非懂,而直到Norvig大叔的版本,藉着一點點殘留的Python基礎,我大概懂了Lisp的精髓。

這一篇不會實現完整的Lisp,但會包含其中最關鍵的部分,也就是關於函數調用的實現。

從一個計算器開始

所以,她和大學時代你用C寫的那個計算器有什麼區別?

從本質上來說其實並沒有。

回憶一下,你從stdin讀進來3 + 4,然後解析出+34這幾個元素,接着 把加法作用於34的值

爲了方便,我們可以讓輸入的格式是形如:(* (+ 1 2) (+ 3 4))的樣子,所以你依然把輸入解析爲*(+ 1 2)(+ 3 4),然後繼續用剛纔的方式 把乘法作用於(+ 1 2)(+ 3 4)的值

讓我們簡單抽象一下,輸入的表達式(Expression)可以分爲幾個部分:

(operator expression1 expression2)

(這裏的第一個參數operator,實際上也可以是expression)

因此,她的內核,實際上就只有一行代碼:

eval(expression) = apply(operator,
      eval(expression1),
      eval(expression2)
)

表達式expression的求值結果,等於把算子作用於子表達式1和子表達式2的值。

我相信我能體會到約翰麥卡錫在60年前,第一次看到她時候的震撼。

表達式

前面講到,她是基於表達式求值的,也就是說每一行源代碼都會有一個求值結果。

作爲一個遞歸函數,肯定會有一個終止條件,求值的終止條件,在於某個子表達式是原子類型。

所以我們把表達式,細分成了原子表達式和複合表達式。

複合表達式如:(+ 1 2)或者如一個變量定義(define pi 3.14)。它的求值邏輯在前面已經講過了。

原子表達式如:數字1,求值結果依然是數字1,字符串foo,求值結果依然是字符串foo

上下文

剛纔介紹原子表達式的時候,我們忽略了一種情況,如果是變量,比如pi,應該怎麼辦?

實際上我們應該嘗試去獲取變量的定義,也就是嘗試從上下文(或者說環境變量)中取值。

那麼上下文又是什麼?

實際上你可以把它簡單的理解成一個Map
(在沒有函數調用的情況下,這樣的解釋總是說的通)。

每當對define相關的表達式進行求值時,比如(define pi 3.14),實際上,就是把這一個鍵值對設置進上下文中。

(還記得我們的apply函數嗎?這就是define關鍵字apply的結果,也可以說是define的語義。)

因此,當嘗試對於pi進行求值時,實際上是去Map里根據key去查value。

Lambda!

終於輪到函數登場了。

我們知道在JVM裏,一次函數調用,意味着棧幀的一次壓棧和出棧。而在我們的解釋器裏,不需要棧的概念。

讓我們來手動模擬一下解釋器的行爲。現在你是一個剛初始化的解釋器,你有一個環境變量,裏面什麼都沒有。

這時候,用戶輸入了(define pi 3.14),此時,環境變量變成了{pi : 3.14}

接下來用戶輸入(* pi 2),此時你從環境中尋找pi的值,判斷其爲一個數值類型後,執行相應的數值計算(這裏假設你已經知道如何處理乘法)

先看一下函數的定義:

(define circle-area
    (lambda (r) (* pi (* r r))))

此時環境變成了:{pi : 3.14 ; circle-area : LAMBDA(…)}

其中LAMBDA(…)代表一個過程,其中又包含以下信息:

函數參數:r
函數體:(* pi (* r r))
本地環境:{pi : 3.14}

這裏姑且把本地環境稱爲local env。
同時它引用了上層環境(global env)的值。
可以簡單的理解爲做了一次**深拷貝**。

接下來,當用戶輸入(circle-area 2),你會從當前環境中尋找circle-area的值,當看到LAMBDA字樣後,判斷其爲一個過程後,執行過程調用

過程調用的步驟如下:

  1. 把函數調用的實參和形參綁定,在這裏也就是把這一個鍵值對壓入本地環境,此時local env = {r : 2 ; pi : 3.14},假設在global env中存在r的定義,則這裏會刷掉重新用實參來賦值。

  2. 計算函數體,當遇到變量時,在local env中查找,如果找得到,則進行相應的運算,如果找不到則報錯。在這個例子裏,會把函數體(* pi (* r r))替換爲(* 3.14 (* 2 2)),是不是又回到了最簡單的場景?

再來一個稍微複雜點的場景。

我們把時間倒退到最開始的時候,假設用戶輸入的是計算階乘函數:

(define fact
    (lambda (n)
        (if (<= n 1) 1 (* n (fact (- n 1))))))

此時global env變爲:{fact : LAMBDA(…)}

其中LAMBDA(…)又包含以下信息:

函數參數:n
函數體:(if (<= n 1) 1 (* n (fact (- n 1))))
local env:{fact : LAMBDA(…)}

此時用戶輸入(fact 3),你判斷fact的值爲過程,首先綁定參數:
local env1 = {n : 3 ; fact : LAMBDA(…)}

再嘗試執行函數體:(* 3 (fact 2))

此時,發現fact仍然爲一個過程,再次執行過程調用,其中:
local env2 = {n : 2 ; local env1的深拷貝},注意這裏的n被覆蓋爲2而不是3

再次嘗試對函數體(* 3 (* 2 (fact 1)))

執行過程中發現fact仍然爲一個過程,再次執行過程調用。終於,函數體被展開成了(* 3 (* 2 1)),一切又回到了最原始的場景。

所以,看到這裏,希望你能“啊哈”一下,這種不停的進行字符串替換的把戲,竟然可以實現計算!

我們沒有依賴棧這種結構,僅靠遞歸,也完成了函數調用。

順便也能理解一個Lisp圈的老梗,只要給它足夠的時間,Lisp表達式總能得到結果。

閉包!

如果稍微聯想一下,其實可以很容易的發現,實際上,剛纔的LAMBDA(…)就是很多語言中閉包的概念。

所謂的閉包,除了函數定義的部分,還帶上了上下文,簡單說就是那個local env。

如果在這裏,再次聽到一聲“啊哈”,那這篇文章就沒有白寫~

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