尾调用优化
一、什么是尾调用(Tail Call)
一个函数A内部的最后一步是调用函数B,函数B调用后的返回值被函数A返回的情形
function f(x) { if (x > 0) { return m(x) } return n(x); }
以下两种情况均不属于尾调用,因为在函数调用后还有其他的操作:
// 情况一 function f(x){ let y = g(x); return y; } // 情况二 function f(x){ return g(x) + 1; }
二、调用栈及尾调用优化
在了解尾调用优化前,需要先理解调用栈,调用栈是解释器(比如浏览器中的 JavaScript 解释器)追踪函数执行流的一种机制。当执行环境中调用了多个函数时,通过这种机制,我们能够追踪到哪个函数正在执行,执行的函数体中又调用了哪个函数
- 每调用一个函数,解释器就会把该函数添加进调用栈并开始执行。
- 正在调用栈中执行的函数还调用了其它函数,那么新函数也将会被添加进调用栈,一旦这个函数被调用,便会立即执行。
- 当前函数执行完毕后,解释器将其清出调用栈,继续执行当前执行环境下的剩余的代码。
- 当分配的调用栈空间被占满时,会引发“堆栈溢出”错误。
const fn1 = (a) => { let b = a + 1; return b; } const fn2 = (x) => { let y = x + 1; return fn1(y); // line A } const result = fn2(1); // line B
在上面的代码中,首先fn2
被压入栈,x
、y
依次被创建并赋值,栈内也会记录相应的信息,同时也记录了该函数被调用的地方,这样在函数 return 后就能知道结果应该返回到哪里。
当fn2中执行到fn1时,fn1
入栈,当它运行结束后就可以出栈,之后fn2
也得到了想要的结果,返回结果后也出栈,此段代码运行结束,如下图所示:
观察上图,第2、3步骤中的fn2,
它内部的一切计算都已经完成了,此时它在栈内的唯一作用就是记录最后结果应该返回到哪一行。因而可以有如下的优化:
这正是尾调用优化,用内层函数的调用栈取代外层函数的调用栈即可不会增加调用栈的深度,通过尾调用优化可减少内存空间的使用,也能提高运行速度。
ES6中,第一次明确规定,所有 ECMAScript 的实现,都必须部署"尾调用优化",目前各浏览器的实现情况具体见这里
三、尾递归优化
递归非常耗费内存,因为需要同时保存成千上百个调用记录,很容易发生"栈溢出"错误(stack overflow)。但对于尾递归来说,由于只存在一个调用记录,所以永远不会发生"栈溢出"错误。
下面是一段求N个数累加的函数,如果使用普通的递归函数如下:
const sum = (n) => { if (n <= 1) return n; return n + sum(n-1) }
改为尾递归调用后如下:
const sum = (n,total=0) => { if(n<=1) return n+total; return sum(n-1,n+total) }
由于尾递归优化只在严格模式下自动实现,在正常模式下就要使用蹦床函数(Trampoline)来将递归转换为循环。
const sum = (n, total = 0) => { if (n <= 1) return n + total; return () => sum(n-1, n + total) } const trampoline = f => (...args) => { let result = f(...args); while (typeof result === 'function') { result = result(); } return result; } const sum0 = trampoline(sum); console.log(sum0(1000000)); // 不会栈溢出