當我寫到這裏的時候,我自己都吃了一驚。 環境、存儲這些比較讓人耳熟的還沒講到,continuation先出來了。 維基百科裏對continuation的翻譯是“延續性”。 這翻譯看着總有些違和感而且那個條目也令人不忍直視。 總之continuation似乎沒有好的中文翻譯,彷彿中國的計算機科學裏沒有continuation這個概念似的。

Continuation這個概念相當於過程式語言裏的函數調用棧。 它是用於保存“現在沒空處理,待會再處理的事”的數據結構。 這樣説有點抽象,舉個例子,函數應用那條表達式的求值過程(call-by-value)是這樣: \begin{equation*}\begin{array}{lcl}   eval((M \; N)) &=& eval(L[X \leftarrow eval(N)]) \\                  && \text{其中} eval(M) = \lambda X.L \end{array}\end{equation*}這個過程依次做這三件事:

  1. 計算$eval(M)$,結果是$\lambda X.L$,
  2. 計算$eval(N)$,
  3. 做替換$L[X \leftarrow eval(N)]$。

解釋器一次只能計算一個表達式,所以當它計算$eval(M)$前,要先把第二步和第三步要做的事備忘到continuation。

假設$M$還是個函數調用$M=(M_1 \; M_2)$。 那麼,計算$eval(M)$又要分三步: 先計算$eval(M_1)=\lambda X.L_1$,第二步計算$eval(M_2)$,第三步做替換$L_1[X \leftarrow eval(M_2)]$。 解釋器只能先計算$eval(M_1)=\lambda X.L_1$,將第二步計算$eval(M_2)$,第三步做替換$L_1[X \leftarrow eval(M_2)]$備忘到continuation。 於是,continuation備忘的事情按待處理的順序依次是:

  1. 計算$eval(M_2)$,
  2. 做替換$L_1[X \leftarrow eval(M_2)]$。
  3. 計算$eval(N)$,
  4. 做替換$L[X \leftarrow eval(N)]$。

可以看到,continuation是一個FILO(先進後出)的數據結構,也就是棧。

在之前的解釋器裏並沒有提到continuation,但是解釋器仍然能正常工作, 這是因為解釋這個解釋器的解釋器——Racket解釋器——幫我們管理了continuation。 這一節的目標是自己管理continuation。

從一個簡單的遞歸函數做起

先説一個概念:尾調用。 假設一個函數體中有一句函數調用表達式$(f \; n)$,這個函數調用被稱為尾調用,如果這條表達式是函數體裏最後求值的表達式。 如$\lambda n.(f \; n)$,和$\lambda n.({if} \; ({iszero} \; n) \; 0 \; (f \; n))$中的$(f \; n)$都是尾調用。 而$\lambda n.(+ \; 1 \; (f \; n))$中的$(f \; n)$不是尾調用,因為計算完$(f \; n)$後還要再算個加法。 一個簡單的判斷是否尾調用的方法是看這條調用表達式是不是不在一個參數位置(let表達式和if表達式先做宏展開)。

學過C或者C++都知道,調用函數的時候是要保存信息到函數調用棧的(我上學時學的這倆,不知道其他語言的課上會不會講函數調用棧)。 尾調用的特點是:尾調用理論上不需要保存信息到函數調用棧——實際上也不需要,但是有些語言就不支持,比如説Python。 換句話説,尾調用不會導致continuation增長。 這個很好理解,因為尾調用是最後求值的表達式了,不需要備忘後面要做的事(後面沒有事了),所以也就不會導致continuation增長。 後面會從continuation的角度説明為什麼尾調用不會令continuation增長。

題外話: 剛才提到“理論上”和“實際上”。 經常能聽到這種句式:理論上怎麼怎麼,可惜,實際上是吧啦吧啦。 其中“吧啦吧啦”是一句對“怎麼怎麼”的否定。 我覺得這是反科學以及讀書無用論的潛意識在作祟,另外有時候是用來逃避責任的藉口。 理論上怎麼樣,實際上就應該怎麼樣。 如果有套理論的結論和現實的結果不同,那麼這就不能稱為理論,最多算個猜想,而且是個錯誤的猜想。

如果一個尾調用是一個遞歸調用,那麼就稱為尾遞歸。 尾遞歸又叫迭代。 我們現在要做的事其實就是把一個遞歸程序(之前寫的解釋器)中的非尾遞歸全改成尾遞歸, 也就是遞歸轉迭代

