1. 程式人生 > >【轉】怎樣寫一個解釋器

【轉】怎樣寫一個解釋器

else 取值 attr 依賴 第一個 一點 等等 做成 函數定義

寫一個解釋器,通常是設計和實現程序語言的第一步。解釋器是簡單卻又深奧的東西,以至於好多人都不會寫,所以我決定寫一篇這方面的入門讀物。

雖然我試圖從最基本的原理講起,盡量不依賴於其它知識,但這並不是一本編程入門教材。我假設你已經理解 Scheme 語言,以及基本的編程技巧(比如遞歸)。如果你完全不了解這些,那我建議你讀一下 SICP 的第一,二章,或者 HtDP 的前幾章,習題可以不做。註意不要讀太多書,否則你就回不來了 ;-) 當然你也可以直接讀這篇文章,有不懂的地方再去查資料。

實現語言容易犯的一個錯誤,就是一開頭就試圖去實現很復雜的語言(比如 JavaScript 或者 Python)。這樣你很快就會因為這些語言的復雜性,以及各種歷史遺留的設計問題而受到挫折,最後不了了之。學習實現語言,最好是從最簡單,最幹凈的語言開始,迅速寫出一個可用的解釋器。之後再逐步往裏面添加特性,同時保持正確。這樣你才能有條不紊地構造出復雜的解釋器。

因為這個原因,這篇文章只針對一個很簡單的語言,名叫“R2”。它可以作為一個簡單的計算器用,還具有變量定義,函數定義和調用等功能。

我們的工具:Racket

本文的解釋器是用 Scheme 語言實現的。Scheme 有很多的“實現”,這裏我用的實現叫做 Racket,它可以在這裏免費下載。為了讓程序簡潔,我用了一點點 Racket 的模式匹配(pattern matching)功能。我對 Scheme 的實現沒有特別的偏好,但 Racket 方便易用,適合教學。如果你用其它的 Scheme 實現,可能得自己做一些調整。

Racket 具有宏(macro),所以它其實可以變成很多種語言。如果你之前用過 DrRacket,那它的“語言設置”可能被你改成了 R5RS 之類的。所以如果下面的程序不能運行,你可能需要檢查一下 DrRacket 的“語言設置”,把 Language 設置成 “Racket”。

技術分享圖片

技術分享圖片

Racket 允許使用方括號而不只是圓括號,所以你可以寫這樣的代碼:

(let ([x 1]
      [y 2])
  (+ x y))

方括號跟圓括號可以互換,唯一的要求是方括號必須和方括號匹配。通常我喜歡用方括號來表示“無動作”的數據(比如上面的 [x 1], [y 2]),這樣可以跟函數調用和其它具有“動作”的代碼,產生“視覺差”。這對於代碼的可讀性是一個改善,因為到處都是圓括號的話,確實有點太單調。

另外,Racket 程序的最上面都需要加上像 #lang racket 這樣的語言選擇標記,這樣 Racket 才可以知道你想用哪個語言變種。

解釋器是什麽

準備工作就到這裏。現在我來談一下,解釋器到底是什麽。說白了,解釋器跟計算器差不多。解釋器是一個函數,你輸入一個“表達式”,它就輸出一個 “值”,像這樣:

技術分享圖片

比如,你輸入表達式 ‘(+ 1 2) ,它就輸出值,整數3。表達式是一種“表象”或者“符號”,而值卻更加接近“本質”或者“意義”。解釋器從符號出發,得到它的意義,這也許就是它為什麽叫做“解釋器”。

需要註意的是,表達式是一個數據結構,而不是一個字符串。我們用一種叫“S表達式”(S-expression)的結構來存儲表達式。比如表達式 ‘(+ 1 2) 其實是一個鏈表(list),它裏面的內容是三個符號(symbol):+, 12,而不是字符串"(+ 1 2)"

從S表達式這樣的“結構化數據”裏提取信息,方便又可靠,而從字符串裏提取信息,麻煩而且容易出錯。Scheme(Lisp)語言裏面大量使用結構化數據,少用字符串,這就是 Lisp 系統比 Unix 系統先進的地方之一。

從計算理論的角度講,每個程序都是一臺機器的“描述”,而解釋器就是在“模擬”這臺機器的運轉,也就是在進行“計算”。所以從某種意義上講,解釋器就是計算的本質。當然,不同的解釋器就會帶來不同的計算。你可能沒有想到,CPU 也是一個解釋器,它專門解釋執行機器語言。

抽象語法樹(Abstract Syntax Tree)

我們用S表達式所表示的代碼,本質上是一種叫做“樹”(tree)的數據結構。更具體一點,這叫做“抽象語法樹”(Abstract Syntax Tree,簡稱 AST)。下文為了簡潔,我們省略掉“抽象”兩個字,就叫它“語法樹”。

