基於CPS變換的尾遞歸轉換算法

前言

衆所周知,遞歸函數容易爆棧,究其緣由,即是函數調用前須要先將參數、運行狀態壓棧,而遞歸則會致使函數的屢次無返回調用,參數、狀態積壓在棧上,最終耗盡棧空間javascript

一個解決的辦法是從算法上解決,把遞歸算法改良成只依賴於少數狀態的迭代算法,然而此事知易行難,線性遞歸還容易,樹狀遞歸就難以轉化了,並且並非全部遞歸算法都有非遞歸實現。java

在這裏,我介紹一種方法,利用CPS變換,把任意遞歸函數改寫成尾調用形式,以continuation鏈的形式,將遞歸佔用的棧空間轉移到堆上,避免爆棧的悲劇
須要注意的是,這種方法並不能下降算法的時間複雜度,如果期望此法縮短運行時間無異於白日作夢算法

下文先引入尾調用、尾遞歸、CPS等概念,而後介紹Trampoline技法,將尾遞歸轉化爲循環形式(無尾調用優化語言的必需品),再sumFibonacci爲例子講解CPS變換過程(雖然這兩個例子能夠輕易寫成迭代算法,不必搞這麼複雜,可是最爲常見好懂,所以拿來作例子,省得說題目都得說半天),最後講通用的CPS變換法則閉包

看完這篇文章,你們能夠去看看Essentials of Programming Languages相關章節,能夠有更深的認識函數

文中代碼皆用JavaScript實現oop

尾調用 && 尾遞歸

先來探討下在什麼狀況下函數調用才須要保存狀態優化

Add(1, 2)MUL(1, 2)這種明顯不須要保存狀態,code

Add(1, MUL(1, 2))這種呢?計算完MUL(1, 2)後須要返回結果接着計算Add,所以計算MUL前須要保存狀態協程

由此,能夠獲得一個結論,只有函數調用處於參數位置上,調用後須要返回的函數調用才須要保存狀態,上面的例子中,Add是不須要保存狀態,MUL須要保存對象

尾調用指的就是,無需返回的函數調用,即函數調用不處於參數位置上,上面的例子中,Add是尾調用,MUL則不是
寫成尾調用形式有助於編譯器對函數調用進行優化,對於有尾調用優化的語言,只要編譯器判斷爲尾調用,就不會保存狀態

尾遞歸則是指,寫成尾調用形式的遞歸函數,下面是一例

fact_iter = (x, r) => x == 1 ? 1 : fact_iter(x-1, x*r)

而下面的例子則不是尾遞歸,由於fact_rec(x-1)處於*的第二個參數位置上

fact_rec = x => x == 1 ? 1 : x * fact_rec(x-1)

由於尾遞歸無需返回,結果只跟傳入參數有關,所以只需用少許變量記錄其參數變化,便能輕易改寫成循環形式,所以尾遞歸和循環是等價的,下面把fact_iter改寫成循環:

function fact_loop(x)
{
    var r = 1
    
    while(x >= 1)
    {
        r *= x
        x--;
    }
    
    return r;
}

CPS ( Continuation Passing Style )

要解釋CPS,便先要解釋continuation
continuation是程序控制流的抽象,表示後面將要進行的計算步驟

好比下面這段階乘函數

fact_rec = x => x == 1 ? 1 : x * fact_rec(x-1)

顯然,計算fact_rec(4)以前要先計算fact_rec(3),計算fact_rec(3)以前要先計算fact_rec(2),...
因而,能夠獲得下面的計算鏈:

1 ---> fact_rec(1) ---> fact_rec(2) ---> fact_rec(3) ---> fact_rec(4) ---> print

展開計算鏈後,再從前日後執行,就能夠獲得最終結果。

對於鏈上的任意一個步驟,在其以前的是歷史步驟,以後的是將要進行的計算,所以以後的都是continuation
好比,對於fact_rec(3),其continuationfact_rec(4) ---> print
對於fact(1),其continuationfact_rec(2) ---> fact_rec(3) ---> fact_rec(4) ---> print

固然,上面的計算鏈不須要咱們手工展開和運行,程序的控制流已經由語法規定好,咱們只須要按語法寫好程序,解釋器自動會幫咱們分解計算步驟並循序漸進地計算

