6.6 尾调用优化
正如前面我们提到的,ES6 包含了一个性能领域的特殊要求。这与一个涉及函数调用的特定优化形式相关:尾调用优化 (Tail Call Optimization,TCO)。
简单地说,尾调用就是一个出现在另一个函数“结尾”处的函数调用。这个调用结束后就没有其余事情要做了(除了可能要返回结果值)。
举例来说,以下是一个非递归的尾调用:
function foo(x) { return x; } function bar(y) { return foo( y + 1 ); // 尾调用 } function baz() { return 1 + bar( 40 ); // 非尾调用 } baz(); // 42
foo(y+1) 是 bar(..) 中的尾调用,因为在 foo(..) 完成后,bar(..) 也完成了,并且只需要返回 foo(..) 调用的结果。然而,bar(40) 不是尾调用,因为在它完成后,它的结果需要加上 1 才能由 baz() 返回。
不详细谈那么多本质细节的话,调用一个新的函数需要额外的一块预留内存来管理调用栈,称为栈帧 。所以前面的代码一般会同时需要为每个 baz() 、bar(..) 和 foo(..) 保留一个栈帧。
然而,如果支持 TCO 的引擎能够意识到 foo(y+1) 调用位于尾部 ,这意味着 bar(..) 基本上已经完成了,那么在调用 foo(..) 时,它就不需要创建一个新的栈帧,而是可以重用已有的 bar(..) 的栈帧。这样不仅速度更快,也更节省内存。
在简单的代码片段中,这类优化算不了什么,但是在处理递归时,这就解决了大问题,特别是如果递归可能会导致成百上千个栈帧的时候。有了 TCO,引擎可以用同一个栈帧执行所有这类调用!
递归是 JavaScript 中一个纷繁复杂的主题。因为如果没有 TCO 的话,引擎需要实现一个随意(还彼此不同!)的限制来界定递归栈的深度,达到了就得停止,以防止内存耗尽。有了 TCO,尾调用的递归函数本质上就可以任意运行,因为再也不需要使用额外的内存!
考虑到前面递归的 factorial(..) ,这次重写成 TCO 友好的:
function factorial(n) { function fact(n,res) { if (n < 2) return res; return fact( n - 1, n * res ); } return fact( n, 1 ); } factorial( 5 ); // 120
这个版本的 factorial(..) 仍然是递归的,但它也是可以 TCO 优化的,因为内部的两次 fact(..) 调用的位置都在结尾处。
有一点很重要,需要注意:TCO 只用于有实际的尾调用的情况。如果你写了一个没有尾调用的递归函数,那么性能还是会回到普通栈帧分配的情形,引擎对这样的递归调用栈的限制也仍然有效。很多递归函数都可以改写,就像刚刚展示的 factorial(..) 那样,但是需要认真注意细节。
ES6 之所以要求引擎实现 TCO 而不是将其留给引擎自由决定,一个原因是缺乏 TCO 会导致一些 JavaScript 算法因为害怕调用栈限制而降低了通过递归实现的概率。
如果在所有的情况下引擎缺乏 TCO 只是降低了性能,那它就不会成为 ES6 所要求的东西。但是,由于缺乏 TCO 确实可以使一些程序变得无法实现,所以它就成为了一个重要的语言特性而不是隐藏的实现细节。
ES6 确保了 JavaScript 开发者从现在开始可以在所有符合 ES6+ 的浏览器中依赖这个优化。这对 JavaScript 性能来说是一个胜利。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论