尾调用与尾递归

584 阅读3分钟

这是我参与更文挑战的第 29 天,活动详情查看:更文挑战

尾调用

ES6 规范新增了一项内存管理优化机制,让 JavaScript 引擎在满足条件时可以重用帧栈。这项优化非常有利于「尾调用」形式的代码。

那么什么是尾调用?简单一点来说就是函数在return的时候执行另一个函数,如下所示:

function innerFunction() {
    console.log('inner')
};

function outerFunction() {
    console.log('outer');
    return innerFunction(); // 尾调用
}

outerFunction();

函数的执行会在内存中形成一个「调用帧」,保存调用位置和内部变量等信息。多个函数的调用帧以栈的形式保存,称为「调用栈」。

在 ES6 优化之前,上述代码在内存中会发生如下操作:

  1. 执行 outerFunction 函数,该函数调用帧被压入调用栈
  2. 执行到 return 语句,需要先执行 innerFunction 函数获得返回值
  3. 执行 innerFunction 函数,该函数调用帧被压入调用栈,此时调用栈内有两个函数调用帧
  4. 执行完 innerFunction 函数体,返回值传给 outerFunction,outerFuntion 再返回值
  5. 将调用帧弹出栈外

在 ES6 之后,该段代码在内存中操作如下:

  1. 执行 outerFunction 函数,该函数调用帧被压入调用栈
  2. 执行到 return 语句,需要先执行 innerFunction 函数获得返回值
  3. 引擎发现这时候把第一个调用帧弹出栈外也没问题,因为 innerFunction 返回值也是 outerFunction 返回值
  4. 将 outerFunction 的调用帧弹出
  5. 执行 innerFunction 函数,该函数调用帧被压入调用栈,此时调用栈内只有一个函数调用帧
  6. 执行完 innerFunction 函数体,计算其返回值
  7. 将 innerFunction 调用帧弹出栈外

那么形成「尾调用」需要哪些条件呢?其实就是要确定外部函数调用帧没必要存在的条件,如下:

  1. 代码在严格模式下执行。因为在正常模式下,函数内部有两个属性,可以跟踪函数的调用栈。在严格模式下,这两个属性都无法使用。
    arguments:返回调用时函数的参数。
    func.caller:返回调用当前函数的那个函数。
    
  2. 外部函数的返回值是对尾调用函数的调用;
  3. 尾调用函数返回后不需要执行额外的逻辑;
  4. 尾调用函数不是引用外部函数作用域中自由变量的闭包。

尾递归

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

递归代码需要同时在内存中保存成千上百个调用帧,很容易发生“栈溢出”错误。那根据上文,如果递归代码能够写成尾调用的形式,就能够提前将外层函数的调用帧弹出栈外,使得调用栈中永远只有一个调用帧存在,从而避免了递归导致“栈溢出”的问题。

function factorial(n) {
  if (n === 1) return 1;
  return n * factorial(n - 1);
}

factorial(5) // 120

上面这段代码通过递归的方式实现了阶乘,最多需要在内存中同时保存 n 个函数调用帧。而改写成尾递归的方式,只需要保存一个函数调用帧。

function factorial(n, total) {
  if (n === 1) return total;
  return factorial(n - 1, n * total);
}

factorial(5, 1) // 120

相关资料

JavaScript 高级程序设计

尾调用优化