然而,當現有語法沒法知足咱們的控制流需求怎麼辦?好比咱們想從一個函數跳轉至另外一個函數的某處執行,語言並無提供這樣的跳起色制,那便須要手工傳遞控制流了。

CPS是一種顯式地把continuation做爲對象傳遞的coding風格,以便能更自由地操控程序的控制流

既然是一種風格,天然須要有約定,CPS約定:每一個函數都須要有一個參數kontkontcontinuation的簡寫,表示對計算結果的後續處理

好比上面的fact_rec(x)就須要改寫爲fact_rec(x, kont),讀做 「計算出x階乘後,用kont對階乘結果作處理」

kont一樣須要有約定,由於continuation是對某計算階段結果作處理的,所以規定kont爲一個單參數輸入,單參數輸出的函數,即kont的類型是a->b

所以,按CPS約定改寫後的fact_rec以下:

fact_rec = (x, kont) => x == 1 ? kont(1) : fact_rec(x-1, res => kont(x*res))

當咱們運行fact_rec(4, r=>r),就能夠獲得結果24

模擬一下fact_rec(3, r=>r)的執行過程,就會發現,解釋器會先將計算鏈分解展開

fact_rec(3, r=>r)
fact_rec(2, res => (r=>r)(3*res))
fact_rec(1, res => (res => (r=>r)(3*res))(2*res))
(res => (res => (r=>r)(3*res))(2*res))(1)

固然,這種風格很是反人類,由於內層函數被外層函數的參數分在兩端包裹住,不符合人類的線性思惟

咱們寫成下面這種符合直覺的形式

1 ---> res => 2*res ---> res => 3*res ---> res => res

鏈上每個步驟的輸出做爲下一步驟的輸入

當解釋器展開成上面的計算鏈後,便開始從左往右的計算,直到運行完全部的計算步驟

須要注意到的是,由於kont承擔了函數後續全部的計算流程,所以不須要返回,因此對kont的調用即是尾調用
當咱們把程序中全部的函數都按CPS約定改寫之後,程序中全部的函數調用就都變成了尾調用了,而這正是本文的目的
這個改寫的過程就稱爲CPS變換

須要警戒的是,CPS變換並不是沒有狀態保存這個過程,它只是把狀態保存到continuation對象中,而後一級一級地往下傳,所以空間複雜度並無下降,只是不須要由函數棧幀來承受保存狀態的負擔而已

CPS約定簡約,卻可顯式地控制程序的執行,程序裏各類形式的控制流均可以用它來表達(好比協程、循環、選擇等)
因此不少函數式語言的實現都採用了CPS形式,將語句的執行分解成一個小步驟一次執行,
固然,也由於CPS形式過於簡潔,表達起來過於繁瑣,能夠當作一種高級的彙編語言

Trampoline技法

通過CPS變換後,遞歸函數已經轉化成一條長長的continuation

尾調用函數層層嵌套,永不返回,然而在缺少尾調用優化的語言中,並不知曉函數不會返回,狀態、參數壓棧依舊會發生,所以須要手動強制彈出下一層調用的函數,禁止解釋器的壓棧行爲,這就是所謂的Trampoline

由於continuation只接受一個結果參數,而後調用另外一個continuation處理結果,所以咱們須要顯式地用變量vkont分別表示上一次的結果、下一個continuation,而後在一個循環裏不斷地計算continuation,直處處理完整條continuation鏈,而後返回結果

function trampoline(kont_v)  // kont_v = { kont: ..., v: ... }
{
    while(kont_v.kont)
        kont_v = kont_v.kont(kont_v.v);
    
    return kont_v.v;
}

kont_v.kont是一個bounce,每次執行kont_v.kont(kont_v.v)時,都會根據上次結果計算出本次結果,而後彈出下一級continuation,而後保存在對象{v: ..., kont: ...}

固然,在bounce中用bind的話,就不須要構造對象顯式保存v了,由於bind會將v保存到閉包中,此時,trampoline變成:

function trampoline(kont)
{
    while(typeof kont == "function")
        kont = kont();
    return kont.val;
}

bind改寫會更簡潔,然而,由於想要求的值有多是個function,咱們須要在bounce裏用對象{val: ...}把結果包裝起來

具體應用可看下面的例子

線性遞歸的CPS變換:求和

求和的遞歸實現:

sum = x => { if(x == 0) return 0; else return x + sum(x-1) }

