在平時的代碼裏,遞歸是很常見的,然而它可能會帶來的調用棧溢出問題有時也使人頭疼:
咱們知道, 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複製代碼
尾調用
作優化):
fn2
被壓入棧,
x
、
y
依次被建立並賦值,棧內也會記錄相應的信息,同時也記錄了該函數被調用的地方,這樣在函數 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();複製代碼
這裏a
和a1
中的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)
}複製代碼
尾遞歸
,由於
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是對尾遞歸
函數進行處理的一種技巧。咱們須要先把上面的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引擎作了一次很是規的「尾調用優化」。是否是挺有意思 σ`∀´)σ
(這麼有意思,你就不點個關注嗎 σ`∀´)σ 之後會寫更多意思的內容哦 σ`∀´)σ