衆所周知,遞歸函數容易爆棧,究其緣由,即是函數調用前須要先將參數、運行狀態壓棧,而遞歸則會致使函數的屢次無返回調用,參數、狀態積壓在棧上,最終耗盡棧空間。javascript
一個解決的辦法是從算法上解決,把遞歸算法改良成只依賴於少數狀態的迭代算法,然而此事知易行難,線性遞歸還容易,樹狀遞歸就難以轉化了,並且並非全部遞歸算法都有非遞歸實現。java
在這裏,我介紹一種方法,利用CPS變換
,把任意遞歸函數改寫成尾調用形式,以continuation
鏈的形式,將遞歸佔用的棧空間轉移到堆上,避免爆棧的悲劇。
須要注意的是,這種方法並不能下降算法的時間複雜度,如果期望此法縮短運行時間無異於白日作夢算法
下文先引入尾調用、尾遞歸、CPS
等概念,而後介紹Trampoline
技法,將尾遞歸轉化爲循環形式(無尾調用優化語言的必需品),再以sum
、Fibonacci
爲例子講解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
,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)
,其continuation
是fact_rec(4) ---> print
對於fact(1)
,其continuation
是fact_rec(2) ---> fact_rec(3) ---> fact_rec(4) ---> print
固然,上面的計算鏈不須要咱們手工展開和運行,程序的控制流已經由語法規定好,咱們只須要按語法寫好程序,解釋器自動會幫咱們分解計算步驟並循序漸進地計算
然而,當現有語法沒法知足咱們的控制流需求怎麼辦?好比咱們想從一個函數跳轉至另外一個函數的某處執行,語言並無提供這樣的跳起色制,那便須要手工傳遞控制流了。
CPS
是一種顯式地把continuation
做爲對象傳遞的coding
風格,以便能更自由地操控程序的控制流
既然是一種風格,天然須要有約定,CPS
約定:每一個函數都須要有一個參數kont
,kont
是continuation
的簡寫,表示對計算結果的後續處理
好比上面的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
形式過於簡潔,表達起來過於繁瑣,能夠當作一種高級的彙編語言
通過CPS變換
後,遞歸函數已經轉化成一條長長的continuation
鏈
尾調用函數層層嵌套,永不返回,然而在缺少尾調用優化的語言中,並不知曉函數不會返回,狀態、參數壓棧依舊會發生,所以須要手動強制彈出下一層調用的函數,禁止解釋器的壓棧行爲,這就是所謂的Trampoline
由於continuation
只接受一個結果參數,而後調用另外一個continuation
處理結果,所以咱們須要顯式地用變量v
、kont
分別表示上一次的結果、下一個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: ...}
把結果包裝起來
具體應用可看下面的例子
求和的遞歸實現:
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) ) }; }
好了,如今咱們已經完成了sum
的CPS變換
,你們仔細看看,上面的函數已是尾遞歸形式啦。
如今還有最後的問題,怎麼去調用?好比要算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) )
也能起到一樣的效果
由於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 1
,fib = (x, kont) => x == 0 ? kont(1) : ( x == 1 ? kont(1) ...
當x != 1 or 1
,須要先計算x-1
的fib
,再計算出x-2
的fib
,而後將兩個結果相加,而後將kont應用到相加結果上
fib = (x, kont) => x == 0 ? kont(1) : x == 1 ? kont(1) : fib( x - 1, res1 => fib(x - 2, res2 => kont(res1 + res2) ) )
以上即是fib
經CPS變換
後的尾遞歸形式,可見難點在於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) )
也能起到一樣的效果
對於基本表達式如數字、變量、函數對象、參數是基本表達式的內建函數(如四則運算等)等,不須要進行變換,
如果函數定義,則須要添加一個參數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
解釋器,往後有時間以此爲例詳細講講