返回介绍

第一部分 类型和语法

第二部分 异步和性能

6.6 尾调用优化

发布于 2023-05-24 16:38:21 字数 1856 浏览 0 评论 0 收藏 0

正如前面我们提到的,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 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文