深入理解JavaScript中的尾调用与尾递归优化

作者:霁月

时间:2026-10-21

浏览:446

本文深入解析JavaScript中的尾调用与尾递归概念,阐述其通过复用调用栈帧节省内存的原理。详细介绍了尾递归的三种手动优化方案:循环改写、蹦床函数及高阶包装器,并分析了ES6规范中尾调用优化的现状、V8引擎的限制原因及识别尾调用的具体规则。

文章目录

尾调用与尾递归基础

什么是尾调用?

尾调用是函数式编程中一个核心概念,其定义简明扼要:若函数执行的最后一步是调用另一个函数,且该被调用函数的返回值直接作为当前函数的返回值返回,则此调用即为尾调用。示例如下:


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

在函数f中,最后一步操作是调用函数g,且g的返回值被f直接返回,这构成了尾调用。

然而,以下两种情况并不属于尾调用:


// 情况一
function f(x){
 let y = g(x);
 return y;
}

// 情况二
function f(x){
 return g(x) + 1;
}

上述代码中,第一种情况在调用函数g后还执行了其他操作,因此不属于尾调用,即便语义完全一致。第二种情况同样属于调用后还有操作,即使代码写在一行内。

尾调用之所以重要,是因为它不会在调用栈上创建新的堆栈帧,而是直接更新现有的调用栈,使得调用栈所占空间始终保持常量级别,从而节省内存并避免栈溢出风险。以上述示例为例,尾调用的调用栈结构如下:


[f(x)] => [g(x)]

当进入下一个函数调用时,前一个函数内部的局部变量(若存在)不再需要,因此调用栈长度不会增加,可以直接跳入被尾调用的函数。而在非尾调用的情况下,调用栈结构如下:


[f(x)] => [1 + g(x)]

可以看到,调用栈长度增加了一位,原因是函数f中的常量1必须保留在调用栈中,等待函数g调用返回后才能被计算回收。如果函数g内部还调用了函数h,则需等待h返回,依此类推,调用栈会越来越长。若此解释仍不够直观,尾调用还有一种更常见且直观的特殊形式——尾递归。

尾递归

顾名思义,若在一个尾调用中,函数最后的尾调用位置是该函数自身,则称为尾递归。递归应用广泛,但若实现不当,极易消耗大量内存导致栈溢出。通常解释递归会使用阶乘或斐波那契数列求和作为示例,此处以斐波那契数列为例。斐波那契数列是一个无限数列,从第三项开始,每项均为前两项之和:


0, 1, 1, 2, 3, 5, 8, 13, 21, ... 

若要计算第n项(从第0项开始)的值,使用递归是常见手段。若采用非尾递归形式,代码可写为:


function fibonacci(n) { 
 if (n === 0) return 0
 if (n === 1) return 1
 return fibonacci(n - 1) + fibonacci(n - 2)
}

以n=5为例,fibonacci函数的调用栈展开如下:


[fibonacci(5)]
[fibonacci(4) + fibonacci(3)]
[(fibonacci(3) + fibonacci(2)) + (fibonacci(2) + fibonacci(1))]
[((fibonacci(2) + fibonacci(1)) + (fibonacci(1) + fibonacci(0))) + ((fibonacci(1) + fibonacci(0)) + fibonacci(1))]
[fibonacci(1) + fibonacci(0) + fibonacci(1) + fibonacci(1) + fibonacci(0) + fibonacci(1) + fibonacci(0) + fibonacci(1)]
[1 + 0 + 1 + 1 + 0 + 1 + 0 + 1]
5 

仅计算到第5项,调用栈长度已达8。稍复杂的递归若不加以注意,极易超出限制并消耗大量内存。若采用尾递归方式优化此过程,则可避免该问题,使用尾递归求斐波那契数列值的代码如下:


function fibonacciTail(n, a = 0, b = 1) { 
 if (n === 0) return a
 return fibonacciTail(n - 1, b, a + b)
}

在此例中,每次调用后递归传入fibonacciTail函数的n值依次递减1,用于记录剩余递归次数。而a和b两个参数在每次递归计算后再次传入fibonacciTail函数,以调用栈形式表示如下:


fibonacciTail(5) === fibonacciTail(5, 0, 1) 
fibonacciTail(4, 1, 1) 
fibonacciTail(3, 1, 2) 
fibonacciTail(2, 2, 3) 
fibonacciTail(1, 3, 5) 
fibonacciTail(0, 5, 8) => return 5 

可见,每次递归并未增加调用栈长度,仅更新当前堆栈帧,从而避免了内存浪费和栈溢出风险。

