這是一篇 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 的不少概念時, 這些 知識是必須的.app
這兩個多是人類對世界認識的終極問題: 世界上最基本的, 不可再分的物質單 位是什麼? 這些最基本的物質單位是怎麼組成這個大千世界的?框架
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 也能有正確的輸出了.
那麼咱們能夠介紹三個原子操做, 用以操縱列表. 其實, 所謂操縱列表, 也只是 操縱二叉樹而已. 因此咱們有這麼三個操做:
經過如下幾個操做, 結合對應的二叉樹圖, 能比較好的理解這個 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 也只不過是語法糖衣而已, 由於上面的寫法等價於:
雖說這篇文章不太注重語言的實用性. 但這裏仍是列出咱們常常用到的一些操 做, 這能極大地方便咱們的編程, 你們也能夠想一想他們是怎麼實現的.
至關於 C 中的 switch
沒有循環語句...... 至少沒有必要的循環語句. Scheme 認爲, 任何的循環迭代 均可以用遞歸來實現. 咱們也不用擔憂遞歸會把棧佔滿, 由於 Scheme 會自動處 理尾遞歸的狀況. 一個簡單的 0 到 10 迭代能夠寫成這樣.
很明顯, 當咱們遞歸調用 iterate 的時候, 咱們沒必要保存當前的函數環境. 因 爲咱們遞歸調用完畢後就立刻返回, 而不會再使用當前的環境, 這是一給尾遞歸 的例子. Scheme 能自動處理相似的狀況甚至作一些優化, 不會浪費多餘的空間, 也不會下降效率. 因此徹底能夠代替循環.
固然咱們有些便於循環迭代的操做, 你們能夠試試本身實現他們. (固然在解釋 器內部一般不會用純 scheme 語句實現他們). 咱們最經常使用的是 map 操做
運行一下這個例子, 就能理解 map 的做用了.
我想其餘語言的入門教程都不會有這麼一節: 這門語言的運做原理是怎麼樣的 . 但這麼一節內容是 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. 那麼, 咱們的參數表寫法其實能夠很是靈活的, 能夠試試這兩個語句 的結果:
這樣 "匹配" 的意義是否會更加清楚呢? 因爲這樣的機制, 再加上能夠靈活運 用 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 中, 變量能夠是一個原子值 (如數字), 或者是對一個複合對象的引用.
在這裏咱們引入一組操做, 它能夠幫助測試, 理解這樣的對象系統:
咱們能夠進行以下測試:
另外咱們能夠想一想如下操做造成的對象的結構:
它造成的結構應該是這樣的
因此 (eq? (cdr a) (cdr b)) 的值應該是真.
接下來咱們要研究: 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 關係:
這裏的最後一項就是咱們的全局命名空間, 函數 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 的遞歸調用圖以下:
下面的箭頭表示函數返回的路徑. 若是咱們每次的遞歸調用都是函數體中的最後 一個語句, 就說明: 好比從 (interate 2) 返回到 (iterate 1) 時, 咱們什麼 都不用幹, 又返回到 (iterate 0) 了. 在 iterate 中, 咱們每一層遞歸都符合 這個條件, 因此咱們就給它一個捷徑:
讓他直接返回到調用 (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 中的 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 跳過層層遞 歸, 直接返回答案.
因此, 咱們有這樣的一個過程:
當 un-fix 爲空時, 說明全部值都已經選定, 咱們就能夠檢驗值並選擇下一步動 做. 吸引咱們的是 choose 的實現, choose 要作的工做就是在 un-fix 中的第 一項裏選定一個值, 放到 fix 中, 而後遞歸地調用 do-search 進入下一層遞歸 . 在 C 中, 它的工做是用循環完成的, 在 Scheme 中, 它倒是這麼一個遞歸的 過程:
咱們在上面說過將一個循環轉換成遞歸的過程, 如今你們就要把這個遞歸從新化 爲咱們熟悉的循環了. (prev-fail) 至關於 C 中循環結束後天然退出, 這退到 了上一個回溯點. 而下面 call/cc 的過程在遞歸 do-search 的時候建立了一個 回溯點. 好比, 在 do-search 中運行 (fail), 就會回溯回這裏, 遞歸地調用 choose 來選定下一個值.
你們能夠寫出相應的 C 程序進行對照, 應該可以理解到 fail 參數在這裏的使 用. 其實這樣回溯實現確實是比較囉嗦的 --- 可是, 若是咱們能不寫任何代碼, 讓機器自動完成這樣的搜索計算呢?
簡言之, 咱們只須要一個函數
而後給定 a, b 的可選範圍, 而後系統就告訴咱們 a b 的值, 咱們不用關心它 是怎麼搜索出來的.
有這東西麼? 在 Scheme 中請相信奇蹟, 用 continuation 能夠方便地實現這樣 的系統. 下面, 咱們要介紹這個系統, 一個 continuation 的著名應用 --- amb 操做符實現非肯定計算.
amb 操做符是一個通用的搜索手段, 它實現這樣一個非肯定計算: 一個函數有 若干參數, 這些參數並無一個固定的值, 而只給出了一個可選項列表. 系統能 自動地選擇一個合適的組合, 以使得函數能正確執行到輸出合法的結果.
咱們用 (amb 1 2 3) 這樣的形式去提供一個參數的可選項, 而 (amb) 則表示沒 有可選項, 計算失敗. 因此, 所謂一個函數能正確執行到輸出合法結果, 就是指 函數能返回一個肯定值或一個 amb 形式提供的不肯定值; 而函數沒有合法結果, 或是計算失敗, 就是指函數返回了 (amb). 系統能自動選擇/搜索合適的參數組 合, 使函數執行到合適的分支, 避免計算失敗, 到最後正確輸出結果 --- 其實 說了這麼多, 就是一個對函數參數組合的搜索 --- 不過它是全自動的. 好比:
有了上面的基礎, 咱們知道用 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.
咱們的整個實現以下, 過程並不複雜, 不過確實比較晦澀, 因此也附帶了註釋:
咱們能夠再敲入上面 test-amb 那段程序看看效果. 咱們發現, 其實咱們寫 (amb) 的時候, 作的就是上面 search-match 實現中的 (fail), 那麼整個過程 又能夠套回到上面的實現上去了. 以上程序的執行流程分析有點難, 呵呵, 準備 幾張草稿紙好好畫一下就能明白了.
================= End