Javascript中的尾递归及其优化

在平时的代码里,递归是很常见的,然而它可能会带来的调用栈溢出问题有时也使人头疼:

咱们知道, js 引擎(包括大部分语言)对于函数调用栈的大小是有限制的,以下图(虽然都是很老的浏览器,但仍是有参考价值):

为了解决递归时调用栈溢出的问题,除了把递归函数改成迭代的形式外,改成尾递归的形式也能够解决(虽然目前不少浏览器没有对尾递归(尾调用)作优化,依然会致使栈溢出,但了解尾递归的优化方式仍是有价值的。并且咱们能够经过一个统一的工具函数把尾递归转化为不会溢出的形式,这些下文会一一展开)。
在讨论尾递归以前,咱们先了解一下尾调用,以及 js 引擎如何对其进行优化。javascript

尾调用

当函数a的最后一个动做是调用函数b时,那么对函数b的调用形式就是尾调用。好比下面的代码里对fn1的调用就是尾调用:
html

const fn1 = (a) => {
  let b = a + 1;
  return b;
}

const fn2 = (x) => {
  let y = x + 1;
  return fn1(y);        // line A
}

const result = fn2(1);  // line B复制代码

咱们知道,在代码执行时,会产生一个调用栈,调用某个函数时会将其压入栈,当它 return 后就会出栈,下图是对于这段代码简易示例的调用栈(没有对 尾调用作优化):

首先 fn2被压入栈, xy依次被建立并赋值,栈内也会记录相应的信息,同时也记录了该函数被调用的地方,这样在函数 return 后就能知道结果应该返回到哪里。而后 fn1入栈,当它运行结束后就能够出栈,以后 fn2也获得了想要的结果,返回结果后也出栈,此段代码运行结束。
仔细看一下以上过程,你有没有以为第二第三步中 fn2的存在有些多余?它内部的一切计算都已经完成了,此时它在栈内的惟一做用就是记录最后结果应该返回到哪一行。于是能够有以下的优化:

在第二步调用 fn1时, fn2便可出栈,并把 line B信息给 fn1,而后将 fn1入栈,最后把 fn1的结果返回到 line B便可,这样就减少了调用栈的大小。

辨别是不是尾调用

const a = () => {
  b();
}复制代码

这里b的调用不是尾调用,由于函数a在调用b后还隐式地执行了一段return undefined,以下面这段代码:
java

const a = () => {
  b();
  return undefined;
}复制代码
若是咱们把它当作 尾调用并按照上面的方法优化的话,就得不到函数 a正确的返回结果了。

const a = () => b() || c();
const a1 = () => b() && c();复制代码

这里aa1中的b都不是尾调用,由于在它调用以后还有判断的动做以及可能的对于c的调用,而c都是尾调用git

const a = () => {
  let result = b();
  return result;
}复制代码

对于这段代码,有文章指出b并非尾调用,即使它与const a = () => b()是等价的,然后者显然是尾调用。这就涉及到定义的问题了,我以为没必要过于纠结,尾调用的真正目的是为了进行优化,防止栈溢出,我测试了下支持尾调用的 safari 浏览器,在严格模式下用相似的代码执行一段递归函数,结果是不会致使栈溢出,因此 safari 对这种形式的代码作了优化。es6

尾递归

如今就轮到本篇文章的主角——尾递归了,它其实只是尾调用的一种特殊状况,即每次递归调用都是尾调用,看一下下面这段简单的递归代码:github

const sum = (n) => {
  if (n <= 1) return n;
  return n + sum(n-1)
}复制代码
就是计算从1到n的整数的和,显然这段代码并非 尾递归,由于 sum(n-1)调用后还须要一步计算的过程,因此当n较大时就会致使栈溢出。咱们能够把这段代码改成 尾递归的形式:

const sum = (n, prevSum = 0) => {
  if (n <= 1) return n + prevSum;
  return sum(n-1, n + prevSum)
}复制代码
这样就是 尾递归了,这段代码在 safari 里以严格模式运行时,不会出现栈溢出错误,由于它对 尾调用作了优化。那有多少浏览器会作优化呢?其实在 es6 的规范里,就已经定义了对 尾调用的优化,不过目前浏览器对其支持状况很很差:

具体见 这里

