scheme 之門

scheme 之門

 

開始以前

 

這是一篇 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 組成的對, 記爲:測試

(a . b)
 
畫圖
(a . b) - b
 |  
a
 
請你們將頭往左側 45 度, 這其實就是一個二叉樹.
 

若是是要聚合三個或以上的數據呢? pair 的方法還適用嗎? 咱們是否須要引入 其餘方法? 答案是不須要, 咱們遞歸地使用 pair 結構就能夠了. 聚合 a, b, c, 記爲 (a . (b . c)), 它的簡化記法是 (a b . c).

你們都能想到了, 遞歸地使用 pair 二叉樹的結構, 就能表達任意多個對象組成 的序列. 好比 (a b . c), 畫圖就是:

(a b . c) - (b . c) - c
 |           |
 a           b

請你們繼續將頭左側 45 度. 這樣的一個表示序列的樹的特色是, 樹的左結點是 成員對象, 右結點指向一顆包含其餘成員的子樹. 但你們也發現了一個破例: 圖 中的 c 是右邊的葉子. 解決這個問題的辦法是: 咱們引入一個 "無" 的概念:

(a b c . nil) - (b c . nil) - (c . nil) - nil
 |               |             |
 a               b             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. 若是那是一個符號, 則返回該符號所綁定的對象. (這個概念咱們會遲點解釋)
  3. 若是那是一個列表, 把列表的第一項做爲方法, 其餘做爲參數, 執行之.

因此, 咱們能夠試試這個 (+ 1 2), 這下就能正確執行了.

那麼, 若是我就想要他返回 (+ 1 2) 這個列表呢? 試試這樣 (quote (+ 1 2)) quote 是一個很特殊的操做, 意思是它的參數不按規則處理, 而是直接做爲數據 返回. 咱們會經常用到它, 因此也有一個簡化的寫法 '(+ 1 2), 在前面加一個 單引號就能夠了. 這樣子, 'kyhpudding 也能有正確的輸出了.

那麼咱們能夠介紹三個原子操做, 用以操縱列表. 其實, 所謂操縱列表, 也只是 操縱二叉樹而已. 因此咱們有這麼三個操做:

  • cons: 將它的兩個參數組合起來, 造成新的二叉樹/pair
  • car: 返回參數的左子樹
  • cdr: 返回參數的右子樹

經過如下幾個操做, 結合對應的二叉樹圖, 能比較好的理解這個 Scheme 最基礎 的設計:

