[翻译] JS的递归与TCO尾调用优化

这两天搜了下JS递归的相关文章, 觉得这篇文章很不错, 就顺手翻译了下,也算给自己做个笔记,题目是我自己加的。原文很长,写得也很详尽,这里并非逐字翻译, 而是作者所讲的主要概念加上我自己的一些理解,本文中解决方案的实际意义并不是特别大,但算法的逻辑挺有意思,不过也略抽象,理解需要花点时间(囧,估计我太闲了) 文中的用例?全部来自原文:

原文链接:(原题为:理解JS函数式编程中的递归)
Understanding recursion in functional JavaScript programming

递归存在的问题

在JS的递归调用中,JS引擎将为每次递归开辟一段内存用以储存递归截止前的数据,这些内存的数据结构以“栈”的形式存储,这种方式开销非常大,并且一般浏览器可用的内存非常有限。
下面这个函数使用递归的方式求和:

//使用递归将求和过程复杂化
function sum(x, y) {
    if (y > 0) {
      return sum(x + 1, y - 1);
    } else {
      return x;
    }
}

sum(1, 10); // => 11

当运算规模较小时,这种方式可以正常输出结果,可是当把参数变为sum(1,100000)时,就会造成“栈溢出错误(stack overflow 这可不是那个问答网站哦)”浏览器就会报错Uncaught RangeError: Maximum call stack size exceeded

尾调用优化 Tail Call Optimisation

在有些语言中,执行尾递归时将会被自动识别,继而在运行时优化成循环的形式,这种优化逻辑大多是Tail Call Optimisation尾部调用优化,(尾调用概念就是在函数最后一步调用其他函数,尾递归即在最后一步调用自身)关于尾递归与尾调优化更详细的概念解读可以看下阮一峰的这篇文章? 尾调用优化 (也就是说执行尾递归时,程序无须储存之前调用栈的值,直接在最后一次递归中输出函数运算结果,这样就大大节省了内存,而这种优化逻辑就是在代码执行的时候将其转换为循环的形式)
另外在Babel的说明文档中也提到了尾调用? BABEL Tail Calls

以上的sum函数, 使用尾递归,将是这个样子:

function sum(x, y) {
    function recur(a, b) {
        if (b > 0) {
            return recur(a + 1, b - 1);
        } else {
            return a;
        }
    }
//尾递归即在程序尾部调用自身,注意这里没有其他的运算
    return recur(x, y);
}

sum(1, 10); // => 11

以上这种写法在有TCO机制的语言中将在执行时内部优化成循环形式而不会产生“栈溢出”错误,注意,在当前版本的JS中以上写法是无效的!因为在当前普遍的JS版本(ES5)中并没有这个优化机制。但是在ES6中已经实现了这个机制 在当前普遍的JS版本中我们只能使用替代方案。

这里插一句:使用Babel可以在当前JS版本中用ES6的特性(Babel可以将使用ES6特性编程的代码转换成兼容的ES5形式),将原sum()函数输入Babel的编译器后,确实被转换成了循环的形式,感兴趣的同学可以自己试试:
BABEL编译器转换sum()函数的结果如下(对于算法逻辑不太感兴趣的同学看到这里就差不多了,
可以直接将一些深递归放到Babel中转换下就可以了):

var _again = true;

  _function: while (_again) {
    var x = _x,
        y = _x2;
    _again = false;

    if (y > 0) {
      _x = x + 1;
      _x2 = y - 1;
      _again = true;
      continue _function;
    } else {
      return x;
    }   } } 

替代方案

在当前的JS版本(ES5)中可以使用以下方式来优化递归。
我们可以定义一个Trampolining(蹦床)函数来解决参数过大造成的“栈溢出”问题。

    //放入trampoline中的函数将被转换为函数的输出结果
function trampoline(f) {
    while (f && f instanceof Function) {
        f = f();
    }
    return f;
}

function sum(x, y) {
    function recur(x, y) {
        if (y > 0) {
          return recur.bind(null, x + 1, y - 1);
        } else {
          return x;
        }
    }
//
    return trampoline(recur.bind(null, x, y));
}

sum(1, 10); // => 11

