JavaScript的尾调用优化详解

    科技2022-08-12  88

    先看一个用递归求fibonacci数的例子(下面例子用到了JS的新的变量类型BigInt,不了解的可以去学一下)

    先不用管代码的内容,先关注代码的执行时间

    1.无尾调用优化的

    console.time('执行时间'); function fibonacci(n) { return Number(fibonacciTool(BigInt(n))); } function fibonacciTool(n) { if (n === 0n) { return 0n; } if (n === 1n) { return 1n; } return fibonacci(n - 1n) + fibonacci(n - 2n); } console.log(fibonacci(40)); console.timeEnd('执行时间');

    执行以上代码,得到结果如下截图 2.有尾调用优化的

    "use strict" console.time('执行时间'); function fib(n) { const result = fibTool(0n, 1n, BigInt(n)); return Number(result); }; function fibTool(a, b, n) { if (n === 0n) { return a; } return fibTool(b, a + b, n - 1n); } console.log(fib(40)); console.timeEnd('执行时间');

    可以看到,没有尾调用优化的代码的执行时间为18秒左右,有尾调用优化的代码的时间仅为4.6毫秒左右,可见,尾调用优化对于程序的性能影响是非常大的,尤其是在多层递归调用的情况下,那么下面看看什么叫做尾调用优化。

    ECAMScript6新增了一项内存管理优化机制,让JavaScript引擎在满足条件时可以重用栈帧。这项优化非常适合“尾调用”,即外部函数的返回值是一个内部函数的返回值,比如:

    function outerFunction(){ return innerFunction(); //尾调用 }

    在ES6优化之前,执行这个例子会在内存中发生如下操作:

    (1)执行到outerFunction函数时,第一个栈帧被推到栈上。

    (2)执行到outerFunction函数体,到return语句。计算返回值必须先计算innerFunction。

    (3)执行到innerFunction函数体,第二个栈帧被推倒栈上。

    (4)执行innerFunction函数体,计算其返回值。

    (5)将返回值传回outerFunction,然后outerFunction再返回值。

    (6)将栈帧弹出栈外。

    在ES6优化之后,执行这个例子会在内存中发生如下操作。

    (1)执行到outerFunction函数时,第一个栈帧被推到栈上。

    (2)执行到outerFunction函数体,到return语句。为求值返回语句,必须先求值innerFunction。

    (3)引擎发现把第一个栈帧弹出栈外也没问题,因为innerFunction的返回值也是outerFunction的返回值。

    (4)弹出outerFunction的栈帧。

    (5)执行到innerFunction函数体,栈帧被推倒栈上。

    (6)执行innerFunction函数体,计算返回值。

    (7)将innerFunction的栈帧弹出栈外。

    很明显,第一种情况下每多调用一次嵌套函数,就会多增加一个栈帧。而第二种情况下无论调用多少次嵌套函数,都只有一个栈帧。这就是ES6尾调用优化的关键:如果函数的逻辑允许基于尾调用将其销毁,则引擎就会那么做。

    尾调用优化的条件

    尾调用优化的条件就是确定外部栈帧真的没有必要存在了。设计的条件如下:

    (1)代码在严格模式下执行;

    (2)外部函数的返回值是对尾调用函数的调用;

    (3)尾调用函数返回后不需要执行额外的逻辑;

    (4)尾调用函数不是引用外部函数作用域中自由变量的闭包。

    下面展示了几个违反上述条件的函数,因此都不符合尾调用优化的要求:

    //无优化:尾调用没有返回 "use strict"; function outerFunction(){ innerFunction(); } //无优化:尾调用没有直接返回 "use strict"; function outerFunction(){ let innerFunctionResult = innerFunction(); return innerFunctionResult; } //无优化:尾调用返回后进行了额外的操作 "use strict"; function outerFunction(){ return innerFunction().toString(); } //无优化:尾调用是一个闭包 "use strict"; function outerFunction(){ let foo = 'bar'; function innerFunction(){return foo;} return innerFunction(); }

    下面是几个符合尾调用优化条件的例子:

    //有优化:栈帧销毁前执行参数计算 "use strict"; function outerFunction(a, b){ return innerFunction(a + b); } //有优化:初始返回值不涉及栈帧 "use strict"; function outerFunction(a, b){ if (a < b){ return a; } return innerFunction(a + b); } //有优化:两个内部函数都在尾部 "use strict"; function outerFunction(a, b){ return condition ? innerFunctionA() : innerFunctionB(); }

    差异化尾调用和递归尾调用是最容易让人混淆的地方。无论是递归尾调用还是非递归尾调用,都可以应用优化。引擎并不区分尾调用中调用的是函数自身还是其他函数。不过,这个优化在递归场景下的效果是最明显的,因为递归代码最容易在栈内存中迅速产生大量栈帧,比如说在本文开头举的fibonacci递归调用的例子。

    注意:之所以要求严格模式,主要是因为在非严格模式下函数调用中允许使用f.arguments和f.caller,而它们都会引用外部函数的栈帧。显然,这意味着不能应用优化了。因此尾调用优化要求必须在严格模式下有效,以防止引用这些属性。

    参考资料:《JavaScript高级程序设计(第四版)》

    Processed: 0.012, SQL: 9