Tail Call

36 阅读3分钟

尾调用

Tail Call 指某个函数的最后一步是调用另一个函数。

 // 函数f 的最后一步是调用函数g
 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)
 }
 => 等价于:
 function f(x) {
     g(x)
     return undefined
 }
 ​
 // 尾调用不一定出现在函数尾部,只要是最后一步操作即可:
 // 函数m 和n 都属于尾调用
 function f(x) {
     if (x > 0) {
         return m(x)
     }
     return n(x)
 }

尾调用优化

函数调用会在内存形成一个“调用记录”,又称“调用帧”,保存调用位置和内部变量等信息。

如果在函数A 的内部调用函数B,那么在A 的调用帧上方还会形成一个B 的调用帧。等到B 运行结束,将结果返回到A,B 的调用帧才会消失。如果函数B 内部还调用函数C ,那就还有一个C 的调用帧。所有的调用帧形成一个“调用栈(call stack)”。

使用尾调用的话,因为已经是函数的最后一步,所以这时可以不必再保留当前的执行上下文,从而节省了内存,这就是尾调用优化。

尾调用优化的条件就是确定外部栈帧真的没有必要存在了,涉及条件如下:

  • 代码在严格模式下执行:在非严格模式下函数调用中允许使用f.arguments 和f.caller,而它们都会引用外部函数的栈帧。
  • 外部函数的返回值是对尾调用函数的调用
  • 尾调用函数返回后不需要执行额外的逻辑
  • 尾调用函数不是引用外部函数作用域中自由变量的闭包
 "use strict"
 ​
 // 无优化:尾调用没有返回
 function outerFunction() {
     innerFunction()
 }
 ​
 // 无优化:尾调用没有直接返回
 function outerFunction() {
     let innerFunctionResult = innerFunction()
     return innerFunctionResult
 }
 ​
 ​
 // 无优化:尾调用返回后必须转型为字符串
 function outerFunction() {
     return innerFunction().toString()
 }
 ​
 ​
 // 无优化:尾调用是一个闭包
 function outerFunction() {
     let foo = 'bar'
     function innerFunction() {
         return foo
     }
     return innerFunction()
 }
 ​

尾递归

函数调用自身称为递归,如果尾调用自身就称为尾递归。

递归非常耗费内存,因为需要同时保存成百上千个调用帧,很容易发生栈溢出错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生栈溢出错误。

 // 计算n 的阶乘,最多需要保存n 个调用记录,复杂度为O(n)
 function factorial(n) {
     if (n === 1) { return 1}
     return n * factorial(n - 1)
 }
 ​
 // 改为尾递归,只保留一个调用记录,复杂度为O(1)
 function factorial(n, total) {
     if (n === 1) { return total}
     return factorial(n - 1, n * total)
 }
 ​
 // 非尾递归的Fibonacci 数列
 function Fibonacci() {
     if (n <= 1) {return 1}
     return Fibonacci(n - 1) + Fibonacci(n - 2)
 }
 ​
 Fibonacci(10) // 89
 Fibonacci(100) // 堆栈溢出
 Fibonacci(500) // 堆栈溢出
 ​
 ​
 // 尾递归优化
 function Fibonacci2(n, ac1 = 1, ac2 = 1) {
     if (n <= 1) {return ac2}
     return Fibonacci2(n - 1, ac2, ac1 + ac2)
 }
 ​
 Fibonacci2(100) // 573147844013817200000

非尾递归的Fibonacci 数列,栈帧数的内存复杂度是O(2n)

递归函数的改写

尾递归的实现往往需要改写递归函数,确保最后一步只调用自身,也就是把所有用到的内部变量改写成函数的参数,但是这样做的缺点是不太直观(参数太多,费解)。

 // 法1 在尾递归函数之外再提供一个正常形式的函数
 function tailFactorial(n, total) {
     if (n === 1) { return total }
     return tailFactorial(n - 1, n * total)
 }
 ​
 function factorial(n) {
     return tailFactorial(n, 1)
 }
 factorial(5) // 120
 // 法2 柯里化(currying),将多参数的函数转换成单参数形式
 function currying(fn, n) {
     return function(m) {
         return fn.call(this, m, n)
     }
 }
 ​
 function tailFactorial(n, total) {
     if (n === 1) { return total }
     return tailFactorial(n - 1, n * total)
 }
 ​
 const factorial = currying(tailFactorial, 1)
 factorial(5) // 120
 ​
 // 通过柯里化将尾递归函数tailFactorial 变为只接受1个参数的factorial。
 // 法3 ES6 的函数默认值
 function factorial(n, total = 1) {
     if (n === 1) { return total }
     return factorial(n - 1, n * total)
 }
 factorial(5) // 120