基于CPS变更的尾递归转换算法

媒介

尽人皆知,递归函数随意马虎爆栈,究其缘由,就是函数挪用前须要先将参数、运转状况压栈,而递归则会致使函数的屡次无返回挪用,参数、状况积压在栈上,终究耗尽栈空间

一个处置惩罚的要领是从算法上处置惩罚,把递归算法改进成只依赖于少数状况的迭代算法,然则此事知易行难,线性递送还随意马虎,树状递归就难以转化了,而且并不是一切递归算法都有非递归完成。

在这里,我引见一种要领,运用CPS变更,把恣意递归函数改写成尾挪用情势,以continuation链的情势,将递归占用的栈空间转移到堆上,防止爆栈的悲剧
须要注重的是,这类要领并不能下降算法的时候庞杂度,如果希望此法压缩运转时候无异于白日做梦

下文先引入尾挪用、尾递归、CPS等观点,然后引见Trampoline技法,将尾递归转化为轮回情势(无尾挪用优化言语的必需品),再sumFibonacci为例子解说CPS变更历程(虽然这两个例子能够随意马虎写成迭代算法,没必要搞这么庞杂,然则最为罕见好懂,因而拿来做例子,以免说题目都得说半天),末了讲通用的CPS变更轨则

看完这篇文章,人人能够去看看Essentials of Programming Languages相干章节,能够有更深的熟悉

文中代码皆用JavaScript完成

尾挪用 && 尾递归

先来讨论下在什么状况下函数挪用才须要保留状况

Add(1, 2)MUL(1, 2)这类显著不须要保留状况,

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诠释器,往后有时候以此为例细致讲讲

    原文作者:cheukyin
    原文地址: https://segmentfault.com/a/1190000008489245
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