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引擎作了一次很是規的「尾調用優化」。是否是挺有意思 σ`∀´)σ


(這麼有意思,你就不點個關注嗎 σ`∀´)σ 之後會寫更多意思的內容哦 σ`∀´)σ

相關文章
相關標籤/搜索