什么是「尾调用优化」?

2,243 阅读3分钟

本文首发于个人网站:「一名前端攻城师的个人修养」

什么是尾调用

尾调用 Tail Call 是函数式编程的一个重要概念,本身非常简单,指某个函数的最后一步是调用一个函数并返回:

function f(x) {
    return g(x)
}

尾调用优化

函数调用会在内存中形成一个调用记录,称为调用帧 Call Frame,调用帧会保存调用位置和内部变量等信息。层层得到的调用帧最终形成了调用栈 Call Stack。

尾调用是外部函数的最后一步操作,如果在内部函数中,已经不再需要外部函数的调用位置、内部变量等信息,那么我们就不需要保留外层函数的调用帧,直接用内层函数的调用帧取代外层函数的即可。

这就叫做尾调用优化 Tail Call Optimization,即只保留内层函数的调用帧。如果所有函数都是尾调用,那么完全可以做到每次执行时调用帧只有一项,这将大大节省内存。这就是尾调用优化的意义。

尾调用优化的条件本质是确定外层函数的相关信息,是否已经没有存在的必要了。因此我们可以归纳下列几点尾调用优化的条件:

  1. 代码在严格模式下执行。
  2. 外部函数的返回值是对内层函数的调用。
  3. 尾调用函数返回后不需要执行额外的逻辑。
  4. 尾调用函数不是引用外部函数作用域中自由变量的闭包,并且不会使用外层函数的内部变量。

尾递归

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

递归非常耗内存,因为需要同时保存成百上千个调用帧,很容易发生栈溢出 stack overflow 错误(尽管有的时候我们可以人为扩栈,但是这并不总是一个好的解决办法)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生栈溢出错误。

尾调用优化对递归操作意义重大,ES6 已经将尾调用优化写入语言规格。ES6 明确规定,所有 ECMAScript 的实现都必须部署尾调用优化。也即,在 ES6 中,只要使用尾递归,就不会发生栈溢出,相对节省内存。

严格模式

由于正常模式下函数内部有两个变量(见下)可以跟踪函数的调用帧,因此 ES6 的尾调用优化只在严格模式下开启,正常模式下无效。

  • func.arguments 返回调用时函数的参数。
  • func.caller 返回调用当前函数的那个函数。

尾调用优化时,函数的调用栈会改写,因此上面两个变量会失真。严格模式下禁用这两个变量,所有尾调用模式仅在严格模式下生效。