怎樣寫一個直譯器——王垠
怎樣寫一個直譯器
寫一個直譯器,通常是設計和實現程式語言的第一步。直譯器是簡單卻又深奧的東西,以至於好多人都不會寫,所以我決定寫一篇這方面的入門讀物。
雖然我試圖從最基本的原理講起,儘量不依賴於其它知識,但這並不是一本程式設計入門教材。我假設你已經理解 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([x1]
- [y2])
- (+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))
,就對應如下的語法樹結構:
其中,*
,兩個+
,1
,2
,3
,4
都是葉節點,而那三個紅色節點,都表示子樹結構:'(+ 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
(完整的程式碼和示例,可以在這裡下載。)
這個演算法很簡單,我們可以把它用文字描述如下:
- 如果輸入
exp
是一個數,那就返回這個數。 - 否則如果
exp
是像(,e1 ,e2)
這樣的子樹,那麼分別對e1
和e2
遞迴呼叫tree-sum
,進行求和,得到v1
和v2
,然後返回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)
說白了,模式就是一個可以含有“名字”(像 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))
有什麼不同?發現沒有,這個算術表示式比起二叉樹,只不過在每個子樹結構裡多出了一個操作符:一個 *
和兩個 +
。它不再是一棵二叉樹,而是一種更通用的樹結構。
這點區別,也就帶來了二叉樹求和與直譯器演算法的區別。對二叉樹進行求和的時候,在每個子樹節點,我們都做加法。而對錶達式進行解釋的時候,在每一個子樹節點,我們不一定進行加法。根據子樹的“操作符”不同,我們可能會選擇加,減,乘,除四種操作。
好了,下面就是這個計算器的程式碼。它接受一個表示式,輸出一個數字作為結果。
- #langracket; 宣告用 Racket 語言
- (definecalc
- (lambda(exp)
- (matchexp; 分支匹配:表示式的兩種情況
- [(?number?x)x]; 是數字,直接返回
- [`(,op,e1,e2); 匹配提取操作符op和兩個運算元e1,e2
- (let([v1(calce1)] ; 遞迴呼叫 calc 自己,得到 e1 的值
- [v2(calce2)]) ; 遞迴呼叫 calc 自己,得到 e2 的值
- (match op ; 分支匹配:操作符 op 的 4 種情況
- ['+(+v1v2)] ; 如果是加號,輸出結果為 (+ v1 v2)
- ['-(-v1v2)] ; 如果是減號,乘號,除號,相似的處理
- ['*(*v1v2)]
- ['/(/v1v2)]))])))
你可以得到如下的結果:
- (calc '(+ 1 2))
- ;; => 3
- (calc '(* 2 3))
- ;; => 6
- (calc '(* (+ 1 2) (+ 3 4)))
- ;; => 21
(完整的程式碼和示例,可以在這裡下載。)
跟之前的二叉樹求和程式碼比較一下,你會發現它們驚人的相似,因為直譯器本來就是一個樹遍歷演算法。不過你發現它們有什麼不同嗎?它們的不同點在於:
算術表示式的模式裡面,多出了一個“操作符”(op)葉節點:
(,op ,e1 ,e2)
對子樹 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)))
是個巢狀的函式定義,它也可以被看成是有兩個引數(x
和 y
)的函式,這個函式返回 x
和 y
的和。當這樣的函式被呼叫的時候,需要兩層呼叫,就像這樣:
- (((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 程式。你可以先留意一下各部分的註釋。
- #langracket
- ;;; 以下三個定義 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
- ['+(+v1v2)]
- ['-(-v1v2)]
- ['*(*v1v2)]
- ['/(/v1v2)]))])))
- ;; 直譯器的“使用者介面”函式。它把 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)
的值的時候,它分成兩步操作:
- 把
x
繫結到 1,這樣函式體內才能看見這個繫結。 - 進入
f
的函式體,對x * 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