跟普通的樹結構一樣,語法樹裏的節點,要麽是一個“葉節點”,要麽是一顆“子樹”。葉節點是不能再細分的“原子”,比如數字,字符串,操作符,變量名。而子樹是可以再細分的“結構”,比如算術表達式,函數定義,函數調用,等等。

舉個簡單的例子,表達式 ‘(* (+ 1 2) (+ 3 4)),就對應如下的語法樹結構:

技術分享圖片

其中,*,兩個+1234 都是葉節點,而那三個紅色節點,都表示子樹結構:‘(+ 1 2)‘(+ 3 4)‘(* (+ 1 2) (+ 3 4))

樹遍歷算法

在基礎的數據結構課程裏,我們都學過二叉樹的遍歷操作,也就是所謂先序遍歷,中序遍歷和後序遍歷。語法樹跟二叉樹,其實沒有很大區別,所以你也可以在它上面進行遍歷。解釋器的算法,就是在語法樹上的一種遍歷操作。由於這個淵源關系,我們先來做一個遍歷二叉樹的練習。做好了之後,我們就可以把這段代碼擴展成一個解釋器。

這個練習是這樣:寫出一個函數,名叫tree-sum,它對二叉樹進行“求和”,把所有節點裏的數加在一起,返回它們的和。舉個例子,(tree-sum ‘((1 2) (3 4))),執行後應該返回 10。註意:這是一顆二叉樹,所以不會含有長度超過2的子樹,你不需要考慮像 ((1 2) (3 4 5)) 這類情況。需要考慮的例子是像這樣:(1 2)(1 (2 3)), ((1 2) 3) ((1 2) (3 4)),……

(為了達到最好的學習效果,你最好試一下寫出這個函數再繼續往下看。)

好了,希望你得到了跟我差不多的結果。我的代碼是這個樣子:

#lang racket

