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.timeconsole.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引擎进行尾递归优化后的fibonacciTailfibonacciTailLambda计算时间复杂度都没有超过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,它接受两个参数xy,并返回x+y
我们定义了一个蹦床递归函数addRecursiveTrampoline,它接受两个参数xy,并返回一个匿名函数()=>addRecursiveTrampoline(x+1)(y-1),此匿名函数并不会立即执行,所以不会产生新的栈帧。直到蹦床函数的循环体内的f不再是函数引用,才会执行匿名函数,并返回x+y

接下来,我们尝试计算addRecursive(1)(100000),10万次函数调用非常轻松导致了栈溢出。
但是使用蹦床函数处理递归后,计算结果正常输出,不会报错。

  • 11
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值