尾部调用优化 (TCO)

正如我们早前简单提到的,ES6包含了一个冒险进入性能世界的具体需求。它是关于在函数调用时可能会发生的一种具体的优化形式:尾部调用优化(TCO)

简单地说,一个“尾部调用”是一个出现在另一个函数“尾部”的函数调用,于是在这个调用完成后,就没有其他的事情要做了(除了也许要返回结果值)。

例如,这是一个带有尾部调用的非递归形式:

  1. function foo(x) {
  2. return x;
  3. }
  4. function bar(y) {
  5. return foo( y + 1 ); // 尾部调用
  6. }
  7. function baz() {
  8. return 1 + bar( 40 ); // 不是尾部调用
  9. }
  10. 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友好的:

  1. function factorial(n) {
  2. function fact(n,res) {
  3. if (n < 2) return res;
  4. return fact( n - 1, n * res );
  5. }
  6. return fact( n, 1 );
  7. }
  8. factorial( 5 ); // 120

这个版本的factorial(..)仍然是递归的,而且它还是可以进行TCO优化的,因为两个内部的fact(..)调用都在 尾部位置

注意: 一个需要注意的重点是,TCO尽在尾部调用实际存在时才会实施。如果你没用尾部调用编写递归函数,性能机制将仍然退回到普通的栈帧分配,而且引擎对于这样的递归的调用栈限制依然有效。许多递归函数可以像我们刚刚展示的factorial(..)那样重写,但是要小心处理细节。

ES6要求各个引擎实现TCO而不是留给它们自行考虑的原因之一是,由于对调用栈限制的恐惧,缺少TCO 实际上趋向于减少特定的算法在JS中使用递归实现的机会。

如果无论什么情况下引擎缺少TCO只是安静地退化到性能差一些的方式上,那么它可能不会是ES6需要 要求 的东西。但是因为缺乏TCO可能会实际上使特定的程序不现实,所以与其说它只是一种隐藏的实现细节,不如说它是一个重要的语言特性更合适。

ES6保证,从现在开始,JS开发者们能够在所有兼容ES6+的浏览器上信赖这种优化机制。这是JS性能的一个胜利!