众所周知,递归函数容易爆栈,究其缘由,即是函数调用前须要先将参数、运行状态压栈,而递归则会致使函数的屡次无返回调用,参数、状态积压在栈上,最终耗尽栈空间。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
解释器,往后有时间以此为例详细讲讲