注意

许多介绍尾调用和尾递归的文章至此结束,但实际情况并非如此简单。在未进行任何优化的情况下,尾调用与其他递归方式无异,仍会产生调用栈,同样存在栈溢出风险。尾递归之所以能优化,是因为每次递归调用时,当前作用域中的局部变量已无用,无需层层增加调用栈再在最后层层回收,当前调用帧可直接丢弃,这才是尾调用可优化的根本原因。

由于尾递归是尾调用的一种特殊形式,相对简单,在ES6未开启尾调用优化时,我们可以手动为尾递归进行优化。

尾递归优化方案

尾递归优化

改写为循环

优化的原因在于调用栈过多,只要避免函数内部的递归调用即可解决问题,其中一种方法是用循环替代递归。仍以斐波那契数列为例:


function fibonacciLoop(n, a = 0, b = 1) { 
 while (n--) {
 [a, b] = [b, a + b]
 }
 return a
}

这样,由于不存在函数的多次调用,将递归转变为循环,避免了调用栈的无限增加。

蹦床函数

另一种优化方法是借助蹦床函数,其原理是接受一个函数作为参数,在蹦床函数内部执行该函数;若函数返回值仍是函数,则继续执行。


function trampoline(f) { 
 while (f && f instanceof Function) {
 f = f()
 }
 return f
}

可见,此处未在函数内部调用其他函数,而是在循环中重复调用同一函数,这也避免了增加调用栈长度。接下来需将原斐波那契函数改写为每次返回另一个函数的版本:


function fibonacciFunc(n, a = 0, b = 1) { 
 if (n > 0) {
 [a, b] = [b, a + b]
 return fibonacciFunc.bind(null, n - 1, a, b)
 } else {
 return a
 }
}

trampoline(fibonacciFunc(5)) // return 5 

实际的尾递归优化

实际上,真正的尾递归优化并非如上所示。上述两种方法均改写了尾递归函数本身,而真正的尾递归优化应是非入侵式的。以下是尾递归优化的一种实现:


function tailCallOptimize(f) { 
 let value,
 active = false
 const accumulated = []
 return function accumulator() {
 accumulated.push(arguments)
 if (!active) {
 active = true
 while (accumulated.length) {
 value = f.apply(this, accumulated.shift())
 }
 active = false
 return value
 }
 }
}

随后将原fibonacciTail函数传入tailCallOptimize函数,得到一个新函数,该函数的执行过程即为经过尾递归优化的结果:


const fibonacciTail = tailCallOptimize(function(n, a = 0, b = 1) { 
 if (n === 0) return a
 return fibonacciTail(n - 1, b, a + b)
})
fibonacciTail(5) // return 5 

下面解释这种优化方式的原理。

1. 首先通过闭包,在tailCallOptimize的作用域中保存唯一的active和accumulated。其中active指示尾递归优化过程是否开始,accumulated用于存放每次递归调用的参数。push方法将参数入列,shift方法将参数出列,保证先进先出的执行顺序。

2. 经过tailCallOptimize包装后返回的是一个新函数accumulator。执行fibonacciTail时实际执行的是该函数。第一次执行时,先将arguments[0]推入队列,active被标记为true,然后进入while循环,取出arguments[0]。在while循环执行中,会将参数类数组arguments[1]推入accumulated队列,然后直接返回undefined,不会递归调用增加调用栈。

3. 随后while循环会发现accumulated中又多了一个arguments[1],再将arguments[2]推入队列。这样,在while循环中对accumulated的操作就是放进去一个、拿出来一个、再放进去一个、再拿出来一个,以此类推。

4. 最后一次while循环返回的即为尾递归的结果。

尾调用优化的引擎限制与识别

问题

实际上,当前的尾递归优化在引擎实现层面仍存在争议。以V8引擎为例,虽然已实现尾递归优化,但默认并未开启。V8团队更倾向于使用显式语法进行优化。原因在于他们认为尾调用优化仍存在一些问题,主要有两点:

难以辨别

在引擎层面消除尾递归是一种隐式行为,程序员在编写代码时可能并未意识到函数是否符合尾调用要求。此外,由于开启了尾调用优化,一旦出现死循环尾递归,也不会引发溢出,这使得问题难以辨别。以下介绍识别尾调用时需注意的几点:

首先,调用函数的方式并不重要,以下几种调用方式只要出现在尾调用位置上,均可被优化: + 普通调用:func(...) + 作为方法调用:obj.method(...) + 使用call或apply调用:func.call(..) 或 func.apply(...)

表达式中的尾调用