(define tree-sum
  (lambda (exp)
    (match exp                         ; 對輸入exp進行模式匹配
      [(? number? x) x]                ; exp是一個數x嗎?如果是,那麽返回這個數x
      [`(,e1 ,e2)                      ; exp是一個含有兩棵子樹的中間節點嗎?
       (let ([v1 (tree-sum e1)]        ; 遞歸調用tree-sum自己,對左子樹e1求值
             [v2 (tree-sum e2)])       ; 遞歸調用tree-sum自己,對右子樹e2求值
         (+ v1 v2))])))                ; 返回左右子樹結果v1和v2的和

你可以通過以下的例子來測試它的正確性:

(tree-sum ‘(1 2))
;; => 3
(tree-sum ‘(1 (2 3)))
;; => 6
(tree-sum ‘((1 2) 3))
;; => 6
(tree-sum ‘((1 2) (3 4)))
;; => 10

(完整的代碼和示例,可以在這裏下載。)

這個算法很簡單,我們可以把它用文字描述如下:

  1. 如果輸入 exp 是一個數,那就返回這個數。
  2. 否則如果 exp 是像 (,e1 ,e2) 這樣的子樹,那麽分別對 e1e2 遞歸調用 tree-sum,進行求和,得到 v1v2,然後返回 v1 + v2 的和。

你自己寫出來的代碼,也許用了 if 或者 cond 語句來進行分支,而我的代碼裏面使用的是 Racket 的模式匹配(match)。這個例子用 if 或者 cond 其實也可以,但我之後要把這代碼擴展成一個解釋器,所以提前使用了 match。這樣跟後面的代碼對比的時候,就更容易看出規律來。接下來,我就簡單講一下這個 match 表達式的工作原理。

模式匹配

現在不得不插入一點 Racket 的技術細節,如果你已經學會使用 Racket 的模式匹配,可以跳過這一節。你也可以通過閱讀 Racket 模式匹配的文檔來代替這一節。但我建議你不要讀太多文檔,因為我接下去只用到很少的模式匹配功能,我把它們都解釋如下。

模式匹配的形式一般是這樣:

(match x
  [模式 結果]
  [模式 結果]
   ...   ...
)    

它先對 x 求值,然後根據值的結構來進行分支。每個分支由兩部分組成,左邊是一個模式,右邊是一個結果。整個 match 語句的語義是這樣:從上到下依次考慮,找到第一個可以匹配 x 的值的模式,返回它右邊的結果。左邊的模式在匹配之後,可能會綁定一些變量,這些變量可以在右邊的表達式裏使用。

模式匹配是一種分支語句,它在邏輯上就是 Scheme(Lisp) 的 cond 表達式,或者 Java 的嵌套條件語句 if ... else if ... else ...。然而跟條件語句裏的“條件”不同,每條 match 語句左邊的模式,可以準確而形象地描述數據結構的形狀,而且可以在匹配的同時,對結構裏的成員進行“綁定”。這樣我們可以在右邊方便的訪問結構成員,而不需要使用訪問函數(accessor)或者 foo.x 這樣的屬性語法(attribute)。而且模式可以有嵌套的子結構,所以它能夠一次性的表示復雜的數據結構。

舉個實在點的例子。我的代碼裏用了這樣一個 match 表達式:

(match exp
  [(? number? x) x]
  [`(,e1 ,e2)
   (let ([v1 (tree-sum e1)]
         [v2 (tree-sum e2)])
     (+ v1 v2))])

第二行裏面的 ‘(,e1 ,e2) 是一個模式(pattern),它被用來匹配 exp 的值。如果 exp‘(1 2),那麽它與‘(,e1 ,e2)匹配的時候,就會把 e1 綁定到 ‘1,把 e2 綁定到 ‘2。這是因為它們結構相同:

`(,e1 ,e2)
‘(  1   2)

說白了,模式就是一個可以含有“名字”(像 e1e2)的結構,像 ‘(,e1 ,e2)。我們拿這個帶有名字的結構,去匹配實際數據,像 ‘(1 2)。當它們一一對應之後,這些名字就被綁定到數據裏對應位置的值。

第一行的“模式”比較特殊,(? number? x) 表示的,其實是一個普通的條件判斷,相當於 (number? exp),如果這個條件成立,那麽它把 exp 的值綁定到 x,這樣右邊就可以用 x 來指代 exp。對於無法細分的結構(比如數字,布爾值),你只能用這種方式來“匹配”。看起來有點奇怪,不過習慣了就好了。

模式匹配對解釋器和編譯器的書寫相當有用,因為程序的語法樹往往具有嵌套的結構。不用模式匹配的話,往往要寫冗長,復雜,不直觀的代碼,才能描述出期望的結構。而且由於結構的嵌套比較深,很容易漏掉邊界情況,造成錯誤。模式匹配可以直觀的描述期望的結構,避免漏掉邊界情況,而且可以方便的訪問結構成員。

由於這個原因,很多源於 ML 的語言(比如 OCaml,Haskell)都有模式匹配的功能。因為 ML(Meta-Language)原來設計的用途,就是用來實現程序語言的。Racket 的模式匹配也是部分受了 ML 的啟發,實際上它們的原理是一模一樣的。

好了,樹遍歷的練習就做到這裏。然而這跟解釋器有什麽關系呢?下面我們只把它改一下,就可以得到一個簡單的解釋器。

一個計算器

計算器也是一種解釋器,只不過它只能處理算術表達式。我們的下一個目標,就是寫出一個計算器。如果你給它 ‘(* (+ 1 2) (+ 3 4)),它就輸出 21。可不要小看這個計算器,稍後我們把它稍加改造,就可以得到一個更多功能的解釋器。

上面的代碼裏,我們利用遞歸遍歷,對樹裏的數字求和。那段代碼裏,其實已經隱藏了一個解釋器的框架。你觀察一下,一個算術表達式 ‘(* (+ 1 2) (+ 3 4)),跟二叉樹 ‘((1 2) (3 4)) 有什麽不同?發現沒有,這個算術表達式比起二叉樹,只不過在每個子樹結構裏多出了一個操作符:一個 * 和兩個 + 。它不再是一棵二叉樹,而是一種更通用的樹結構。

這點區別,也就帶來了二叉樹求和與解釋器算法的區別。對二叉樹進行求和的時候,在每個子樹節點,我們都做加法。而對表達式進行解釋的時候,在每一個子樹節點,我們不一定進行加法。根據子樹的“操作符”不同,我們可能會選擇加,減,乘,除四種操作。

好了,下面就是這個計算器的代碼。它接受一個表達式,輸出一個數字作為結果。

#lang racket                                  ; 聲明用 Racket 語言

(define calc
  (lambda (exp)
    (match exp                                ; 分支匹配:表達式的兩種情況
      [(? number? x) x]                       ; 是數字,直接返回
      [`(,op ,e1 ,e2)                         ; 匹配提取操作符op和兩個操作數e1,e2
       (let ([v1 (calc e1)]                   ; 遞歸調用 calc 自己,得到 e1 的值
             [v2 (calc e2)])                  ; 遞歸調用 calc 自己,得到 e2 的值
         (match op                            ; 分支匹配:操作符 op 的 4 種情況
           [‘+ (+ v1 v2)]                     ; 如果是加號,輸出結果為 (+ v1 v2)
           [‘- (- v1 v2)]                     ; 如果是減號,乘號,除號,相似的處理
           [‘* (* v1 v2)]
           [‘/ (/ v1 v2)]))])))

你可以得到如下的結果:

(calc ‘(+ 1 2))
;; => 3
(calc ‘(* 2 3))
;; => 6
(calc ‘(* (+ 1 2) (+ 3 4)))
;; => 21

(完整的代碼和示例,可以在這裏下載。)

