函數語言程式設計之尾呼叫和尾遞迴

SaltAir發表於2019-01-11

尾呼叫

前一段時間偶然情況下了解到了尾呼叫這個概念,然後就去了解了一下,其實用程式碼來解釋是非常容易的:

function foo(x){
    return x+1
}
function ex(){
    var num = 2
    return foo(num)
}
//尾呼叫不一定出現在函式尾部,只要是最後一步操作即可。
//當一個函式的最後一步是另一個函式的呼叫(只是某個函式的呼叫),那麼,這種情況就稱之為尾呼叫
複製程式碼

尾呼叫為什麼會單獨拿出來作為一個概念並且被討論,究其原因是函式呼叫會在記憶體中形成一個呼叫幀(用以儲存呼叫位置,上下文變數等資訊),如果在函式A內部呼叫了函式B,那麼,會在A的呼叫幀上面再記錄一個B的呼叫幀,等B執行結束將結果返回給A之後,B的呼叫幀消失;如果B內部還呼叫了函式C,那麼,C的呼叫幀。還會出現在B的上方。這就是一個壓棧的過程,因此呼叫過程其實是形成了一個呼叫棧的。 尾呼叫由於是函式的最後一步操作,所以不需要保留外層函式的呼叫記錄,因為呼叫位置、內部變數等資訊都不會再用到了,只要直接用內層函式的呼叫記錄,取代外層函式的呼叫記錄就可以了。

優化

優化方式根據使用的語言不同則有不同實現,此處拿JavaScript來舉例

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 中,只要使用尾遞迴,就不會發生棧溢位,相對節省記憶體,ES6的尾呼叫優化只在嚴格模式下開啟,正常模式是無效的。(參考自阮一峰:www.ruanyifeng.com/blog/2015/0…)

相關文章