学习Javascript之尾调用

  • 时间:2020-04-24 17:46 作者:秦至 来源: 阅读:449
  • 扫一扫,手机访问
摘要:前言本文2433字,阅读大约需要10分钟。总括: 本文详情了尾调用,尾递归的概念,结合实例解释了什么是尾调用优化,并阐述了尾调用优化如今的现状。参考文章:尾递归的后续探索公众号:「前台进阶学习」,回复「666」,获取一揽子前台技术书籍事亲以敬,美过三牲。正文尾调用是函数式编程的一个重要的概念,本篇

前言

本文2433字,阅读大约需要10分钟。

总括: 本文详情了尾调用,尾递归的概念,结合实例解释了什么是尾调用优化,并阐述了尾调用优化如今的现状。

  • 参考文章:尾递归的后续探索
  • 公众号:「前台进阶学习」,回复「666」,获取一揽子前台技术书籍

事亲以敬,美过三牲。

正文

尾调用是函数式编程的一个重要的概念,本篇文章就来学习下尾调用相关的知识。

尾调用

在之前的文章了解Javascript的高阶函数中,有说过在一个函数中输出一个函数,则这个函数可以被成为高阶函数。本文的主角尾调用和它相似,假如一个函数返回的是另一个函数的调用结果,那么就被称为尾调用。例子:

function add(x, y) {  return x + y;}function sum() {  return add(1, 2);}

如上就是一个尾调用的例子sum函数返回了add的调用结果。但下面的例子就不是尾调用:

function add(x, y) {  return x + y;}// 情况1function sum() {  return add(1, 2) + 1;}// 情况2function sum2() {  let a = add(1, 2);  return a;}

上例中情况1和情况2都不是尾调用,情况1在调用add函数后还有一个+1的操作,情况2在调用add函数后还有赋值给a的操作,因而上面的情况都不是尾调用。

尾递归

递归相信大家都知道,就是函数自己调用自己的一种操作。那么,假如一个函数返回的是自己的调用结果就被称为尾递归。也就是说尾递归肯定是尾调用,但尾调用不肯定是尾递归。先看一个常规递归的例子:

function sum(n) {    if (n <= 1) return 1;  return sum(n - 1) + n;}sum(10000); // 50005000

如上sum函数就是一个递归函数,但他不符合我们上面对尾调用的定义,因而它不是一个尾调用函数,更不是一个尾递归函数。改写为尾递归函数:

function sum(n, result = 1) {    if (n <= 1) return result;  return sum(n - 1, result + n);}sum(10000); //  Maximum call stack size exceeded

我们仍然调用sum(10000)但这里却报错了,就是比较常见的堆栈溢出(stack overflow)。关于执行栈(也被称为调用栈)不理解的可以参考之前的博文:了解Javascript中的执行上下文和执行栈。

尾调用优化

现在假设函数A是一个返回了函数B调用结果的函数。函数B是一个返回了函数C结果的函数。相似这样:

function C() {}function B() { return C(); }function A() { return B(); }A();

当函数A被调用的时候会有一个A的函数执行上下文被压入执行栈中,B调用的时候会有一个B的执行上下文被压入执行栈中,直到函数A和函数B都执行结束,对应的执行上下文才会被推出执行栈。假如函数B还返回了一个函数C的调用结果,也会重复这个过程,以此类推,假如这个执行栈内执行上下文的数量超过了最大值那么就会报出堆栈溢出的错误,这是前面的那个例子报错的缘由。看下图,上面函数的执行栈:

image

假如函数B中有对函数A中变量的引用,那么函数A即便执行结束对应的执行上下文也无法从执行栈中被推出,也就是我们常说的闭包。但假如函数B中没有对函数A的引用,执行结束后直接推出函数A的执行上下文多好。

上面的想法假如成真,执行栈中只要要保存上一个函数(最内层函数)的执行上下文就好了,这就是尾调用优化。

尾调用优化:对符合要求的尾调用函数,只在执行栈中保存最内层函数的执行上下文的一种实现。

假如我们优化生效,理想中的执行栈应该是这样的:

image

要知道一个执行上下文中保存的信息是很多的,尾调用优化假如生效,执行栈中的执行上下文只会存在一条,因而可以极大地节约内存。这就是尾调用优化的意义

但尾调用优化仅仅是普通开发者去写可以被优化的函数是做不到的,这个特性一般都需要借助编译器或者是运行环境来支持才可以。Javascript原来是不支持尾递归调用优化的,ES6中才开始规定程序引擎应在严格模式下使用尾调用优化。而且ECMAScript 6限定了尾位置不含闭包的尾调用才能进行优化。这和我们前面说的不谋而合。

但实际笔者经过测试,Chrome( 79.0.3945.130)、Safari( 13.0.3 )都还不支持,也就是说前面那个报堆栈溢出的错误仍然会报。经过查资料,发现只有低版本的node才曾经支持过尾递归调用优化,node(6.0.0)是可以开启尾递归调用优化的。还是前面的例子,但开启了严格模式:

