什么是尾调用
尾调用(Tail Call)是函数式编程的一个重要概念,就是指某个函数的最后一步是调用另一个函数。例如:
function f(x) {
return g(x);
}
上面的代码中,函数f的最后一步是调用函数g,这就是尾调用。
注意:1.尾调用是指函数的最后一步是调用另一个函数,如果调用另一个函数之后还有其他操作,即使语义完全一样也不属于尾调用。
2.尾调用不一定出现在函数尾部,只要是最后一步操作即可。例如:
function f(x) {
if (x > 0) {
return m(x);
}
return n(x);
}
上面的代码中,函数m和n都属于尾调用,因为他们都是函数f的最后一步操作。
尾调用优化
尾调用之所以与其他调用不同,就在于其特殊的调用位置。
尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、每部变量等信息都不会再用到了,直接用内层函数的调用帧取代外层函数即可。
function f() {
let m = 1;
let n = 2;
return g(m + n);
}
f();
等同于
function f() {
return g(3);
}
f();
等同于
g(3);
上面的代码中,如果函数g不是尾调用,函数f就需要保存内部变量m和n的值、g的调用位置等信息。但由于调用g之后,函数f就结束了,所以执行到最后一步,完全可以删除f(x)的调用帧,只保留g(3)的调用帧。
-
这就是“尾调用优化”(Tail Call Optimization),即只保留内层函数的调用帧。如果所有函数都是尾调用,那么完全可以做大每次执行是调用帧只有一项,这将大大节省内存。这就是“尾调用优化”的意义。
注意:只有不再用到外层函数的内部变量,内层函数的调用帧才会取代外层函数的调用帧,否则就无法进行“尾调用优化”。
补充:我们知道,函数调用会在内存形成一个“调用记录”,又称“调用帧”,保存调用位置和内部变量等信息。所有的调用帧就会形成一个“调用栈”
尾递归
函数调用自身称为递归。如果尾调用自身就称为尾递归。
递归非常耗费内存,因为需要同时保存成百上千个调用帧,很容易发生“栈溢出”错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生“栈溢出”错误。
function factorial(n) {
if (n === 1) {
return 1;
}
return n * factorial(n - 1);
}
console.log(factorial(5)); //120
上面代码是一个阶乘函数,计算n的阶乘,最多需要保存n个调用记录,复杂度为O(n)。
如果改写为尾递归,只保留一个调用记录,则复杂度为O(1)。
function factorial(n, sum) {
if (n === 1) {
return sum;
}
return factorial(n - 1,n * sum);
}
console.log(factorial(5,1)); //120
下面再举一个例子,计算斐波那契数列,也能充分说明尾递归优化的重要性。
非尾递归的斐波那契数列实现入下:
function fibonacci(n) {
if (n <= 1) {
return 1;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
console.log(fibonacci(10)); //89
console.log(fibonacci(100)); //堆栈溢出
console.log(fibonacci(500)); //堆栈溢出
尾递归优化的斐波那契数列实现:
function fibonacci(n, ac1 = 1, ac2 = 1) {
if (n <= 1) {
return ac2;
}
return fibonacci(n - 1, ac2, ac1 + ac2);
}
console.log(fibonacci(10)); //89
console.log(fibonacci(100)); //573147844013817200000
console.log(fibonacci(500)); //2.2559151616193602e+104
由此可见。“尾调用优化”对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。ES6也是如此,第一次明确规定,所有ECMAScrit的实现都必须部署“尾调用优化”。这就是说,在ES6中,只要使用尾递归,就不会发货所能栈溢出,相对节省空间。