當參數過大,好比sum(4000000),提示Uncaught RangeError: Maximum call stack size exceeded,爆棧了!

如今,咱們經過CPS變換,將上面的函數改寫成尾遞歸形式:

首先,sum多添加一個參數表示continuation,表示對計算結果進行的後續處理,

sum = (x, kont) => ...

其中,kont是一個單參數函數,形如 res => ...,表示對結果res的後續處理

而後逐狀況考慮

x == 0時,計算結果直接爲0,並將kont應用到結果上,

sum = (x, kont) => { if(x == 0) return kont(0); else ... }

x != 0時,須要先計算x-1的求和,而後將計算結果與x相加,而後把相加結果輸入kont中,

sum = (x, kont) => { 
       if(x == 0) return kont(0); 
       else return sum( x - 1, res => kont(res + x) ) };
}

好了,如今咱們已經完成了sumCPS變換,你們仔細看看,上面的函數已是尾遞歸形式啦。

如今還有最後的問題,怎麼去調用?好比要算4的求和sum(4, kont),這裏的kont應該是什麼呢?

能夠這樣想,當咱們計算出結果,後續的處理就是把結果簡單地輸出,所以kont應爲res => res

sum(4, res => res)

把上面的代碼複製到Console,運行就能獲得結果10

下面咱們模擬一下sum(3, res => res)的運做,以對其有個直觀的認識

sum( 3, res => res )
sum( 2, res => ( (res => res)(res+3) ) )
sum( 1, res => ( res => ( (res => res)(res+3) ) )(res+2) ) )
sum( 0, res => ( res => ( res => ( (res => res)(res+3) ) )(res+2) ) )(res+1) )

// 展開continuation鏈
( res => ( res => ( res => ( (res => res)(res+3) ) )(res+2) ) )(res+1) )(0)

// 收縮continuation鏈
( res => ( res => ( (res => res)(res+3) ) )(res+2) )(0+1)
( res => ( (res => res)(res+3) ) )(0+1+2)
(res => res)(0+1+2+3)
6

從上面的展開過程能夠看到,sum(x, kont)分爲兩個步驟

  • 展開continuation,尾調用函數層層嵌套,先作的continuation在外層,後作的continuation放內層,這也是CPS反人類的緣由人類思考閱讀都是線性的(從上往下,從左往右),而CPS則是從外到內,並且外層函數和參數包裹着內層,閱讀時還須要眼睛在左右兩端不斷遊離

  • 收縮continuation,不斷將外層continuation計算的結果往內層傳

固然,如今運行sum(4000000, res => res),依然會爆棧,由於js默認並無對尾調用作優化,咱們須要利用上面的Trampoline技法將其改爲循環形式(上文已經提過,尾遞歸和循環等價)

但是等等,上面說的Trampoline技法只針對於收縮continuation鏈過程,但是sum(x, kont)還包括展開過程啊?別擔憂,能夠看到展開過程也是尾遞歸形式,咱們只需稍做修改,就能夠將其改爲continuation的形式

