尾呼叫與尾遞迴
本講將對尾呼叫與尾遞迴進行介紹:函式的最後一條執行語句是呼叫一個函式的形式即為尾呼叫;函式尾呼叫自身則為尾遞迴,通過改寫迴圈即可輕鬆寫出尾遞迴函式。在語言支援尾呼叫優化的條件下,尾呼叫能節省很大一部分記憶體空間。
什麼是尾呼叫
問:何為尾呼叫?
說人話:函式的最後一條執行語句是呼叫一個函式,這種形式就稱為尾呼叫。
讓我們看看以下幾個例子。
// 正確的尾呼叫:函式/方法的最後一行是去呼叫function2()這個函式 public int function1(){ return function2(); } // 錯誤例子1:呼叫完函式/方法後,又多了賦值操作 public int function1(){ int x = function2(); return x; } // 錯誤例子2:呼叫完函式後,又多了運算操作 public int function1(){ return function2() + 1; } // 錯誤例子3:f(x)的最後一個動作其實是return null public void function1(){ function2(); }
尾呼叫優化
以Java為例。在Java中,JVM會為每個新建立的執行緒都建立一個棧(stack)。棧是用來儲存棧幀(stack frame)的容器;而棧幀是用來儲存執行緒狀態的容器,其主要包括方法的區域性變量表(local variable table),運算元棧(operand stack),動態連線(dynamic linking)和方法返回地址(return address)等資訊。
(注:Java語言目前還不支援尾呼叫優化,但尾呼叫優化的原理是相通的。)
棧會對棧幀進行壓棧和出棧操作:每當一個Java方法被執行時都會新建立一個棧幀(壓棧,push),方法呼叫結束後即被銷燬(出棧,pop)。
在方法A的內部呼叫方法B,就會在A的棧幀上疊加一個B的棧幀。在一個活動的執行緒中,只有在棧頂的棧幀才是有效的,它被稱為當前棧幀(Current Stack Frame),這個棧幀所關聯的方法則被稱為當前方法(Current Method)。只有當方法B執行結束,將結果返回到A後,B的棧幀才會出棧。
舉個例子。
public int eat(){ return 5; } public int action(){ int x = eat(); return x; } public int imitate(){ int x = action(); return x; } public static void main(String[] args){ imitate(); }
這段程式碼對應的棧的狀況則為如下:
- 首先,在main執行緒中呼叫了
imitate()
方法,便會將它的棧幀壓入棧中。 - 在
imitate()
方法裡,呼叫了action()
方法,由於這不是個尾呼叫,在呼叫完action()
方法後仍存在一個運算操作,因此將action的棧幀壓入棧中後,JVM認為imitate()
方法還沒執行完,便仍然保留著imitate的棧幀。 - 同理:
action()
方法裡對eat()
方法的呼叫也不是尾呼叫,JVM認為在呼叫完eat()
方法後,action()
方法仍未執行結束。因此保留action的棧幀,並繼續往棧中壓入eat的棧幀。 eat()
方法執行完後,其對應棧幀就會出棧;action()
方法和imitate()
方法在執行完後其對應的棧幀也依次出棧。
但假如我們對上述示例程式碼改寫成如下所示:
public int eat(){
return 5;
}
public int action(){
return eat();
}
public int imitate(){
return action();
}
public static void main(String[] args){
imitate();
}
那麼如果尾呼叫優化生效,棧對應的狀態就會為如下:
- 首先,仍然是將
imitate()
方法的棧幀壓入棧中。 - 在
imitate()
方法中對action()
方法進行了尾呼叫,該呼叫屬於imitate()
方法的最後一個執行語句,因此在呼叫action()
方法時就意味著imitate()
方法執行結束:imitate棧幀出棧,action棧幀入棧。 - 同理:action棧幀出棧,eat棧幀入棧。
- 最後,
eat()
方法執行完畢,全流程結束。
我們可以看到,由於尾呼叫是函式的最後一條執行語句,無需再保留外層函式的棧幀來儲存它的區域性變數以及呼叫前地址等資訊,所以棧從始至終就只保留著一個棧幀。這就是尾呼叫優化(tail call optimization),節省了很大一部分的記憶體空間。
但上面只是理論上的理想情況,把程式碼改寫成尾呼叫的形式只是一個前提條件,棧是否真的如我們所願從始至終只保留著一個棧楨還得取決於語言是否支援。例如python就不支援,即使寫成了尾遞迴的形式,棧該爆還是會爆。
尾遞迴
問:何為尾遞迴?
說人話:函式尾呼叫自身,這個形式就稱為尾遞迴。
在手把手教你寫遞迴這篇文章中我們提過,遞迴對空間的消耗大,例如假設我們要計算factorial(1000)
,就需要儲存1000個棧幀,很容易就導致棧溢位。
但假如我們將其改為尾遞迴,那對於那些支援尾呼叫優化的語言來說,就能做到只儲存1個棧幀,有效避免了棧溢位。
那尾遞迴函式要怎麼寫呢?
一個比較實用的方法就是先寫出用迴圈實現的版本,再把迴圈中用到的區域性變數都改為函式的引數即可。這樣再進入下一層函式時就不需要再用到上一層函式的環境了,到最後一層時就包含了前面所有層的計算結果,就不要再返回了。
例如階乘函式。
public int fac(int n) {
int result = 1;
for (int index = 1; index <= n; index++)
result = index * result;
return result;
}
在這個用迴圈實現的版本中,可以看到用到了\(result, index\)這兩個區域性變數,那就將其改為函式的引數。並且通過迴圈可以看出邊界條件是當index == n
時;\(n\)從頭到尾不會變;\(index\)在每次進入下一層迴圈時會遞增,\(result\)在每次進入下一層迴圈時會有變動。我們把這些改動直接照搬,改寫就非常容易了。
所以用尾遞迴實現的版本即為如下:
public int fac(int n, int index, int result) {
if (index == n)
return index * result;
else
return fac(n, index + 1, index * result);
}
再舉個例子,斐波那契數列(0, 1, 1, 2, 3, 5, 8, 13...)
其迴圈實現版本如下:
public int fibo(int n) {
int a = 0;
int b = 1;
int x = 0;
for (int index = 0; index < n; index++){
x = b;
b = a + b;
a = x;
}
return a;
}
區域性變數有\(a, b, index\)(\(x\)作為\(a, b\)賦值的中間變數,在遞迴中可以不需要用到),把這些區域性變數放到引數列表。邊界條件為當index == n
時;並且,在進入下一層迴圈時,\(a\)的值會變為\(b\),\(b\)的值會變為\(a + b\),\(index\)的值加1,把這些改動照搬。
public int fibo(int n, int a, int b, int index) {
if (index == n)
return a;
else
return fibo(n, b, a + b, index + 1);
}
參考
- https://zhuanlan.zhihu.com/p/130885188
- https://www.cnblogs.com/minisculestep/articles/4934947.html
- https://zhuanlan.zhihu.com/p/24305359
- https://www.cnblogs.com/catch/p/3495450.html
創作不易,點個贊再走叭~