尾调用
尾调用指某个函数的最后一步是调用另一个函数。
function f(x) {
return g(x); // 这是尾调用
}
// 情况一
function f(x) {
const y = g(x); // 这不是尾调用
return y;
}
// 情况二
function f(x) {
return g(x) + 1; // 这也不是尾调用
}
尾调用不一定出现在函数尾部,只要是最后一步操作即可。
function f(x) {
if (x > 0) {
return m(x)
}
return n(x);
}
尾调用优化
函数在调用时会在内存形成一个“调用记录”,又称“调用帧”,保存调用位置和内部变量等信息。如果在函数A的内部调用函数 B,那么在 A 的调用记录上方,还会形成一个 B 的调用记录。等到 B 运行结束,将结果返回到 A,B 的调用记录才会消失。如果函数 B 内部还调用函数 C,那就还有一个 C 的调用记录栈,以此类推。所有的调用记录,就形成一个"调用栈" 。
由于尾调用是函数的最后一步操作,所以不需要保留外层函数的调用记录,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用记录,取代外层函数的调用记录就可以了。
function f() {
const m = 1;
const n = 2;
return g(m + n);
}
f();
// 等同于
function f() {
return g(3);
}
f();
// 等同于
g(3);
上面代码中,如果函数 g 不是尾调用,函数f就需要保存内部变量 m 和 n 的值、g 的调用位置等信息。但由于调用 g 之后,函数 f 就结束了,所以执行到最后一步,完全可以删除 f() 的调用记录,只保留 g(3) 的调用记录。
这就叫做"尾调用优化"(Tail call optimization),即只保留内层函数的调用记录。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用记录只有一项,这将大大节省内存。这就是"尾调用优化"的意义。
尾递归
如果尾调用自身,则称为尾递归。
递归非常耗费内存,因为需要同时保存成千上百个调用记录,很容易发生"栈溢出"错误(stack overflow)。但对于尾递归来说,由于只存在一个调用记录,所以永远不会发生"栈溢出"错误。
function fib(n) {
if (n === 0) return 0;
if (n === 1) return 1;
return fib(n - 1) + fib(n - 2);
}
上述是一个关于斐波那契数列的函数,计算斐波那契数列中下标为 n 的数,最多需要保存 2^n 个调用记录,复杂度为 O(2^n)。
如果改写成尾递归,只需要保留一个调用记录。
function fib(n, a = 0, b = 1) {
if (n === 0) return a;
return fib(n - 1, b, a + b);
}
举个例子,求 fib(5) 会经历以下过程:
fib(5)
fib(5, 0, 1)
fib(4, 1, 1)
fib(3, 1, 2)
fib(2, 2, 3)
fib(1, 3, 5)
fib(0, 5, 8)
5
尾递归的实现
尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。比如上面的例子,函数 fib 需要用到两个中间变量 a、b ,那就把这个中间变量改写成函数的参数。这样做的缺点就是不太直观,第一眼很难看出来。
解决办法:
-
函数柯里化
-
使用参数的默认值
网友评论