译者注: 翻译前对 TCO(尾调用)不是很熟悉,于是借助 AI 先了解了一下基本知识。
在 JavaScript 中,尾调用是指一个函数在最后一步调用另一个函数。具体来说,这个调用发生在调用者的返回位置,且该调用的返回值直接被当前函数返回。
优点:
- 节省内存:尾调用优化可以减少内存消耗,因为不需要保留当前函数的执行上下文.
- 提高性能:优化尾调用可以减少函数调用的开销,提升程序的执行效率。
缺点:
- 限制条件:尾调用优化有特定条件,不是所有情况都会被优化。
- 复杂性:在某些情况下,使用尾调用可能导致代码逻辑变得复杂难以理解。
应用场景:
- 递归函数:尾调用常用于优化递归函数,确保递归深度不会导致栈溢出。
- 状态机:在状态机和状态转换过程中,尾调用可以简化控制流程。
- 函数式编程:在函数式编程范式中,尾调用是一种常见的优化方式。
Bun 是一个刚发布了 1.0 版本的 JavaScript 运行时。现在你有了三个在浏览器之外运行 JavaScript 的选择:Node、Deno 和 Bun。Bun 的卖点之一就是其速度!为此,它做了一些有趣的决定。
其中之一便是 Bun 使用了 Zig 进行编写。这造就了一个令人兴奋的宇宙:Node 使用 C++,Deno 使用 Rust,Bun 使用 Zig。这难道不是一场激动人心的语言战争吗?!当然,我们会更关注其他事情。
Node 和 Deno 是基于 V8 引擎构建的,而 Bun 则基于 JavaScriptCore。你可能听说过 V8 是 Chrome 的 JavaScript 引擎。JavaScriptCore 则是 Safari 的引擎。它们有许多有趣的差异,不过我们将重点关注 JavaScriptCore 实现而 V8 尚未实现的优化:尾调用优化。
让我们写一些真实的代码来深入研究!想象一下你需要实现以下功能:
/*
Returns an array of numbers counting from 1 to amount.
Examples:
count(3) => [1, 2, 3]
count(5) => [1, 2, 3, 4, 5]
count(-1) => []
*/
function count(amount: number): number[];
你也可以自己尝试一下!我想大部分的人会这么实现:
function count(amount: number): number[] {
let nums: number[] = [];
for (let i = 1; i <= amount; i++) nums.push(i);
return nums;
}
这是一个很棒的实现,并且完全能运行!但现在,我会任性地提出一个挑战来引入尾调用优化。你可以将上述代码变为递归形式吗?试一试。一番思考之后,你可能会想到这样:
const count = (amount: number) => (amount > 0 ? [...count(amount - 1), amount] : []);
这是个非常简洁的方法!非常像数学课上讲的递归关系。你可能会思考,"看来循环可以用递归来更优雅地表达!"但现在我要分享一个让人难过的消息。尝试执行count(100000)
(Deno 和 Bun 允许直接运行 TypeScript)。你会得到报错Maximum call stack size exceeded
。
递归占用了调用栈上宝贵的内存!或许有命令可以增加程序的调用栈大小,但操作系统会进行限制,堆上的内容限制则会小一些。我们该怎样使用递归而不用担心栈溢出呢?答案是:希望你的 JavaScript 引擎能支持TCO(尾调用),并且可以优化你的递归。
重写函数利用TCO 步骤包含将状态变量转移到函数参数。递归调用必须是函数 AST 的最后一步。TCO 的版本如下:
const count = (amount: number, cur: number[] = []) =>
cur.length >= amount ? cur : count(amount, [...cur, cur.length + 1]);
它看上去不那么地简洁和优雅,但可以被尾调用优化了!如果我们使用 Deno 运行count(100000)
,我们仍然会得到错误error: Uncaught RangeError: Maximum call stack size exceeded
。但使用 Bun 的话,程序就能成功运行了!但仍然有另一个问题……那就是运行速度太慢了。
count(100000)
在 Bun 中利用 TCO 的方法运行需要 7 秒。而原始的for
循环方法只需.01s
。我们要怎样才能让递归的性能接近for
循环呢?我们可以使用引用类型(mutation)。
function count(amount: number, cur: number[] = []) {
if (cur.length >= amount) return cur;
cur.push(cur.length + 1);
return count(amount, cur);
}
这个方法看起来像原来 for 循环的方法。它即不简洁也不优雅。但它能在.01s
跑完count(100000)
。不错!
我心中极简主义的部分十分喜欢 TCO。它可以让一个语言不用循环就能完成复杂且高效的程序。就 JavaScript 而言,这意味着可以用更小的子集表达所有程序。大部分的初学者都会学习循环语句,就像是每种语言的基础或者必修。但事实并非如此。使用 TCO 可以用递归来实现循环并且有类似的性能。
像 LISP 这类以来递归的语言,在语言层面就实现了 TCO。但遗憾的是,TCO 仅在 JavaScriptCore 中实现了。不过感谢 Bun 和 Safari 使用了 JavaScriptCore!