續延傳遞

續延傳遞(Continuation Passing Style, CPS)是一種編程手法,不要相信我可以將它講清楚——在敲這些字的時候,我剛剛開始看《The Little Schemer》的第八章的 multirember&co 這個函數的定義,並且是由於看不懂,因此才寫此文。編程

階乘

下面是階乘函數的定義:編程語言

(define (factorial n)
  (cond ((= n 1) 1)
        (else (* n (factorial (- n 1))))))

若是看不懂這個函數的定義,就不必再看下去了,應該先閱讀 SICP 的第一章。函數式編程

下面是階乘函數的另外一種形式的定義:函數

(define (factorial-cps n k)
  (cond ((= n 1) (k 1))
        (else (factorial-cps (- n 1) (lambda (v) (k (* v n)))))))

看不懂這個函數的定義,是正常現象,由於它是續延傳遞風格的函數。優化

不要理睬 factorial-cps 的定義,來看一下它的用法。下面的代碼能夠計算 3!:ui

> (factorial-cps 3 (lambda (z) z))
6

下面是 factorial-cps 在接受實參 3(lambda (z) z) 以後的執行過程:翻譯

第一步code

(factorial-cps 2 (lambda (v) ((lambda (z) z) (* v 3))))

第二步:對象

(factorial-cps 1 (lambda (v') ((lambda (v) ((lambda (z) z) (* v 3))) (* v' 2))))

第三步遞歸

((lambda (v') ((lambda (v) ((lambda (z) z) (* v 3))) (* v' 2))) 1)

最後獲得的這個表達式雖然複雜,但它只不過是讓一個函數——三個逐層嵌套的匿名函數構成的函數做用於實參 1 而已。若是將這個表達式複製到 Guile 交互解釋器中,求值結果爲 6,剛好是 3!. 也就是說,這個表達式是真正的階乘計算過程。factorial-cps 函數自己並未在階乘方面進行任何計算,它所作的工做就是生成這個階乘計算過程。

承諾鏈

雖然能夠將 factorial-cps 的過程徹底展開,代碼中的任何一個局部都可以理解,可是依然不理解代碼的完整含義。由於咱們的思惟裏尚未徹底的接納續延的概念。

下面的代碼

(factorial-cps 3 (lambda (z) z))

它做出了一個承諾:算出 3! 的結果後,我會將它傳遞給匿名函數 (lambda (z) z)。這個匿名函數只是簡單的將參數做爲返回值。

進入 factorial-cps 計算過程後,該函數會做出承諾:我要先計算 2!,而後將計算結果 v 交給一個匿名函數 (lambda (v) ((lambda (z) z) (* v 3))),由這個函數負責算出 3! 的結果。

factorial-cps 接受了實參 2(lambda (v) ((lambda (z) z) (* v 3))) 時,它承諾:我要先計算 1!,而後將計算結果 v' 交給匿名函數 (factorial-cps 1 (lambda (v') ((lambda (v) ((lambda (z) z) (* v 3))) (* v' 2)))),由這個函數負責算出 3! 的結果。

factorial-cps 傳遞接受實參 1(factorial-cps 1 (lambda (v') ((lambda (v) ((lambda (z) z) (* v 3))) (* v' 2)))) 時,factorial-cps 的第一個謂詞爲真,因而,就獲得了:

((lambda (v') ((lambda (v) ((lambda (z) z) (* v 3))) (* v' 2))) 1)

這是一條承諾鏈,隨着遞歸層次的增長,這條承諾鏈會愈來愈長。當遞歸達到終點時,承諾鏈便建好了。接下來,以重新到舊的順序完成每一個承諾,即可獲得 3! .

Fibonacci

下面是 Fibonacci 函數的定義:

(define (fib n)
  (cond ((= n 0) 0)
        ((= n 1) 1)
        (else (+ (fib (- n 1)) (fib (- n 2))))))

若是看不懂這個函數的定義,就不必再看下去了,應該先閱讀 SICP 的第一章。

下面是 Fibonacci 函數的續延傳遞版本:

