一、概念
在我们介绍尾调用之前,先来看看什么是调用栈。
调用栈(Call Stack)
调用栈(Call Stack) 是一个基本的计算机概念。
这里引入一个概念:栈帧。
栈帧是指为一个函数调用单独分配的那部分栈空间。
当运行的程序从当前函数调用另外一个函数时,就会为下一个函数建立一个新的栈帧,并且进入这个栈帧,这个栈帧称为当前帧。而原来的函数也有一个对应的栈帧,被称为调用帧。
每一个栈帧里面都会存入当前函数的调用位置和局部变量。
x86-64架构上的一般栈帧结构,函数P调用函数Q
当函数被调用时,就会被加入到调用栈顶部,执行结束之后,就会从调用栈顶部移除该函数。并将程序运行权利(帧指针)交给此时栈顶的栈帧。这种后进先出的结构也就是函数的调用栈。
而在JavaScript里,可以很方便的通过console.trace()这个方法查看当前函数的调用帧
function yang1() {
yang2();
}
function yang2() {
yang3();
}
function yang3() {
console.trace();
}
yang1();
二、尾调用
简单的说,就是一个函数执行的最后一步是将另外一个函数调用并返回。
以下是正确示范:
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);
}
上面代码中,
-
情况一是调用函数 g 之后,还有赋值操作,所以不属于尾调用,即使语义完全一样。
-
情况二也属于调用后还有操作,即使写在一行内。
-
情况三等同于下面的代码。
function f(x){
g(x);
return undefined;
}
尾调用不一定出现在函数尾部,只要是最后一步操作即可。
function f(x) {
if (x > 0) {
return m(x)
}
return n(x);
}
尾调用优化
在调用栈的部分我们知道,当一个函数A调用另外一个函数B时,就会形成栈帧,在调用栈内同时存在调用帧A和当前帧B,这是因为当函数B执行完成后,还需要将执行权返回A,那么函数A内部的变量,调用函数B的位置等信息都必须保存在调用帧A中。不然,当函数B执行完继续执行函数A时,就会乱套。
那么现在,我们将函数B放到了函数A的最后一步调用 (即尾调用),那还有必要保留函数A的栈帧么?当然不用,因为之后并不会再用到其调用位置、内部变量。因此直接用函数B的栈帧取代A的栈帧即可。当然,如果内层函数使用了外层函数的变量,那么就仍然需要保留函数A的栈帧,典型例子即是闭包。
其实上面我们已经介绍了如何使用尾调用进行优化,那么需要注意的是,
只有不再用到外层函数的内部变量,内层函数的调用帧才会取代外层函数的调用帧,否则就无法进行 “ 尾调用优化 ” 。
// 无法进行尾调用优化
function addOne(a){
var one = 1;
function inner(b){
return b + one;
}
return inner(a);
}
总得来说,如果所有函数的调用都是尾调用,那么调用栈的长度就会小很多,这样需要占用的内存也会大大减少。这就是尾调用优化的含义。
三、尾递归
函数调用自身,称为递归。如果尾调用自身,就称为尾递归。
最常见的递归,斐波拉契数列(Fibonacci),它是一个无限长的数列,从第三项开始,每项都是前两项的和:
0, 1, 1, 2, 3, 5, 8, 13, 21, ...
普通递归的写法:
function Fibonacci (n) {
if ( n <= 1 ) {return 1};
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
Fibonacci(5); // 8
Fibonacci(10); // 89
// Fibonacci(100)
// Fibonacci(500)
// 栈溢出了
以 n = 5 来说,Fibonacci 函数的调用栈会像这样展开:
[Fibonacci(5)]
[Fibonacci(4) + Fibonacci(3)]
[Fibonacci(3) + Fibonacci(2)] + [Fibonacci(2) + Fibonacci(1)]
[Fibonacci(2) + Fibonacci(1)] + [Fibonacci(1) + Fibonacci(0)] + [Fibonacci(1) + Fibonacci(0)] + [1]
[Fibonacci(1) + Fibonacci(0)] + 1 + 1 + 1 + 1 + 1 + 1
1 + 1 + 1 + 1 + 1 + 1 + 1 + 1
才到第 5 项调用栈长度就有 8 了。
递归非常耗费内存,因为需要同时保存成千上百个调用帧,很容易发生 “ 栈溢出 ” 错误( stack overflow )。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生 “ 栈溢出 ” 错误。
用尾递归优化来求 Fibonacci 数列的值可以写成这样:
function fibonacciTail(n, a = 0, b = 1) {
if (n === 0) return b;
return fibonacciTail(n - 1, b, a + b)
}
fibonacciTail(5);
[fibonacciTail(5, 0, 1)]
[fibonacciTail(4, 1, 1)]
[fibonacciTail(3, 1, 2)]
[fibonacciTail(2, 2, 3)]
[fibonacciTail(1, 3, 5)]
[fibonacciTail(0, 5, 8)]
可以看到,每次递归都不会增加调用栈的长度,只是更新当前的栈帧而已。也就避免了内存的浪费和爆栈的危险。
这就是说,在 ES6 中,只要使用尾递归,就不会发生栈溢出,相对节省内存。
一个栈溢出的例子
// 计算1-N的累加值
function f(n) {
if (n <= 1) {
return 1;
}
return f(n - 1) + n;
}
f(100000);
调用结果:
// 计算1-N的累加值(尾递归)
function f(n, sum = 1) {
if (n <= 1) {
return sum;
}
return f(n - 1, sum + n);
}
f(100000);
调用结果:
什么鬼,说好的尾递归优化呢?
让我们看看V8引擎官方团队的解释
Proper tail calls have been implemented but not yet shipped given that a change to the feature is currently under discussion at TC39.
意思就是人家已经做好了,但是就是还不能不给你用:)嗨呀,好气喔。
当然,人家肯定是有他的正当理由的:
1. 隐式优化问题
在引擎层面消除尾递归是一个隐式的行为,程序员写代码时可能意识不到自己写了死循环的尾递归,而出现死循环后又不会报出stack overflow的错误,难以辨别。
2. 调用栈丢失问题
堆栈信息会在优化的过程中丢失,开发者调试非常困难。
尾递归优化的手动实现
虽然我们暂时用不上ES6的尾递归高端优化,但递归优化的本质还是为了减少调用栈,避免内存占用过多,爆栈的危险。
怎么做可以减少调用栈呢?就是
采用 “ 循环 ” 换掉 “ 递归 ” 。【一切能用递归写的函数,都能用循环写】
方法一
蹦床函数( trampoline ) 可以将递归执行转为循环执行。
function trampoline(f) {
while (f && f instanceof Function) {
f = f();
}
return f;
}
上面就是蹦床函数的一个实现,它接受一个函数f作为参数。只要f执行后返回一个函数,就继续执行。注意,这里是返回一个函数,然后执行该函数,而不是函数里面调用函数,这样就避免了递归执行,从而就消除了调用栈过大的问题。
然后,要做的就是将原来的递归函数,改写为每一步返回另一个函数。
// 计算1-N的累加值(尾递归)
function f(n, sum = 1) {
if (n <= 1) {
return sum;
}
return f.bind(null, n - 1, sum + n);
}
// 不会发生栈溢出
trampoline(f(100000)); // 5000050000
蹦床函数并不是真正的尾递归优化,缺点是需要修改原函数内部的写法。
方法二
尾递归函数转循环方法
function tailCallOptimize(f) {
let value
let active = false
const accumulated = []
return function accumulator() {
accumulated.push(arguments)
if (!active) {
active = true
while (accumulated.length) {
value = f.apply(this, accumulated.shift())
}
active = false
return value
}
}
}
const f = tailCallOptimize(function(n, sum = 1) {
if (n <= 1) {
return sum;
}
return f(n - 1, sum + n);
})
f(100000) // 5000050000
上面代码中,tailCallOptimize函数是尾递归优化的手动实现,它的奥妙就在于状态变量active。默认情况下,这个变量是不激活的。一旦进入尾递归优化的过程,这个变量就激活了。然后,每一轮递归f返回的都是undefined,所以就避免了递归执行;而accumulated数组存放每一轮f执行的参数,总是有值的,这就保证了accumulator函数内部的while循环总是会执行。这样就很巧妙地将 “ 递归 ” 改成了 “ 循环 ” ,而后一轮的参数会取代前一轮的参数,保证了调用栈只有一层。
四、总结
尾递归优化是个好东西,但既然暂时用不上,那我们就该在平时编码的过程中,对使用到了递归的地方特别敏感,时刻避免出现死循环,爆栈等危险。毕竟,好的工具不如好的习惯。