尾呼叫優化
尾呼叫優化其實在阮一峰老師的es6中已經講過了,但很多人可能並沒有重視它,它同樣會經常出現在筆試面試中,典型應用為斐波那契數列的優化,所以在這裡我單獨拿出來說一下。
什麼是尾呼叫?
尾呼叫是函數語言程式設計的一個重要概念,本身非常簡單,一句話就能說清楚,就是指某個函式的最後一步是呼叫另一個函式。
function f(x){
return g(x);
}
上面程式碼中,函式f的最後一步是呼叫函式g,這就叫尾呼叫。
以下三種情況,都不屬於尾呼叫。
// 情況一 function f(x){ let y = g(x); return y; } // 情況二 function f(x){ return g(x) + 1; } // 情況三 function f(x){ g(x); }
上面程式碼中,情況一是呼叫函式g之後,還有賦值操作,所以不屬於尾呼叫,即使語義完全一樣。情況二也屬於呼叫後還有操作,即使寫在一行內。情況三等同於下面的程式碼。
function f(x){
g(x);
return undefined;
}
尾呼叫不一定出現在函式尾部,只要是最後一步操作即可。
function f(x) {
if (x > 0) {
return m(x)
}
return n(x);
}
上面程式碼中,函式m和n都屬於尾呼叫,因為它們都是函式f的最後一步操作。
尾呼叫優化
尾呼叫之所以與其他呼叫不同,就在於它的特殊的呼叫位置。
我們知道,函式呼叫會在記憶體形成一個“呼叫記錄”,又稱“呼叫幀”,儲存呼叫位置和內部變數等資訊。如果在函式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(x) 的呼叫幀,只保留 g(3) 的呼叫幀。
這就叫做“尾呼叫優化”(Tail call optimization),即只保留內層函式的呼叫幀。如果所有函式都是尾呼叫,那麼完全可以做到每次執行時,呼叫幀只有一項,這將大大節省記憶體。這就是“尾呼叫優化”的意義。
注意,只有不再用到外層函式的內部變數,內層函式的呼叫幀才會取代外層函式的呼叫幀,否則就無法進行“尾呼叫優化”。
function addOne(a){
var one = 1;
function inner(b){
return b + one;
}
return inner(a);
}
上面的函式不會進行尾呼叫優化,因為內層函式inner
用到了外層函式addOne
的內部變數one
。
尾遞迴
函式呼叫自身,稱為遞迴。如果尾呼叫自身,就稱為尾遞迴。
遞迴非常耗費記憶體,因為需要同時儲存成千上百個呼叫幀,很容易發生“棧溢位”錯誤(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
還有一個比較著名的例子,就是計算fibonacci 數列,也能充分說明尾遞迴優化的重要性
如果是非尾遞迴的fibonacci 遞迴方法
function Fibonacci (n) {
if ( n <= 1 ) {return 1};
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
Fibonacci(10); // 89
// Fibonacci(100)
// Fibonacci(500)
// 堆疊溢位了
如果我們使用尾遞迴優化過的fibonacci 遞迴演算法
function Fibonacci2 (n , ac1 = 1 , ac2 = 1) {
if( n <= 1 ) {return ac2};
return Fibonacci2 (n - 1, ac2, ac1 + ac2);
}
Fibonacci2(100) // 573147844013817200000
Fibonacci2(1000) // 7.0330367711422765e+208
Fibonacci2(10000) // Infinity
由此可見,“尾呼叫優化”對遞迴操作意義重大,所以一些函數語言程式設計語言將其寫入了語言規格。ES6也是如此,第一次明確規定,所有ECMAScript的實現,都必須部署“尾呼叫優化”。這就是說,在ES6中,只要使用尾遞迴,就不會發生棧溢位,相對節省記憶體。
如果上面的講解你還是不太理解,那麼下面我們來用通俗易懂的語言來講解一下。
使用遞迴函式時,每一層呼叫都會在記憶體中佔用一個棧來儲存資料,在函式執行完之前棧記憶體不可被回收,當進行成千上百層呼叫時就會耗盡棧空間,出現棧溢位的問題。
怎麼辦呢?我們可以將每層遞迴的結果傳給下一層,當進行第一層呼叫結束時,將結果傳給第二層,回收第一層呼叫所佔記憶體,第二層呼叫結束時將結果傳給第三層,回收第二層呼叫所記憶體,這樣我們就可以在進行下一層遞迴時把前一層呼叫所使用的棧回收,消除了棧溢位的問題。具體的做法就是在原函式的基礎上增加能夠接收上一層呼叫結果的引數,並在函式中將函式執行結果傳給下一層。