尾调用(Tail Call)

264 阅读7分钟

一、概念

在我们介绍尾调用之前,先来看看什么是调用栈

调用栈(Call Stack)

调用栈(Call Stack) 是一个基本的计算机概念。

这里引入一个概念:栈帧

栈帧是指为一个函数调用单独分配的那部分栈空间。

当运行的程序从当前函数调用另外一个函数时,就会为下一个函数建立一个新的栈帧,并且进入这个栈帧,这个栈帧称为当前帧。而原来的函数也有一个对应的栈帧,被称为调用帧

每一个栈帧里面都会存入当前函数的调用位置局部变量

image x86-64架构上的一般栈帧结构,函数P调用函数Q

当函数被调用时,就会被加入到调用栈顶部,执行结束之后,就会从调用栈顶部移除该函数。并将程序运行权利(帧指针)交给此时栈顶的栈帧。这种后进先出的结构也就是函数的调用栈

而在JavaScript里,可以很方便的通过console.trace()这个方法查看当前函数的调用帧

function yang1() {
    yang2();
}

function yang2() {
    yang3();
}

function yang3() {
    console.trace();
}

yang1();

image

二、尾调用

简单的说,就是一个函数执行的最后一步是将另外一个函数调用并返回。

以下是正确示范:

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);

调用结果: image

// 计算1-N的累加值(尾递归)
function f(n, sum = 1) {
    if (n <= 1) {
        return sum;
    }
    return f(n - 1, sum + n);
}
f(100000);

调用结果: image

什么鬼,说好的尾递归优化呢? image

让我们看看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循环总是会执行。这样就很巧妙地将 “ 递归 ” 改成了 “ 循环 ” ,而后一轮的参数会取代前一轮的参数,保证了调用栈只有一层。

四、总结

尾递归优化是个好东西,但既然暂时用不上,那我们就该在平时编码的过程中,对使用到了递归的地方特别敏感,时刻避免出现死循环,爆栈等危险。毕竟,好的工具不如好的习惯。

参考

  1. 【ES6标准入门 —— 阮一峰】
  2. 详解JavaScript调用栈、尾递归和手动优化
  3. 深入理解JavaScript中的尾调用(Tail Call)
  4. 朋友你听说过尾递归吗——腾讯前端团队
  5. 尾递归的后续探究——腾讯前端团队