浅谈尾调用优化
什么是尾调用
尾调用的概念非常简单,一句话就能说清楚:外部函数的最后一步是调用一个内部函数,内部函数的返回值就是外部函数的返回值。
function outerFunction() {
return innerFunction();
}
什么是尾调用优化
ES6 规范新增了一项内存管理机制,让 JS 引擎在满足条件时可以重用栈帧,这项优化非常适合尾调用。
在 ES6 优化之前,执行上面的例子会在内存中发生如下操作:
- 执行到 outerFunction 函数体,第一个栈帧被推到栈上;
- 执行 outerFunction 函数体,到 return 语句,计算返回值必须先计算 innerFunction;
- 执行到 innerFunction 函数体,第二个栈帧被推到栈上;
- 执行 innerFunction 函数体,计算其返回值;
- 将返回值传回 outerFunction,然后 outerFunction 再返回值;
- 将栈帧弹出栈外。
在 ES6 优化之后,执行这个例子会在内存中发生如下操作:
- 执行到 outerFunction 函数体,第一个栈帧被推到栈上;
- 执行 outerFunction 函数体,到 return 语句,计算返回值必须先计算 innerFunction;
- 引擎发现把第一个栈帧弹出栈外也没有问题,因为 innerFunction 的返回值也是 outerFunction 的返回值;
- 弹出 outerFunction 的帧栈;
- 执行到 innerFunction 函数体,栈帧被推到栈上;
- 执行 innerFunction 函数体,计算其返回值;
- 将 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) 就不会对浏览器造成威胁了。
总结
递归本质上是一种循环操作,纯粹的函数式编程语言没有循环操作命令,所有的循环都用递归实现,所以尾递归的作用极其重要。一旦使用递归,就最好使用尾递归。
如果文中有错误或者不足之处,欢迎大家在评论区指正。
你的点赞是对我莫大的鼓励!感谢阅读~
转载自:https://juejin.cn/post/7202153876438712380