跟之前的二叉樹求和代碼比較一下,你會發現它們驚人的相似,因為解釋器本來就是一個樹遍歷算法。不過你發現它們有什麽不同嗎?它們的不同點在於:

  1. 算術表達式的模式裏面,多出了一個“操作符”(op)葉節點:(,op ,e1 ,e2)

  2. 對子樹 e1 和 e2 分別求值之後,我們不是返回 (+ v1 v2),而是根據 op 的不同,返回不同的結果:

    (match op
      [‘+ (+ v1 v2)]
      [‘- (- v1 v2)]
      [‘* (* v1 v2)]
      [‘/ (/ v1 v2)])
    

最後你發現,一個算術表達式的解釋器,不過是一個稍加擴展的樹遍歷算法。

R2:一個很小的程序語言

實現了一個計算器,現在讓我們過渡到一種更強大的語言。為了方便稱呼,我給它起了一個萌萌噠名字,叫 R2。R2 比起之前的計算器,只多出四個元素,它們分別是:變量,函數,綁定,調用。再加上之前介紹的算術操作,我們就得到一個很簡單的程序語言,它只有5種不同的構造。用 Scheme 的語法,這5種構造看起來就像這樣:

  • 變量:x
  • 函數:(lambda (x) e)
  • 綁定:(let ([x e1]) e2)
  • 調用:(e1 e2)
  • 算術:(? e2 e2)

(其中,? 是一個算術操作符,可以選擇 +, -, *, / 其中之一)

一般程序語言還有很多其它構造,可是一開頭就試圖去實現所有那些,只會讓人糊塗。最好是把這少數幾個東西搞清楚,確保它們正確之後,才慢慢加入其它元素。

這些構造的語義,跟 Scheme 裏面的同名構造幾乎一模一樣。如果你不清楚什麽是”綁定“,那你可以把它看成是普通語言裏的”變量聲明“。

需要註意的是,跟一般語言不同,我們的函數只接受一個參數。這不是一個嚴重的限制,因為在我們的語言裏,函數可以被作為值傳遞,也就是所謂“first-class function”。所以你可以用嵌套的函數定義來表示有兩個以上參數的函數。

舉個例子, (lambda (x) (lambda (y) (+ x y))) 是個嵌套的函數定義,它也可以被看成是有兩個參數(xy)的函數,這個函數返回 xy 的和。當這樣的函數被調用的時候,需要兩層調用,就像這樣:

(((lambda (x) (lambda (y) (+ x y))) 1) 2)
;; => 3

這種做法在PL術語裏面,叫做咖喱(currying)。看起來啰嗦,但這樣我們的解釋器可以很簡單。等我們理解了基本的解釋器,再實現真正的多參數函數也不遲。

另外,我們的綁定語法 (let ([x e1]) e2),比起 Scheme 的綁定也有一些局限。我們的 let 只能綁定一個變量,而 Scheme 可以綁定多個,像這樣 (let ([x 1] [y 2]) (+ x y))。這也不是一個嚴重的限制,因為我們可以啰嗦一點,用嵌套的 let 綁定:

(let ([x 1])
  (let ([y 2])
    (+ x y)))

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 ‘(+ 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 解釋器處理算術的部分,它是 interp 的最後一個分支。

(match exp
  ... ...
  [`(,op ,e1 ,e2)
   (let ([v1 (interp e1 env)]             ; 遞歸調用 interp 自己,得到 e1 的值
         [v2 (interp e2 env)])            ; 遞歸調用 interp 自己,得到 e2 的值
     (match op                            ; 分支:處理操作符 op 的 4 種情況
       [‘+ (+ v1 v2)]                     ; 如果是加號,輸出結果為 (+ v1 v2)
       [‘- (- v1 v2)]                     ; 如果是減號,乘號,除號,相似的處理
       [‘* (* v1 v2)]
       [‘/ (/ v1 v2)]))])

你可以看到它幾乎跟剛才寫的計算器一模一樣,不過現在 interp 的調用多了一個參數 env 而已。這個 env 是所謂“環境”,我們下面很快就講。

對數字的解釋

對數字的解釋很簡單,把它們原封不動返回就可以了。

[(? number? x) x]

變量和函數

變量和函數是解釋器裏最麻煩的部分,所以我們來仔細看看。

變量(variable)的產生,是數學史上的最大突破之一。因為變量可以被綁定到不同的值,從而使函數的實現成為可能。比如數學函數 f(x) = x * 2,其中 x 是一個變量,它把輸入的值傳遞到函數體 x * 2 裏面。如果沒有變量,函數就不可能實現。

對變量最基本的操作,是對它的“綁定”(binding)和“取值”(evaluate)。什麽是綁定呢?拿上面的函數 f(x) 作為例子。當我們調用 f(1) 時,函數體裏面的 x 等於 1,所以 x * 2 的值是 2,而當我們調用 f(2) 時,函數體裏面的 x 等於 2,所以 x * 2 的值是 4。這裏,兩次對 f 的調用,分別對 x 進行了兩次綁定。第一次 x 被綁定到了 1,第二次被綁定到了 2。

你可以把“綁定”理解成這樣一個動作,就像當你把插頭插進電源插座的那一瞬間。插頭的插腳就是 f(x) 裏面的那個 x,而 x * 2 裏面的 x,則是電線的另外一端。所以當你把插頭插進插座,電流就通過這根電線到達另外一端。如果電線導電性能良好,兩頭的電壓應該相等。

環境

我們的解釋器只能一步一步的做事情。比如,當它需要求 f(1) 的值的時候,它分成兩步操作:

  1. x 綁定到 1,這樣函數體內才能看見這個綁定。
  2. 進入 f 的函數體,對 x * 2 進行求值。

這就像一個人做出這兩個動作:

  1. 把插頭插進插座 。
  2. 到電線的另外一頭,測量它的電壓,並且把結果乘以 2。

在第一步和第二步之間,我們如何記住 x 的值呢?通過所謂“環境”!我們用環境記錄變量的值,並且把它們傳遞到變量的“可見區域”。變量的可見區域,用術語說叫做“作用域”(scope)。

在我們的解釋器裏,用於處理環境的代碼如下:

;; 空環境
(define env0 ‘())

;; 對環境 env 進行擴展,把 x 映射到 v
(define ext-env
  (lambda (x v env)
    (cons `(,x . ,v) env)))

