尾部调用优化 (TCO)
正如我们早前简单提到的,ES6包含了一个冒险进入性能世界的具体需求。它是关于在函数调用时可能会发生的一种具体的优化形式:尾部调用优化(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)
不是 一个尾部调用,因为在它完成后,在baz()
能返回它的结果前,这个结果必须被加1。
不过于深入本质细节而简单地说,调用一个新函数需要保留额外的内存来管理调用栈,它称为一个“栈帧(stack frame)”。所以前面的代码段通常需要同时为baz()
,bar(..)
,和foo(..)
都准备一个栈帧。
然而,如果一个支持TCO的引擎可以认识到foo(y+1)
调用位于 尾部位置 意味着bar(..)
基本上完成了,那么当调用foo(..)
时,它就并没有必要创建一个新的栈帧,而是可以重复利用既存的bar(..)
的栈帧。这不仅更快,而且也更节省内存。
在一个简单的代码段中,这种优化机制没什么大不了的,但是当对付递归,特别是当递归会造成成百上千的栈帧时,它就变成了 相当有用的技术。引擎可以使用TCO在一个栈帧内完成所有调用!
在JS中递归是一个令人不安的话题,因为没有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 实际上趋向于减少特定的算法在JS中使用递归实现的机会。
如果无论什么情况下引擎缺少TCO只是安静地退化到性能差一些的方式上,那么它可能不会是ES6需要 要求 的东西。但是因为缺乏TCO可能会实际上使特定的程序不现实,所以与其说它只是一种隐藏的实现细节,不如说它是一个重要的语言特性更合适。
ES6保证,从现在开始,JS开发者们能够在所有兼容ES6+的浏览器上信赖这种优化机制。这是JS性能的一个胜利!