尾调用优化
在 ES6
中对函数最有趣的改动或许就是一项引擎优化,它改变了尾部调用的系统。尾调用(tail call)指的是调用函数的语句是另一个函数的最后语句,就像这样:
function doSomething() {
return doSomethingElse(); // tail call
}
在 ES5
引擎中实现的尾调用,其处理就像其他函数调用一样:一个新的栈帧(stack frame)被创建并推到调用栈之上,用于表示该次函数调用。这意味着之前每个栈帧都被保留在内存中,当调用栈太大时会出问题。
有何不同?
ES6
在严格模式下力图为特定尾调用减少调用栈的大小(非严格模式的尾调用则保持不变)。当满足以下条件时,尾调用优化会清除当前栈帧并再次利用它,而不是为尾调用创建新的栈帧:
-
尾调用不能引用当前栈帧中的变量(意味着该函数不能是闭包);
-
进行尾调用的函数在尾调用返回结果后不能做额外操作;
-
尾调用的结果作为当前函数的返回值。
作为一个例子,下面代码满足了全部三个条件,因此能被轻易地优化:
"use strict";
function doSomething() {
// optimized
return doSomethingElse();
}
该函数对 doSomethingElse()
进行了一次尾调用,并立即返回了其结果,同时并未访问局部作用域的任何变量。一个小改动——不返回结果,就会产生一个无法被优化的函数:
"use strict";
function doSomething() {
// not optimized - no return
doSomethingElse();
}
类似的,如果你的函数在尾调用返回结果之后进行了额外操作,那么该函数也无法被优化:
"use strict";
function doSomething() {
// not optimized - must add after returning
return 1 + doSomethingElse();
}
此例在 doSomethingElse()
的结果上对其进行了加 1
操作,而没有直接返回该结果,这已足以关闭优化。
无意中关闭优化的另一个常见方式,是将函数调用的结果储存在一个变量上,之后才返回了结果,就像这样:
"use strict";
function doSomething() {
// not optimized - call isn't in tail position
var result = doSomethingElse();
return result;
}
本例之所以不能被优化,是因为 doSomethingElse()
的值并没有立即被返回。
使用闭包或许就是需要避免的最困难情况,因为闭包能够访问上层作用域的变量,会导致尾调用优化被关闭。例如:
"use strict";
function doSomething() {
var num = 1,
func = () => num;
// not optimized - function is a closure
return func();
}
此例中闭包 func()
需要访问局部变量 num
,虽然调用 func()
后立即返回了其结果,但是对于 num
的引用导致优化不会发生。
如何控制尾调用优化
在实践中,尾调用优化在后台进行,所以不必对此考虑太多,除非要尽力去优化一个函数。尾调用优化的主要用例是在递归函数中,而且在其中的优化具有最大效果。考虑以下计算阶乘的函数:
function factorial(n) {
if (n <= 1) {
return 1;
} else {
// not optimized - must multiply after returning
return n * factorial(n - 1);
}
}
此版本的函数并不会被优化,因为在递归调用 factorial()
之后还要执行乘法运算。如果 n
是一个大数字,那么调用栈的大小会增长,并且可能导致堆栈溢出。
为了优化此函数,你需要确保在最后的函数调用之后不会发生乘法运算。为此你可以使用一个默认参数来将乘法操作移出 return
语句。有结果的函数携带着临时结果进入下一次迭代,这样创建的函数的功能与前例相同,但它能被 ES6
的引擎所优化。此处是新的代码:
function factorial(n, p = 1) {
if (n <= 1) {
return 1 * p;
} else {
let result = n * p;
// optimized
return factorial(n - 1, result);
}
}
在重写的 factorial()
函数中,添加了第二个参数 p
,其默认值为 1
。p
参数保存着前一次乘法的结果,因此下一次的结果就能在进行函数调用之前被算出。当 n
大于 1
时,会先进行乘法运算并将其结果作为第二个参数传入 factorial()
。这就允许 ES6
引擎去优化这个递归调用。
尾调用优化是你在书写任意递归函数时都需要考虑的因素,因为它能提供显著的性能提升, 尤其是被应用到计算复杂度很高的函数时。