為了熟悉遞歸轉迭代的套路,先拿一個簡單的遞歸函數練練手。 這個函數就是之前用到的double函數: \begin{equation*}\begin{array}{lcl}   double(0) &=& 0 \\   double(n) &=& 2 + double(n-1), \text{其中} n \neq 0 \end{array}\end{equation*} 計算double函數的過程有一個狀態量:當前計算的表達式$double(n)$或者一個數字$n$(計算結果)。 現在加入另一個狀態量continuation,記為$\kappa$(因為continuation第一個字母c發音k,所以用了個長的像k的希臘字母……)。 計算double函數的過程只有第二行是遞歸過程,備忘的事是加2。 $\kappa$定義為: \begin{equation*}\begin{array}{lcl}   \kappa &=& {mt} \\          &|& \left<\kappa, +2\right> \end{array}\end{equation*} mt表示空的continuation,也就是説沒有後續的事情了。

加入continuation後的求值過程如下: \begin{equation*}\begin{array}{lcl}   \left<double(0), \kappa\right>_v &\rightarrow_{v}& \left<0, \kappa\right>_c \\   \left<double(n), \kappa\right>_v &\rightarrow_{v}& \left<double(m), \left<\kappa, +2\right>\right>_v \\                                         && \text{其中} n \neq 0, m = n - 1 \\   \left<n, \left<\kappa, +2\right>\right>_c &\rightarrow_{c}& \left<m, \kappa\right>_c \\                                      && \text{其中} m = n + 2 \\   \left<n, {mt}\right>_c &\rightarrow_{c}& \text{輸出} n \end{array}\end{equation*} 下面解釋一下。 計算開始時的狀態表示為$\left<double(n), {mt}\right>_v$。 下標v表示第一個狀態量是一個表達式,要對這個表達式求值。 用箭頭$\rightarrow$表示一步,帶下標v的箭頭$\rightarrow_v$表示這一步對錶達式求值, 帶下標c的箭頭$\rightarrow_c$表示這一步從continuation中取出一件備忘的事執行。 對於$n \neq 0$,$\left<double(n), \kappa\right>_v$的下一步要做的是,計算$double(n-1)$,同時將加2備忘到$\kappa$, 也就是$\left<double(m), \left<\kappa, +2\right>\right>_v$,其中$m=n-1$。 當$n = 0$時,$double(0)$求得值$0$,所以$\left<double(0), \kappa\right>_v \rightarrow_{v} \left<0, \kappa\right>_c$。 用下標c表示第一個狀態是一個數字,下一步該從continuation取出備忘的事來辦了。 最後是$\left<n, \kappa\right>_c$的情況,如果$\kappa$不是空:$\kappa=\left<\kappa',+2\right>$,就加2到$n$上; 如果$\kappa$是空:$\kappa={mt}$,説明continuation沒事了,而這時表達式也求完了,於是返回最終結果$n$。

然後是寫代碼。 針對$\rightarrow_v$和$\rightarrow_c$需要兩個函數。 函數value-of/k是$\rightarrow_v$(Lisp命名規範裏一般用斜槓表示with的意思)。 函數apply-cont是$\rightarrow_c$。

CompositeItemWriteListener使用_遞歸

CompositeItemWriteListener使用_調用棧_02

上面代碼中用Lisp裏的list數據結構來保存continuation。 我們可以不用list來保存continuation。 Continuation備忘的是“待做的事”。 這個“待做的事”可以理解為一個過程,也就是一個函數。 所以,可以用函數來保存continuation!

空mt是一個直接返回參數的函數(lambda (v) v)。 $\left<\kappa, +2\right>$先加2,然後應用$\kappa$:(lambda (v) (apply-cont cont v)),其中cont是$\kappa$。 完整代碼:

CompositeItemWriteListener使用_調用棧_03

用函數來保存continuation的寫法看起來蠻像回調函數(callback)。 Lisp的一個噱頭是程序與數據統一對待,這也算是一個體現吧。 用函數還能實現對象(面向對象編程的對象),這是題外話了。 用函數保存數據一個好處是熟悉這種方法後寫起來很方便; 壞處是擴展訪問方式比較麻煩,並且調試的時候不能打印詳細信息。

順便再説一下,這種帶着一個continuation當參數傳來傳去的代碼風格叫continuation passing style,也就是傳説中的CPS。 將一段不帶continuation的代碼轉換成continuation passing style的過程叫CPS變換。 (更準確的説,CPS變換是指將代碼中的非尾調用轉換成尾調用。)