学习Javascript之尾调用

前言

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

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

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

事亲以敬,美过三牲。前端

正文

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

尾调用

在以前的文章理解Javascript的高阶函数中,有说过在一个函数中输出一个函数,则这个函数能够被成为高阶函数。本文的主角尾调用和它相似,若是一个函数返回的是另外一个函数的调用结果,那么就被称为尾调用。例子:程序员

function add(x, y) {
  return x + y;
}
function sum() {
  return add(1, 2);
}
复制代码

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

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」,获取一揽子前端技术书籍

前端进阶学习
相关文章
相关标签/搜索