Javascript进阶之尾调用优化
什么是尾调用优化?什么是尾递归?
**尾调用(Tail Call)**是一种特殊的函数调用形式,指的是在一个函数的最后执行的函数调用,也就是说这个函数调用之后没有任何其他的计算需要执行。
简单说就是一个函数末尾return另一个函数作为结果,且不能有其他运算。
来看例子:
function callbackFunc (x) {
return x * 2;
};
function isTailCall (x) {
return callbackFunc(x);// 尾调用
};
function isNotTailCall (x) {
return callbackFunc(x) + 1;// 非尾调用,因为还需要进行运算
};
function isNotTailCall2 (x) {
const y = callbackFunc(x);
return y;// 非尾调用,因为返回的不是函数
}
function isNotTailCall3 (x) {
callbackFunc(x);// 非尾调用,因为没有return
}
上面的例子中,因为在函数isTailCall
的最后一步调用了callbackFunc
,所以isTailCall
是尾调用。
而isNotTailCall
的最后一步调用了callbackFunc
,但是获得callbackFunc
的返回值后,还需要进行+1
运算,所以isNotTailCall
就不是尾调用。
isNotTailCall2
的最后一步返回的不是函数,所以也不是尾调用。
isNotTailCall3
的最后一步没有return,所以也不是尾调用。
栈帧(Stack Frame)是计算机内存中用于存储函数调用信息的区域,每调用一个函数,就会在栈帧中开辟一块内存,用于存储函数的参数、局部变量、返回地址等信息。
如果函数调用层次过深,栈空间就会被占满,发生栈溢出错误。
而由于尾调用是函数的最后一个动作,所以不需要保留当前函数的上下文,这使得编译器或解释器有机会优化内存使用,释放掉当前函数的栈帧,只保留尾调用函数的栈帧,或者复用当前函数的栈帧。这种优化策略就是尾调用优化(Tail Call Optimization, TCO)。
作为更进一步的特例**尾递归(Tail Recursion)**是指函数的最后一步调用自身,并且没有做其他操作。
const factorial = (n,acc=1) => n==1? acc : factorial(n-1,acc*n);
上面的例子中,阶乘计算函数factorial
的最后一步调用自身,并且没有做其他操作,所以它是尾递归函数。
针对尾递归的优化,就称为尾递归优化(Tail Recursion Optimization, TRO)。
主流的现代Javascript引擎是支持尾调用优化的,当然也包括尾递归优化。
实验验证node.js的尾递归优化
虽然ES6引入了尾调用优化,但是有很多常见的误解,比如:
必须显式使用严格模式才能使用尾调用优化。(X)
Lambda表达式不能使用尾调用优化。(X)
也有很多人不能直观理解尾调用优化的效果,下面我们通过一个实验来验证一下node.js的尾递归优化。
//test.mjs
function fibonacci(n) {
return n <= 1 ? n : fibonacci(n - 1) + fibonacci(n - 2);
};//常规思路的递归实现斐波那契数列通项公式
function fibonacciTail(n, pred = 0, an = 1) {
if (n <= 1) return an;
return fibonacciTail(n - 1, an, an + pred);
};//尾递归的斐波那契数列通项公式
const fibonacciTailLambda= (n, pred = 0, an = 1) => n <= 1 ? an : fibonacciTailLambda(n - 1, an, an + pred);//尾递归的斐波那契数列通项公式 λ表达式写法
{
for (let n = 0; n <= 50; n += 5) {//测试用的阶数
try {
console.time('fibonacci');
console.log(`计算斐波那契数列第${n}项:${fibonacci(n)}`);
console.timeEnd('fibonacci');//测量常规递归实现的斐波那契数列计算耗时
} catch (e) {
console.error(e.message);
}
console.time('fibonacciTail');
void fibonacciTail(n);
console.timeEnd('fibonacciTail');//测量尾递归实现的斐波那契数列计算耗时
console.time('fibonacciTailLambda');
void fibonacciTailLambda(n);
console.timeEnd('fibonacciTailLambda');//测量尾递归λ表达式写法的斐波那契数列计算耗时
}
}
上面定义了三个功能类似的斐波那契数列通项公式函数,分别是常规递归实现的fibonacci
,尾递归实现的fibonacciTail
,以及尾递归λ表达式写法的fibonacciTailLambda
。
我们通过for
循环测试了斐波那契数列的前50项,并分别测量三个函数的计算耗时。
我们通过console.time
和console.timeEnd
来测量三个函数的计算耗时,并打印出结果。
运行结果:
计算斐波那契数列第0项:0
fibonacci: 6.125ms
fibonacciTail: 0.033ms
fibonacciTailLambda: 0.028ms
计算斐波那契数列第5项:5
fibonacci: 0.158ms
fibonacciTail: 0.025ms
fibonacciTailLambda: 0.004ms
计算斐波那契数列第10项:55
fibonacci: 0.399ms
fibonacciTail: 0.009ms
fibonacciTailLambda: 0.006ms
计算斐波那契数列第15项:610
fibonacci: 0.252ms
fibonacciTail: 0.003ms
fibonacciTailLambda: 0.053ms
计算斐波那契数列第20项:6765
fibonacci: 0.672ms
fibonacciTail: 0.003ms
fibonacciTailLambda: 0.003ms
计算斐波那契数列第25项:75025
fibonacci: 0.953ms
fibonacciTail: 0.003ms
fibonacciTailLambda: 0.002ms
计算斐波那契数列第30项:832040
fibonacci: 8.734ms
fibonacciTail: 0.005ms
fibonacciTailLambda: 0.003ms
计算斐波那契数列第35项:9227465
fibonacci: 98.491ms
fibonacciTail: 0.004ms
fibonacciTailLambda: 0.003ms
计算斐波那契数列第40项:102334155
fibonacci: 1.036s // 1036毫秒
fibonacciTail: 0.003ms
fibonacciTailLambda: 0.003ms
计算斐波那契数列第45项:1134903170
fibonacci: 11.322s // 11322毫秒
fibonacciTail: 0.006ms
fibonacciTailLambda: 0.003ms
计算斐波那契数列第50项:12586269025
fibonacci: 2:24.606 (m:ss.mmm) // 144606毫秒
fibonacciTail: 0.004ms
fibonacciTailLambda: 0.003ms
结果显示,尾递归实现的fibonacciTail
和尾递归λ表达式写法的fibonacciTailLambda
的计算耗时都很短,在微秒级别,而且基本不随着n的增加而增加,可以认为时间复杂度不高于O(n)。
实验中我们也没有启用严格模式(use strict),这反驳了必须显式使用严格模式才能使用尾调用优化,以及Lambda表达式不能使用尾调用优化的说法。
从图上不难看出,常规递归实现的fibonacci
时间复杂度为O(2^n),而在node.js的V8引擎进行尾递归优化后的fibonacciTail
和fibonacciTailLambda
计算时间复杂度都没有超过O(n)。
如果继续增加n的阶数,fibonacci
将很快耗尽内存,引发栈溢出错误,而fibonacciTail
可以一直计算到结果超过Javascript的最大安全整数(Number.MAX_SAFE_INTEGER),时间仍然很快且不会有栈溢出问题。
蹦床函数(Trampoline Function)
“蹦床”是一种优化递归调用的技术,其思路就是将深度递归转化为迭代。
**“蹦床函数”(Trampoline Function)**这个名字来源于其工作原理的形象比喻。这个名称形象地描述了这种技术的工作方式:就像运动员在蹦床上跳跃一样,每次跳跃都会回到蹦床,然后再弹起来。在编程中,这个类比可以这样解释:
1.初始跳起:程序开始时调用了一个递归函数,这就好比运动员第一次跳上蹦床。
2.反弹:递归函数不直接执行下一次递归调用,而是返回一个函数引用(或一个包裹着下一步要做的事情的对象),这就像运动员从蹦床上弹起,等待再次落地。
3.再次落地:外部的蹦床函数负责捕获这个返回的函数引用,并立即执行它。这类似于运动员再次落到蹦床上。
4.重复过程:这个过程会重复进行,直到达到递归的基本情况或停止条件。每一步都像是一次新的弹跳。
这种技术之所以被称作“蹦床”,是因为它模拟了运动员在蹦床上不断弹跳的行为,即函数调用不断地“弹回”到外部的“蹦床”函数中,循环执行直到递归结束。
在JavaScript中,我们可以通过一个循环来实现这个“蹦床”行为,不断地执行返回的函数,直到不再返回新的函数调用为止。这种方式避免了传统的递归带来的栈溢出问题,因为它把递归转变成了迭代的形式。
const trampoline = f => {
while ( f instanceof Function ) {
f=f();
}
return f;
};//蹦床函数
const addRecursive = x => y => y<=0? x : addRecursive(x+1)(y-1);//递归相加
const addRecursiveTrampoline = x => y => y<=0? x : ()=>addRecursiveTrampoline(x+1)(y-1);//蹦床递归相加
try{
console.log(trampoline(addRecursive(1)(100000)));//栈溢出
}catch(e){
console.error(e.message);//Maximum call stack size exceeded
}
try{
console.log(trampoline(addRecursiveTrampoline(1)(100000)));//正常输出 100001
}catch(e){
console.error(e.message);//不会报错
}
上面的例子中,我们定义了一个trampoline
函数,它接受一个函数作为参数,并不断地执行这个函数直到不再返回新的函数调用为止。
然后我们定义了一个递归函数addRecursive
,它接受两个参数x
和y
,并返回x+y
。
我们定义了一个蹦床递归函数addRecursiveTrampoline
,它接受两个参数x
和y
,并返回一个匿名函数()=>addRecursiveTrampoline(x+1)(y-1)
,此匿名函数并不会立即执行,所以不会产生新的栈帧。直到蹦床函数的循环体内的f
不再是函数引用,才会执行匿名函数,并返回x+y
。
接下来,我们尝试计算addRecursive(1)(100000)
,10万次函数调用非常轻松导致了栈溢出。
但是使用蹦床函数处理递归后,计算结果正常输出,不会报错。