最近文章總沒人看,必須起個炫酷的題目才行吶。
今天拜讀了王垠大神的一篇經典作品《怎樣寫一個解釋器》,讀完后頓覺清風拂面、耳聰目明、如臨仙境。我對“解釋器”這一神秘概念長達數年的迷戀,今天終于得以一探究竟。
從非科班出身的我看來,解釋器就是一個能夠解釋執行某種源代碼的程序,例如Python解釋器、JavaScript解釋器等等。然而如何實現一個解釋器卻是我等草民從來沒有想過的。正如吃過豬肉,何必還要看過豬跑。
然而探尋計算機的本質乃是我等草根程序員的一大夢想,這夢想無關金錢,是心底最純粹的愿望。
幸運的是,垠神孜孜不倦的為我們提供精彩的科普文章,打破學術壁壘,把精深的理論變換為通俗易懂的文字。說實話,多年前,我就是受到垠神的文章影響,才決定走編程這條路,進而發現這個美妙的天地。好了,對垠神的吹捧到此為止,他的付款鏈接掛了,所以這篇文章就當做小小的廣告費吧。
解釋器的原理
無論是Python還是JavaScript,要想解釋執行源代碼,必須先讀取文本程序,解析成抽象語法樹(Abstract Syntax Tree, AST),然后再解釋執行。
例如,對于如下的AST,很容易解釋出它想要的結果。我們只需要遞歸地求根節點的值,就可以得到表達式的結果,即(1+2)*(3+4)=21
。
顯然,實際中的表達式不會只包含四則運算,還會有函數調用、變量綁定等等。
等等!請注意我的措辭,為什么是變量綁定而不是變量聲明或變量賦值?而且,我沒有提到分支語句、循環語句,不是省略,而是真的沒有這些功能!
這里需要解釋一波了,不然讀者肯定有些摸不著頭腦。本文,也即垠神文中所講的解釋器是純函數式編程語言的解釋器,并非我們日常見到的Python、JavaScript等集合了函數式編程和面向對象編程的全能語言。
在我看來,純粹的函數式編程有一個有趣的特點,代碼只有一句。這一句話內部的操作全部以遞歸的方式展開,非常神奇。因此,函數式編程中不需要分支語句和循環語句,因為代碼并非按照從上到下的流程執行的。當需要某些類似于分支和循環的功能的時候,這些功能會以函數的形式提供出來。
實現解釋器的過程就是創建一門新的編程語言
很顯然,每一種新的編程語言必然配套一個解釋器(或者編譯器),那么到底是先有編程語言還是先有解釋器,似乎和“雞生蛋、蛋生雞”的問題并無兩樣。
那我們就當做先發明一門編程語言好了,這個語言叫“R2”,它提供的語法如下:
變量:x
函數:(lambda (x) e)
綁定:(let ([x e1]) e2)
調用:(e1 e2)
算術:(? e2 e2)
什么?你說看不懂這些語法是什么意思?垠神發明的語言豈是凡夫俗子能夠看懂的,笑。
現在的程序員啊,都是too young too naive,什么語言火學什么,前些年學Java、學PHP,最近又開始學Python、學JavaScript。然而真正看透一切的人,學的是Lisp!你看看人家王垠、Paul Graham、John Maccarthy,哪一個不是大名鼎鼎。
所以看到這幾條語法不要慌,這就是Lisp最基礎的語法。不過呢,我也不打算在這里講,想深入了解的請參考教程《Yet Another Scheme Tutorial》。畢竟從紫藤大爺那里受益匪淺,不給打個廣告說不過去。
假如我們已經實現了一個R2的解釋器,那么它可以執行R2的源代碼,像下面這個樣子:
(r2 '(+ 1 2))
;; => 3
(r2 '(* 2 3))
;; => 6
(r2 '(* 2 (+ 3 4)))
;; => 14
(r2 '(* (+ 1 2) (+ 3 4)))
;; => 21
(r2 '((lambda (x) (* 2 x)) 3))
;; => 6
(r2
'(let ([x 2])
(let ([f (lambda (y) (* x y))])
(f 3))))
;; => 6
(r2
'(let ([x 2])
(let ([f (lambda (y) (* x y))])
(let ([x 4])
(f 3)))))
;; => 6
簡單來說,就是輸入一句話,輸出一個結果。這句話就是R2源代碼(記住函數式程序只有一行)。
接下來,讓我們欣賞一下R2解釋器的實現吧。
R2解釋器的實現
#lang racket
;;; 以下三個定義 env0, ext-env, lookup 是對環境(environment)的基本操作:
;; 空環境
(define env0 '())
;; 擴展。對環境 env 進行擴展,把 x 映射到 v,得到一個新的環境
(define ext-env
(lambda (x v env)
(cons `(,x . ,v) env)))
;; 查找。在環境中 env 中查找 x 的值。如果沒找到就返回 #f
(define lookup
(lambda (x env)
(let ([p (assq x env)])
(cond
[(not p) #f]
[else (cdr p)]))))
;; 閉包的數據結構定義,包含一個函數定義 f 和它定義時所在的環境
(struct Closure (f env))
;; 解釋器的遞歸定義(接受兩個參數,表達式 exp 和環境 env)
;; 共 5 種情況(變量,函數,綁定,調用,數字,算術表達式)
(define interp
(lambda (exp env)
(match exp ; 對exp進行模式匹配
[(? symbol? x) ; 變量
(let ([v (lookup x env)])
(cond
[(not v)
(error "undefined variable" x)]
[else v]))]
[(? number? x) x] ; 數字
[`(lambda (,x) ,e) ; 函數
(Closure exp env)]
[`(let ([,x ,e1]) ,e2) ; 綁定
(let ([v1 (interp e1 env)])
(interp e2 (ext-env x v1 env)))]
[`(,e1 ,e2) ; 調用
(let ([v1 (interp e1 env)]
[v2 (interp e2 env)])
(match v1
[(Closure `(lambda (,x) ,e) env-save)
(interp e (ext-env x v2 env-save))]))]
[`(,op ,e1 ,e2) ; 算術表達式
(let ([v1 (interp e1 env)]
[v2 (interp e2 env)])
(match op
['+ (+ v1 v2)]
['- (- v1 v2)]
['* (* v1 v2)]
['/ (/ v1 v2)]))])))
;; 解釋器的“用戶界面”函數。它把 interp 包裝起來,掩蓋第二個參數,初始值為 env0
(define r2
(lambda (exp)
(interp exp env0)))
怎么來講解這段精彩的代碼呢。我想了很久,最后決定不講解。最優秀的代碼應該由最優秀的人來講解,而那個人正是這段代碼的作者——王垠。所以想要一窺究竟的請移步王垠的博客,在本文開頭已經給出了鏈接。
所以,這篇文章從頭到尾都是廣告,但是別急,作為一個負責任的作者,我還是要寫點原創的東西的。
精妙何在?
讓我們來品一品這個簡單的R2語言的解釋器的精髓在哪里。不客氣的說,用Racket來實現一個Racket的閹割版R2,似乎不足為道。其實王垠在文中也說了,如果不考慮Lexical Scope的話,可以用更簡短的代碼實現。那么,為什么要考慮Lexical Scope,如何實現Lexical Scope,就成了代碼的精妙之處。
有過Java開發經驗的同學應該有這樣的印象:Java的匿名內部類和局部內部類只能訪問外部的final變量。而其它語言則沒有這樣奇葩的限制。這個規則在幾年間始終困擾著我,直到看完王垠的文章,才恍然大悟。
顯然,Java是沒有閉包的概念的。與Java不同,JavaScript有閉包的概念,當我們寫出這樣的代碼:
var a = 1
var b = function() {
return a + 1
}
a = 2
b() // => 2
結果是2而不是3。說明函數b在定義時同它當時所在的環境打包在一起,稱為閉包。后面再修改a的值,也已經無法影響閉包中的a,所以結果是2而不是3。
這種行為用專用術語來講,就是Lexical Scope,或Static Scope。與此相對,還有Dynamic Scope,以Java為例,當我們寫出這樣的代碼:
class A {
public int a = 1;
public void onClick() {
System.out.println(a + 1);
}
}
A a = new A();
a.a = 2;
a.onClick(); // => 3
結果是3而不是2。說明函數onClick在定義時并沒有固定住a的值,所以當我們修改a的值后,可以隨時體現到輸出結果中。這就是Dynamic Scope的表現。
現在,我們再來思考Java的那個“奇葩的規定”。只允許匿名內部類和局部內部類訪問外部的final變量,不就相當于強行把Dynamic Scope中可能改變的變量固定住了?換句話說,Java的設計者想要實現Lexical Scope的效果,但卻沒有從根本上實現,而是用了一種取巧的方式,來實現與Lexical Scope一致的效果。至于他們為什么非要實現Lexical Scope,顯然是因為Dynamic Scope有諸多弊端,比如變量之間互相影響之類的,我的理解非常有限,就不擅自點評了。
花這么長篇幅分析了一通Lexical Scope與Dynamic Scope,以及閉包的概念。或許大家還是很難理解,畢竟這些東西都太抽象了。事實上,讓我真正理解這些概念的,是在我讀懂了上面的R2解釋器源碼的一剎那。看看閉包是如何實現的,函數的調用是如何實現的,就容易理解了。
如果你真的懶得讀,那就聽一下我給出的更加通俗易懂的解釋吧。事實上,閉包就是一個函數,但不僅僅是一個函數,閉包是把函數和當前的環境變量打包在了一起。所謂的環境變量,就是到目前為止,當前作用域中所能訪問到的所有變量的集合。記住,是當前作用域中,也就是函數定義時所處的作用域中,所有變量的集合。這也是稱其為Lexical Scope的原因,作用域與代碼結構保持一致,而與運行時無關。當函數真正被調用的時候,閉包中與其綁定的環境變量就可以被函數內部所使用。所以,函數內部的表達式中引用的變量要么是函數參數,要么在閉包的環境變量中定義過,否則就會出錯。
好了,解釋了半天,我猜大家還是一頭霧水,而我已經黔驢技窮了。寫這篇文章的初衷,也并非真的想講清楚如何實現一個解釋器,而是希望大家能夠抱有一絲興趣,去讀讀王垠的原文。還有一些介紹Lisp以及函數式編程的博客,給了我極大的啟發,一并附在文末,希望感興趣的同學看一看,發現這個奇妙的世界。也不枉我耽誤寶貴的看動漫時間來寫這篇文了。
Update in 2018-02-01
非常抱歉,由于筆者水平有限,文中出現了嚴重的錯誤,現糾正如下。
在最后一節中,我分析了Java和JavaScript的不同表現。然而,事實上我并非JavaScript的資深用戶,以至于出現了非常低級的錯誤。
var a = 1
var b = function() {
return a + 1
}
a = 2
b() // => 2
這段JavaScript代碼的輸出結果其實不是2,而是3。也就是說,在函數b定義之后,對變量a的更改仍然影響到了b函數體內的a,與我文中所說不一致。
于是我重新考慮這個問題。定義函數b時,變量a連同函數b的函數體形成一個閉包,這一點不應該有錯。與之類似地,文中定義的R2的代碼也可以構建閉包
(r2
'(let ([x 2])
(let ([f (lambda (y) (* x y))])
(let ([x 4])
(f 3)))))
;; => 6
也是在定義函數之后修改了函數內用到的變量,而這里閉包中的x卻沒有被后來的賦值操作影響,為什么JavaScript閉包中的a卻受到影響了呢。
以下僅發表我的看法,如有不同意見歡迎不吝賜教。
我認為JavaScript形成閉包和R2形成閉包并無本質的不同,但是變量的存在形式卻有所差異。R2是一個純函數式編程語言,不存在全局變量的概念,因而所有變量只存在于自己的作用域中,沒有訪問其它平行作用域的途徑。事實上,所有變量的傳遞都是值拷貝,因此所有的變量都是常量。然而JavaScript并非純函數式編程語言,變量是可以通過參數傳遞的,所以在上面的例子中,無論是閉包中的a還是外面的a,其實都是同一個變量,指向同一處內存地址,外部的改變自然可以影響閉包內的值。
這樣看來,JavaScript和Java相比區別似乎不那么大了。唯一的不同是,Java認為生成閉包后,閉包中的值不應該被其它方法修改,否則會使顯得程序混亂,所以強制規定閉包中引用的變量必須為final。
結論似乎有點牽強,畢竟只是我個人的猜測。歡迎大家提出不同意見,一起交流。
最后,感謝@翻翻兒 指出文中的錯誤,不然不知道要誤導多少人。
推薦資料
怎樣寫一個解釋器 王垠
《Yet Another Scheme Tutorial》 紫藤貴文
Lisp的本質 Slava Akhmechet
函數式程序設計的另類指南 Slava Akhmechet