资讯专栏INFORMATION COLUMN

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

pekonchan / 788人阅读

这两天搜了下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,但也挺佩服这哥们:“我靠,这也能想得到!”

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/86241.html

相关文章

  • 翻译连载 | 第 9 章:递归(下)-《JavaScript轻量级函数式编程》 |《你不知道JS

    摘要:每个函数调用都将开辟出一小块称为堆栈帧的内存。当第二个函数开始执行,堆栈帧增加到个。当这个函数调用结束后,它的帧会从堆栈中退出。保持堆栈帧跟踪函数调用的状态,并将其分派给下一个递归调用迭。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson-《You-Dont-Know-JS》作者 关于译者:这是一个流淌着沪江血液的纯粹工程:认真,是 HTM...

    LeviDing 评论0 收藏0
  • JS递归二叉树遍历

    摘要:貌似大部分语言中的递归都差不多,之所以在标题加是因为搜了下后感觉网上用来描述这概念的不多,简单地说递归就是函数调用自己的过程。 貌似大部分语言中的递归都差不多, 之所以在标题加JS是因为搜了下后感觉网上用js来描述这概念的不多, 简单地说递归就是函数调用自己的过程。下面的栗子可以很直观地展示递归的执行过程: function rec(x){ if(x!==1){ console....

    church 评论0 收藏0
  • 前端笔记(四) ES6常用语法

    摘要:函数更好的尾递归优化实现传入类数组对象且每次的值在改变。尾递归实现改写一般的递归函数确保最后一步只调用自身。返回一个遍历器对象用循环遍历。和循环它是一种遍历器接口为各种不同的数据结构提供统一的访问机制。 解构赋值 //数组的解构赋值 let [a, b, c] = [1, 2, 3]; a // 1 b // 2 c // 3 let [a, [[b], c]] = [1, [[2]...

    church 评论0 收藏0
  • JavaScript中递归

    摘要:第三次第四次设想,如果传入的参数值特别大,那么这个调用栈将会非常之大,最终可能超出调用栈的缓存大小而崩溃导致程序执行失败。注意尾递归不一定会将你的代码执行速度提高相反,可能会变慢。 译者按: 程序员应该知道递归,但是你真的知道是怎么回事么? 原文: All About Recursion, PTC, TCO and STC in JavaScript 译者: Fundebug ...

    Jacendfeng 评论0 收藏0
  • js 实现斐波那契数列(数组缓存、动态规划、调用优化)

    摘要:根据该规则,返回第个斐波那契数。尾递归函数调用自身,称为递归。一个前端眼中的斐波那契数列解斐波那契数列的实用解法调用栈尾递归和手动优化尾调用优化译我从用写斐波那契生成器中学到的令人惊讶的件事 斐波那契数列是以下一系列数字: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, ... 在种子数字 0 和 1 ...

    赵连江 评论0 收藏0

发表评论

0条评论

pekonchan

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<