什么是尾调用?
尾调用是函数式编程的重要概念之一,即在某个函数的最后一步来调用另一个函数。
function f(x){
return g(x);
}
而除此之外的其他写法和形式都不是尾调用。比如说:
// 情况一
function f(x){
let y = g(x);
return y;
}
// 情况二
function f(x){
return g(x) + 1;
}
// 情况三
function f(x){
g(x);
}
尾调用之所以被称为尾调用,就是因为它的调用位置比较特殊。
还记得我们初学js函数部分吧,函数名保存在栈内存,函数值保存在堆内存中。函数调用会产生一个‘调用记录’,用来保存调用位置和内部变量等信息。也就是说,在函数A内部调用函数B,那么在函数A的调用帧上方,还会形成一个B的调用帧。等B运行结束,将结果返回给了A,B的调用帧才消失。
当然了,如果函数B内部调用了函数C,那么B的上方也会有函数C的调用帧。以此类推,从而形成了‘调用栈’(call stack)。
但是当嵌套调用比较多的时候,比如说递归的时候,会造成相当多的调用帧。
所以我们需要用到尾调用优化,来减少调用帧的使用。尾调用因为在函数的最后一步,所以不需要保留外层函数的调用帧。
例子:
function f(){
let m = 1;
let n = 2;
return g(m + n);
}
f();
// 等同于
function f(){
return g(3);
}
// 等同于
g(3);
在上面的代码中,如果函数 g 不是尾调用的话,那么函数 f 就不能结束,而应该去保存内部变量m和n的值以及 g 的调用位置等信息。
但正因为函数 g 是尾调用,所以函数 f 就结束了,所以当函数执行到函数 g 的时候,就可以删除函数 f(x) 的调用帧,只保留 g(3) 的调用帧。
这就是尾调用优化。 即只保留内层函数的调用帧。如果所有函数都是尾调用,那么就能实现每次执行都是只有一项调用帧,这将大大节省内存。
尾递归
尾递归其实也是尾调用的一种。函数调用自身,称为递归。
递归是非常消耗内存的,因为需要保存N个调用帧,所以说特别容易发生‘栈溢出’错误。但是尾递归可以完美解决。
function factorial(n){
if(n === 1) return 1;
return n * factorial(n - 1);
}
factorial(5) // 120
这是一个计算5的阶乘,所以需要保存5个调用栈。
对了。突然想起来的直接说吧。
为了减少函数的耦合,我们应该使用arguments.callee
而如果改为尾递归的话,就只需要保留一个调用记录了。
function factorial(n, total = 1){
if(n === 1) return total;
return arguments.callee(n - 1, n * total);
}
factorial(5) // 120
这样就可以实现尾递归优化,我们这里使用ES6的函数默认值写法来实现多参变一参,当然也可以使用 柯里化。
另外一个例子就是计算 Fibonacci 数列, 也能充分说明尾递归优化的重要性。
function Fibonacci (n) {
if (n <= 1) return 1;
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
Fibonacci(10); // 89
Fibonacci(100); // 堆栈溢出
Fibonacci(500); // 堆栈溢出
而经过尾递归优化的 Fibonacci 数列实现如下:
function Fibonacci(n, ac1 = 1, ac2 = 1){
if(n <= 1) return ac2;
return Fibonacci(n - 1, ac2, ac1 + ac2)
}
Fibonacci(100); // 573147844013817200000
Fibonacci(1000); // 7.0330367711422765e+208
所以,‘尾调用优化’对于递归操作意义重大。在ES6中只要使用尾递归,就不会发生栈溢出,就会节省内存。