大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发!

尾调用优化 TCO(Tail Call Optimization) 是一种通过避免堆栈帧累积来优化递归函数的技术。

由于尾调用是函数的最后一步操作,因此无需保留外层函数调用记录,因为调用位置、内部变量等信息都不会再用到,只要直接用内层函数的调用记录取代外层函数的调用记录即可。
在 ECMAScript 6 (ES6) 中,不需要 TCO,但某些 JavaScript 引擎可能对其提供有限的支持。接下来通过一个简单的示例以通俗易懂的语言来了解 TCO,考虑一个计算数字阶乘的函数,代码示例如下:
// 计算 n 的阶乘function factorial(n) { if (n === 0) { return 1; } else { return n * factorial(n - 1); }}在此代码中,阶乘函数 factorial 递归调用自身,将当前数字 n 与 (n - 1) 的阶乘相乘。 然而,该代码没有充分利用 TCO。要启用 TCO,可以修改函数以使用 accumulator 参数:
function factorial(n, accumulator = 1) { if (n === 0) { return accumulator; } else { return factorial(n - 1, n * accumulator); }}// 调用函数factorial(3)以上示例引入一个称为 accumulator 的额外参数,其跟踪中间结果。 在每次递归调用中,不是直接返回 n * Factorial(n - 1),而是通过将累加器与 n 相乘来更新 accumulator,并将其传递给下一个递归调用。
通过此修改,递归调用现在是尾调用,因为其是返回之前的最后一个操作,并且不需要任何进一步的计算。 它允许 JavaScript 引擎通过重用当前堆栈帧来优化调用,从而防止大输入值 n 时出现潜在的堆栈溢出错误。
但是,需要注意的是,ECMAScript 6 并不强制要求 TCO,其可用性取决于 JavaScript 引擎。 不同引擎可能以不同的方式处理尾部调用,因此不能保证优化在所有环境中一致工作。
注意:尾调用优化只在严格模式下开启,非严格模式无效。非严格模式下,函数内有两个变量,即:arguments、func.caller 可跟踪函数调用栈,而当尾调用优化发生时,函数调用栈会改写,两个变量会失真,而严格模式禁用了这两个变量。
2. 尾调用优化(TCO)兼容性为了确保更广泛的兼容性和可靠的优化,开发者可以考虑使用提供 TCO 功能的迭代方法或库,例如 Babel 的 babel-plugin-tailcall-optimization 或像 ClojureScript 这样编译为具有内置 TCO 支持的 JavaScript 的语言。
如果是 Babel 需要安装相应的包,然后导入到 babelrc 中:
npm install babel-plugin-tailcall-optimization --save-dev// 如果是 Babel6 可以使用 babel-plugin-tailcall-optimization@1// .babelrc"plugins": ["tailcall-optimization"]babel-plugin-tailcall-optimization 插件通过使用 while 循环对函数进行尾部调用来重写函数。比如下面是带尾调用的原始函数:
function counter (n, acc = 0) { if (n === 0) { return acc } else { return counter(n - 1, acc + 1) }}Babel 会将其编译为如下代码:
function counter(n, acc = 0) { var _repeat = true; var _n, _acc; while (_repeat) { _repeat = false; if (n === 0) { return acc; } else { _n = n - 1 _acc = acc + 1 n = _n acc = _acc _repeat = true; continue; } }}babel-plugin-tailcall-optimization 插件不会影响没有 TCO 的功能,因此可以安全使用。对于斐波那契数列示例 benchmark.js 结果是:
无 TCO 的斐波那契数列 x 270,170 操作 / 秒 ±1.14%(采样 85 次)斐波那契数列,TCO x 1,298,276 操作 / 秒 ±1.24%(采样 83 次)因此 TCO 优化后的功能几乎快了 5 倍。但是 babel-plugin-tailcall-optimization 插件也有自己的局限性,比如:
当插件检测到 tailcalled(尾调用) 函数中的函数创建时,不会对其进行优化,因为实现非常困难,查看该问题:https://phabricator.babeljs.io/T6869不适用于相互递归函数,但问题不大,因为即使 JVM 也不会这样做3. ES6 的 PTC 的问题ES6 指定了适当的尾部调用(PTC,即 Proper Tail Calls), 使用 PTC,位于尾部位置的调用不得创建额外的堆栈帧。目前,该功能虽然可能导致调试困难、实现困难,但已然被纳入并标准化为 ES6 的一部分。
最近的 ES6 实现已经开始认真考虑尾部调用的实现,然而在实现 PTC 中也遇到了诸多问题,典型的包括:
3.1 PTC 性能很多人相信 PTC 是一种引擎优化策略,比如:JSC, 但 v8 团队却证明 PTC 无效而保持中立,而 Chakra 团队由于其他限制无法在不降低恰好包含尾部调用或的现有代码性能的情况下实现该功能,即牺牲了规范一致性。
由于 PTC 自动选择在一堆 Web 代码中使用尾部调用,因此性能下降是一个非常严重的问题。 而 STC 通过有意何时使用该功能来避免这个问题,这可能涉及对实现的评估以及使用该功能是否会为独特的场景带来理想的性能特征。
通过更改 PTC 的设计以要求某种语法选择加入可以解决许多问题。而 T39 的最新提案 proposal-ptc-syntax 试图为该语法创建一个提案,以替代 ES6 中的 PTC 规范。
3.2 PTC 开发工具开发人员广泛依赖调用堆栈来调试代码,而实施 PTC 将导致许多信息丢失。
开发者可以通过实现某种形式的影子堆栈(例如 JSC 的 ShadowChicken)来缓解,该堆栈维护可以在调试期间向用户渲染的帧的侧堆栈。然而,这是有成本的,因此可能只能在开发工具打开的情况下启用(因此不能解决下面的 Error.stack 问题)。 同时,也不是潜在问题的灵丹妙药,例如:在调试工作流程中引入 GC 不确定性。
function foo(n) { return bar(n*2);}function bar() { throw new Error();}foo(1);以上代码示例对 bar 的调用将重用 foo 中的帧(Frame),因为对 bar 的调用位于尾部位置。 因此,在 Error.stack 和开发工具中, foo 帧将会丢失。
STC 通过刻意选择何时删除帧来规避这个问题。 开发人员工具不需要向开发人员显示每个帧,因为开发人员已选择忽略。 或者,开发人员工具可以使用 PTC 已有能力来提供不同的且可能更好的体验。
3.3 Error.stack由于省略了堆栈帧,JavaScript 异常在启用 PTC 的情况下将具有不同的 error.stack 信息。
function foo(n) { return bar(n*2);}function bar() { throw new Error();}try { foo(1);} catch(e) { print(e.stack);}/*启动 PTC 后的错误堆栈Error at bar at foo at Global Codeoutput with PTC (note how it appears that bar is called from Global Code)Error at bar at Global Code*/STC 通过使尾部调用成为显式来避免此类问题。因此,今天有效的代码通过启动 STC 将继续有效,包括:收集并报告给服务器的任何错误堆栈信息。
4. ES6 的 STC(Syntactic Tail Calls)STC 提案希望推进尾部调用的语法选择,比如上面的 return continue。
return continue 显式表明接下来是尾调用,因此引擎不用增加堆栈。 在大多数情况下,将 return continue 与非尾部调用一起使用可能会出现语法错误(例如:return continue 1 + foo() 会提前抛出)。 有时候,return continue 可能依然会对语法上正确的尾部调用启用错误或警告,这些尾部调用不能保证不会增加堆栈,例如 FireFox 中的跨领域调用(Cross-realm Calls)。
此外,选择使用 return continue 的开发人员可以管理省略的堆栈帧的复杂性,因此调试场景需要侵入性较小的解决方案(或者可能不需要解决方案),并且可以维护现有代码的语义。
function factorial(n, acc = 1) { if (n === 1) { return acc; }// 显式 continue return continue factorial(n - 1, acc * n)}let factorial = (n, acc = 1) => continue n == 1 ? acc : factorial(n - 1, acc * n);// 或者 continue 是表达式形式let factorial = (n, acc = 1) => n == 1 ? acc : continue factorial(n - 1, acc * n);5.本文总结本文主要和大家介绍尾调用优化 TCO(Tail Call Optimization), TCO是一种通过避免堆栈帧累积来优化递归函数的技术。因为篇幅问题,关于 TCO 主题只是做了一个简短的介绍,但是文末的参考资料提供了大量优秀文档以供学习,如果有兴趣可以自行阅读。如果大家有什么疑问欢迎在评论区留言。
参考资料https://dev.to/diwakarkashyap/tail-call-optimization-in-es6javascript-in-easy-language-11e6
https://www.ruanyifeng.com/blog/2015/04/tail-call.html
https://suki.gitbook.io/notes/articles/javascript/tail_call_optimization
https://github.com/krzkaczor/babel-plugin-tailcall-optimization
https://douglasmoura.dev/en-US/understanding-tail-call-optimization-with-javascript
https://github.com/tc39/proposal-ptc-syntax
https://www.youtube.com/watch?app=desktop&v=SPri4PTUY_8
https://www.youtube.com/watch?app=desktop&v=Wpkh5YSqwqg
https://javascript.plainenglish.io/js-recursion-and-tail-call-optimization-with-pictures-cbf8f766a31