1. 程式人生 > >Javascript的尾遞迴及其優化

Javascript的尾遞迴及其優化

在平時的程式碼裡,遞迴是很常見的,然而它可能會帶來的呼叫棧溢位問題有時也令人頭疼:

我們知道, js 引擎(包括大部分語言)對於函式呼叫棧的大小是有限制的,如下圖(雖然都是很老的瀏覽器,但還是有參考價值):

為了解決遞迴時呼叫棧溢位的問題,除了把遞迴函式改為迭代的形式外,改為尾遞迴的形式也可以解決(雖然目前大部分瀏覽器沒有對尾遞迴(尾呼叫)做優化,依然會導致棧溢位,但瞭解尾遞迴的優化方式還是有價值的。而且我們可以通過一個統一的工具函式把尾遞迴轉化為不會溢位的形式,這些下文會一一展開)。
在討論尾遞迴之前,我們先了解一下尾呼叫,以及 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 後就會出棧,下圖是對於這段程式碼簡易示例的呼叫棧(沒有對 尾呼叫做優化):

首先 fn2被壓入棧, xy依次被建立並賦值,棧內也會記錄相應的資訊,同時也記錄了該函式被呼叫的地方,這樣在函式 return 後就能知道結果應該返回到哪裡。然後 fn1入棧,當它執行結束後就可以出棧,之後 fn2也得到了想要的結果,返回結果後也出棧,此段程式碼執行結束。
仔細看一下以上過程,你有沒有覺得第二第三步中 fn2的存在有些多餘?它內部的一切計算都已經完成了,此時它在棧內的唯一作用就是記錄最後結果應該返回到哪一行。因而可以有如下的優化:

在第二步呼叫 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 的規範裡,就已經定義了對 尾呼叫的優化,不過目前瀏覽器對其支援情況很不好:

具體見 這裡
即便將來大部分瀏覽器都支援 尾呼叫優化了,按照 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…