ECMAScript 6 提供了*尾调用优化*,您可以使用它进行一些函数调用而不会增加调用堆栈。本章解释了它的工作原理及其带来的好处。
**警告:**尽管尾调用优化是语言规范的一部分,但 它不受许多引擎支持,而且这种情况可能永远不会改变。
粗略地说,每当函数最后要做的事情是调用另一个函数时,后者就不需要返回到它的调用者。因此,不需要在调用堆栈上存储任何信息,函数调用更像是一个 goto(跳转)。这种调用称为*尾调用*;不增加堆栈称为*尾调用优化* (TCO)。
让我们看一个例子来更好地理解 TCO。我将首先解释它如何在没有 TCO 的情况下执行,然后解释如何在有 TCO 的情况下执行。
假设有一个 JavaScript 引擎,它通过将局部变量和返回地址存储在堆栈上来管理函数调用。这样的引擎将如何执行代码?
**步骤 1.** 最初,堆栈上只有全局变量 id
和 f
。
堆栈条目的块编码当前作用域的状态(局部变量,包括参数),称为*堆栈帧*。
**步骤 2.** 在 C 行,调用 f()
:首先,将要返回的位置保存在堆栈上。然后分配 f
的参数,执行跳转到其主体。堆栈现在如下所示。
堆栈上现在有两个帧:一个用于全局作用域(底部),一个用于 f()
(顶部)。 f
的堆栈帧包括返回地址,C 行。
**步骤 3.** 在 B 行调用 id()
。同样,会创建一个堆栈帧,其中包含返回地址和 id
的参数。
**步骤 4.** 在 A 行,返回结果 x
。 id
的堆栈帧被移除,执行跳转到返回地址,B 行。(处理返回值的方法有很多种。两种常见的解决方案是将结果留在堆栈上或将其移交给寄存器。我在这里忽略了执行的这一部分。)
堆栈现在如下所示
**步骤 5.** 在 B 行,将 id
返回的值返回给 f
的调用者。同样,最顶层的堆栈帧被移除,执行跳转到返回地址,C 行。
**步骤 6.** C 行接收值 3
并将其记录下来。
如果您查看上一节,那么有一个步骤是不必要的——步骤 5。B 行中发生的一切都是将 id()
返回的值传递给 C 行。理想情况下,id()
可以自己完成,并且可以跳过中间步骤。
我们可以通过以不同的方式实现 B 行中的函数调用来实现这一点。在调用发生之前,堆栈如下所示。
如果我们检查调用,我们会发现它是 f()
中的最后一个动作。一旦 id()
完成,f()
执行的唯一剩余动作就是将 id
的结果传递给 f
的调用者。因此,不再需要 f
的变量,并且可以在进行调用之前移除其堆栈帧。赋予 id()
的返回地址是 f
的返回地址,C 行。在 id()
的执行过程中,堆栈如下所示
然后 id()
返回值 3
。您可以说它是为 f()
返回该值的,因为它将其传输到 f
的调用者,C 行。
让我们回顾一下:B 行中的函数调用是一个尾调用。这样的调用可以在堆栈不增长的情况下完成。要确定函数调用是否是尾调用,我们必须检查它是否处于*尾部位置*(即函数中的最后一个动作)。下一节将解释如何做到这一点。
我们刚刚了解到尾调用是可以更有效地执行的函数调用。但是什么算作尾调用呢?
首先,调用函数的方式无关紧要。如果以下调用出现在尾部位置,则都可以对其进行优化
func(···)
obj.method(···)
call()
进行的直接方法调用:func.call(···)
apply()
进行的直接方法调用:func.apply(···)
箭头函数可以将表达式作为主体。因此,对于尾调用优化,我们必须找出函数调用在表达式中的尾部位置。只有以下表达式可以包含尾调用
? :
)||
)&&
),
)让我们看看它们每一个的例子。
? :
) f()
和 g()
都处于尾部位置。
||
) f()
不在尾部位置,但 g()
在尾部位置。要了解原因,请查看以下代码,它等效于前面的代码
逻辑或运算符的结果取决于 f()
的结果,这就是为什么该函数调用不在尾部位置的原因(调用者对其执行的操作不是返回它)。但是,g()
处于尾部位置。
f()
不在尾部位置,但 g()
在尾部位置。要了解原因,请查看以下代码,它等效于前面的代码
逻辑与运算符的结果取决于 f()
的结果,这就是为什么该函数调用不在尾部位置的原因(调用者对其执行的操作不是返回它)。但是,g()
处于尾部位置。
,
) f()
不在尾部位置,但 g()
在尾部位置。要了解原因,请查看以下代码,它等效于前面的代码
对于语句,适用以下规则。
只有这些复合语句可以包含尾调用
{}
分隔,带或不带标签)if
:在“then”子句或“else”子句中。do-while
、while
、for
:在其主体中。switch
:在其主体中。try-catch
:仅在 catch
子句中。 try
子句具有 catch
子句作为上下文,不能优化掉。try-finally
、try-catch-finally
:仅在 finally
子句中,它是其他子句的上下文,不能优化掉。在所有原子(非复合)语句中,只有 return
可以包含尾调用。所有其他语句都有不能优化掉的上下文。如果 expr
包含尾调用,则以下语句包含尾调用。
在非严格模式下,大多数引擎都具有以下两个属性,允许您检查调用堆栈
func.arguments
:包含最近一次调用 func
的参数。func.caller
:引用最近调用 func
的函数。使用尾调用优化时,这些属性不起作用,因为它们依赖的信息可能已被删除。因此,严格模式禁止这些属性(如语言规范中所述),并且尾调用优化仅在严格模式下有效。
以下代码中的函数调用 bar()
不在尾部位置
原因是 foo()
的最后一个动作不是函数调用 bar()
,而是(隐式地)返回 undefined
。换句话说,foo()
的行为如下
调用者可以依赖 foo()
始终返回 undefined
。如果 bar()
由于尾调用优化而为 foo()
返回结果,那么这将改变 foo
的行为。
因此,如果我们希望 bar()
成为尾调用,我们必须按如下方式更改 foo()
。
如果函数进行的主要递归调用位于尾部位置,则该函数是*尾递归*。
例如,以下函数不是尾递归的,因为 A 行中的主要递归调用不在尾部位置
factorial()
可以通过尾递归辅助函数 facRec()
实现。A 行中的主要递归调用位于尾部位置。
也就是说,一些非尾递归函数可以转换为尾递归函数。
尾调用优化使得通过递归实现循环成为可能,而不会增加堆栈。以下是两个例子。
forEach()
findIndex()