'use strict';function sum(n, result = 1) {    if (n <= 1) return result;  return sum(n - 1, result + n);}sum(10000);

我们看下使用node(6.0.0)调用上面代码的结果:

RangeError: Maximum call stack size exceeded    at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:4:13)    at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:6:10)    at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:6:10)    at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:6:10)

如上还是报错了,堆栈溢出。不论是node还是浏览器对于尾递归调用优化默认都是关闭的,在node中需要加一个参数--harmony_tailcalls才能开启尾递归调用优化。再看下:

$ node --harmony_tailcalls tail-call.js                        5000050000

正常返回了结果。修改下代码我们看下实际的调用栈:

'use strict';function sum(n, result = 1) {    console.trace();    if (n <= 1) return result;    return sum(n - 1, result + n);}const result = sum(3);console.log(result);

未开启尾调用优化之前:

Trace    at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:3:13)        at Object.<anonymous> (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:7:16)Trace    at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:3:13)    at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:5:10)        at Object.<anonymous> (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:7:16)Trace    at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:3:13)    at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:5:10)    at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:5:10)        at Object.<anonymous> (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:7:16)

如上打印,无用的信息都被我删除掉了,我们再看下开启尾调用优化之后的:

Trace    at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:3:13)    at Object.<anonymous> (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:7:16)Trace    at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:3:13)    at Object.<anonymous> (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:7:16)Trace    at sum (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:3:13)    at Object.<anonymous> (/Users/mac/Desktop/demo/html-css-js-demo/tail-call.js:7:16)

可以看到和我们预期的是一样的,执行栈中一直只有一个执行上下文。空间复杂度从O(n)被降到了O(1)。大大的节约了内存空间。

这里留给我们两个问题,一个是不开启尾递归调用优化的情况下堆栈溢出的报错如何处理,一个是尾递归调用既然好处这么大为啥要默认关闭呢?。先看第一个问题:

处理堆栈溢出报错

  1. for循环。根本起因是执行上下文太多导致的爆栈,那么不调用函数自然可以处理这个问题:
'use strict';function sum(n) {    let res = 0;    for (var i = 0; i < n; i++) {        res += i;    }    return res;}const result = sum(3);console.log(result);
  1. 某些情况下的确无法使用for循环,还是要调用函数,此时可以利用弹跳床函数,所谓弹跳床函数,相当于函数的一个中转站。
// 弹跳床函数,执行函数,假如函数返回类型还是函数则继续执行,直到执行结束function trampoline(f) {  while (f && f instanceof Function) {    f = f();  }  return f;}

相应的我们的原函数需要改写如下:

function sum(n, result = 1) {  if (n <= 1) return result;    return sum.bind(null, n - 1, result + n);}

此时调用:

trampoline(sum(100000));

就不会报错堆栈溢出了。

尾调用优化默认关闭

看到这想必肯定很好奇,既然尾调用优化如此高效,为何都默认关闭了这个特性呢?答案分为两方面:

  1. 隐式优化问题。因为引擎消除尾递归是隐式的,函数能否符合尾调用而被消除了尾递归很难被程序员自己辨别;
  2. 调用栈丢失问题。尾调用优化要求除掉尾调用执行时的调用堆栈,这将导致执行流中的堆栈信息丢失。

Chrome下使用尾递归写法的方法仍旧出现调用栈溢出的起因在于:

  • 直接起因: 各大浏览器(除了safari)根本就没部署尾调用优化;
  • 根本起因: 尾调用优化仍旧有隐式优化和调用栈丢失的问题;

既然尾调用优化是默认关闭的,是不是说尾调用没什么用了呢?其实不然,尾调用是函数式编程一个重要的概念,正当的应用尾调用可以大大提高我们代码的可读性和可维护性,相比带来的一点性能损失,写更优雅更易读的代码更为的重要。

以上。


能力有限,水平一般,欢迎勘误,不胜感激。

订阅更多文章可关注公众号「前台进阶学习」,回复「666」,获取一揽子前台技术书籍

前台进阶学习
  • 全部评论(0)
最新发布的资讯信息
【系统环境|】从谷歌到手机厂商都下决心了,要清除32位应用这匹“害群之马”(2025-10-17 05:41)
【系统环境|】Windows上使用QEMU创建aarch64(ARM64)虚拟机(2025-10-17 05:40)
【系统环境|】nodejs 如何安装在aarch64平台(2025-10-17 05:39)
【系统环境|】常用git命令-从远程更新代码合并分支、提交代码等(2025-10-17 05:38)
【系统环境|】技术干货|常用的 Git 功能和选项(2025-10-17 05:38)
【系统环境|】掌握git命令,图解一目了然(2025-10-17 05:37)
【系统环境|】总结几个常用的Git命令的使用方法(2025-10-17 05:36)
【系统环境|】这篇 Git 教程太清晰了,很多 3 年经验程序员都收藏了(2025-10-17 05:35)
【系统环境|】Git常用命令及操作指南(2025-10-17 05:35)
【系统环境|】「实用」盘点那些开发中最常用的Git命令(2025-10-17 05:34)
手机二维码手机访问领取大礼包
返回顶部