指某个函数的最后一步是return的是另一个函数调用,而不进行任何其他运算。
以下两种情况都不是尾调用:
//情况1:
function f(x){
let y = g(x);
return y;
}
//情况2:
function f(x){
return g(x) + 1;
}
尾调用之所以与其他调用不同,就在于它的特殊的调用位置。
我们知道,函数调用会在内存形成一个”调用记录”,又称”调用帧”(call frame),保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的调用记录上方,还会形成一个B的调用记录。等到B运行结束,将结果返回到A,B的调用记录才会消失。如果函数B内部还调用函数C,那就还有一个C的调用记录栈,以此类推。所有的调用记录,就形成一个“调用栈”(call stack)。
function foo () { console.log(111); }
function bar () { foo(); }
function baz () { bar(); }
baz();
上面的每个函数在调用另一个函数的时候,并没有 return 该调用,所以JS引擎会认为你还没有执行完,会保留你的调用帧。
但是如果更改成下面这样:
function foo () { console.log(111); }
function bar () { return foo(); }
function baz () { return bar(); }
baz();
尾调用优化只在严格模式下有效。
在非严格模式下,大多数引擎会包含下面两个属性,以便开发者检查调用栈:
func.arguments: 表示对 func最近一次调用所包含的参数
func.caller: 引用对 func最近一次调用的那个函数
在尾调用优化中,这些属性不再有用,因为相关的信息可能以及被移除了。因此,严格模式(strict mode)禁止这些属性。尾调用优化后,每次return的内层函数的调用记录会取代外层函数的调用记录,调用栈中始终只保持了一条调用帧。
函数调用自身,称为递归。如果尾调用自身,就称为尾递归。
递归非常耗费内存,因为需要同时保存成千上百个调用记录,很容易发生”栈溢出”错误(stack overflow)。但对于尾递归来说,由于只存在一个调用记录,所以永远不会发生”栈溢出”错误。
function factorial(n) {
if (n === 1) return 1;
return n * factorial(n - 1);
}
factorial(5) // 120
上面代码是一个阶乘函数,计算n的阶乘,最多需要保存n个调用记录,复杂度 O(n) 。
如果改写成尾递归,只保留一个调用记录,复杂度 O(1) 。
function factorial(n, total) {
if (n === 1) return total;
return factorial(n - 1, n * total);
}
factorial(5, 1) // 120
尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。比如上面的例子,阶乘函数 factorial 需要用到一个中间变量 total ,那就把这个中间变量改写成函数的参数。
理解标红文字很重要,很重要,很重要。
可以通过下面的例子来加强理解:
例子:
使用尾递归:
// 使用递归的方法,把一个数组反转。把需要返回的数组arr作为参数,每次传下去。最后直接返回这个参数arr即可。
function reverse(A,arr=[]) {
// 每次取出数组最后一项,拼接在arr后面
return A.length ? reverse(A.slice(0,-1),arr.concat(A.slice(-1))) : arr
}
let A = [1,2,3,4,5,6]
console.log(reverse(A)) // 6 5 4 3 2 1
如果不使用尾递归可能需要这样:
//方法1:需要定义额外的变量
let arr = []
function reverse(A) {
arr = arr.concat(A.slice(-1))
return A.length ? reverse(A.slice(0, -1)) : arr
}
//方法2:多次递归调用,调用记录一直在增加
function reverse(A) {
return A.length ? [...A.slice(-1),...reverse(A.slice(0, -1))] : A
}
尾递归的方法很明显更好。
尾递归不一定会将你的代码执行速度提高;相反,可能会变慢。不过,尾递归可以让你使用更少的内存。