;; 取值。在環境中 env 中查找 x 的值
(define lookup
  (lambda (x env)
    (let ([p (assq x env)])
      (cond
       [(not p) #f]
       [else (cdr p)]))))

這裏我們用一種最簡單的數據結構,Scheme 的 association list,來表示環境。Association list 看起來像這個樣子:((x . 1) (y . 2) (z . 5))。它是一個兩元組(pair)的鏈表,左邊的元素是 key,右邊的元素是 value。寫得直觀一點就是:

((x . 1)
 (y . 2)
 (z . 5))

查表操作就是從頭到尾搜索,如果左邊的 key 是要找的變量,就返回整個 pair。簡單吧?效率很低,但是足夠完成我們現在的任務。

ext-env 函數擴展一個環境。比如,如果原來的環境 env1((y . 2) (x . 1)) 那麽 (ext-env x 3 env1),就會返回 ((x . 3) (y . 2) (x . 1))。也就是把 (x . 3) 加到 env1 的最前面去。

那我們什麽時候需要擴展環境呢?當我們進行綁定的時候。綁定可能出現在函數調用時,也可能出現在 let 綁定時。我們選擇的數據結構,使得環境自然而然的具有了作用域(scope)的特性。

環境其實是一個堆棧(stack)。內層的綁定,會出現在環境的最上面,這就是在“壓棧”。這樣我們查找變量的時候,會優先找到最內層定義的變量。

舉個例子:

(let ([x 1])         ; env=‘()。綁定x到1。
  (let ([y 2])       ; env=‘((x . 1))。綁定y到2。
    (let ([x 3])     ; env=‘((y . 2) (x . 1))。綁定x到3。
      (+ x y))))     ; env=‘((x . 3) (y . 2) (x . 1))。查找x,得到3;查找y,得到2。
;; => 5

這段代碼會返回5。這是因為最內層的綁定,把 (x . 3) 放到了環境的最前面,這樣查找 x 的時候,我們首先看到 (x . 3),然後就返回值3。之前放進去的 (x . 1) 仍然存在,但是我們先看到了最上面的那個(x . 3),所以它被忽略了。

這並不等於說 (x . 1) 就可以被改寫或者丟棄,因為它仍然是有用的。你只需要看一個稍微不同的例子,就知道這是怎麽回事:

(let ([x 1])          ; env=‘()。綁定x到1。
  (+ (let ([x 2])     ; env=‘((x . 1))。綁定x到2。
       x)             ; env=‘((x . 2) (x . 1))。查找x,得到2。
   x))                ; env=‘((x . 1))。查找x,得到1。
;; => 3               ; 兩個不同的x的和,1+2等於3。

這個例子會返回3。它是第3行和第4行裏面兩個 x 的和。由於第3行的 x 處於內層 let 裏面,那裏的環境是 ((x . 2) (x . 1)),所以查找 x 的值得到2。第4行的 x 在內層 let 外面,但是在外層 let 裏面,那裏的環境是 ((x . 1)),所以查找 x 的值得到1。這很符合直覺,因為 x 總是找到最內層的定義。

值得註意的是,環境被擴展以後,形成了一個新的環境,而原來的環境並沒有被改變。比如,上面的 ((y . 2) (x . 1)) 並沒有刪除或者修改,只不過是被“引用”到一個更大的列表裏去了。

這樣不對已有數據進行修改(mutation)的數據結構,叫做“函數式數據結構”。函數式數據結構只生成新的數據,而不改變或者刪除老的。它可能引用老的結構,然而卻不改變老的結構。這種“不修改”(immutable)的性質,在我們的解釋器裏是很重要的,因為當我們擴展一個環境,進入遞歸,返回之後,外層的代碼必須仍然可以訪問原來外層的環境。

當然,我們也可以用另外的,更高效的數據結構(比如平衡樹,串接起來的哈希表)來表示環境。如果你學究一點,甚至可以用函數來表示環境。這裏為了代碼簡單,我們選擇了最笨,然而正確,容易理解的數據結構。

對變量的解釋

了解了變量,函數和環境,我們來看看解釋器對變量的“取值”操作,也就是 match 的第一種情況。

[(? symbol? x) (lookup x env)]

這就是在環境中,沿著從內向外的“作用域順序”,查找變量的值。

這裏的 (? symbol? x) 是一種特殊的模式,它使用 Scheme 函數 symbol? 來判斷輸入是否是一個符號,如果是,就把它綁定到 x,然後你就可以在右邊用 x 來指稱這個輸入。

對綁定的解釋

現在我們來看看對 let 綁定的解釋:

[`(let ([,x ,e1]) ,e2)                           
 (let ([v1 (interp e1 env)])              ; 解釋右邊表達式e1,得到值v1
   (interp e2 (ext-env x v1 env)))]       ; 把(x . v1)擴充到環境頂部,對e2求值

通過代碼裏的註釋,你也許已經可以理解它在做什麽。我們先對表達式 e1 求值,得到 v1。然後我們把 (x . v1) 擴充到環境裏,這樣 (let ([x e1]) ...) 內部都可以看到 x 的值。然後我們使用這個擴充後的環境,遞歸調用解釋器本身,對 let 的主體 e2 求值。它的返回值就是這個 let 綁定的值。

Lexical Scoping 和 Dynamic Scoping

下面我們準備談談函數定義和調用。對函數的解釋是一個微妙的問題,很容易弄錯,這是由於函數體內也許會含有外層的變量,叫做“自由變量”。所以在分析函數的代碼之前,我們來了解一下不同的“作用域”(scoping)規則。

我們舉個例子來解釋這個問題。下面這段代碼,它的值應該是多少呢?

(let ([x 2])
  (let ([f (lambda (y) (* x y))])
    (let ([x 4])
      (f 3))))

在這裏,f 函數體 (lambda (y) (* x y)) 裏的那個 x,就是一個“自由變量”。x 並不是這個函數的參數,也不是在這個函數裏面定義的,所以我們必須到函數外面去找 x 的值。

我們的代碼裏面,有兩個地方對 x 進行了綁定,一個等於2,一個等於4,那麽 x 到底應該是指向哪一個綁定呢?這似乎無關痛癢,然而當我們調用 (f 3) 的時候,嚴重的問題來了。f 的函數體是 (* x y),我們知道 y 的值來自參數 3,可是 x 的值是多少呢?它應該是2,還是4呢?

在歷史上,這段代碼可能有兩種不同的結果,這種區別一直延續到今天。如果你在 Scheme (Racket)裏面寫以上的代碼,它的結果是6。

;; Scheme
(let ([x 2])
  (let ([f (lambda (y) (* x y))])
    (let ([x 4])
      (f 3))))

;; => 6

現在我們來看看,在 Emacs Lisp 裏面輸入等價的代碼,得到什麽結果。如果你不熟悉 Emacs Lisp 的用法,那你可以跟我做:把代碼輸入 Emacs 的那個叫 *scratch* 的 buffer。把光標放在代碼最後,然後按 C-x C-e,這樣 Emacs 會執行這段代碼,然後在 minibuffer 裏顯示結果:

技術分享圖片

結果是12!如果你把代碼最內層的 x 綁定修成其它的值,輸出會隨之改變。

奇怪吧?Scheme 和 Emacs Lisp,到底有什麽不一樣呢?實際上,這兩種看似差不多的 “Lisp 方言”,采用了兩種完全不同的作用域方式。Scheme 的方式叫做 lexical scoping (或者 static scoping),而 Emacs 的方式叫做 dynamic scoping。

那麽哪一種方式更好呢?或者用哪一種都無所謂?答案是,dynamic scoping 是非常錯誤的做法。歷史的教訓告訴我們,它會帶來許許多多莫名其妙的 bug,導致 dynamic scoping 的語言幾乎完全沒法用。這是為什麽呢?

原因在於,像 (let ((x 4)) …) 這樣的變量綁定,只應該影響它內部“看得見”的 x 的值。當我們看見 (let ((x 4)) (f 3)) 的時候,並沒有在 let 的內部看見任何叫“x” 的變量,所以我們“直覺”的認為,(let ((x 4)) …)x 的綁定,不應該引起 (f 3) 的結果變化。

然而對於 dynamic scoping,我們的直覺卻是錯誤的。因為 f 的函數體裏面有一個 x,雖然我們沒有在 (f 3) 這個調用裏面看見它,然而它卻存在於 f 定義的地方。要知道,f 定義的地方也許隔著幾百行代碼,甚至在另外一個文件裏面。而且調用函數的人憑什麽應該知道, f 的定義裏面有一個自由變量,它的名字叫做 x?所以 dynamic scoping 在設計學的角度來看,是一個反人類的設計 :)

相反,lexical scoping 卻是符合人們直覺的。雖然在 (let ((x 4)) (f 3)) 裏面,我們把 x 綁定到了 4,然而 f 的函數體並不是在那裏定義的,我們也沒在那裏看見任何 x,所以 f 的函數體裏面的 x,仍然指向我們定義它的時候看得見的那個 x,也就是最上面的那個 (let ([x 2]) ...),它的值是 2。所以 (f 3) 的值應該等於 6,而不是12。

對函數的解釋

為了實現 lexical scoping,我們必須把函數做成“閉包”(closure)。閉包是一種特殊的數據結構,它由兩個元素組成:函數的定義和當前的環境。我們把閉包定義為一個 Racket 的 struct 結構:

(struct Closure (f env))

有了這個數據結構,我們對 (lambda (x) e) 的解釋就可以寫成這樣:

[`(lambda (,x) ,e)
 (Closure exp env)]

註意這裏的 exp 就是 ``(lambda (,x) ,e)` 自己。

有意思的是,我們的解釋器遇到 (lambda (x) e),幾乎沒有做任何計算。它只是把這個函數包裝了一下,把它與當前的環境一起,打包放到一個數據結構(Closure)裏面。這個閉包結構,記錄了我們在函數定義的位置“看得見”的那個環境。稍候在調用的時候,我們就能從這個閉包的環境裏面,得到函數體內的自由變量的值。

對調用的解釋

好了,我們終於到了最後的關頭,函數調用。為了直觀,我們把函數調用的代碼拷貝如下:

[`(,e1 ,e2)                                            
 (let ([v1 (interp e1 env)]             ; 計算函數 e1 的值
       [v2 (interp e2 env)])            ; 計算參數 e2 的值
   (match v1
     [(Closure `(lambda (,x) ,e) env-save)      ; 用模式匹配的方式取出閉包裏的各個子結構
      (interp e (ext-env x v2 env-save))]))]    ; 在閉包的環境env-save中把x綁定到v2,解釋函數體     

函數調用都是 (e1 e2) 這樣的形式,e1 表示函數,e2 是它的參數。我們需要先分別求出函數 e1 和參數 e2 的值。

函數調用就像把一個電器的插頭插進插座,使它開始運轉。比如,當 (lambda (x) (* x 2)) 被作用於 1 時,我們把 x 綁定到 1,然後解釋它的函數體 (* x 2)。但是這裏有一個問題,函數體內的自由變量應該取什麽值呢?從上面閉包的討論,你已經知道了,自由變量的值,應該從閉包的環境查詢。

操作數 e1 的值 v1 是一個閉包,它裏面包含一個函數定義時保存的環境 env-save。我們把這個環境 env-save 取出來,那我們就可以查詢它,得到函數體內自由變量的值。然而函數體內不僅有自由變量,還有對函數參數的使用,所以我們必須擴展這個 env-save 環境,把參數的值加進去。這就是為什麽我們使用 (ext-env x v2 env-save),而不只是 env-save

你可能會奇怪,那麽解釋器的環境 env 難道這裏就不用了嗎?是的。我們通過 env 來計算 e1e2 的值,是因為 e1e2 裏面的變量,在“當前環境”(env)裏面看得見。可是函數體的定義,在當前環境下是看不見的。它的代碼在別的地方,而那個地方看得見的環境,被我們存在閉包裏了,它就是 env-save。所以我們把 v1 裏面的閉包環境 env-save 取出來,用於計算函數體的值。

有意思的是,如果我們用 env,而不是env-save 來解釋函數體,那我們的語言就變成了 dynamic scoping。現在來實驗一下:你可以把 (interp e (ext-env x v2 env-save)) 裏面的 env-save 改成 env,再試試我們之前討論過的代碼,它的輸出就會變成 12。那就是我們之前講過的,dynamic scoping 的結果。

(r2
‘(let ([x 2])
   (let ([f (lambda (y) (* x y))])
     (let ([x 4])
       (f 3)))))

;; => 12

你也許發現了,如果我們的語言是 dynamic scoping,那就沒必要使用閉包了,因為我們根本不需要閉包裏面保存的環境。這樣一來,dynamic scoping 的解釋器就可以寫成這樣:

(define interp
  (lambda (exp env)
    (match exp                                          
      ... ...
      [`(lambda (,x) ,e)                          ; 函數:直接返回自己的表達式
       exp]
      ... ...
      [`(,e1 ,e2)                                       
       (let ([v1 (interp e1 env)]
             [v2 (interp e2 env)])
         (match v1
           [`(lambda (,x) ,e)                     ; 調用:直接使用函數的表達式本身
            (interp e (ext-env x v2 env))]))]
      ... ...
)))

