說點題外話
第一次看到λ演算的時候,腦子裏只有一個詞來形容它:美感。
(相對來說,需要讀寫頭和紙帶的圖靈機,可能只算得上一個能work的方案。)
類似的感覺只在看到Lambert的Paxos以及中本聰的區塊鏈的時候出現過。
爲了表示對作者的敬仰之情,我特地把之前做的項目起名爲“丘奇”,也算是能力之內的一種致敬吧。
λ演算和程序語言的淵源,要從麥卡錫和他的Lisp說起,當然如果這麼別下去,大概就沒完沒了了,在這裏我想表達的是,自從那一顆小小的種子種下起,就一直期待着,能實現一個基於Lambda演算的解釋器,或者說一個Lisp的解釋器。藉此去理解一下,計算或者說編程的本質到底是什麼。
從麥卡錫開始,到格雷爾姆,到弗裏德曼,到王垠,實際上我看過好多個版本的Lisp解釋器的實現,但一直似懂非懂,而直到Norvig大叔的版本,藉着一點點殘留的Python基礎,我大概懂了Lisp的精髓。
這一篇不會實現完整的Lisp,但會包含其中最關鍵的部分,也就是關於函數調用的實現。
從一個計算器開始
所以,她和大學時代你用C寫的那個計算器有什麼區別?
從本質上來說其實並沒有。
回憶一下,你從stdin讀進來3 + 4
,然後解析出+
、3
、4
這幾個元素,接着 把加法作用於3
和4
的值 。
爲了方便,我們可以讓輸入的格式是形如:(* (+ 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字樣後,判斷其爲一個過程後,執行過程調用。
過程調用的步驟如下:
把函數調用的實參和形參綁定,在這裏也就是把這一個鍵值對壓入本地環境,此時
local env = {r : 2 ; pi : 3.14}
,假設在global env中存在r
的定義,則這裏會刷掉重新用實參來賦值。計算函數體,當遇到變量時,在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。
如果在這裏,再次聽到一聲“啊哈”,那這篇文章就沒有白寫~