即使未来大部分浏览器都支持尾调用优化了,按照 es6 的规范,也只会在严格模式下触发,这明显会很不方便。但咱们能够经过一个统一的方法对尾递归函数进行处理,让其再也不致使栈溢出。浏览器

Trampoline

Trampoline是对尾递归函数进行处理的一种技巧。咱们须要先把上面的sum函数改造一下,再由trampoline函数处理便可:
异步

const sum0 = (n, prevSum = 0) => {
  if (n <= 1) return n + prevSum;
  return () => sum0(n-1, n + prevSum)
}
const trampoline = f => (...args) => {
  let result = f(...args);
  while (typeof result === 'function') {
    result = result();
  }
  return result;
}
const sum = trampoline(sum0);

console.log(sum(1000000)); // 不会栈溢出复制代码

能够看到,这里实际上就是把本来的递归改成了迭代,这样就不会有栈溢出的问题啦。

固然,若是一个方法能够写成尾递归的形式,那它确定也能被写成迭代的形式(其实理论上全部递归都能被写成迭代的形式,不过有些用迭代实现起来会很复杂),但有些场景下使用递归可能会更加直观,若是它能被转为尾递归,你就能够直接用trampoline函数进行处理,或者把它改写成迭代的方法(或是在特殊场景下,在支持尾调用优化的浏览器里以严格模式运行)函数

参考:

blog.logrocket.com/using-tramp…
2ality.com/2015/06/tai…
www.zhihu.com/question/30…工具

---------更新---------

咦,不是应该结束了吗,怎么还有内容!

如下内容只是奇技淫巧,不必定能运用到实践中,仅供娱乐或开拓思惟(下面不是本文的正经内容,因此画风可能不同,只是随意写写~)

奇技淫巧

让咱们利用起js的异步机制!把递归调用放到settimeout中异步执行,每次递归执行结束后再把下一次递归调用放到settimeout里。这样函数执行一次后就直接返回了,它会退出调用栈,下一次递归调用函数会被settimeout推入回调队列里,在js的回调队列里永远最多都只有一个函数待执行,函数调用栈里固然也永远最多只有一个函数~(若是不考虑其它函数)

仍是之前面的sum函数举例,显然咱们不能同步地获得最终结果,能够经过一个回调函数去获取最终的值。因而我欢快地写起了下面的代码:

sum2 = (num, callback, sum = 0) => {
  if (num < 1) {
    callback(sum);
    return;
  }

  setTimeout(() => sum2(num-1, callback, sum + num), 0);
}

sum2(1000, v => console.log(v));复制代码

运行!

怎么这么慢?

由于settimeout有延时啊,最小4ms,因此每一次递归都被settimeout延迟了一小会,性能大打折扣!虽然只是奇技淫巧,但这么差的性能仍是让人不爽,必须优化!(*  ̄︿ ̄)

从新想一下,每次settimeout均可以理解为把当前调用栈清空,而后再执行settimeout中的函数。那么咱们不就能够把同步递归调用与settimeout结合!每递归个5000层,settimeout一次!(5000只是个比较保险的数字,能够针对不一样浏览器的上限作不一样处理)

sum3 = (num, callback, sum = 0, batchLeft = 5000) => {
  if (num < 1) {
    callback(sum);
    return;
  }
  batchLeft--;
  if (batchLeft > 0) 
  sum3(num-1, callback, sum + num, batchLeft)
  else setTimeout(() => sum3(num-1, callback, sum + num, 5000), 0);}

sum3(30000, v => console.log(v));复制代码

(若是真的要实际使用的话,最好对这个函数封装一下,不要把sum和batchLeft这两个变量暴露出来)

这样咱们就用js实现了永不会致使栈溢出的递归函数!不须要trampoline!不须要改迭代!这是真·递归!(即使是settimeout中的调用也是递归,只不过延后执行了)。只不过写法很啰嗦,还把本来能够同步执行的函数改为了麻烦的异步。


其实咱们再回头想一下,这个settimeout调用形式的自己就是一种尾递归,咱们是用settimeout把递归函数延迟到最后执行了,并且都延迟到上一个函数执行结束且出栈了,能够理解为咱们利用了js异步自己的特性,使js引擎作了一次很是规的“尾调用优化”。是否是挺有意思 σ`∀´)σ


(这么有意思,你就不点个关注吗 σ`∀´)σ 之后会写更多意思的内容哦 σ`∀´)σ

相关文章
相关标签/搜索