1. 程式人生 > >怎樣寫一個直譯器——王垠

怎樣寫一個直譯器——王垠

怎樣寫一個直譯器

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

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

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

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

我們的工具:Racket

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

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

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

  1. (let([x1]
  2. [y2])
  3. (+xy))

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

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

直譯器是什麼

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

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

需要注意的是,表示式是一個數據結構,而不是一個字串。我們用一種叫“S表示式”(S-expression)的結構來儲存表示式。比如表示式 '(+ 1 2) 其實是一個連結串列(list),它裡面的內容是三個符號(symbol):+1 和 2,而不是字串"(+ 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)),……

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

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

  1. #lang racket
  2. (define tree-sum
  3. (lambda (exp)
  4. (match exp ; 對輸入exp進行模式匹配
  5. [(? number? x) x] ; exp是一個數x嗎?如果是,那麼返回這個數x
  6. [`(,e1 ,e2) ; exp是一個含有兩棵子樹的中間節點嗎?
  7. (let ([v1 (tree-sum e1)] ; 遞迴呼叫tree-sum自己,對左子樹e1求值
  8. [v2 (tree-sum e2)]) ; 遞迴呼叫tree-sum自己,對右子樹e2求值
  9. (+ v1 v2))]))) ; 返回左右子樹結果v1和v2的和

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

  1. (tree-sum '(1 2))
  2. ;; => 3
  3. (tree-sum '(1 (2 3)))
  4. ;; => 6
  5. (tree-sum '((1 2) 3))
  6. ;; => 6
  7. (tree-sum '((1 2) (3 4)))
  8. ;; => 10

(完整的程式碼和示例,可以在這裡下載。)

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

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

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

模式匹配

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

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

  1. (match x
  2. [模式 結果]
  3. [模式 結果]
  4. ... ...
  5. )

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

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

舉個實在點的例子。我的程式碼裡用了這樣一個 match 表示式:

  1. (match exp
  2. [(? number? x) x]
  3. [`(,e1 ,e2)
  4. (let ([v1 (tree-sum e1)]
  5. [v2 (tree-sum e2)])
  6. (+ v1 v2))])

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

  1. `(,e1 ,e2)
  2. '( 1 2)

說白了,模式就是一個可以含有“名字”(像 e1 和 e2)的結構,像 '(,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)) 有什麼不同?發現沒有,這個算術表示式比起二叉樹,只不過在每個子樹結構裡多出了一個操作符:一個 * 和兩個 + 。它不再是一棵二叉樹,而是一種更通用的樹結構。

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

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

  1. #langracket; 宣告用 Racket 語言
  2. (definecalc
  3. (lambda(exp)
  4. (matchexp; 分支匹配:表示式的兩種情況
  5. [(?number?x)x]; 是數字,直接返回
  6. [`(,op,e1,e2); 匹配提取操作符op和兩個運算元e1,e2
  7. (let([v1(calce1)] ; 遞迴呼叫 calc 自己,得到 e1 的值
  8. [v2(calce2)]) ; 遞迴呼叫 calc 自己,得到 e2 的值
  9. (match op ; 分支匹配:操作符 op 的 4 種情況
  10. ['+(+v1v2)] ; 如果是加號,輸出結果為 (+ v1 v2)
  11. ['-(-v1v2)] ; 如果是減號,乘號,除號,相似的處理
  12. ['*(*v1v2)]
  13. ['/(/v1v2)]))])))

你可以得到如下的結果:

  1. (calc '(+ 1 2))
  2. ;; => 3
  3. (calc '(* 2 3))
  4. ;; => 6
  5. (calc '(* (+ 1 2) (+ 3 4)))
  6. ;; => 21

(完整的程式碼和示例,可以在這裡下載。)

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

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

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

    1. (match op
    2. ['+ (+ v1 v2)]
    3. ['- (- v1 v2)]
    4. ['* (* v1 v2)]
    5. ['/ (/ 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))) 是個巢狀的函式定義,它也可以被看成是有兩個引數(x 和 y)的函式,這個函式返回 x 和 y 的和。當這樣的函式被呼叫的時候,需要兩層呼叫,就像這樣:

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

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

另外,我們的繫結語法 (let ([x e1]) e2),比起 Scheme 的繫結也有一些侷限。我們的 let 只能繫結一個變數,而 Scheme 可以繫結多個,像這樣 (let ([x 1] [y 2]) (+ x y))。這也不是一個嚴重的限制,因為我們可以囉嗦一點,用巢狀的 let 繫結:

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

R2 的直譯器

下面是我們今天要完成的直譯器,它可以執行一個 R2 程式。你可以先留意一下各部分的註釋。

  1. #langracket
  2. ;;; 以下三個定義 env0, ext-env, lookup 是對環境(environment)的基本操作:
  3. ;; 空環境
  4. (define env0 '())
  5. ;; 擴充套件。對環境 env 進行擴充套件,把 x 對映到 v,得到一個新的環境
  6. (define ext-env
  7. (lambda (x v env)
  8. (cons `(,x . ,v) env)))
  9. ;; 查詢。在環境中 env 中查詢 x 的值。如果沒找到就返回 #f
  10. (define lookup
  11. (lambda (x env)
  12. (let ([p (assq x env)])
  13. (cond
  14. [(not p) #f]
  15. [else (cdr p)]))))
  16. ;; 閉包的資料結構定義,包含一個函式定義 f 和它定義時所在的環境
  17. (struct Closure (f env))
  18. ;; 直譯器的遞迴定義(接受兩個引數,表示式 exp 和環境 env)
  19. ;; 共 5 種情況(變數,函式,繫結,呼叫,數字,算術表示式)
  20. (define interp
  21. (lambda (exp env)
  22. (match exp ; 對exp進行模式匹配
  23. [(? symbol? x) ; 變數
  24. (let ([v (lookup x env)])
  25. (cond
  26. [(not v)
  27. (error "undefined variable" x)]
  28. [else v]))]
  29. [(? number? x) x] ; 數字
  30. [`(lambda (,x) ,e) ; 函式
  31. (Closure exp env)]
  32. [`(let ([,x ,e1]) ,e2) ; 繫結
  33. (let ([v1 (interp e1 env)])
  34. (interp e2 (ext-env x v1 env)))]
  35. [`(,e1 ,e2) ; 呼叫
  36. (let ([v1 (interp e1 env)]
  37. [v2 (interp e2 env)])
  38. (match v1
  39. [(Closure `(lambda (,x) ,e) env-save)
  40. (interp e (ext-env x v2 env-save))]))]
  41. [`(,op ,e1 ,e2) ; 算術表示式
  42. (let ([v1 (interp e1 env)]
  43. [v2 (interp e2 env)])
  44. (match op
  45. ['+(+v1v2)]
  46. ['-(-v1v2)]
  47. ['*(*v1v2)]
  48. ['/(/v1v2)]))])))
  49. ;; 直譯器的“使用者介面”函式。它把 interp 包裝起來,掩蓋第二個引數,初始值為 env0
  50. (define r2
  51. (lambda (exp)
  52. (interp exp env0)))

這裡有一些測試例子:

  1. (r2 '(+ 1 2))
  2. ;; => 3
  3. (r2 '(* 2 3))
  4. ;; => 6
  5. (r2 '(* 2 (+ 3 4)))
  6. ;; => 14
  7. (r2 '(* (+ 1 2) (+ 3 4)))
  8. ;; => 21
  9. (r2 '((lambda (x) (* 2 x)) 3))
  10. ;; => 6
  11. (r2
  12. '(let ([x 2])
  13. (let ([f (lambda (y) (* x y))])
  14. (f 3))))
  15. ;; => 6
  16. (r2
  17. '(let ([x 2])
  18. (let ([f (lambda (y) (* x y))])
  19. (let ([x 4])
  20. (f 3)))))
  21. ;; => 6

(完整的程式碼和示例,可以在這裡下載。)

在接下來的幾節,我們來仔細看看這個直譯器的各個部分。

對基本算術操作的解釋

算術操作一般都是程式裡最基本的構造,它們不能再被細分為多個步驟,所以我們先來看看對算術操作的處理。以下就是 R2 直譯器處理算術的部分,它是 interp 的最後一個分支。

  1. (match exp
  2. ... ...
  3. [`(,op ,e1 ,e2)
  4. (let ([v1 (interp e1 env)] ; 遞迴呼叫 interp 自己,得到 e1 的值
  5. [v2 (interp e2 env)]) ; 遞迴呼叫 interp 自己,得到 e2 的值
  6. (match op ; 分支:處理操作符 op 的 4 種情況
  7. ['+ (+ v1 v2)] ; 如果是加號,輸出結果為 (+ v1 v2)
  8. ['- (- v1 v2)] ; 如果是減號,乘號,除號,相似的處理
  9. ['* (* v1 v2)]
  10. ['/ (/ 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)。

在我們的直譯器裡,用於處理環境的程式碼如下:

  1. ;; 空環境
  2. (define env0 '())
  3. ;; 對環境 env 進行擴充套件,把 x 對映到 v
  4. (define ext-env
  5. (lambda (x v env)
  6. (cons `(,x . ,v) env)))
  7. ;; 取值。在環境中 env 中查詢 x 的值
  8. (define lookup
  9. (lambda (x env)
  10. (let ([p (assq x env)])
  11. (cond
  12. [(not p) #f]
  13. [else (cdr p)]))))

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

  1. ((x . 1)
  2. (y . 2)
  3. (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)。內層的繫結,會出現在環境的最上面,這就是在“壓棧”。這樣我們查詢變數的時候,會優先找到最內層定義的變數。

舉個例子:

  1. (let ([x 1]) ; env='()。繫結x到1。
  2. (let ([y 2]) ; env='((x . 1))。繫結y到2。
  3. (let ([x 3]) ; env='((y . 2) (x . 1))。繫結x到3。
  4. (+ x y)))) ; env='((x . 3) (y . 2) (x . 1))。查詢x,得到3;查詢y,得到2。
  5. ;; => 5

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

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

  1. (let ([x 1]) ; env='()。繫結x到1。
  2. (+ (let ([x 2]) ; env='((x . 1))。繫結x到2。
  3. x) ; env='((x . 2) (x . 1))。查詢x,得到2。
  4. x)) ; env='((x . 1))。查詢x,得到1。
  5. ;; => 3 ; 兩個不同的x的和,1+2等於3

這個例子會返回3。它是第3行和第4行裡面兩個 x 的和。由於第3行的 x