Javascript中的尾遞迴及其優化

顧二凡發表於2019-03-02

在平時的程式碼裡,遞迴是很常見的,然而它可能會帶來的呼叫棧溢位問題有時也令人頭疼:
Javascript中的尾遞迴及其優化
我們知道, js 引擎(包括大部分語言)對於函式呼叫棧的大小是有限制的,如下圖(雖然都是很老的瀏覽器,但還是有參考價值):
Javascript中的尾遞迴及其優化
為了解決遞迴時呼叫棧溢位的問題,除了把遞迴函式改為迭代的形式外,改為尾遞迴的形式也可以解決(雖然目前很多瀏覽器沒有對尾遞迴(尾呼叫)做優化,依然會導致棧溢位,但瞭解尾遞迴的優化方式還是有價值的。而且我們可以通過一個統一的工具函式把尾遞迴轉化為不會溢位的形式,這些下文會一一展開)。
在討論尾遞迴之前,我們先了解一下尾呼叫,以及 js 引擎如何對其進行優化。

尾呼叫

當函式a的最後一個動作是呼叫函式b時,那麼對函式b的呼叫形式就是尾呼叫。比如下面的程式碼裡對fn1的呼叫就是尾呼叫:

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 後就會出棧,下圖是對於這段程式碼簡易示例的呼叫棧(沒有對尾呼叫做優化):
Javascript中的尾遞迴及其優化
首先fn2被壓入棧,xy依次被建立並賦值,棧內也會記錄相應的資訊,同時也記錄了該函式被呼叫的地方,這樣在函式 return 後就能知道結果應該返回到哪裡。然後fn1入棧,當它執行結束後就可以出棧,之後fn2也得到了想要的結果,返回結果後也出棧,此段程式碼執行結束。
仔細看一下以上過程,你有沒有覺得第二第三步中fn2的存在有些多餘?它內部的一切計算都已經完成了,此時它在棧內的唯一作用就是記錄最後結果應該返回到哪一行。因而可以有如下的優化:
Javascript中的尾遞迴及其優化
在第二步呼叫fn1時,fn2即可出棧,並把line B資訊給fn1,然後將fn1入棧,最後把fn1的結果返回到line B即可,這樣就減小了呼叫棧的大小。

辨別是否是尾呼叫

const a = () => {
  b();
}複製程式碼

這裡b的呼叫不是尾呼叫,因為函式a在呼叫b後還隱式地執行了一段return undefined,如下面這段程式碼:

const a = () => {
  b();
  return undefined;
}複製程式碼
如果我們把它當做尾呼叫並按照上面的方法優化的話,就得不到函式a正確的返回結果了。

const a = () => b() || c();
const a1 = () => b() && c();複製程式碼

這裡aa1中的b都不是尾呼叫,因為在它呼叫之後還有判斷的動作以及可能的對於c的呼叫,而c都是尾呼叫

const a = () => {
  let result = b();
  return result;
}複製程式碼

對於這段程式碼,有文章指出b並不是尾呼叫,即便它與const a = () => b()是等價的,而後者顯然是尾呼叫。這就涉及到定義的問題了,我覺得不必過於糾結,尾呼叫的真正目的是為了進行優化,防止棧溢位,我測試了下支援尾呼叫的 safari 瀏覽器,在嚴格模式下用類似的程式碼執行一段遞迴函式,結果是不會導致棧溢位,所以 safari 對這種形式的程式碼做了優化。

尾遞迴

現在就輪到本篇文章的主角——尾遞迴了,它其實只是尾呼叫的一種特殊情況,即每次遞迴呼叫都是尾呼叫,看一下下面這段簡單的遞迴程式碼:

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 的規範裡,就已經定義了對尾呼叫的優化,不過目前瀏覽器對其支援情況很不好:
Javascript中的尾遞迴及其優化
具體見這裡

即便將來大部分瀏覽器都支援尾呼叫優化了,按照 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引擎做了一次非常規的“尾呼叫優化”。是不是挺有意思 σ`∀´)σ


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


相關文章