什么是尾调用?
尾调用是函数式编程里比较重要的一个概念,尾调用的概念非常简单,一句话就能说清楚,它的意思是在函数的执行过程中,如果最后一个动作是一个函数的调用,即这个调用的返回值被当前函数直接返回,则称为尾调用,如下所示:
function f(x) { return g(x) }
在 f 函数中,最后一步操作是调用 g 函数,并且调用 g 函数的返回值被 f 函数直接返回,这就是尾调用。
而下面两种情况就不是尾调用:
// 情况一 function f(x){ let y = g(x); return y; } // 情况二 function f(x){ return g(x) + 1; }
上面代码中,情况一是调用函数g之后,还有别的操作,所以不属于尾调用,即使语义完全一样。情况二也属于调用后还有操作,即使写在一行内。。
为什么说尾调用重要呢,原因是它不会在调用栈上增加新的堆栈帧,而是直接更新调用栈,调用栈所占空间始终是常量,节省了内存,避免了爆栈的可能性。用上面的栗子来说,尾调用的调用栈是这样的:
[f(x)] => [g(x)]
由于进入下一个函数调用时,前一个函数内部的局部变量(如果有的话)都不需要了,那么调用栈的长度不会增加,可以直接跳入被尾调用的函数。如果是非尾调用的情况下,调用栈会长这样:
[f(x)] => [1 + g(x)]
可以看到,调用栈的长度增加了一位,原因是 f 函数中的常量 1 必需保持保持在调用栈中,等待 g 函数调用返回后才能被计算回收。如果 g 函数内部还调用了函数 h 的话,就需要等待 h 函数返回,以此类推,调用栈会越来越长。如果这样解释还不够直观的话,尾调用还有一种特殊情况叫做尾递归,它的应用更广,看起来也更直观。
尾递归
顾名思义,在一个尾调用中,如果函数最后的尾调用位置上是这个函数本身,则被称为尾递归。递归很常用,但如果没写好的话也会非常消耗内存,导致爆栈。一般解释递归会用阶乘或者是斐波那契数列求和作为示例,这里用后者来解释一下。Fibonacci 数列就不多做解释了,它是一个长这样的无限长的数列,从第三项开始,每项都是前两项的和:
0, 1, 1, 2, 3, 5, 8, 13, 21, ...
如果要计算第 n 项(从第 0 项开始)的值的话,写成递归是常用的手段。如果是非尾递归的形式,可以写成这样:
function fibonacci(n) { if (n === 0) return 0 if (n === 1) return 1 return fibonacci(n - 1) + fibonacci(n - 2) }
以 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)) + fibonacci(1))] [fibonacci(1) + fibonacci(0) + fibonacci(1) + fibonacci(1) + fibonacci(0) + fibonacci(1) + fibonacci(0) + fibonacci(1)] [1 + 0 + 1 + 1 + 0 + 1 + 0 + 1] 5
才到第 5 项调用栈长度就有 8 了,一些复杂点的递归稍不注意就会超出限度,同时也会消耗大量内存。而如果用尾递归的方式来优化这个过程,就可以避免这个问题,用尾递归来求 Fibonacci 数列的值可以写成这样:
function fibonacciTail(n, a = 0, b = 1) { if (n === 0) return a return fibonacciTail(n - 1, b, a + b) }
在这里,每次调用后递归传入 fibonacciTail 函数的 n 会依次递减 1,它实际上是用来记录递归剩余的次数。而 a 和 b 两个参数在每次递归时也会在计算后再次传入 fibonacciTail 函数,写成调用栈的形式就很清楚了:
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) => return 5
可以看到,每次递归都不会增加调用栈的长度,只是更新当前的堆栈帧而已。也就避免了内存的浪费和爆栈的危险。
注意
很多介绍尾调用和尾递归的文章讲到这里就结束了,实际上情况并非这么简单,尾调用在没有进行任何优化的时候和其他的递归方式一样,该产生的调用栈一样会产生,一样会有爆栈的危险。而尾递归之所以可以优化,是因为每次递归调用的时候,当前作用域中的局部变量都没有用了,不需要层层增加调用栈再在最后层层回收,当前的调用帧可以直接丢弃了,这才是尾调用可以优化的原因。
由于尾递归是尾调用的一种特殊形式,相对简单一些,在 ES6 没有开启尾调用优化的时候,我们可以手动为尾递归做一些优化。
尾递归优化
改写为循环