(cons '(叄 . 續延) 《為自己寫本-Guile-書》)

Mathilda91發表於2016-05-26
(car 《為自己寫本-Guile-書》)

在本書前言中,我宣稱本書的主題是用 Guile 實現一個文式程式設計工具。接下來,第一章中講述瞭如何編寫這個文式程式設計工具的命令列介面,第二章表面上是講述 Guile 的 I/O 機制,實際上在最後講述瞭如何利用 Guile 的 I/O 機制對文式程式設計元文件進行初步解析,然而本章所講的東西——續延(Continuation)卻與本書主題無必要的關係。本來應該承接第二章的主題寫下去的,但是這一章卻沒這樣做,結果造成了讀者(我)對後續章節能回到主題上來這樣一種期待,我有意無意之間就在現實中創造了一個續延。如果不知道我說什麼,可以簡單的將這些理解為:本章與本書的主題無關,閱讀時可以跳過去。以後需要了,可以再回到這裡。回到這裡之後,可能又會導致後續章節發生變化。

setjmp/longjmp

在講 Guile 的續延之前,先回顧一下 C 語言標準庫提供的 setjmplongjmp 這兩個函式。看下面的示例:

#include <setjmp.h>
#include <stdio.h>

jmp_buf env;

void
foo(void) {
        printf("Entering foo!\n");
        longjmp(env, 1984);
        printf("Exiting foo!\n");
}

int
main(void) {
        int i = setjmp(env);
        if (i == 0) {
                foo();
        } else {
                printf("The result of foo: %d\n", i);
        }
}

程式輸出結果為:

Entering foo!
The result of foo: 1984

這個程式的控制流如下圖所示,

setjmp/longjmp 的控制流

第一次知道 setjmplongjmp 的存在時,對於已經具備很多 C 語言程式設計經驗的我而言,依然覺得很神奇——竟然有辦法從一個函式的內部直接跳轉到另一個函式的內部。我們此時此刻在 foo 函式裡所作出的決定,竟然對已經發生了的事件產生了不可逃避的影響!

call/cc

以下 Guile 程式碼與上一節的 C 程式碼近乎等效:

(define cc 'current-continuation)

(define (foo)
  (display "Entering foo!\n")
  (cc 1984)
  (display "Exiting foo!\n"))

(let ((i (call/cc (lambda (k)
                    (set! cc k)
                    (k 0)))))
  (cond  ((= i 0) (foo))
         (else (begin
                 (display "The result of foo: ")
                 (display i)
                 (newline)))))

上述 Guile 程式碼與上一節 C 程式碼的對應關係如下:

  • call/cc 類似於 setjmp
  • 全域性變數 (cc 1984) 類似於全域性變數 envlongjmp 的『合體』——longjmp(env, 1984)

續延

在下面的這行 C 程式碼中,

int i = setjmp(env);

int i = 是一個續延,可將它寫為 int i = [],表示這個賦值過程在等待所賦之值的到來。setjmp 函式第一次被執行後的返回值是 0,這表示當前的續延 int i = [] 呼叫了 setjmp 函式,得到了值 0,使得它的計算過程達到終點。

後來,在 foo 函式中執行了 longjmp(env, 1984),導致程式的執行點又跳到上一行程式碼中的 setjmp(env) 位置,將 longjmp 的引數值 1984 傳遞給 setjmp 函式,然後第二次執行 setjmp 函式,讓它返回 1984,於是就完成了對 i 的第二次賦值。可以將這個過程想象為,我們將 int i = [] 這個續延儲存到了 env 這個全域性變數中,然後在其他地方可以通過 longjmp 讓這個續延再次得到所賦之值。

將一個計算過程中的某個計算單元『抽走』,這就製造了一個續延。無論何時,只要重新補上缺失的計算單元,這個計算過程會基於所填補的計算單元產生相應的結果。這沒有什麼高深莫測的東西,在生活中我們經常運用續延這種技巧。譬如,考試時,遇到不會做的題目,可以暫時跳過去——大不了不掙這些題目的分,等把後面的題目都完成了,再回頭跟它們慢慢死磕。

續延在等候它所缺失的計算單元,這種行為類似於函式們在等候引數值的傳入。如果向續延提供了它所缺失的計算單元,續延就會將這個計算單元對映為續延所對應的計算過程的最終計算結果。如果向函式提供了引數值,函式會將這些引數值對映會函式的返回值。所以,在行為上續延與函式是等價的,所以可將其視為一種另類的函式。

簡單的說,續延就是在表示式上挖了個洞,讓它變成了一種類似函式的東西。

call-with-current-continuation

call/cccall-with-current-continuation 的簡寫,意思是『用當前的續延來呼叫』。來呼叫什麼?一個匿名函式:

(lambda (k)
  (set! cc k)
  (k 0))

這個匿名函式的形參 k 是一個續延。call/cc 會捕捉當前的續延,將它作為引數傳遞給這個匿名函式,即呼叫這個匿名函式。

對於上一節的 Guile 程式碼而言,call/cc 捕捉的當前續延是:

(let ((i []))
  (cond  ((= i 0) (foo))
         (else (begin
                 (display "The result of foo: ")
                 (display i)
                 (newline)))))

假設這個續延為 i-賦值續延,它會被 call/cc 作為引數傳遞給上述的匿名函式:

((lambda (k)
  (set! cc k)
  (k 0)) i-賦值續延)

這個匿名函式接受這個續延後,會執行以下兩個運算過程:

(set! cc i-賦值續延)
(i-賦值續延 0)

第一個運算過程是用全域性變數 cc 記錄這個續延。第二個計算過程是以引數值 0 『呼叫』這個續延——引數值 0 恰好填補了 i-賦值續延 所缺失的計算單元,結果 i 被繫結到 0 上,使得續延變為:

(let ((i 0))
  (cond  ((= i 0) (foo))
         (else (begin
                 (display "The result of foo: ")
                 (display i)
                 (newline)))))

接下來,cond 的第一個謂詞 (= 1 0) 的結果為真,於是進入 foo 函式的計算過程,結果會遇到 (cc 1984)。由於在 call/cc 語句中,已將 i-賦值續延 記錄於 cc。因此 (cc 1984) 本質上就是用 1984 來填補 i-賦值續延 所缺失的計算單元,將其變為:

(let ((i 1984))
  (cond  ((= i 0) (foo))
         (else (begin
                 (display "The result of foo: ")
                 (display i)
                 (newline)))))

現在 i 的值就變成了 1984 了,因此接下來 cond 的第一個謂詞的結果為假,從而進入 else 分支。最終得到以下結果:

Entering foo!
The result of foo: 1984

注意,在 foo 函式中,當 (cc 1984) 語句被執行時,本質上它會將當前的程式環境切換到 i-賦值續延 環境,因此位於它後面的 (display "Exiting foo!\n") 語句不會有執行機會。

它有什麼用?

十三年前,王垠寫過一篇文章『二叉樹匹配問題』,較為詳細的詮釋了《Teach Yourself Scheme in Fixnum Days》這本書的第十三章中的一個續延示例。可以結合這兩份文件瞭解一下續延的應用場合。這個二叉樹匹配問題是基於續延構造了一個二叉樹結點生成器來解決的。很坦誠的說,這兩份文件所講的東西,目前我也只是似懂非懂。也許只有在真正需要使用續延的時候,方能真正知道怎麼運用它。我覺得在現實中續延真正有用的地方就在於實現協程。不過《Teach Yourself Scheme in Fixnum Days》第十四章、十五章對續延有著更有趣的應用——非確定性運算與引擎。

(cdr 《為自己寫本-Guile-書》)

相關文章