註意到這個解釋器的函數有多容易實現嗎?它就是這個函數的表達式自己,原封不動。用函數的表達式本身來表示它的值,是很直接很簡單的做法,也是大部分人一開頭就會想到的。然而這樣實現出來的語言,就不知不覺地采用了 dynamic scoping。

這就是為什麽很多早期的 Lisp 語言,比如 Emacs Lisp,都使用 dynamic scoping。這並不是因為它們的設計者在 dynamic scoping 和 lexical scoping 兩者之中做出了選擇,而是因為使用函數的表達式本身來作為它的值,是最直接,一般人都會首先想到的做法。

另外,在這裏我們也看到環境用“函數式數據結構”表示的好處。閉包被調用時它的環境被擴展,但是這並不會影響原來的那個環境,我們得到的是一個新的環境。所以當函數調用返回之後,函數的參數綁定就自動“註銷”了。

如果你用一個非函數式的數據結構,在綁定參數時不生成新的環境,而是對已有環境進行賦值,那麽這個賦值操作就會永久性的改變原來環境的內容。所以你在函數返回之後必須刪除參數的綁定。這樣不但麻煩,而且在復雜的情況下很容易出錯。

思考題:可能有些人看過 lambda calculus,這些人可能知道 (let ([x e1]) e2) 其實等價於一個函數調用:((lambda (x) e2) e1)。現在問題來了,我們在討論函數和調用的時候,很深入的討論了關於 lexical scoping 和 dynamic scoping 的差別。既然 let 綁定等價於一個函數定義和調用,為什麽之前我們討論對綁定的時候,沒有討論過 lexical scoping 和 dynamic scoping 的問題,也沒有制造過閉包呢?