ES6的箭头函数可以使用一个表达式作为其函数体,函数返回值即为该表达式的返回值。在表达式中,以下几种情况可能包含尾调用:

三元运算符(? :)


const a = x => x ? f() : g() 

在此例中,函数f和g均位于尾调用位置。为便于理解,可将函数改写如下:


const a = x => { 
 if (x) {
 return f()
 } else {
 return g()
 }
}

可见,f和g的返回值均被直接返回,符合尾调用的定义。

逻辑运算符(|| 与 &&)

首先是||运算符:


const a = () => f() || g() 

此处函数f不在尾递归位置,而函数g在尾递归位置。为何如此?将函数改写后便清晰可见:


const a = () => { 
 const result = f()
 if (result) {
 return result
 } else {
 return g()
 }
}

||运算符的结果依赖于函数f的返回值,而非直接返回f的返回值,直接返回的仅有g函数的返回值。&&运算符的情况同理:


const a = () => f() && g() 

将函数改写为:


const a = () => { 
 const result = f()
 if (!result) {
 return result
 } else {
 return g()
 }
}

说明函数f也不在尾递归位置,而函数g在尾递归位置。

逗号运算符(,)


const a = () => (f(), g()) 

将函数改写一下:


const a = () => { 
 f()
 return g()
}

可见,在尾递归位置上的仍然只有一个g函数。

语句中的尾调用

在JS语句中,以下几种情况可能包含尾调用: + 代码块中(由{}分隔的语句) + if语句的then或else块中 + do-while,while,for循环的循环体中 + switch语句的执行代码块中 + try-catch语句的catch块中 + try-finally,try-catch-finally语句的finally块中

此外,return语句也可以包含尾调用。若return的表达式包含尾调用,则return语句包含尾调用,这无需多解释。

单独的函数调用不是尾调用

以下函数是否包含尾调用呢:


function foo() { 
 bar()
}

答案是否定的。先将函数改写如下:


function foo() { 
 bar()
 return undefined
}

可以看到,return语句返回的仅是undefined,而非bar函数的返回值,因此此处不存在尾调用。

尾调用只能出现在严格模式中

在非严格模式中,大多数引擎会在函数上增加以下两个属性: + func.arguments 包含调用函数时传入的参数 + func.caller 返回当前函数的调用者

但一旦进行尾调用优化,中间调用帧会被丢弃,这两个属性便失去了原本的意义。这也是在严格模式中不允许使用这两个属性的原因。

堆栈信息丢失

除了开发者难以辨别尾调用外,另一个原因是堆栈信息在优化过程中会丢失,这对调试极为不便。此外,一些依赖于堆栈错误信息进行用户信息收集分析的工具可能会失效。针对此问题,实现一个影子堆栈可解决堆栈信息缺失问题,但这种解决方式相当于对堆栈进行了模拟,不能保证始终符合实际虚拟机堆栈的真实状态。另外,影子堆栈的性能开销也非常大。

基于以上原因,V8团队建议使用特殊语法来指定尾递归优化。TC39标准委员会有一个尚无定论的提案,名为“从语法上指定尾部调行为”,该提案由来自Mozilla和微软的委员提出。提案的具体内容可查看相关链接,主要提出了三种手动指定尾调用优化的语法。

附手动优化语法

Return Continue


function factorial(n, acc = 1) { 
 if (n === 1) {
 return acc;
 }

 return continue factorial(n - 1, acc * n)
}

let factorial = (n, acc = 1) => continue 
 n == 1 ? acc
 : factorial(n - 1, acc * n);

// or, if continue is an expression form:
let factorial = (n, acc = 1) => 
 n == 1 ? acc
 : continue factorial(n - 1, acc * n);

Function sigil


// # sigil, though it's already 'claimed' by private state.
#function() { /* all calls in tail position are tail calls */ }

// Note that it's hard to decide how to readably sigil arrow functions.

// This is probably most readable.
() #=> expr
// This is probably most in line with the non-arrow sigil.
#() => expr

// rec sigil similar to async functions
rec function() { /* likewise */ } 
rec () => expr 

!-return


function () { !return expr }

// It's a little tricky to do arrow functions in this method.
// Obviously, we cannot push the ! into the expression, and even
// function level sigils are pretty ugly.

// Since ! already has a strong meaning, it's hard to read this as
// a tail recursive function, rather than an expression.
!() => expr

// We could do like we did for # above, but it also reads strangely:
() !=> expr

总结

以上就是本文的全部内容,希望本文的内容对大家的学习或工作能带来一定的帮助。如有任何疑问,欢迎留言交流。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章