( r => sum( x - 1, res => kont(res + x) )(null)

如此即可把continuation鏈的展開和收縮過程統一塊兒來,寫成如下的循環形式

function trampoline(kont_v)
{
    while(kont_v.kont)
        kont_v = kont_v.kont(kont_v.v);
    
    return kont_v.v;
}

function sum_bounce(x, kont)
{    
    if(x == 0) return {kont: kont, v: 0};
    else return { kont: r => sum_bounce(x - 1, res => {
                                                 return { kont: kont, 
                                                          v: res + x }
                                               } ),
                  v: null };
}

var sum = x => trampoline( sum_bounce(x, res => 
                                            {return { kont: null, 
                                                      v: res } }) )

OK,以上即是改爲循環形式的尾遞歸寫法
sum(4000000)輸入Console,稍等片刻,便能獲得答案8000002000000

固然,用bind的話能夠改寫成更簡約的形式:

function trampoline(kont)
{
    while(typeof kont == "function")
        kont = kont();
    return kont.val;
}

function sum_bounce(x, kont)
{    
    if(x == 0) return kont.bind(null, {val: 0});
    else return sum_bounce.bind( null, x - 1, res => kont.bind(null, {val: res.val + x}) );
}

var sum = x => trampoline( sum_bounce(x, res => res) )

也能起到一樣的效果

樹狀遞歸的CPS變換:Fibonacci

由於Fibonacci樹狀遞歸,轉換起來要比線性遞歸的sum麻煩一些,先寫出普通的遞歸算法

fib = x => x == 0 ? 1 : ( x == 1 ? 1 : fib(x-1) + fib(x-2) )

一樣,當參數過大,好比fib(40000),就會爆棧

開始作CPS變換,有前面例子鋪墊,下面只講關鍵點

添加kont參數,則fib = (x, kont) => ...

分狀況考慮

x == 0 or 1fib = (x, kont) => x == 0 ? kont(1) : ( x == 1 ? kont(1) ...

x != 1 or 1,須要先計算x-1fib,再計算出x-2fib,而後將兩個結果相加,而後將kont應用到相加結果上

fib = (x, kont) => 
      x == 0 ? kont(1) : 
      x == 1 ? kont(1) : 
               fib( x - 1, res1 => fib(x - 2, res2 => kont(res1 + res2) ) )

以上即是fibCPS變換後的尾遞歸形式,可見難點在於kont的轉化,這裏須要好好揣摩

最後利用Trampoline技法將尾遞歸轉換成循環形式

function trampoline(kont_v)
{
    while(kont_v.kont)
        kont_v = kont_v.kont(kont_v.v);
    
    return kont_v.v;
}

function fib_bounce(x, kont)
{    
    if(x == 0 || x == 1) return {kont: kont, v: 1};
    else return { 
                  kont: r => fib_bounce( x - 1, 
                                         res1 => 
                                         {
                                            return { 
                                             kont: r => fib_bounce(x - 2,
                                                                   res2 =>
                                                                   { 
                                                                     return  { 
                                                                       kont: kont,
                                                                       v: res1 + res2
                                                                     }
                                                                   }), 
                                             v: null 
                                           }
                                         } ),
                  v: null 
                };
}

var fib = x => trampoline( fib_bounce(x, res => 
                                            {return { kont: null, 
                                                      v: res } }) )

OK,以上即是改爲循環形式的尾遞歸寫法
console中輸入fib(5)fib(6)fib(7)能夠驗證其正確性,

固然,當你運行fib(40000)時,發現的確沒有提示爆棧了,可是程序卻卡死了,何也?

正如我在前言說過,這種方法並不會下降樹狀遞歸算法的時間複雜度,只是將佔用的棧空間以閉包鏈的形式轉移至堆上,免去爆棧的可能,可是當參數過大時,運行復雜度太高,continuation鏈過長也致使大量內存被佔用,所以,優化算法纔是王道

固然,用bind的話能夠改寫成更簡約的形式:

function trampoline(kont)
{
    while(typeof kont == "function")
        kont = kont();
    return kont.val;
}

fib_bounce = (x, kont) =>
 x == 0 ? kont.bind(null, {val: 1}) : 
 x == 1 ? kont.bind(null, {val: 1}) : 
          fib_bounce.bind( null, x - 1, 
                           res1 => fib_bounce.bind(null, x - 2,
                                                   res2 => kont.bind(null, {val: res1.val + res2.val}) ) )

var fib = x => trampoline( fib_bounce(x, res => res) )

也能起到一樣的效果

CPS變換法則

對於基本表達式如數字、變量、函數對象、參數是基本表達式的內建函數(如四則運算等)等,不須要進行變換,

如果函數定義,則須要添加一個參數kont,而後對函數體作CPS變換

如果參數位置有函數調用的函數調用,fn(simpleExp1, exp2, ..., expn),如exp2就是第一個是函數調用的參數
則過程比較複雜,用僞代碼表述以下:(<<...>>內表示表達式, <<...@exp...>表示對exp求值後再代回<<...>>中):

cpsOfExp(<< fn(simpleExp1, exp2, ..., expn) >>, kont)
= cpsOfExp(exp2, << r2 => @cpsOfExp(<< fn(simpleExp1, r2, ..., expn) >>, kont) >>)

順序表達式的變換亦與上相似

固然這個問題不是這麼容易講清楚,首先你須要對你想要變換的語言瞭如指掌,知道其表達式類型、求值策略等,
JavaScript語法較爲繁雜,解釋起來不太方便,
以前我用C++模板寫過一個CPS風格的Lisp解釋器,往後有時間以此爲例詳細講講

相關文章
相關標籤/搜索