什么是尾调用?
尾调用是函数式编程里比较重要的一个概念,尾调用的概念非常简单,一句话就能说清楚,它的意思是在函数的执行过程中,如果最后一个动作是一个函数的调用,即这个调用的返回值被当前函数直接返回,则称为尾调用,如下所示:
function f(x) { return g(x) }</div>
在 f 函数中,最后一步操作是调用 g 函数,并且调用 g 函数的返回值被 f 函数直接返回,这就是尾调用。
而下面两种情况就不是尾调用:
// 情况一 function f(x){ let y = g(x); return y; } // 情况二 function f(x){ return g(x) + 1; }</div>
上面代码中,情况一是调用函数g之后,还有别的操作,所以不属于尾调用,即使语义完全一样。情况二也属于调用后还有操作,即使写在一行内。。
为什么说尾调用重要呢,原因是它不会在调用栈上增加新的堆栈帧,而是直接更新调用栈,调用栈所占空间始终是常量,节省了内存,避免了爆栈的可能性。用上面的栗子来说,尾调用的调用栈是这样的:
[f(x)] => [g(x)]</div>
由于进入下一个函数调用时,前一个函数内部的局部变量(如果有的话)都不需要了,那么调用栈的长度不会增加,可以直接跳入被尾调用的函数。如果是非尾调用的情况下,调用栈会长这样:
[f(x)] => [1 + g(x)]</div>
可以看到,调用栈的长度增加了一位,原因是 f 函数中的常量 1 必需保持保持在调用栈中,等待 g 函数调用返回后才能被计算回收。如果 g 函数内部还调用了函数 h 的话,就需要等待 h 函数返回,以此类推,调用栈会越来越长。如果这样解释还不够直观的话,尾调用还有一种特殊情况叫做尾递归,它的应用更广,看起来也更直观。
尾递归
顾名思义,在一个尾调用中,如果函数最后的尾调用位置上是这个函数本身,则被称为尾递归。递归很常用,但如果没写好的话也会非常消耗内存,导致爆栈。一般解释递归会用阶乘或者是斐波那契数列求和作为示例,这里用后者来解释一下。Fibonacci 数列就不多做解释了,它是一个长这样的无限长的数列,从第三项开始,每项都是前两项的和:
0, 1, 1, 2, 3, 5, 8, 13, 21, ...</div>
如果要计算第 n 项(从第 0 项开始)的值的话,写成递归是常用的手段。如果是非尾递归的形式,可以写成这样:
function fibonacci(n) { if (n === 0) return 0 if (n === 1) return 1 return fibonacci(n - 1) + fibonacci(n - 2) }</div>
以 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</div>
才到第 5 项调用栈长度就有 8 了,一些复杂点的递归稍不注意就会超出限度,同时也会消耗大量内存。而如果用尾递归的方式来优化这个过程,就可以避免这个问题,用尾递归来求 Fibonacci 数列的值可以写成这样:
function fibonacciTail(n, a = 0, b = 1) { if (n === 0) return a return fibonacciTail(n - 1, b, a + b) }</div>
在这里,每次调用后递归传入 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</div>
可以看到,每次递归都不会增加调用栈的长度,只是更新当前的堆栈帧而已。也就避免了内存的浪费和爆栈的危险。
注意
很多介绍尾调用和尾递归的文章讲到这里就结束了,实际上情况并非这么简单,尾调用在没有进行任何优化的时候和其他的递归方式一样,该产生的调用栈一样会产生,一样会有爆栈的危险。而尾递归之所以可以优化,是因为每次递归调用的时候,当前作用域中的局部变量都没有用了,不需要层层增加调用栈再在最后层层回收,当前的调用帧可以直接丢弃了,这才是尾调用可以优化的原因。
由于尾递归是尾调用的一种特殊形式,相对简单一些,在 ES6 没有开启尾调用优化的时候,我们可以手动为尾递归做一些优化。
尾递归优化
改写为循环
之所以需要优化,是因为调用栈过多,那么只要避免了函数内部的递归调用就可以解决掉这个问题,其中一个方法是用循环代替递归。还是以 Fibonacci 数列举例:
function fibonacciLoop(n, a = 0, b = 1) { while (n--) { [a, b] = [b, a + b] } return a }</div>
这样,不存在函数的多次调用,将递归转变为循环,避免了调用栈的无限增加。
蹦床函数
另一个优化方法是借助一个蹦床函数的帮助,它的原理是接受一个函数作为参数,在蹦床函数内部执行函数,如果函数的返回是也是一个函数,就继续执行。
function trampoline(f) { while (f && f instanceof Function) { f = f() } return f }</div>
可以看到,这里也没有在函数内部调用函数,而是在循环中重复调用同一个函数,这也避免了增加调用栈长度,下面要做的是将原来的 Fibonacci 函数改写为每次返回另一个函数的版本:
function fibonacciFunc(n, a = 0, b = 1) { if (n > 0) { [a, b] = [b, a + b] return fibonacciFunc.bind(null, n - 1, a, b) } else { return a } } trampoline(fibonacciFunc(5)) // return 5</div>
实际的尾递归优化
实际上,真正的尾递归优化并非像上面一样,上面的两种方法实际上都改写了尾递归函数本身,而真正的尾递归优化应该是非入侵式的,下面是尾递归优化的一种实现:
function tailCallOptimize(f) { let value, 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 } } }</div>
然后将原来的 fibonacciTail 函数传入 tailCallOptimize 函数,得到一个新函数,这个新函数的执行过程就是经过尾递归优化的了:
const fibonacciTail = tailCallOptimize(function(n, a = 0, b = 1) { if (n === 0) return a return fibonacciTail(n - 1, b, a + b) }) fibonacciTail(5) // return 5</div>
下面解释一下这种优化方式的原理。
1. 首先通过闭包,在 tailCallOptimize 的作用域中保存唯一的 active 和 accumulated,其中 active 指示尾递归优化过程是否开始,accumulated 用来存放每次递归调用的参数,push 方法将参数入列,shift 方法将参数出列,保证先进先出顺序执行。
2. 经过 tailCallOptimize 包装后返回的是一个新函数 accumulator,执行 fibonacciTail 时实际执行的是这个函数,第一次执行时,现将 arguments0 推入队列,active 会被标记为 true,然后进入 while 循环,取出 arguments0。在 while 循环的执行中,会将参数类数组 arguments1 推入 accumulated 队列,然后直接返回 undefined,不会递归调用增加调用栈。
3. 随后 while 循环会发现 accumulated 中又多了一个 arguments1,然后再将 arguments2 推入队列。这样,在 while 循环中对 accumulated 的操作就是放进去一个、拿出来一个、再放进去一个、再拿出来一个,以此类推。
4. 最后一次 while 循环返回的就是尾递归的结果了。
问题
实际上,现在的尾递归优化在引擎实现层面上还是有问题的。拿 V8 引擎来说,尾递归优化虽然已经实现了,但默认是不开启的,V8 团队还是更倾向于用显式的语法来优化。原因是在他们看来,尾调用优化仍然存在一些问题,主