学习Javascript之尾调用

前言

本文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;
}
// 情况1
function sum() {
  return add(1, 2) + 1;
}
// 情况2
function 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的调用结果,也会重复这个过程,以此类推,如果这个执行栈内执行上下文的数量超过了最大值那么就会报出堆栈溢出的错误,这是前面的那个例子报错的缘由。看下图,上面函数的执行栈:

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

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

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

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

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

但尾调用优化仅仅是普通开发者去写可以被优化的函数是做不到的,这个特性一般都需要借助编译器或是运行环境来支持才可以。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」,获取一揽子前端技术书籍

原文地址:https://www.cnblogs.com/jztan/p/12384813.html

时间: 2024-10-27 13:03:13

学习Javascript之尾调用的相关文章

学习Javascript的编程风格

Javascript编程风格 Douglas Crockford是Javascript权威,Json格式就是他的发明. 去年11月他有一个演讲(Youtube),谈到了好的Javascript编程风格是什么. 我非常推荐这个演讲,它不仅有助于学习Javascript,而且能让你心情舒畅,因为Crockford讲得很幽默,时不时让听众会心一笑. 下面,我根据这个演讲和Crockford编写的代码规范,总结一下"Javascript编程风格". 所谓"编程风格"(pro

如何系统地学习JavaScript

在过去,JavaScript只是被用来做一些简单的网页效果,比如表单验证.浮动广告等,所以那时候JavaScript并没有受到重视.自从AJAX开始流行后,人们发现利用JavaScript可以给用户带来更好的体验,甚至利用这一优点开发了大型网页游戏,于是这门小语言被重视了起来.现在,很多公司会招专门的JavaScript工程师,通常JavaScript是WEB前端开发的必备技能.简单介绍了JavaScriptr的好处,并不代表大家就会去学习甚至把它学好,兴趣是很关键的,我认为兴趣是最好的老师,它

如何理解并学习javascript中的面向对象(OOP) [转]

如果你想让你的javascript代码变得更加优美,性能更加卓越.或者,你想像jQuery的作者一样,写出属于自己优秀的类库(哪怕是基于 jquery的插件).那么,你请务必要学习javascript面向对象,否则你无法更灵活的使用javascript这门语言. 什么事闭包?到底什么是原型?(知道闭包和原型的,就算得上是javascript的高手了.但真正能够理解,并且灵活运用的人并不多)到底该如何学习javascript中的面向对象呢?在javascript这么语言正如日中天,相信不少人正在为

学习JavaScript中的异步Generator

本文和大家分享的主要是javascript中异步Generator相关内容,一起来看看吧,希望对大家学习javascript 有所帮助. 异步的generators和异步iteration已经到来 ! 这是错误的, 它们现在还在 阶段 3 ,这表示他们很有可能在JavaScript未来的一个版本中发布. 在他们发布之前,你可以通过 Babel 来在你的项目中使用还在阶段3的建议内容. 网站基本上还是一些分散运行的应用,因为任何在语言上的修改都会造成永久的影响,所以所有的未来的版本都需要向后兼容.

JavaScript函数的调用模式有哪些?

函数是JavaScript 世界里的第一公民,换句话来说,就是我们如果可以精通 JavaScript 函数的使用,那么对JavaScript 的运用可以更游刃有余了.熟悉 JavaScript 的人应该都知道,同样的函数,以不同的方式调用的话,受影响最大的应该是  this  .下面我们来说说 JavaScript 函数的各种调用模式,希望对大家 学习javascript有所帮助. 一.普通函数的调用模式 所谓普通函数的调用模式,也是JavaScript 函数的最简单的一种调用模式,直接就是函数

轻松学习JavaScript十一:JavaScript基本类型(包含类型转换)和引用类型

一值的类型 早在介绍JS的数据类型的时候就提到过基本类型和引用类型,不过在说两种类型之前,我们先来了解一下变量的 值的类型.在ECMAScript中,变量可以存在两种类型的值,即原始值和引用值. (1)原始值 存储在栈中的简单数据段,也就是说,它们的值直接存储在变量访问的位置. (2)引用值 存储在堆中的对象,也就是说,存储在变量处的值是一个指针,指向存储对象的内存处. 为变量赋值时,ECMAScript的解释程序必须判断该值是原始类型,还是引用类型.要实现这一点,解释程序则需 尝试判断该值是否

鼎育教育成都java培训机构学习javascript注意的4个问题

鼎育教育成都java培训机构学习javascript注意的4个问题 1.关于javascript伪协议:通过一个链接调用javascript函数,比如: link example 作用:伪协议启用javascript,通过链接调用javascript函数 缺点:需要支持javascirpt功能的浏览器 不支持javascript浏览器:打开失败 支持但禁用javascript浏览器:do nothing 2.关于javascript内嵌事件,通过一些事件触发,比如onclick等,比如: lin

学习JavaScript

学习JavaScript JavaScript的历史虽短,但却发展迅速.最初NetScape开发了LiveScript语言,使得它的Navigator和Web服务器产品有了基本的脚本编写功能.当Navigator2.0中加入了Java小程序后,NetScape才把LiveScript变成了JavaScript,至此JavaScript诞生. 本文将以一个有趣的小程序为开端,介绍JavaScript的基本语法与功能.此趣味程序为小球碰撞仿真程序,它可仿真弹性碰撞和动能有损的不完全恢复碰撞.你若想了

一步步学习javascript基础篇(7):BOM和DOM

一.什么是BOM.什么是DOM BOM即浏览器对象模型,主要用了访问一些和网页无关的浏览器功能.如:window.location.navigator.screen.history等对象. DOM即文档对象模型,针对HTML(或XML)文档的API(应用程序编程接口).描绘的一个层次化的节点树,开发人员可以添加.修改和删除页面的某一部分. 二.细说BOM对象 1.window对象 window对象表示浏览器的一个实例,同时也是ECMAScript 规定的 Global 对象.(Global :所