不足之處

現在你已經學會了如何寫出一個簡單的解釋器,它可以處理一個相當強的,具有“first-class 函數”的語言。出於教學的考慮,這個解釋器並沒有考慮實用的需求,所以它並不能作為“工業應用”。在這裏,我指出它的一些不足之處。

  1. 缺少必要的語言構造。我們的語言裏缺少好些實用語言必須的構造:遞歸,數組,賦值操作,字符串,自定義數據結構,…… 作為一篇基礎性的讀物,我不能把這些都加進來。如果你對這些有興趣,可以看看其它書籍,或者等待我的後續作品。

  2. 不合法代碼的檢測和報告。你也許發現了,這個解釋器的 match 表達式,全都假定了輸入都是合法的程序,它並沒有檢查不合法的情況。如果你給它一個不合法的程序,它的行為會變得詭異。一個實用的解釋器,必須加入對代碼格式進行全面檢測,報告不合法的代碼結構。

  3. 低效率的數據結構。在 association list 裏面查找變量,是線性的復雜度。當程序有很多變量的時候就有性能問題。一個實用的解釋器,需要更高效的數據結構。這種數據結構不一定非得是函數式的。你也可以用非函數式的數據結構(比如哈希表),經過一定的改造,達到同樣的性質,卻具有更高的效率。 ? 另外,你還可以把環境轉化成一個數組。給環境裏的每個變量分配一個下標(index),在這個數組裏就可以找到它的值。如果你用數組表示環境,那麽這個解釋器就向編譯器邁進了一步。

  4. S表達式的歧義問題。為了教學需要,我們的解釋器直接使用S表達式來表達語法樹,用模式匹配來進行分支遍歷。在實際的語言裏,這種方式會帶來比較大的問題。因為S表達式是一種通用的數據結構,用它表示的東西,看起來都差不多的樣子。一旦程序的語法構造多起來,直接對S表達式進行模式匹配,會造成歧義。 ?

    比如 (,op ,e1 ,e2) ,你以為它只匹配二元算術操作,比如 (+ 1 2)。但它其實也可以匹配一個 let 綁定: (let ([x 1]) (* x 2))。這是因為它們頂層元素的數目是一樣的。為了消除歧義,你得小心的安排模式的順序,比如你必須把 (let ([,x ,e1]) ,e2) 的模式放在 (,op ,e1, e2) 前面。所以最好的辦法,是不要直接在S表達式上寫解釋器,而是先寫一個“parser”,這個parser把S表達式轉換成 Racket 的 struct 結構。然後解釋器再在 struct 上面進行分支匹配。這樣解釋器不用擔心歧義問題,而且會帶來效率的提升。

付費方式

如果你喜歡這篇文章,願意鼓勵我繼續出品這類精品,歡迎向我的支付寶賬號進行捐款。

技術分享圖片

在美國的朋友,也可以通過paypal捐款:【PayPal付款鏈接】

【轉】怎樣寫一個解釋器