在以上的方案中, trampoline函数接受一个函数作为参数,如果参数是函数就被执行后返回,如果参数不是函数将被直接返回,嵌套函数recur中,当y>0时返回一个参数更新了的函数,这个函数被转入trampoline中循环,直到recur返回xx不是函数于是在trampoline中被直接返回。原文中作者对于每一步都有详尽的解释, 感兴趣的同学建议可以去看看原文。简单地说:以上逻辑就是将递归变成一个条件, 而外层trampoline函数执行这个条件判断并循环。好吧,接下来更绕的来了-_-#

以上这种方法虽然解决了大参数递归的问题,但是却需要将代码转换成trampoline的模式,比较不灵活, 下面作者介绍了一种更灵活方便的方案。

更好的方案

作者在此警告:前方高能, 该方法不需要改动源码,但是略抽象,理解可能需要花点时间。


function tco(f) {
    var value;
    var active = false;
    var accumulated = [];

    return function accumulator() {
        accumulated.push(arguments);

        if (!active) {
            active = true;

            while (accumulated.length) {
                value = f.apply(this, accumulated.shift());
            }

            active = false;

            return value;
        }
    }
}
//这种方式确实有点奇怪,但的确没有改动很多源码,只是以直接量的形式使用tco函数包裹源码
var sum = tco(function(x, y) {
    if (y > 0) {
      return sum(x + 1, y - 1)
    }
    else {
      return x
    }
});
sum(1, 10) // => 11
sum(1, 100000) // => 100001 没有造成栈溢出
  • 首先以函数表达式的形式将tco函数的返回值赋给sum,tco函数的返回值是accumulator函数,也就是说当执行sum(1,10)的时候即是在执行accumulator(1,10),牢记这点对后续理解很有帮助。

  • accumulator是个闭包,这意味着可以访问在tco中定义的valueactive以及accumulated

  • 前面已经讲了,当我们执行sum的时候相当于是执行accumulator,于是accumulator 将实参传入accumulated数组,比如执行sum(1,10)那么这里传入的就是类数组对象[1,10],accumulated现在就是一个length为1的二维数组。

  • 进入while循环,这里是关键:value = f.apply(this, accumulated.shift()); 在这条语句中, f表示外包的匿名函数,它判断y的值后返回一个sum (这里很容易产生混乱,如果我们忽略while循环中的细节,很容易将其误认为也是递归)

  • 匿名函数f判断y的值返回一个sumsum的参数被改变了,前面提到执行sum相当于执行accumulator,于是新的参数被加入到了accumulator但是因为这时active的值依然是true(因为现在执行流还在while循环里),所以执行这个被返回的sum就会得到一个undefined的值,value被赋值为undefined

  • 可是因为执行了被返回的sum(也就是执行了accumulator)尽管没有进入if(!active),但是执行了第一条语句,所以accumulated被重新push进了在外包的匿名函数中被修改的实参,所以while循环继续(理解这里是关键)。

  • while循环一直执行到accumulated中的值为空, 在value = f.apply(this, accumulated.shift()); 每次return一次sum后accumulated 都会被重新推入一个实参(accumulated的length始终为1),直到匿名的外包函数return出x,于是x的值被赋给value被返回出来。

注意:以上主要还是根据我自己的理解来阐述逻辑, 确实比较绕,作者原文写得更加详细

总结

以上方法就是在不改动源码的情况下实现的TCO优化, 作者在该文章的Update中介绍了另外的非TCO的优化递归的方法,不过篇幅有限就不再贴出来了,就我自身感觉而言,如果对算法的逻辑实现不感兴趣, 大可以直接用Babel将深递归转换成优化后的形式。另外这也有一篇介绍JS中递归与循环的的文章,其中也有TCO优化的相关介绍:
?Recursion in Functional JavaScript

感觉以上代码的实际意义可能并没有那么大, 为了写这篇博客也是耗了我一天,囧rz,但也挺佩服这哥们:“我靠,这也能想得到!”

阅读 7.2k

推荐阅读

蚂蚁金服海外银行诚招前端:JD:[链接] 简历请投:jiongjiong.lc@antfin.com

56 人关注
30 篇文章
专栏主页