关于 "尾调用优化" 的那些事儿

时间:2022-12-27 12:00:35

大家好,我是 CoderBin

前言

本文将给大家介绍 JavaScript 函数中关于尾调用优化的优点与写法,助你提升编码能力????

如果文中有不对、疑惑的地方,欢迎在评论区留言指正????

1. 尾调用优化是什么

首先,尾调用的概念非常简单:就是指 某个函数的最后一步是调用另一个函数 。那什么是尾调用优化呢?

ECMAScript 6 规范新增了一项内存管理优化机制,让 JavaScript 引擎在满足条件时可以重用栈帧 。具体来说,这项优化非常适合“尾调用”,即 外部函数的返回值是一个内部函数的返回值。 例如:

function outerFn() {
  return innerFn();  // 尾调用
}

在 ES6 优化前后,执行上面的代码在内存中的表现是不一样的,在讲解之前,需要先了解 "函数调用栈"

2. 函数调用栈的补充

我们知道,函数调用会在内存形成一个"调用记录",又称"调用帧"(call frame),保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的调用记录上方,还会形成一个B的调用记录。等到B运行结束,将结果返回到A,B的调用记录才会消失。如果函数B内部还调用函数C,那就还有一个C的调用记录栈,以此类推。所有的调用记录,就形成一个"调用栈"(call stack)。

3. 函数执行在内存中的表现

了解了函数调用栈之后,就可以开始说明上面代码的执行过程了。

3.1 首先是 ES6 优化之前的过程

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

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

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

很明显,第一种情况下每多调用一次嵌套函数,就会多增加一个栈帧。而第二种情况下无论调用多少次嵌套函数,都只有一个栈帧。这就是ES6尾调用优化的关键:如果函数的逻辑允许基于尾调用将其销毁,则引擎就会那么做。

注意:现在还没有办法测试尾调用优化是否起作用。不过,因为这是 ES6 规范所规定的,兼容的浏览器实现都能保证在代码满足条件的情况下应用这个优化。

4. 尾调用优化的条件

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

  • 代码在严格模式下执行;
  • 外部函数的返回值是对尾调用函数的调用;
  • 尾调用函数返回后不需要执行额外的逻辑;
  • 尾调用函数不是引用外部函数作用域中*变量的闭包。

5. 不符合尾调用优化的要求的例子

下面展示了几个违反上述条件的函数:

'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()
}

6. 符合尾调用优化的要求的例子

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

'use strict'

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

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

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

差异化尾调用和递归尾调用是容易让人混淆的地方。无论是递归尾调用还是非递归尾调用,都可以应用优化。引擎并不区分尾调用中调用的是函数自身还是其他函数。不过,这个优化在递归场景下的效果是最明显的,因为递归代码最容易在栈内存中迅速产生大量栈帧。

注意:之所以要求严格模式,主要因为在非严格模式下函数调用中允许使用f.argumentsf.caller,而它们都会引用外部函数的栈 帧。显然,这意味着不能应用优化了。因此尾调用优化要求必须在严格模式下有效,以防止引用这些属性。

7. 尾调用递归的改造

可以通过把简单的递归函数转换为待优化的代码来加深对尾调用优化的理解。下面是一个通过递归计算斐波纳契数列的函数:

function fib(n) {
  if (n < 2) {
    return n
  }
  return fib(n - 1) + fib(n - 2)
}
console.log(fib(0)) // 0
console.log(fib(1)) // 1
console.log(fib(2)) // 1
console.log(fib(3)) // 2
console.log(fib(4)) // 3
console.log(fib(5)) // 5
console.log(fib(6)) // 8

显然这个函数不符合尾调用优化的条件,因为返回语句中有一个相加的操作。结果,fib(n) 的栈帧数的内存复杂度是 O(2^n)。因此,即使这么一个简单的调用也可以给浏览器带来麻烦:

fib(1000)

当然,解决这个问题也有不同的策略,比如把递归改写成迭代循环形式。不过,也可以保持递归实现,但将其重构为满足优化条件的形式。为此可以使用两个嵌套的函数,外部函数作为基础框架,内部函数执行递归:

'use strict'
// 基础框架
function fib(n) {
  return fibImpl(0, 1, n)
}
// 执行递归
function fibImpl(a, b, n) {
  if (n === 0) {
    return a
  }
  return fibImpl(b, a + b, n - 1)
}

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


每文一句:学习犹如登山,有的人则注重最终目标,有的人则注重前进的过程,不论哪种,都有其各自丰富的内涵,无孰优劣孰之分,只要你觉得适合即可。

本次的分享就到这里,如果本章内容对你有所帮助的话欢迎点赞+收藏。文章有不对的地方欢迎指出,有任何疑问都可以在评论区留言。希望大家都能够有所收获,大家一起探讨、进步!