本文已参与「新人创作礼」活动,一起开启掘金创作之路
ECMAScript 6 提供了尾调用优化(tail call optimization)功能,以使得对某些函数的调用不会造成调用栈(call stack)的增长。本文解释了这项功能,以及其带来的好处。
-
-
什么是尾调用优化
- 1.1. 正常的执行
- 1.2. 尾调用优化
-
-
-
检查函数调用是否在尾部发生
- 2.1. 表达式中的尾调用
- 2.2. 声明语句中的尾调用
- 2.3. 尾调用优化只在严格模式下有效
- 2.4. 单独的函数调用不算在尾部
-
-
-
尾递归函数
- 3.1. 尾递归循环
-
1. 什么是尾调用优化?
调用一个新的函数需要额外的一块预留内存来管理调用栈,称为栈帧。如果支持TCO的引擎能够意识到函数调用处于尾部,它就不需要创建一个新的栈帧,而是重用已有的栈帧,这样不仅速度快,而且更省内存。尾调用的递归函数本质上可以任意运行,因为再也不需要额外的内存。
粗略的来说,如果当一个函数所做的最后一件事是调用了另一个函数,而后者不需要返回调用者函数中再去做任何动作时;以及由此可知,在这种情况下没有调用者的额外信息需要被储存在调用栈(call stack)上,函数间的调用更像一种goto跳转的时候 -- 这种调用就被成为尾调用(tail call),此时使得内存栈不再增长的行为就称为尾调用优化(TCO - tail call optimization)。
举个例子来更好的理解下TCO。首先说明一下是否用TCO的区别:
function id(x) {
return x; // (A)
}
function f(a) {
const b = a + 1;
return id(b); // (B)
}
console.log(f(2)); // (C)
复制代码
1.1 正常的执行
假设有一个JS引擎通过 存储本地变量并返回栈上的地址 来管理方法调用。该引擎会如何执行上述代码呢?\
Step 1. 最初,栈上只有全局变量id和f。
栈会对当前作用域的状态(包括本地变量、参数等)进行编码,形成被称为“调用帧”(frame)的一块。\
Step 2. 在代码中的C行,f()被调用:首先,将要return到的位置被记录在栈中;然后f的参数a被分配并执行。
栈现在看起来是这样的:共有两个调用帧,一个是位于底部的全局作用域,另一个是其上方 的f()。\
Step 3. id() 在B行中被调用。再次形成了一个调用帧,包含了id将要返回到的地址及其参数x被分配和调用的值。
Step 4. 在行A,结果x被返回。id的调用栈被移除,执行过程跳转到其调用帧中存储的要return的位置,也就是行B。(处理返回值有多种途径,最常见的两种是将结果留在栈中和在寄存器中处理之,此处按下不表)
栈现在是这副模样的了:
Step 5. 在行B中,从id中返回的值将继续返回给f的调用者。照旧,最上面的调用帧被移除,执行过程跳转到要return的位置 -- 行C。
Step 6. 行C接收到返回值3并完成打印工作。
1.2 尾调用优化
function id(x) {
return x; // (A)
}
function f(a) {
const b = a + 1;
return id(b); // (B)
}
console.log(f(2)); // (C)
复制代码
回顾上个章节的过程,其实 step 5 是多余的。行B中发生的全部事情其实只不过是把id()中返回的值传递给行C罢了。理想情况是,id()可以自行完成这一步,而跳过二传手 step 5。\
可以通过对行B的函数调用采取不一样的实现方式来达成以上目的。栈在调用发生前是这样的:\
检查这次调用就会发现,它是f()的最后一个行为。一旦id()完成,f()剩余执行的唯一行为就是把前者的结果返回给自身的调用者。因此,f中的变量就不需要了,其调用帧也就可以在这次调用之前被移除了。赋给id()的将要return地址直接可以是f的return地址,也就是行C了。在id()执行期间,栈看起来就是这样的:\
id()返回了数值3,或者可以说它为f()返回了这个值;因为通过行C,该值被传递给了f的调用者。
不难发现,行B的函数调用就是一个尾调用。这样的调用可以在栈0增长的情况下完成。要判断函数调用是否是尾调用,必须检查其是否处于尾部(比如最后一个行为)。下一章节将讲述如何做到。
文章写的太好,以至于只能转载!!! 文章转载自:juejin.cn/post/684490…