(define (fib-cps n k)
  (cond ((= n 0) (k 0))
        ((= n 1) (k 1))
        (else (fib-cps (- n 1)
                   (lambda (v)
                     (fib-cps (- n 2)
                          (lambda (v')
                            (k (+ v v')))))))))

假設使用 fib-cps 計算 (fib n)

(fib-cps n (lambda (z) z))

這行代碼做出了第一個承諾:我要算出 (fib n),將結果傳給 (lambda (z) z)

對於 (fib-cps n (lambda (z) z)) 這個任務,fib-cps 函數做出承諾:我要先算出 (fib (- n 1)) 的結果 v,而後將 v 傳遞給下面這個匿名函數:

(lambda (v)
  (fib-cps (- n 2)
           (lambda (v')
             (k (+ v v')))))

這個匿名函數也是在做承諾:我先算出 (fib (- n 2)) 的值 v',而後將 v' 傳給下面這個匿名函數:

(lambda (v')
  (k (+ v v')))

這個匿名函數也是在做承諾:我先算出 (+ v v'),而後將結果傳給函數 k,而這裏的 k 剛好是 (lambda (z) z)

續延傳遞

假設使用 fib-cps 計算 (fib n)

(fib-cps n (lambda (z) z))

(lambda (z) z)) 是一個續延,表示 fib-cps 函數在算出 (fib n) 的結果以後要作的事。這個幾乎什麼也沒作的很是普通的匿名函數之因此能成爲續延,是由於它接受的參數 zfib-cps 在應用這個匿名函數以前獲得的計算結果——函數原本要做爲結果返回的值變成了這個函數的續延的參數了。

將一個函數的續延做爲參數傳遞給這個函數,這就是續延傳遞。

來看:

(lambda (v)
  (fib-cps (- n 2)
           (lambda (v')
             (k (+ v v')))))

這是 fib-cps 在計算出 (fib (- n 1)) 以後要作的事。這件事是什麼呢?是 (k (+ (fib (- n 1)) (fib (- n 2)))),而 k 就是 fib-cps 在計算出 (fib n) 以後要作的事,即 (lambda (z) z)

有什麼用?

將本來很直白的

(define (fib n)
  (cond ((= n 0) 0)
        ((= n 1) 1)
        (else (+ (fib (- n 1)) (fib (- n 2))))))

寫成

(define (fib-cps n k)
  (cond ((= n 0) (k 0))
        ((= n 1) (k 1))
        (else (fib-cps (- n 1)
                   (lambda (v)
                     (fib-cps (- n 2)
                          (lambda (v')
                            (k (+ v v')))))))))

這樣作有什麼好處?

注意 fibfib-cps 的遞歸形式的不一樣,前者是符合人類思惟模式的普通遞歸,後者是尾遞歸——函數的求值結果是其自身的應用。尾遞歸的好處是,編譯/解釋器有機會將其優化爲不會致使棧溢出的形式。

非尾遞歸形式的遞歸函數,由於上層遞歸過程在下層遞歸過程返回計算結果以前不會退出,因此它們佔據的棧空間不會被釋放。這樣,每執行一次遞歸都會消耗一部分棧空間——當遞歸深度過大時,棧空間會耗盡,致使運算過程當中斷。若是將這種遞歸形式改寫爲續延傳遞形式,那麼它就變成尾遞歸了。因爲尾遞歸函數,每次遞歸時,其上層遞歸過程已經徹底執行完了,它們不必再佔據棧空間,所以編譯器/解釋器能夠將它們所佔用的棧空間釋放——尾遞歸優化。

王垠寫了 40 行咱們看不懂的 Scheme 代碼。聽說這 40 行代碼能夠自動將非尾遞歸形式的遞歸函數『翻譯』爲續延傳遞風格的函數。若是真的是這樣,這彷佛是很是強大的技術。有了這種技術,不再用擔憂遞歸會致使棧溢出。不過,fib-cps 雖然是尾遞歸的,但其運算效率可能還不及 fib!下面的 fib* 是效率更高的尾遞歸形式:

(define (fib* n)
  (define (fib-iter a b count)
    (cond ((= count 0) b)
          (else (fib-iter (+ a b) a (- count 1)))))
  (fib-iter 1 0 n))

這種尾遞歸形式可能不可能存在相似王垠 40 行代碼的代碼自動變換出來,它是基於動態規劃方法構造出來的。基於動態規劃方法所構造的運算過程一般與具體的問題密切相關。

事實上,fib-cps 只是從機器層面消除了遞歸棧,但邏輯上的遞歸棧依然是存在的,這個棧就是做爲函數參數的續延,它會隨着遞歸的深度不斷的累積。從機器角度來看,續延傳遞變換能夠將遞歸過程所用的棧空間轉換爲存儲遞歸過程的堆空間——前提是編譯器/解釋器不會將續延對象複製到棧空間再傳遞給函數,而是傳遞續延對象的引用。

也就是說,雖然基於續延傳遞變換,能夠將任何遞歸函數變化爲尾遞歸形式,可是這並不是續延傳遞變換真正重要的應用場景。甚至能夠說續延傳遞變換與尾遞歸沒有必然聯繫,只不過續延傳遞變換剛好在形式上是尾遞歸而已。

那麼續延傳遞變換真正重要的應用場景是什麼?經過階乘與 Fibonacci 函數的例子能夠看出,續延傳遞風格的函數能夠將特定的計算過程在遞歸函數的參數中積累起來。換句話說,基於續延傳遞,能夠將特定的遞歸運算過程展開爲一個層層嵌套的函數構成的表達式,就像在作泰勒展開運算同樣,使得編譯器/解釋器有機會對展開結果進行優化。不過,我不清楚具體能夠優化什麼,這是函數式編程語言編譯/解釋器專家關心的事。

結語

寫完此文後,我依然沒看完《The Little Schemer》的第八章。

相關文章
相關標籤/搜索