浅谈尾调用优化

1,293 阅读4分钟

我正在参加「掘金·启航计划」

什么是尾调用

尾调用的概念非常简单,一句话就能说清楚:外部函数的最后一步是调用一个内部函数,内部函数的返回值就是外部函数的返回值。

function outerFunction() {
    return innerFunction();
}

什么是尾调用优化

ES6 规范新增了一项内存管理机制,让 JS 引擎在满足条件时可以重用栈帧,这项优化非常适合尾调用。

在 ES6 优化之前,执行上面的例子会在内存中发生如下操作:

  1. 执行到 outerFunction 函数体,第一个栈帧被推到栈上;
  2. 执行 outerFunction 函数体,到 return 语句,计算返回值必须先计算 innerFunction;
  3. 执行到 innerFunction 函数体,第二个栈帧被推到栈上;
  4. 执行 innerFunction 函数体,计算其返回值;
  5. 将返回值传回 outerFunction,然后 outerFunction 再返回值;
  6. 将栈帧弹出栈外。

在 ES6 优化之后,执行这个例子会在内存中发生如下操作:

  1. 执行到 outerFunction 函数体,第一个栈帧被推到栈上;
  2. 执行 outerFunction 函数体,到 return 语句,计算返回值必须先计算 innerFunction;
  3. 引擎发现把第一个栈帧弹出栈外也没有问题,因为 innerFunction 的返回值也是 outerFunction 的返回值;
  4. 弹出 outerFunction 的帧栈;
  5. 执行到 innerFunction 函数体,栈帧被推到栈上;
  6. 执行 innerFunction 函数体,计算其返回值;
  7. 将 innerFunction 栈帧弹出栈外。

很明显,第一种情况下每多调用一次嵌套函数,就会多增加一个栈帧。而第二种情况无论调用多少次嵌套函数,都只有一个栈帧。

这就是 ES6 尾调用优化的关键:如果函数的逻辑允许基于尾调用将其销毁,则引擎就会这么做。

尾调用优化的条件

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

1. 代码在严格模式下执行

之所以要求严格模式,主要因为在非严格模式下函数调用中允许使用 f.arguments 和 f.caller,而它们都会引用外部函数的栈帧。
尾调用优化发生时,函数的调用栈会改写,因此上面两个变量就会失真。
严格模式禁用这两个变量,所以尾调用优化要求必须在严格模式下有效,以防止引用这些属性。

2. 外部函数的返回值是对尾调用函数的调用

"use strict"

// 无优化:尾调用没有返回
function outerFunction() {
    innerFunction();
}

// 无优化:尾调用没有直接返回
function outerFunction() {
    let innerFunctionResult = innerFunction();
    return innerFunctionResult;
}

3. 尾调用函数返回后不需要执行额外的逻辑

"use strict"

// 无优化:尾调用返回后还需要转型为字符串
function outerFunction() {
    return innerFunction().toString();
}

4. 尾调用函数不是引用外部函数作用域中自由变量的闭包

"use strict"

// 无优化:尾调用是一个闭包
function outerFunction() {
    let foo = 'bar';
    function innerFuncton() {
        return foo;
    }
    return innerFunction();
}

下面是几个符合尾调用优化条件的例子:

"use strict"

// 有优化:栈帧销毁前执行参数计算
function outerFunction(x, y) {
    return innerFunction(x + y);
}

// 有优化:初始返回值不涉及栈帧
function outerFunction(x, y) {
    if (x < y) return x;
    return innerFunction(x + y);
}

// 有优化:两个内部函数都在尾部
function outerFunction(condition) {
    return condition ? innerFunctionA() : innerFunctionB();
}

尾递归

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

递归非常耗费内存,因为需要同时保存成千上百个调用栈帧,很容易发生"栈溢出"错误。

但对于尾递归来说,由于只存在一个调用栈帧,所以永远不会发生"栈溢出"错误。

尾调用优化在递归场景下的效果是最明显的。

下面是一个通过递归计算斐波那契序列的函数:

function fib(n) {
    if (n < 2) return n;
    return fib(n - 1) + fib(n - 2);
}

显然这个函数不符合尾调用优化的条件,因为返回语句中有一个相加的额外操作。

结果,fib(n) 的栈帧数的内存复杂度是 O(2ⁿ)。

即使这么一个简单的调用也可以给浏览器带来麻烦: fib(1000)。

下面我们利用尾递归进行重构:

"use strict"

function fib(n, a = 0, b = 1) {
  if (n < 2) return b;
  return fib(n - 1, b, a + b);
}

尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。

重构之后,就可以满足尾调用优化的所有条件,再调用 fib(1000) 就不会对浏览器造成威胁了。

总结

递归本质上是一种循环操作,纯粹的函数式编程语言没有循环操作命令,所有的循环都用递归实现,所以尾递归的作用极其重要。一旦使用递归,就最好使用尾递归。

如果文中有错误或者不足之处,欢迎大家在评论区指正。

你的点赞是对我莫大的鼓励!感谢阅读~