(cons 'a (cons 'b '()))
(car '(a b c))
(cdr '(a b c))

無處不在的函數

基本的函數概念

Scheme 是一門函數式語言, 由於它的函數與數據有徹底平等的地位, 它能夠在 運行時被實時建立和修改. 也由於它的所有運行均可以用函數的方式來解釋, 莫 能例外.

比方說, 把 if 語句做爲函數來解釋? (if cond if-part else-part) 是這麼一 個特殊的函數: 它根據 cond 是否爲真, 決定執行並返回 if-part 仍是 else-part. 好比, 我能夠這樣寫:

((if (i-am-feeling-lucky) + -) my-happyness 1)

if 函數會根據我開心與否 (i-am-feeling-lucky 是一個由我決定它的返回值的 函數 :P) 返回 + 或 - 來做爲對個人開心值的操做. 所謂無處不在的函數, 其 意義大抵如此.

把一串操做序列當成函數呢? Scheme 是沒有 "return" 的, 把一串操做序列 看成一個總體, 它的返回值就是這一串序列的最後一個的返回值. 好比咱們能夠 寫

(begin (+ 1 2) (+ 3 4))

它的返回是 7.

無名的能量之源

接下來, 咱們就要接觸到 Scheme 的靈魂 --- Lambda. 你們能夠注意到 drScheme 的圖標, 那就是希臘字母 Lambda. 能夠說明 Lambda 運算在 Scheme 中是多麼重要.

NOTE: 這裏原本應該插一點 Lambda 運算的知識的, 可是一來我本身數學就不怎 麼好沒什麼信心能講好, 二來說太深了也沒有必要. 你們若是對 Lambda 運算的 理論有興趣的話, 能夠自行 Google 相關資料.

Lambda 可以返回一個匿名的函數. 在這裏須要注意兩點: 第一, 我用的是 "返 回" 而不是 "定義". 由於 Lambda 一樣能夠當作一個函數 --- 一個可以生 成函數的函數. 第二, 它是匿名的, 意思是, 一個函數並不必定須要與一個名字 綁定在一塊兒, 咱們有時侯須要這麼幹, 但也有不少時候不須要.

咱們能夠看一個 Lambda 函數的基本例子:

((lambda (x y) (+ x y)) 1 2)

這裏描述了一個加法函數的生成和使用. (lambda (x y) (+ x y)) 中, lambda 的第一個參數說明了參數列表, 以後的描述了函數的行爲. 這就生成了一個函數 , 咱們再將 1 和 2 做用在這個函數上, 天然能獲得結果 3.

咱們先引入一個 define 的操做, define 的做用是將一個符號與一個對象綁定 起來. 好比

(define name 'kyhpudding)
以後再敲入 name, 這時候 Scheme 解釋器就知道如何處理它了, 它會返回一個
kyhpudding.

咱們天然也能夠用 define 把一個符號和函數綁定在一塊兒, 就獲得了咱們經常使用的 有名函數.

(define add
        (lambda (x y) (+ x y)))

作一個簡單的替換, 上面的例子就能夠寫成 (add 1 2), 這樣就好理解多了.

上面的寫法有點晦澀, 而咱們常常用到的是有名函數, 因此咱們有一個簡單的寫 法, 咱們把這一類簡化的寫法叫 "語法糖衣". 在前面咱們也遇到一例, 將 (quote x) 寫成 'x 的例子. 上面的定義, 咱們能夠這樣寫

(define (add x y) (+ x y))

Lambda 運算有極其強大的能力, 上面只不過是用它來作傳統的 "定義函數" 的工做. 它的能力遠不止如此. 這裏只是舉幾個小小的例子:

咱們常常會須要一些用於迭代的函數, 好比這個:

(define (inc x) (+ x 1))

咱們也須要減的, 乘的, 還有其餘各類亂七八糟的操做, 咱們須要每次迭代不是 1, 而是 2, 等等等等. 咱們很天然地有這個想法: 咱們寫個函數來生成這類迭 代函數如何? 在 Scheme 中, 利用 lambda 運算, 這是可行且很是簡單的. 由於 在 Scheme 中, 函數跟普通對象是有一樣地位的, 而 "定義" 函數的 lambda, 實際上是可以動態地爲咱們創造並返回函數對象的. 因此咱們能夠這麼寫:

(define (make-iterator method step)
        (lambda (x) (method x step)))
 
沒有語法糖衣的寫法是:
 
(define make-iterator
        (lambda (method step)
                (lambda (x) (method x step))))

這個簡單的例子, 已經可以完成咱們在 C 之類的語言沒法完成的事情. 要生成 上面的 inc 函數, 咱們能夠這麼寫:

(define inc (make-iterator + 1))

這個例子展現的是 Scheme 利用 Lambda 運算獲得的能力. 利用它, 咱們能夠寫 出製造函數的函數, 或者說製造機器的機器, 這極大地擴展了這門語言的能力 . 咱們在之後會有更復雜的例子.

接下來, 咱們會介紹 Scheme 的一些語言特性是怎麼用 Lambda 運算實現的 --- 說 Scheme 的整個機制是由 Lambda 驅動的也不爲過.

好比, 在 Scheme 中咱們能夠在任何地方定義 "局部變量", 咱們能夠這麼寫:

(let ((x 1) (y 2)) 運用這些局部變量的語句)

其實 let 也只不過是語法糖衣而已, 由於上面的寫法等價於:

((lambda (x y)
         運用這些局部變量的語句)
1 2)

一些經常使用的函數

雖說這篇文章不太注重語言的實用性. 但這裏仍是列出咱們常常用到的一些操 做, 這能極大地方便咱們的編程, 你們也能夠想一想他們是怎麼實現的.

cond

至關於 C 中的 switch

(cond
(條件1 執行體)
(條件2 執行體)
(else 執行體))

循環語句

沒有循環語句...... 至少沒有必要的循環語句. Scheme 認爲, 任何的循環迭代 均可以用遞歸來實現. 咱們也不用擔憂遞歸會把棧佔滿, 由於 Scheme 會自動處 理尾遞歸的狀況. 一個簡單的 0 到 10 迭代能夠寫成這樣.

(define (iterate x)
        (if (= x 10)
            x
            (iterate (+ x 1))))
(iterate 0)

很明顯, 當咱們遞歸調用 iterate 的時候, 咱們沒必要保存當前的函數環境. 因 爲咱們遞歸調用完畢後就立刻返回, 而不會再使用當前的環境, 這是一給尾遞歸 的例子. Scheme 能自動處理相似的狀況甚至作一些優化, 不會浪費多餘的空間, 也不會下降效率. 因此徹底能夠代替循環.

固然咱們有些便於循環迭代的操做, 你們能夠試試本身實現他們. (固然在解釋 器內部一般不會用純 scheme 語句實現他們). 咱們最經常使用的是 map 操做

(map (lambda (x) (+ x 1)) '(1 2 3))

運行一下這個例子, 就能理解 map 的做用了.

更多的數據操做

  • cadr cddr caddr 之類, 就是 car 和 cdr 的組合, 你們能夠一個個試 . drScheme 支持到 cadddr...
  • append: 將兩個列表拼接在一塊兒.

無始無終的太極

我想其餘語言的入門教程都不會有這麼一節: 這門語言的運做原理是怎麼樣的 . 但這麼一節內容是 Scheme 的入門教程必有的. Scheme 把它最核心, 最底層 的機制都提供出來給用戶使用, 使它有很是強大的能力. 因此知道它的運行機理 是很是重要的.

這一節和下一節都是在分析 Scheme 的運行原理. 在這一節中, 咱們會用一個太 極圖來分析一條 Scheme 語句是怎麼被執行的. 在下一節, 咱們會在這一節的基 礎上引入 Scheme 的對象/內存管理機制. 從而獲得一個比較完整的 Scheme 運 行原理, 並用 Scheme 語言表示出來.

咱們先從 eval 和 apply 的用法提及. eval 接受一個參數, 結果是執行那個參 數的語句, 而 apply 則接受兩個參數, 第一個參數表示一個函數, 第二個參數 是做用於這個函數的參數列表. 例如:

(eval '(+ 1 2))
(apply + '(1 2))

咱們能夠輕易發現, 這二者是能夠輕易轉化的:

(define (eval exp) (apply (car exp) (cdr exp)))
(define (apply method arguments) (eval (cons method arguments)))

可是顯然, 真正的實現不可能如此, 否則 eval 一次就沒完沒了地轉圈了. 咱們 在前面提到 Scheme 的基本運行邏輯, 其實也是 eval 的基本原理:

  1. 若是那是一個數, 則返回這個數
  2. 若是那是一個符號, 則返回該符號所綁定的對象.
  3. 若是那是一個列表, 把列表的第一項做爲方法, 其餘做爲參數, 執行之.

咱們來實現一個這樣的邏輯, 要注意的是, 下面的 eval 和 apply 的寫法都只 是說明概念, 並非真實可運行的. 但用 Scheme 寫一個 Scheme 解釋器是確實 可行的:

(define (eval exp)
   (cond
    ((number? exp) exp)
    ((symbol? exp) 返回 exp 所指的對象)
    ((list? exp) (apply (eval (car exp)) (cdr exp)))
    (else 'error)))

在第三項, 咱們很天然地用了 apply 來實現. 注意 apply 接受的第一個參數必 須是一個函數對象, 而不能是一個相似 add 的名字, 因此咱們要遞歸地調用 eval 解析出它的第一個參數. 那麼 apply 要怎麼實現呢? 咱們來看一個實例:

有定義: (define (add x y) (+ x y))
執行: (add x (+ y 1))

用 eval 執行它的時候, 會執行

(apply (lambda (x y) (+ x y)) '(x (+ y 1))).

在執行它的時候 , 爲了運行它, 咱們要知道 add 和 x 表明什麼, 咱們還得知道 (+ y 1) 的結果, 不然咱們的計算沒法繼續下去. 咱們用什麼來求得這些值呢

--- 顯然是eval. 所以 apply 的處理流程大體以下:

(define (apply method arguments)
   (執行 method (map eval arguments)))

咱們獲得的仍是一個互相遞歸的關係. 不過這個遞歸是有盡頭的, 當咱們遇到原 子對象時, 在 eval 處就會直接返回, 而不會再進入這個遞歸. 因此 eval 和 apply 互相做用, 最終把程序解釋成原子對象並獲得結果. 這種循環不息的互相 做用, 能夠表示爲這樣一個太極:

這就是一個 Scheme 解釋器的核心.

然而, 咱們上面的模型是不盡準確的. 好比, (if cond if-part else-part) 把 這個放入 apply 中的話, if-part 和 else-part 都會被執行一遍, 這顯然不是 咱們但願的. 所以, 咱們須要有一些例外的邏輯來處理這些事情, 這個例外邏輯 一般會放在 eval. (固然理論上放在 apply 裏也能夠, 你們能夠試一下寫, 不 過這樣在 eval 中也要有特殊的邏輯之處 "if" 這個符號所對應的值). 咱們 能夠把 eval 改爲這樣

(define (eval exp)
   (cond
    ((number? exp) exp)
    ((symbol? exp) 返回 exp 所指的對象)
    ((list? exp)
     (cond
      ((if? (car exp)) (if 的特殊處理 (cdr exp)))
      (還有其餘的例如 quote, cond 的都得放在這裏)
      (else (apply (eval (car exp)) (cdr exp)))))
    (else 'error)))

這樣咱們的邏輯就比較完整了.

另外 apply 也要作一些改動, 對於 apply 的 method, 它有多是相似 "+" 這樣的內置的 method, 咱們叫它作 primitive-proceure, 還有由 lambda 定義 的 method, 他們的處理方法是不同的.

(define (apply method arguments)
  (if (primitive-procedure? method)
      (處理內置函數 (map eval arguments))
      (處理 lambda (map eval arguments))

在下一節, 咱們就會從 lambda 函數是怎麼執行的講起, 並再次修改 eval 和 apply 的定義, 使其更加完整. 在這裏咱們會提到一點點 lambda 函數的執行原 理, 這其實算是一個 trick 吧.

咱們這樣定義 lambda 函數

(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, 狀況就比較複雜了.

(define a '(1 . 2))
(define b '(1 . 2))

若是咱們修改了 a 的 car 的值, 咱們不但願 b 的值也一樣的被改變. 所以雖 然 a 和 b 在 define 時的值同樣, 但他們不是相同的對象, 咱們要分別表示他 們. 可是 在這個時候

(define a '(1 . 2))
(define b a)

a 和 b 應該指的是同一個對象, 否則 define 的定義就會很尷尬 (define 不是 賦值, 而是綁定). 修改了 a 的 car, b 也應該同時改變.

答案很明顯了: 對 pair 對象, 咱們應把它表示爲一個引用 --- 熟悉 Java 的 同窗也會知道一個相同的原則: 在 Java 中, 變量能夠是一個原子值 (如數字), 或者是對一個複合對象的引用.

在這裏咱們引入一組操做, 它能夠幫助測試, 理解這樣的對象系統:

  • set!: 不要漏了歎號, 修改一個符號的綁定
  • set-car!: 修改 pair 中左邊值的綁定
  • set-cdr!: 修改 pair 中右邊值的綁定
  • eq?: 測試兩個對象是否相等
  • equal?: 測試兩個對象的值是否相等.

咱們能夠進行以下測試:

(define a '(1 . 2))
(define b '(1 . 2))
(set-car! a 3)
查看 a 和 b 的值
 
(define a '(1 . 2))
(define b a)
(set-car! a 3)
查看 a 和 b 的值
 
(eq? '(1 2) '(1 2))
(equal? '(1 2) '(1 2))

另外咱們能夠想一想如下操做造成的對象的結構:

(define a '(1 2))
(define b (cons 3 (cdr a)))

它造成的結構應該是這樣的

a: (1 2) - (2) - ()
            |
b: (3 2) ---+

因此 (eq? (cdr a) (cdr b)) 的值應該是真.

lambda 的祕密

接下來咱們要研究: Scheme 是怎麼執行一個 lambda 函數的? 運行一個 lambda 函數, 最重要的就是創建一個局部的命名空間, 以支持局部變量 --- 對 Scheme 來講, 所謂局部變量就是函數的參數了. 只要創建好這樣的一個命名空間, 剩下 的事情就是在此只上逐條運行語句而已了.

咱們首先能夠看這樣的一個例子:

(define x 10)
((lambda (x) x) 20)

結果固然是 20, 這說明了 Scheme 在運行 lambda 函數時會創建一個局部的命名 空間 --- 在 Scheme 中, 它叫作 environment, 爲了與其餘的資料保持一致, 我 們會沿用這個說法, 並把它簡寫爲 env. 並且這個局部 env 有更高的優先權 . 那咱們彷佛能夠把尋找一個符號對應的對象的過程描述以下, 這也是 C 語言程 序的行爲:

  1. 先在函數的局部命名空間裏搜索
  2. 若是找不到, 在全局變量中搜索.

可是 Scheme 中, 函數是能夠嵌套的:

(define x 10)
(define (test x)
  (define (test2 x) x)
  (test2 (+ x x)))
(test 20)

很好, 這不就是一個棧的結構嗎? 咱們在運行中維護一個 env 的棧, 搜索一個名 稱綁定時從棧頂搜索到棧底就能夠了.

這在 Pascal 等靜態語言中是可行的 (Pascal 也支持嵌套的函數定義). 可是在 Scheme 中不行 --- Scheme 的函數是能夠動態生成的, 這會產生一些棧沒法處 理的狀況, 好比咱們上面使用過的例子:

(define (make-iterator method step)
        (lambda (x) (method x step)))
(define inc (make-iterator + 1))
(define dec (make-iterator - 1))

執行 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:

(define (make-lambda 參數表 代碼 env)
   (list 參數表 代碼 env))

這樣就能夠表示 lambda 函數與一個 env 的綁定. 那麼咱們執行 lambda 函數 的行爲能夠這麼描述:

(define (make-env func)
  (list (match-binding (car func) (caddr func))))
 
match-binding 就是咱們上面介紹過的參數表匹配過程.
 
(define (run-lambda func)
  (let ((new-env (make-env func)))
     (eval (cadr func) new-env)))

這樣咱們就能夠徹底清楚的解釋 make-iterator 的行爲了. 在執行 (make-iterator + 1) 時, make-env 生成了這樣的一個 new-env:

(((method +) (step 1)) global-env)
global-env 是 (((make-iterator 函數體)) '())

這個 new-env 會做爲參數 env 去調用 eval. 在 eval 執行到 lambda 一句時, 又會以這樣的參數來調用 make-lambda, 所以這樣的一個 env 就會綁定到這個 lambda 函數上. 同理, 咱們調用 (make-iterator - 1) 的時候, 就能獲得另外一 個 env 的綁定.

這種特性使 "函數" 在 scheme 中的含義很是豐富, 使用很是靈活, 如下這個 例子實現了很是方便調試的函數計數器:

(define (make-counter method)
   (let ((counter 0))
      (lambda arguments
         (if (eq? (car arguments) 'print)
             counter
             (begin
                (set! counter (+ counter 1))
                (apply method arguments))))))
 
(define add (make-counter +))

用普通的參數調用 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 的實現, 此次要怎麼改 動你們都清楚得很了.

經過此次修改, 咱們也能夠解釋自動處理尾遞歸爲何是可行的. 咱們在上面舉 出了一個尾遞歸的例子:

(define (iterate x)
        (if (= x 10)
            x
            (iterate (+ x 1))))
(iterate 0)

在 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 去實現面向對 象的功能. 一個基本的 "類" 的模板是相似這樣的:

(define (make-object 初始化參數)
   (let ((成員變量1 初始值 1) (成員變量2 初始值 2) ...)
      (define (成員函數1 參數表)
         blablabla)
      ....
      (lambda (cmd . args)
        (cond
         ((eq? cmd 接口名1) (apply 成員函數1 args))
         ....))))

使用

(define obj (make-object blabla))
(obj 接口名 參數)

這樣就能很方便地把它和其餘語言中的對象對應起來了.

Scheme 雖然沒有真正的, 複雜的面向對象概念, 沒有繼承之類的咚咚, 但 Scheme 可以實現更靈活, 更豐富的面向對象功能. 好比, 咱們前面舉過的 make-counter 的例子, 它就是一個函數調用計數器的類, 並且, 它能提供徹底 透明的接口, 這一點, 其餘語言就很難作到了.

創造機器的機器

真實存在的時光機器

在上一節中, 咱們引入了 context 的概念, 這個概念表明 scheme 解釋器在任 什麼時候刻的運行狀態. 若是咱們有一種機制, 可以把某個時候的 context 封存起 來, 到想要的時候, 再把它調出來, 這必定會很是有趣 --- 對, 就像遊戲中的 存檔同樣. 若是真有這樣的機制, 那就簡直是真實存在的時光機器了.

Scheme 還真的有這個機制 --- 它把 context 也當作一個對象, 能夠由用戶自 由地使用, 這使咱們能完成不少 "神奇" 的事情. 在上一節, 咱們爲了方便理 解, 使用了 "context" 這一叫法, 在這裏, 咱們恢復它的正式稱呼 --- 這一 節, 咱們研究 continuation.

咱們仍是從它的用法提及, continuation 的使用從 call-with-current-continuation 開始, 這個名字長得實在難受, 咱們按慣例 一概縮寫爲 call/cc. call/cc 能夠這樣使用

(call/cc (lambda (cont) blablablabla))

它接受一個函數做爲參數, 而這個函數的參數就是這個 continuation 對象. 我 們要怎麼用這個對象呢? 如下是一個最簡單的例子:

(+ 1 (call/cc (lambda (cont) (cont 2))))

你們能夠試試它的結果, 與 (+ 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 的框架, 很簡單的.

老實說, 上面這個一點都不像時光機, 也不見得有多強大. 咱們再來點好玩的:

(define g-cont '())
(let ((x (call/cc
          (lambda (cont) (set! g-cont cont)))))
  (if (number? x) (+ x x)))

以上這些語句固然不會有執行結果, 由於 call/cc 沒有返回任何值給 x, 在 if 語句以後就沒法繼續下去了. 不過, 在這裏咱們把這個 continuation 保存成了 一個全局變量 g-cont. 如今咱們能夠試試: (g-cont 10). 你們能夠看到結果了 : 這纔是時光機啊, 經過 g-cont, 咱們把解釋器送回從前, 讓 x 有了一個值, 而後從新計算了 let 之中的內容, 得出咱們所要的答案.

這樣的機制固然不只僅是好玩的, 它能夠實現 "待定參數" 的功能: 有的函數 並不能直接被調用, 由於它的參數可能由不一樣的調用者提供, 也可能相隔很長時 間才分別提供. 但不管如何, 只要參數一齊, 函數就要立刻獲得執行 --- 這是 一種很是常見的模塊間通信模式, 但用普通的函數調用方法沒法實現, 其餘方法 也很難實現得簡單漂亮, continuation 卻使它變得很是簡單. 好比

(define (add x y)
   (if (and (number? x) (number? y)) (display (+ x y))))
 
(define slot-x '())
(define slot-y '())
 
(add (call/cc (lambda (cont) (set! slot-x cont)))
     (call/cc (lambda (cont) (set! slot-y cont))))

到咱們用相似 (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 中, 它倒是這麼一個遞歸的 過程:

(define (choose fix to-fix un-fix success prev-fail)
  (if (null? to-fix)
      (prev-fail)
      (begin
    (call/cc
     (lambda (fail)
       (do-search (append fix (list (car to-fix))) un-fix
               success fail)))
    (choose fix (cdr to-fix) un-fix success prev-fail))))

咱們在上面說過將一個循環轉換成遞歸的過程, 如今你們就要把這個遞歸從新化 爲咱們熟悉的循環了. (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), 那麼整個過程 又能夠套回到上面的實現上去了. 以上程序的執行流程分析有點難, 呵呵, 準備 幾張草稿紙好好畫一下就能明白了.

終章的名實之辯

 

================= End

相關文章
相關標籤/搜索