開始之前
這是一篇 Scheme 的介紹文章. Scheme 是一個 LISP 的方言, 相對於 Common LISP 或其他方言, 它更強調理論的完整和優美, 而不那么強調實用價值. 我在 學習 Scheme 的時候, 常想的不是 "這有什么用", 而是 "為什么" 和 "它 的本質是什么". 我覺得這樣的思考對學習計算機是非常有益的.
我不知道 "Scheme 之道" 這個題目是否合適, 我還沒到能講 "XXX 之道" 的時候. 但 Scheme 確實是一個極具哲學趣味的語言, 它往往專注於找出事物的 本質, 用最簡單, 最通用的方法解決問題. 這樣的思路下, 我們會碰到許多以往 不會遇到, 或不甚留意的問題, 使我們對這些問題, 以及計算機科學的其他方面 有新的認識和思考.
講 Scheme 的好書有很多, 但 Scheme 在這些書中往往就像指着月亮的慧能的手 指或是道家的拂塵, 指引你發現計算機科學中的某些奇妙之處, 但 Scheme 本身 卻不是重點. 如SICP (Structure and Interpretation of Computer Programs) 用 Scheme 來指引學生學習計算機科學中的基本概念; HTDP (How to design programs) 用Scheme 來介紹程序設計中常用的技巧和方法. 而這篇文章, 着眼點 也不是scheme 本身, 或者着眼點不在 scheme 的 "形", 而在與 scheme 的 "神". 怎么寫一個好的 scheme 程序不是我的重點, 我的重點是 "這個設計真 美妙", "原來本質就是如此", 如是而已. Scheme 的一些理論和設計啟發了我 , 使我在一些問題, 一些項目上有了更好的想法. 感念至今, 所以寫一系列小文 將我的體會與大家分享.
要體驗 Scheme, 當然首先要一個 Scheme 的編程環境. 我推薦 drScheme (http://www.drscheme.org), 跨平台, 包括了一個很好的編輯和調試界面 . Debian/Ubuntu 用戶直接 apt-get 安裝即可.
希望讀者有基本的編程和數據結構知識. 因為解釋 Scheme 的很多概念時, 這些 知識是必須的.
數據結構的本質論
世界的終極問題
這兩個可能是人類對世界認識的終極問題: 世界上最基本的, 不可再分的物質單 位是什么? 這些最基本的物質單位是怎么組成這個大千世界的?
Scheme 也在試圖解答這個問題.
Scheme 認為以下兩種東西是原子性的, 不可再分的: 數, 符號. 數這個好理解, 符號這個概念就有點麻煩了. 做個比方, "1" 是一個數字, 但它其實是一個符 號, 我們用這個符號去代表 "1" 這個概念, 我們也可以用 "一" 或 "one" 代表這個概念, 當然 "1" 也可以表示 "真" 的概念, 或者什么都 不表示. 而 kyhpudding 也是一個 Scheme 中的符號, 它可以代表任何東西, Scheme 能理解的或不能理解的. 這都沒所謂, Scheme 把它作為一個原子單位對 它進行處理: 1 能跟其他數字作運算得出一個新的數字, 但 1 始終還是 1, 它 不會被分解或變成其他什么東西, 作為符號的 kyhpudding 也大抵如此.
下一個問題是: 怎么將原子組成各種復合的數據結構 --- 有沒有一種統一的方 法?
我們從最簡單的問題開始: 怎么將兩個對象聚合在一起? 於是我們引入了 "對 " (pair) 的概念, 用以聚合兩個對象: a 和 b 組成的對, 記為:
如果是要聚合三個或以上的數據呢? pair 的方法還適用嗎? 我們是否需要引入 其他方法? 答案是不需要, 我們遞歸地使用 pair 結構就可以了. 聚合 a, b, c, 記為 (a . (b . c)), 它的簡化記法是 (a b . c).
大家都能想到了, 遞歸地使用 pair 二叉樹的結構, 就能表達任意多個對象組成 的序列. 比如 (a b . c), 畫圖就是:
請大家繼續將頭左側 45 度. 這樣的一個表示序列的樹的特點是, 樹的左結點是 成員對象, 右結點指向一顆包含其他成員的子樹. 但大家也發現了一個破例: 圖 中的 c 是右邊的葉子. 解決這個問題的辦法是: 我們引入一個 "無" 的概念:
這個無的概念我們用 "()" 來表達 (總不能什么都沒有吧). 記 (a . ()) 為 (a). 那么上圖就可以表示為 (a b c). 這樣的結構我們就叫做列表 --- List. 這是 Scheme/LISP 中應用最廣的概念. LISP 其實就是 "LISt Processing" 的意思.
這樣的結構表達能力很強, 因為它是可遞歸的, 它可以表達任何東西. 比方說, 一個普通的二叉樹可以像這樣表示 (根 (根 左子樹 右子樹) 右子樹). 大家也 可以想想其他的數據結構怎么用這種遞歸的列表來表示.
開始編程啦
好了, 我們可以開始寫一點很簡單的 Scheme 程序. 比方說, 打入 1, 它就會返 回 1. 然后, 打入一個列表 (1 2), 出錯鳥... 打入一個符號: kyhpudding, 也 出錯鳥......
於是我們就要開始講一點點 Scheme 的運作原理了. 剛才我們講了 Scheme 中的 數據結構, 其實不但是 Scheme 處理的數據, 整個 Scheme 程序都是由這樣的列 表和原子對象構成的, 一個合法的 Scheme 數據結構就是一個 Scheme 語句. 那 么這樣的語句是怎么運行的呢? 總結起來就是三條邏輯:
- 如果那是一個數, 則返回這個數
- 如果那是一個符號, 則返回該符號所綁定的對象. (這個概念我們會遲點解釋)
- 如果那是一個列表, 把列表的第一項作為方法, 其他作為參數, 執行之.
所以, 我們可以試試這個 (+ 1 2), 這下就能正確執行了.
那么, 如果我就想要他返回 (+ 1 2) 這個列表呢? 試試這樣 (quote (+ 1 2)) quote 是一個很特殊的操作, 意思是它的參數不按規則處理, 而是直接作為數據 返回. 我們會常常用到它, 所以也有一個簡化的寫法 '(+ 1 2), 在前面加一個 單引號就可以了. 這樣子, 'kyhpudding 也能有正確的輸出了.
那么我們可以介紹三個原子操作, 用以操縱列表. 其實, 所謂操縱列表, 也只是 操縱二叉樹而已. 所以我們有這么三個操作:
- cons: 將它的兩個參數組合起來, 形成新的二叉樹/pair
- car: 返回參數的左子樹
- cdr: 返回參數的右子樹
通過以下幾個操作, 結合對應的二叉樹圖, 能比較好的理解這個 Scheme 最基礎 的設計:
無處不在的函數
基本的函數概念
Scheme 是一門函數式語言, 因為它的函數與數據有完全平等的地位, 它可以在 運行時被實時創建和修改. 也因為它的全部運行都可以用函數的方式來解釋, 莫 能例外.
比方說, 把 if 語句作為函數來解釋? (if cond if-part else-part) 是這么一 個特殊的函數: 它根據 cond 是否為真, 決定執行並返回 if-part 還是 else-part. 比如, 我可以這樣寫:
if 函數會根據我開心與否 (i-am-feeling-lucky 是一個由我決定它的返回值的 函數 :P) 返回 + 或 - 來作為對我的開心值的操作. 所謂無處不在的函數, 其 意義大抵如此.
把一串操作序列當成函數呢? Scheme 是沒有 "return" 的, 把一串操作序列 當作一個整體, 它的返回值就是這一串序列的最后一個的返回值. 比如我們可以 寫
它的返回是 7.
無名的能量之源
接下來, 我們就要接觸到 Scheme 的靈魂 --- Lambda. 大家可以注意到 drScheme 的圖標, 那就是希臘字母 Lambda. 可以說明 Lambda 運算在 Scheme 中是多么重要.
NOTE: 這里本來應該插一點 Lambda 運算的知識的, 但是一來我自己數學就不怎 么好沒什么信心能講好, 二來講太深了也沒有必要. 大家如果對 Lambda 運算的 理論有興趣的話, 可以自行 Google 相關資料.
Lambda 能夠返回一個匿名的函數. 在這里需要注意兩點: 第一, 我用的是 "返 回" 而不是 "定義". 因為 Lambda 同樣可以看成一個函數 --- 一個能夠生 成函數的函數. 第二, 它是匿名的, 意思是, 一個函數並不一定需要與一個名字 綁定在一起, 我們有時侯需要這么干, 但也有很多時候不需要.
我們可以看一個 Lambda 函數的基本例子:
這里描述了一個加法函數的生成和使用. (lambda (x y) (+ x y)) 中, lambda 的第一個參數說明了參數列表, 之后的描述了函數的行為. 這就生成了一個函數 , 我們再將 1 和 2 作用在這個函數上, 自然能得到結果 3.
我們先引入一個 define 的操作, define 的作用是將一個符號與一個對象綁定 起來. 比如
我們自然也可以用 define 把一個符號和函數綁定在一起, 就得到了我們常用的 有名函數.
做一個簡單的替換, 上面的例子就可以寫成 (add 1 2), 這樣就好理解多了.
上面的寫法有點晦澀, 而我們經常用到的是有名函數, 所以我們有一個簡單的寫 法, 我們把這一類簡化的寫法叫 "語法糖衣". 在前面我們也遇到一例, 將 (quote x) 寫成 'x 的例子. 上面的定義, 我們可以這樣寫
Lambda 運算有極其強大的能力, 上面只不過是用它來做傳統的 "定義函數" 的工作. 它的能力遠不止如此. 這里只是舉幾個小小的例子:
我們經常會需要一些用於迭代的函數, 比如這個:
我們也需要減的, 乘的, 還有其他各種亂七八糟的操作, 我們需要每次迭代不是 1, 而是 2, 等等等等. 我們很自然地有這個想法: 我們寫個函數來生成這類迭 代函數如何? 在 Scheme 中, 利用 lambda 運算, 這是可行且非常簡單的. 因為 在 Scheme 中, 函數跟普通對象是有同樣地位的, 而 "定義" 函數的 lambda, 其實是能夠動態地為我們創造並返回函數對象的. 所以我們可以這么寫:
這個簡單的例子, 已經能夠完成我們在 C 之類的語言無法完成的事情. 要生成 上面的 inc 函數, 我們可以這么寫:
這個例子展示的是 Scheme 利用 Lambda 運算得到的能力. 利用它, 我們可以寫 出制造函數的函數, 或者說制造機器的機器, 這極大地擴展了這門語言的能力 . 我們在以后會有更復雜的例子.
接下來, 我們會介紹 Scheme 的一些語言特性是怎么用 Lambda 運算實現的 --- 說 Scheme 的整個機制是由 Lambda 驅動的也不為過.
比如, 在 Scheme 中我們可以在任何地方定義 "局部變量", 我們可以這么寫:
其實 let 也只不過是語法糖衣而已, 因為上面的寫法等價於:
一些常用的函數
雖然說這篇文章不太注重語言的實用性. 但這里還是列出我們經常用到的一些操 作, 這能極大地方便我們的編程, 大家也可以想想他們是怎么實現的.
cond
相當於 C 中的 switch
循環語句
沒有循環語句...... 至少沒有必要的循環語句. Scheme 認為, 任何的循環迭代 都可以用遞歸來實現. 我們也不用擔心遞歸會把棧占滿, 因為 Scheme 會自動處 理尾遞歸的情況. 一個簡單的 0 到 10 迭代可以寫成這樣.
很明顯, 當我們遞歸調用 iterate 的時候, 我們不必保存當前的函數環境. 因 為我們遞歸調用完畢后就馬上返回, 而不會再使用當前的環境, 這是一給尾遞歸 的例子. Scheme 能自動處理類似的情況甚至做一些優化, 不會浪費多余的空間, 也不會降低效率. 所以完全可以代替循環.
當然我們有些便於循環迭代的操作, 大家可以試試自己實現他們. (當然在解釋 器內部通常不會用純 scheme 語句實現他們). 我們最常用的是 map 操作
運行一下這個例子, 就能理解 map 的作用了.
更多的數據操作
- cadr cddr caddr 之類, 就是 car 和 cdr 的組合, 大家可以一個個試 . drScheme 支持到 cadddr...
- append: 將兩個列表拼接在一起.
無始無終的太極
我想其他語言的入門教程都不會有這么一節: 這門語言的運作原理是怎么樣的 . 但這么一節內容是 Scheme 的入門教程必有的. Scheme 把它最核心, 最底層 的機制都提供出來給用戶使用, 使它有非常強大的能力. 所以知道它的運行機理 是非常重要的.
這一節和下一節都是在分析 Scheme 的運行原理. 在這一節中, 我們會用一個太 極圖來分析一條 Scheme 語句是怎么被執行的. 在下一節, 我們會在這一節的基 礎上引入 Scheme 的對象/內存管理機制. 從而得到一個比較完整的 Scheme 運 行原理, 並用 Scheme 語言表示出來.
我們先從 eval 和 apply 的用法說起. eval 接受一個參數, 結果是執行那個參 數的語句, 而 apply 則接受兩個參數, 第一個參數表示一個函數, 第二個參數 是作用於這個函數的參數列表. 例如:
我們可以輕易發現, 這兩者是可以輕易轉化的:
但是顯然, 真正的實現不可能如此, 不然 eval 一次就沒完沒了地轉圈了. 我們 在前面提到 Scheme 的基本運行邏輯, 其實也是 eval 的基本原理:
- 如果那是一個數, 則返回這個數
- 如果那是一個符號, 則返回該符號所綁定的對象.
- 如果那是一個列表, 把列表的第一項作為方法, 其他作為參數, 執行之.
我們來實現一個這樣的邏輯, 要注意的是, 下面的 eval 和 apply 的寫法都只 是說明概念, 並不是真實可運行的. 但用 Scheme 寫一個 Scheme 解釋器是確實 可行的:
在第三項, 我們很自然地用了 apply 來實現. 注意 apply 接受的第一個參數必 須是一個函數對象, 而不能是一個類似 add 的名字, 所以我們要遞歸地調用 eval 解析出它的第一個參數. 那么 apply 要怎么實現呢? 我們來看一個實例:
用 eval 執行它的時候, 會執行
在執行它的時候 , 為了運行它, 我們要知道 add 和 x 代表什么, 我們還得知道 (+ y 1) 的結果, 否則我們的計算無法繼續下去. 我們用什么來求得這些值呢
--- 顯然是eval. 因此 apply 的處理流程大致如下:
我們得到的還是一個互相遞歸的關系. 不過這個遞歸是有盡頭的, 當我們遇到原 子對象時, 在 eval 處就會直接返回, 而不會再進入這個遞歸. 所以 eval 和 apply 互相作用, 最終把程序解釋成原子對象並得到結果. 這種循環不息的互相 作用, 可以表示為這樣一個太極:
這就是一個 Scheme 解釋器的核心.
然而, 我們上面的模型是不盡准確的. 比如, (if cond if-part else-part) 把 這個放入 apply 中的話, if-part 和 else-part 都會被執行一遍, 這顯然不是 我們希望的. 因此, 我們需要有一些例外的邏輯來處理這些事情, 這個例外邏輯 通常會放在 eval. (當然理論上放在 apply 里也可以, 大家可以試一下寫, 不 過這樣在 eval 中也要有特殊的邏輯之處 "if" 這個符號所對應的值). 我們 可以把 eval 改成這樣
這樣我們的邏輯就比較完整了.
另外 apply 也要做一些改動, 對於 apply 的 method, 它有可能是類似 "+" 這樣的內置的 method, 我們叫它做 primitive-proceure, 還有由 lambda 定義 的 method, 他們的處理方法是不一樣的.
在下一節, 我們就會從 lambda 函數是怎么執行的講起, 並再次修改 eval 和 apply 的定義, 使其更加完整. 在這里我們會提到一點點 lambda 函數的執行原 理, 這其實算是一個 trick 吧.
我們這樣定義 lambda 函數
那么我們在 apply 這個 lambda 函數的時候會發生什么呢? apply 會根據參數 表和參數做一次匹配, 比如, 參數表是 (x y) 參數是 (1 2), 那么 x 就是 1, y 就是 2. 那么, 我們的參數表寫法其實可以非常靈活的, 可以試試這兩個語句 的結果:
((lambda x x) 1 2) <= 注意兩個 x 都是沒有括號的哦 ((lambda (x . y) (list x y)) 1 2 3)
這樣 "匹配" 的意義是否會更加清楚呢? 由於這樣的機制, 再加上可以靈活運 用 eval 和 apply, 可以使 Scheme 的函數調用非常靈活, 也更加強大.
唯心主義的對象管理系統
關於對象
既然這一節我們要講對象管理系統. 我們首先就要研究對象, 研究在 Scheme 內 部是如何表示一個對象. 在 Scheme 中, 我們的對象可以分成兩類: 原子對象和 pair.
我們要用一種辦法唯一地表示一個對象. 對原子對象, 這沒什么好說的, 1 就是 1, 2 就是 2. 但是對 pair, 情況就比較復雜了.
如果我們修改了 a 的 car 的值, 我們不希望 b 的值也同樣的被改變. 因此雖 然 a 和 b 在 define 時的值一樣, 但他們不是相同的對象, 我們要分別表示他 們. 但是 在這個時候
a 和 b 應該指的是同一個對象, 不然 define 的定義就會很尷尬 (define 不是 賦值, 而是綁定). 修改了 a 的 car, b 也應該同時改變.
答案很明顯了: 對 pair 對象, 我們應把它表示為一個引用 --- 熟悉 Java 的 同學也會知道一個相同的原則: 在 Java 中, 變量可以是一個原子值 (如數字), 或者是對一個復合對象的引用.
在這里我們引入一組操作, 它可以幫助測試, 理解這樣的對象系統:
- set!: 不要漏了嘆號, 修改一個符號的綁定
- set-car!: 修改 pair 中左邊值的綁定
- set-cdr!: 修改 pair 中右邊值的綁定
- eq?: 測試兩個對象是否相等
- equal?: 測試兩個對象的值是否相等.
我們可以進行如下測試:
另外我們可以想想以下操作形成的對象的結構:
它形成的結構應該是這樣的
所以 (eq? (cdr a) (cdr b)) 的值應該是真.
lambda 的秘密
接下來我們要研究: Scheme 是怎么執行一個 lambda 函數的? 運行一個 lambda 函數, 最重要的就是建立一個局部的命名空間, 以支持局部變量 --- 對 Scheme 來說, 所謂局部變量就是函數的參數了. 只要建立好這樣的一個命名空間, 剩下 的事情就是在此只上逐條運行語句而已了.
我們首先可以看這樣的一個例子:
結果當然是 20, 這說明了 Scheme 在運行 lambda 函數時會建立一個局部的命名 空間 --- 在 Scheme 中, 它叫做 environment, 為了與其他的資料保持一致, 我 們會沿用這個說法, 並把它簡寫為 env. 而且這個局部 env 有更高的優先權 . 那我們似乎可以把尋找一個符號對應的對象的過程描述如下, 這也是 C 語言程 序的行為:
- 先在函數的局部命名空間里搜索
- 如果找不到, 在全局變量中搜索.
但是 Scheme 中, 函數是可以嵌套的:
很好, 這不就是一個棧的結構嗎? 我們在運行中維護一個 env 的棧, 搜索一個名 稱綁定時從棧頂搜索到棧底就可以了.
這在 Pascal 等靜態語言中是可行的 (Pascal 也支持嵌套的函數定義). 但是在 Scheme 中不行 --- Scheme 的函數是可以動態生成的, 這會產生一些棧無法處 理的情況, 比如我們上面使用過的例子:
執行 inc 和 dec 的時候, 它執行的是 (method x step), x 的值當然很好確定 , 但是method 和 step 的值就有點麻煩了. 我們調用 make-iterator 生成 inc 和dec 的時候, 用的是不同的參數, 執行 inc 和 dec 的時候, method 和 step 的值當然應該不一樣, 應該分別等於調用 make-iterator 時的參數. 這樣的特性 , 就沒法用一個棧的模型來解釋了.
一個更令人頭痛的問題是: 運行 lambda 函數時會創造一個 env, 現在看起來, 這個 env 不是一個臨時性的存在, 即使是在函數執行完以后, 它都有存在的必要 , 不然像上例中, inc 在運行時就沒法正確地找到 + 和 1 了. 這是一種我們從 未遇到的模型.
我們要修改函數的定義. 在 Scheme 中, 函數不僅是一段代碼, 它還要和一個 environment 相連. 比如, 在調用 (make-iterator + 1) 的時候, 生成的函數要 與執行函數 make-iterator 實時產生的 env 相連, 在這里, method = +, step = 1; 而調用 (make-iterator - 1) 的時候, 生成的函數是在與另一個 env --- 第二次調用 make-iterator 產生的 env 相連, 在這里, method = -, step = 1. 另外, 各個 env 也是相連的. 在執行函數 inc 時, 他會產生一個含有名稱 x 的 env, 這個 env 要與跟lambda 函數相連的的 lambda 相連. 這樣我們在只 含有 x 的 env 中找不到method, 可以到與其相連的 env 中找. 我們可以畫圖如 下來執行 (inc 10) 時的 env 關系:
inc: ((x 10)) -> ((method +) (step 1)) -> ((make-iterator 函數體))
這里的最后一項就是我們的全局命名空間, 函數 make-iterator 是與這個空間 相連的.
於是我們可以這樣表示一個 env 和一個 lambda 函數對象: 一個 env 是這么一 個二元組 (名稱綁定列表 與之相連的上一個 env). 一個 lambda 是一個這樣的 三元組: (參數表 代碼 env).
由此我們需要修改 eval 和 apply. 解釋器運行時, 需要一直保持着一個 "當 前 env". 這個當前 env 應該作為參數放進 eval 和 apply 中, 並不斷互相傳 遞. 在生成一個 lambda 對象時, 我們要這樣利用 env:
這樣就可以表示 lambda 函數與一個 env 的綁定. 那么我們執行 lambda 函數 的行為可以這么描述:
這樣我們就可以完全清楚的解釋 make-iterator 的行為了. 在執行 (make-iterator + 1) 時, make-env 生成了這樣的一個 new-env:
這個 new-env 會作為參數 env 去調用 eval. 在 eval 執行到 lambda 一句時, 又會以這樣的參數來調用 make-lambda, 因此這樣的一個 env 就會綁定到這個 lambda 函數上. 同理, 我們調用 (make-iterator - 1) 的時候, 就能得到另一 個 env 的綁定.
這種特性使 "函數" 在 scheme 中的含義非常豐富, 使用非常靈活, 以下這個 例子實現了非常方便調試的函數計數器:
用普通的參數調用 add 時, 它會執行一個正常的加法操作. 但如果調用 (add 'print), 它就會返回這個函數被執行了多少次. 這樣的一個測試用 wrapper 是 完全透明的. 正因為 scheme 函數可以與一個 env, 一堆值相關聯, 才能實現這 么一個功能.
自動垃圾收集
我們的問題遠未解決.
C 語言中, 局部變量放在棧中, 執行完函數, 棧頂指針一改, 這些局部變量就全 沒了. 這好理解得很. 但根據我們上面的分析, Scheme 中的函數執行完后, 它 創造的 env 還不能消失. 這樣的話, 不就過一會就爆內存了么......
所以我們需要一個自動垃圾收集系統, 把用不着的內存空間全部收回. 大家可能 都是在 Java 中接觸這么一個概念, 但自動垃圾收集系統的祖宗其實是 LISP, Scheme 也繼承了這么一個神奇的系統.
自動垃圾收集系統可以以一句唯心主義的話來概括: 如果你沒法看到它了, 它就 不存在了. 在 Java 中, 它似乎是一個很神奇的機制, 但在 Scheme 中, 它卻簡 單無比.
我們引入上下文的概念: 一個上下文 (context), 包括當前執行的語句, 當前 env, 以及上一個與之相連的 context --- 如我們所知, 在調用 lambda 函數時 , 會產生一個新的 env, 但其實它也產生一個新的 context, 包括了 lambda 中 的代碼, 新的 env, 以及對調用它的 context 的引用 (這就好比在 x86 中調用 CALL 指令壓棧的當前指令地址, 在使用 RET 的時候可以彈出返回正確的地方 ). 它是這樣的一個三元組: (code env prev-context). 任何時候, 我們都處於 一個這樣的上下文中.
引入這個概念, 是因為一個 context, 說明了任何我們能夠訪問和以后可能會訪 問的對象集合: 正要運行的代碼當然是我們能訪問的, env 是所有我們能夠訪問 的變量的集合, 而 prev-context 則說明了我們以后可能能夠訪問的東西: 在函 數執行完畢返回后, 我們的 context 會恢復到 prev-context, prev-context 包含的內容是我們以后可能訪問到的.
如上所述, code, env, 以及 context 本身都可以描述為標准的 LIST 結構, 那 我們所謂能 "看到" 的對象, 就是當前 context 這個大表中的所有內容. 其 他的東西, 都是垃圾, 要被收走.
比如, 我們處在 curr-context 中, 調用 (add 1 2). 那會產生一個新的 new-context, 在執行完 (add 1 2) 后, 我們又回到了 curr-context, 它與 new-context 不會有任何的聯系 --- 我們無論如何也不可能在這里訪問到執行 add 時的局部變量. 所以執行 add 時產生的 env 之類, 都會被當作垃圾收走.
當我們使用的內存多於某個閾值, 自動垃圾收集機制就會啟動. 有了上面的介紹 , 我們會發現這么個機制簡單的不值得寫出來: 當前的 context 是一個 LIST, 遍歷這個 LIST, 把里面的所有對象標記為有用. 然后遍歷全部對象, 把沒有標 記為有用的對象全部當垃圾回收, 完了. 當然真實實現遠遠不是如此, 會有很多 的優化, 但它的基本理論就是如此.
好了, 我們要再一次修改 eval, apply 和 run-lambda 的實現, 這次要怎么改 動大家都清楚得很了.
通過這次修改, 我們也可以解釋自動處理尾遞歸為什么是可行的. 我們在上面舉 出了一個尾遞歸的例子:
在 C 語言中, 再新的新手也不會寫這種狂吃內存的愚蠢代碼, 但在 Scheme 中, 它是很合理的寫法 --- 因為有自動垃圾收集.
在每次調用函數的時候, 我們可以做這樣的分析, iterate 的遞歸調用圖如下:
(iterate 0) -> (iterate 1) -> (iterate 2) .... | ^ | ^ | ----+ +----------+ +-------------+
下面的箭頭表示函數返回的路徑. 如果我們每次的遞歸調用都是函數體中的最后 一個語句, 就說明: 比如從 (interate 2) 返回到 (iterate 1) 時, 我們什么 都不用干, 又返回到 (iterate 0) 了. 在 iterate 中, 我們每一層遞歸都符合 這個條件, 所以我們就給它一個捷徑:
(iterate 0) -> (interate 1) -> ... (interate 10) | <-----------------------------------------+
讓他直接返回到調用 (iterate 0) 之前. 在實現上, 我們可以這么做: 比如, 我們處在 (iterate 0) 的 context 中, 調用 (iterate 1). 我們把 (iterate 1) 的 context 中的 prev-context 記為 (iterate 0) 的 prev-context, 而不 是 (iterate 0) 的 context, 就能形成這么一條捷徑了. 我們每一層遞歸都這 么做, 可以看到, 其實每一層遞歸的 context 中的 prev-context 都是調用 (interate 0) 之前的 context! 所以其實執行 (interate 10) 的時候, 與前面 的 context 沒有任何聯系, 前面遞歸產生的 context 都是幽魂野鬼, 內存不足 時隨時可以回收, 因此不用擔心浪費內存. 而 Scheme 自動完成分析並構造捷徑 的過程, 所以在 Scheme 中可以用這樣的遞歸去實現迭代而保持高效.
面向對象的 Scheme
我們可以這樣定義一個對象: 對象就是數據和在數據之上的操作的集合.
Scheme 中的 lambda 函數, 不但有代碼, 還和一個 environment, 一堆數據相 連 --- 那不就是對象了么. 在 Scheme 中, 確實可以用 lambda 去實現面向對 象的功能. 一個基本的 "類" 的模板是類似這樣的:
使用
這樣就能很方便地把它和其他語言中的對象對應起來了.
Scheme 雖然沒有真正的, 復雜的面向對象概念, 沒有繼承之類的咚咚, 但 Scheme 能夠實現更靈活, 更豐富的面向對象功能. 比如, 我們前面舉過的 make-counter 的例子, 它就是一個函數調用計數器的類, 而且, 它能提供完全 透明的接口, 這一點, 其他語言就很難做到了.
創造機器的機器
真實存在的時光機器
在上一節中, 我們引入了 context 的概念, 這個概念代表 scheme 解釋器在任 何時刻的運行狀態. 如果我們有一種機制, 能夠把某個時候的 context 封存起 來, 到想要的時候, 再把它調出來, 這一定會非常有趣 --- 對, 就像游戲中的 存檔一樣. 如果真有這樣的機制, 那就簡直是真實存在的時光機器了.
Scheme 還真的有這個機制 --- 它把 context 也看成一個對象, 可以由用戶自 由地使用, 這使我們能完成很多 "神奇" 的事情. 在上一節, 我們為了方便理 解, 使用了 "context" 這一叫法, 在這里, 我們恢復它的正式稱呼 --- 這一 節, 我們研究 continuation.
我們還是從它的用法說起, continuation 的使用從 call-with-current-continuation 開始, 這個名字長得實在難受, 我們按慣例 一律縮寫為 call/cc. call/cc 可以這樣使用
它接受一個函數作為參數, 而這個函數的參數就是這個 continuation 對象. 我 們要怎么用這個對象呢? 以下是一個最簡單的例子:
大家可以試試它的結果, 與 (+ 1 2) 相同. 這里最重要的一句是 (cont 2). 我 們從一開始就說, Scheme 中的一切都是函數, 在上一節中我們知道, 為了執行一 個函數, 我們創建一個 context (continuation), 那 context 的行為的最終結 果就是返回一個值了. 而 (cont 2) 這樣用法相當於是給 cont 這個 continuation 下個斷言: 這個 context(continuation) 的返回值就是 2, 不用 再往下算了 --- 我們也可以這么想象, 當解釋器運行到 (cont 2) 的時候, 就把 整個 (call/cc ....) 替換成 2, 所以得到我們要的結果.
沒什么特別, 對吧. 但這一點點已經能有很重要的應用 --- 我的函數有很多條 語句 (這在 C 等過程式語言中很常見, 在 Scheme 這類語言中倒是少見的), 我 想讓它跑到某個點就直接 return; 我需要一個像 try ... catch 這樣的例外機 制, 而不想寫一個 N 層的 if. 上面的 continuation 用法就已經能做到了, 大 家可以試試寫一個 try ... catch 的框架, 很簡單的.
老實說, 上面這個一點都不像時光機, 也不見得有多強大. 我們再來點好玩的:
以上這些語句當然不會有執行結果, 因為 call/cc 沒有返回任何值給 x, 在 if 語句之后就無法繼續下去了. 不過, 在這里我們把這個 continuation 保存成了 一個全局變量 g-cont. 現在我們可以試試: (g-cont 10). 大家可以看到結果了 : 這才是時光機啊, 通過 g-cont, 我們把解釋器送回從前, 讓 x 有了一個值, 然后重新計算了 let 之中的內容, 得出我們所要的答案.
這樣的機制當然不僅僅是好玩的, 它可以實現 "待定參數" 的功能: 有的函數 並不能直接被調用, 因為它的參數可能由不同的調用者提供, 也可能相隔很長時 間才分別提供. 但無論如何, 只要參數一齊, 函數就要馬上得到執行 --- 這是 一種非常常見的模塊間通訊模式, 但用普通的函數調用方法無法實現, 其他方法 也很難實現得簡單漂亮, continuation 卻使它變得非常簡單. 比如
到我們用類似 (slot-x 10) 的形式提供完整的 x y 參數值后, add 就會正確地 計算. 在這里, add 不用擔心是誰, 在什么時候給它提供參數, 而參數的提供者 也不必關心它提供的數據是給哪個函數, 哪段代碼使用. 這樣, 模塊之間的耦合 度就很低, 而依然能簡單, 准確地實現功能. 實在非 continuation 不能為也.
不過要注意的是, continuation 並不是真的如游戲的存檔一般 --- 我們知道 continuation 的實現, 一個 continuation 只不過是一個簡單的對象指針, 它 不會真的復制保存下全部運行狀態. 我們保存下一個 continuation, 修改了全 局變量, 然后再回到那個 continuation, 全局變量是不會變回來的. 有了前一 章的知識, 大家很清楚什么才會一直在那里不被改動 --- 這個 continuation 所關聯的私有 env 才是不會被改動的.
既然有時光機的特性, continuation 會是一個強力的實現回溯算法的工具. 我們 可以用 continuation 保存一個回溯點, 當我們的搜索走到一個死胡同, 可以退 回上一個保存的回溯點, 選擇其他的方案.
比方說, 在 m0 = (4 2 3) 和 m1 = (1 2 3) 中搜索一個組合, 使 m0 < m1, 這 是一個非常簡單的搜索問題. 我們遞歸地先選一個 m0 的值, 再選一個 m1 的值 , 到下一層遞歸的時候, 由於沒有東西可選了. 所以我們檢驗是否 m0 < m1, 如 果是, 退出, 否則就回溯到上一回溯點, 選擇下一個值.
回溯點的保存當然是用 continuation, 我們不但要在一個嘗試失敗時使用 continuation 作回溯, 還需要在得到正確答案時用 continuation 跳過層層遞 歸, 直接返回答案.
所以, 我們有這樣的一個過程:
(define (search-match . ls) (define (do-search fix un-fix success fail) (if (null? un-fix) (if (< (car fix) (cadr fix)) (success fix) (fail)) (choose fix (car un-fix) (cdr un-fix) success fail))) (call/cc (lambda (success) (do-search '() ls success (lambda () (error "Search failed"))))))
當 un-fix 為空時, 說明所有值都已經選定, 我們就可以檢驗值並選擇下一步動 作. 吸引我們的是 choose 的實現, choose 要做的工作就是在 un-fix 中的第 一項里選定一個值, 放到 fix 中, 然后遞歸地調用 do-search 進入下一層遞歸 . 在 C 中, 它的工作是用循環完成的, 在 Scheme 中, 它卻是這么一個遞歸的 過程:
我們在上面說過將一個循環轉換成遞歸的過程, 現在大家就要把這個遞歸重新化 為我們熟悉的循環了. (prev-fail) 相當於 C 中循環結束后自然退出, 這退到 了上一個回溯點. 而下面 call/cc 的過程在遞歸 do-search 的時候創建了一個 回溯點. 比如, 在 do-search 中運行 (fail), 就會回溯回這里, 遞歸地調用 choose 來選定下一個值.
大家可以寫出相應的 C 程序進行對照, 應該能夠理解到 fail 參數在這里的使 用. 其實這樣回溯實現確實是比較啰嗦的 --- 但是, 如果我們能不寫任何代碼, 讓機器自動完成這樣的搜索計算呢?
簡言之, 我們只需要一個函數
(define (test a b) (< a b))
然后給定 a, b 的可選范圍, 然后系統就告訴我們 a b 的值, 我們不用關心它 是怎么搜索出來的.
有這東西么? 在 Scheme 中請相信奇跡, 用 continuation 可以方便地實現這樣 的系統. 下面, 我們要介紹這個系統, 一個 continuation 的著名應用 --- amb 操作符實現非確定計算.
amb 操作符是一個通用的搜索手段, 它實現這樣一個非確定計算: 一個函數有 若干參數, 這些參數並沒有一個固定的值, 而只給出了一個可選項列表. 系統能 自動地選擇一個合適的組合, 以使得函數能正確執行到輸出合法的結果.
我們用 (amb 1 2 3) 這樣的形式去提供一個參數的可選項, 而 (amb) 則表示沒 有可選項, 計算失敗. 所以, 所謂一個函數能正確執行到輸出合法結果, 就是指 函數能返回一個確定值或一個 amb 形式提供的不確定值; 而函數沒有合法結果, 或是計算失敗, 就是指函數返回了 (amb). 系統能自動選擇/搜索合適的參數組 合, 使函數執行到合適的分支, 避免計算失敗, 到最后正確輸出結果 --- 其實 說了這么多, 就是一個對函數參數組合的搜索 --- 不過它是全自動的. 比如:
(define (test-amb a b) (if (< a b) (list a b) (amb))) (test-amb (amb 4 2 3) (amb 1 2 3))
有了上面的基礎, 我們知道用 continuation 可是方便地實現它, amb 操作其實 是上面的搜索過程的通用化. 不同的是, 在這里, 給出可選參數的形式更加自由 , 像上面把參數划分為 fix 和 un-fix 的方法不適用了.
我們使用一個共享的回溯點的棧來解決問題. 在執行 (amb 4 2 3) 的時候, 我 們就選定 4, 然后設置一個回溯點, 壓入棧中, 執行 (amb 1 2 3) 時也如此 . 而當計算失敗要重新選擇時, 我們從棧中 POP 出回溯點來跑. 我們注意 (amb 1 2 3) 選擇完 3 之后的情況, 在上面的 search-match 實現中, 這相當於 choose 中的 (prev-fail) 語句. 但是 (amb 1 2 3) 並不知道 (amb 4 2 3) 的 存在, 無法這么做, 而借助這個共享棧, 我們可以獲得 (amb 4 2 3) 的回溯點, 使計算繼續下去. 用這樣的方法, 我們就無須使用嚴密控制的 fix 和 un-fix, 能夠自由使用 amb.
我們的整個實現如下, 過程並不復雜, 不過確實比較晦澀, 所以也附帶了注釋:
(define fail-link '()) ; fail continuation 棧 ;; amb-fail 在失敗回溯時調用, 它另棧頂的 fail continuation 出棧 ;; 並恢復到那個 continuation 中去 (define (amb-fail) (if (null? fail-link) (error "amb process failed") (let ((prev (car fail-link))) (set! fail-link (cdr fail-link)) (prev)))) (define (amb . ls) (define (do-amb success curr) (if (null? curr) (amb-fail) ; 沒有可選項, 失敗 (begin (call/cc (lambda (fail) (set! fail-link (cons fail fail-link)) ;設置回溯 ;; 返回一個選項到需要的位置 (success (car curr)))) ;; 回溯點 (do-amb success (cdr curr))))) (call/cc (lambda (success) (do-amb success ls))))
我們可以再敲入上面 test-amb 那段程序看看效果. 我們發現, 其實我們寫 (amb) 的時候, 做的就是上面 search-match 實現中的 (fail), 那么整個過程 又可以套回到上面的實現上去了. 以上程序的執行流程分析有點難, 呵呵, 准備 幾張草稿紙好好畫一下就能明白了.