這樣就實現了一個解釋器?

最近文章總沒人看,必須起個炫酷的題目才行吶。

今天拜讀了王垠大神的一篇經典作品《怎樣寫一個解釋器》,讀完后頓覺清風拂面、耳聰目明、如臨仙境。我對“解釋器”這一神秘概念長達數年的迷戀,今天終于得以一探究竟。

從非科班出身的我看來,解釋器就是一個能夠解釋執行某種源代碼的程序,例如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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,698評論 6 539
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,202評論 3 426
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,742評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,580評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,297評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,688評論 1 327
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,693評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,875評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,438評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,183評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,384評論 1 372
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,931評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,612評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,022評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,297評論 1 292
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,093評論 3 397
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,330評論 2 377

推薦閱讀更多精彩內容