尾呼叫優化

阮一峰發表於2015-04-10

尾呼叫(Tail Call)是函數語言程式設計的一個重要概念,本文介紹它的含義和用法。

一、什麼是尾呼叫?

尾呼叫的概念非常簡單,一句話就能說清楚,就是指某個函式的最後一步是呼叫另一個函式。


function f(x){
  return g(x);
}

上面程式碼中,函式f的最後一步是呼叫函式g,這就叫尾呼叫。

以下兩種情況,都不屬於尾呼叫。


// 情況一
function f(x){
  let y = g(x);
  return y;
}

// 情況二
function f(x){
  return g(x) + 1;
}

上面程式碼中,情況一是呼叫函式g之後,還有別的操作,所以不屬於尾呼叫,即使語義完全一樣。情況二也屬於呼叫後還有操作,即使寫在一行內。

尾呼叫不一定出現在函式尾部,只要是最後一步操作即可。


function f(x) {
  if (x > 0) {
    return m(x)
  }
  return n(x);
}

上面程式碼中,函式m和n都屬於尾呼叫,因為它們都是函式f的最後一步操作。

二、尾呼叫優化

尾呼叫之所以與其他呼叫不同,就在於它的特殊的呼叫位置。

我們知道,函式呼叫會在記憶體形成一個"呼叫記錄",又稱"呼叫幀"(call frame),儲存呼叫位置和內部變數等資訊。如果在函式A的內部呼叫函式B,那麼在A的呼叫記錄上方,還會形成一個B的呼叫記錄。等到B執行結束,將結果返回到A,B的呼叫記錄才會消失。如果函式B內部還呼叫函式C,那就還有一個C的呼叫記錄棧,以此類推。所有的呼叫記錄,就形成一個"呼叫棧"(call stack)。

尾呼叫由於是函式的最後一步操作,所以不需要保留外層函式的呼叫記錄,因為呼叫位置、內部變數等資訊都不會再用到了,只要直接用內層函式的呼叫記錄,取代外層函式的呼叫記錄就可以了。


function f() {
  let m = 1;
  let n = 2;
  return g(m + n);
}
f();

// 等同於
function f() {
  return g(3);
}
f();

// 等同於
g(3);

上面程式碼中,如果函式g不是尾呼叫,函式f就需要儲存內部變數m和n的值、g的呼叫位置等資訊。但由於呼叫g之後,函式f就結束了,所以執行到最後一步,完全可以刪除 f() 的呼叫記錄,只保留 g(3) 的呼叫記錄。

這就叫做"尾呼叫優化"(Tail call optimization),即只保留內層函式的呼叫記錄。如果所有函式都是尾呼叫,那麼完全可以做到每次執行時,呼叫記錄只有一項,這將大大節省記憶體。這就是"尾呼叫優化"的意義。

三、尾遞迴

函式呼叫自身,稱為遞迴。如果尾呼叫自身,就稱為尾遞迴。

遞迴非常耗費記憶體,因為需要同時儲存成千上百個呼叫記錄,很容易發生"棧溢位"錯誤(stack overflow)。但對於尾遞迴來說,由於只存在一個呼叫記錄,所以永遠不會發生"棧溢位"錯誤。


function factorial(n) {
  if (n === 1) return 1;
  return n * factorial(n - 1);
}

factorial(5) // 120

上面程式碼是一個階乘函式,計算n的階乘,最多需要儲存n個呼叫記錄,複雜度 O(n) 。

如果改寫成尾遞迴,只保留一個呼叫記錄,複雜度 O(1) 。


function factorial(n, total) {
  if (n === 1) return total;
  return factorial(n - 1, n * total);
}

factorial(5, 1) // 120

由此可見,"尾呼叫優化"對遞迴操作意義重大,所以一些函數語言程式設計語言將其寫入了語言規格。ES6也是如此,第一次明確規定,所有 ECMAScript 的實現,都必須部署"尾呼叫優化"。這就是說,在 ES6 中,只要使用尾遞迴,就不會發生棧溢位,相對節省記憶體。

四、遞迴函式的改寫

尾遞迴的實現,往往需要改寫遞迴函式,確保最後一步只呼叫自身。做到這一點的方法,就是把所有用到的內部變數改寫成函式的引數。比如上面的例子,階乘函式 factorial 需要用到一箇中間變數 total ,那就把這個中間變數改寫成函式的引數。這樣做的缺點就是不太直觀,第一眼很難看出來,為什麼計算5的階乘,需要傳入兩個引數5和1?

兩個方法可以解決這個問題。方法一是在尾遞迴函式之外,再提供一個正常形式的函式。


function tailFactorial(n, total) {
  if (n === 1) return total;
  return tailFactorial(n - 1, n * total);
}

function factorial(n) {
  return tailFactorial(n, 1);
}

factorial(5) // 120

上面程式碼通過一個正常形式的階乘函式 factorial ,呼叫尾遞迴函式 tailFactorial ,看起來就正常多了。

函數語言程式設計有一個概念,叫做柯里化(currying),意思是將多引數的函式轉換成單引數的形式。這裡也可以使用柯里化。


function currying(fn, n) {
  return function (m) {
    return fn.call(this, m, n);
  };
}

function tailFactorial(n, total) {
  if (n === 1) return total;
  return tailFactorial(n - 1, n * total);
}

const factorial = currying(tailFactorial, 1);

factorial(5) // 120

上面程式碼通過柯里化,將尾遞迴函式 tailFactorial 變為只接受1個引數的 factorial 。

第二種方法就簡單多了,就是採用ES6的函式預設值。


function factorial(n, total = 1) {
  if (n === 1) return total;
  return factorial(n - 1, n * total);
}

factorial(5) // 120

上面程式碼中,引數 total 有預設值1,所以呼叫時不用提供這個值。

總結一下,遞迴本質上是一種迴圈操作。純粹的函數語言程式設計語言沒有迴圈操作命令,所有的迴圈都用遞迴實現,這就是為什麼尾遞迴對這些語言極其重要。對於其他支援"尾呼叫優化"的語言(比如Lua,ES6),只需要知道迴圈可以用遞迴代替,而一旦使用遞迴,就最好使用尾遞迴。

([說明] 本文摘自我寫的《ECMAScript 6入門》

五、嚴格模式

ES6的尾呼叫優化只在嚴格模式下開啟,正常模式是無效的。

這是因為在正常模式下,函式內部有兩個變數,可以跟蹤函式的呼叫棧。

  • arguments:返回撥用時函式的引數。
  • func.caller:返回撥用當前函式的那個函式。

尾呼叫優化發生時,函式的呼叫棧會改寫,因此上面兩個變數就會失真。嚴格模式禁用這兩個變數,所以尾呼叫模式僅在嚴格模式下生效。

六、參考連結

(完)

相關文章