微博上看到有人在討論尾遞歸,想起以前曾看過老趙寫的一篇相關的博客,介紹的比較詳細了,相信很多人都看過,我也在下面留了言,但挑了個刺,表示文章在關鍵點上一帶而過了,老趙自然是懂的,但看的人如果不深入思考,未必真正的明白,下面我說說我的理解。
什么是尾遞歸
什么是尾遞歸呢?(tail recursion), 顧名思議,就是一種“不一樣的”遞歸,說到它的不一樣,就得先說說一般的遞歸。對於一般的遞歸,比如下面的求階乘,教科書上會告訴我們,如果這個函數調用的深度太深,很容易會有爆棧的危險。
// 先不考慮溢出問題
int func(int n) { if (n <= 1) return 1; return (n * func(n-1)); }
原因很多人的都知道,讓我們先回顧一下函數調用的大概過程:
1)調用開始前,調用方(或函數本身)會往棧上壓相關的數據,參數,返回地址,局部變量等。
2)執行函數。
3)清理棧上相關的數據,返回。
因此,在函數 A 執行的時候,如果在第二步中,它又調用了另一個函數 B,B 又調用 C.... 棧就會不斷地增長不斷地裝入數據,當這個調用鏈很深的時候,棧很容易就滿 了,這就是一般遞歸函數所容易面臨的大問題。
而尾遞歸在某些語言的實現上,能避免上述所說的問題,注意是某些語言上,尾遞歸本身並不能消除函數調用棧過長的問題,那什么是尾遞歸呢?在上面寫的一般遞歸函數 func() 中,我們可以看到,func(n) 是依賴於 func(n-1) 的,func(n) 只有在得到 func(n-1) 的結果之后,才能計算它自己的返回值,因此理論上,在 func(n-1) 返回之前,func(n),不能結束返回。因此func(n)就必須保留它在棧上的數據,直到func(n-1)先返回,而尾遞歸的實現則可以在編譯器的幫助下,消除這個限制:
// 先不考慮溢出
int tail_func(int n, int res) { if (n <= 1) return res; return tail_func(n - 1, n * res); } // 像下面這樣調用
tail_func(10000000000, 1);
從上可以看到尾遞歸把返回結果放到了調用的參數里。這個細小的變化導致,tail_func(n, res)不必像以前一樣,非要等到拿到了tail_func(n-1, n*res)的返回值,才能計算它自己的返回結果 -- 它完全就等於tail_func(n-1, n*res)的返回值。因此理論上:tail_func(n)在調用tail_func(n-1)前,完全就可以先銷毀自己放在棧上的東西。
這就是為什么尾遞歸如果在得到編譯器的幫助下,是完全可以避免爆棧的原因:每一個函數在調用下一個函數之前,都能做到先把當前自己占用的棧給先釋放了,尾遞歸的調用鏈上可以做到只有一個函數在使用棧,因此可以無限地調用!
尾遞歸的調用棧優化特性
相信讀者都注意到了,我一直在強調,尾遞歸的實現依賴於編譯器的幫助(或者說語言的規定),為什么這樣說呢?先看下面的程序:
1 #include <stdio.h>
2
3 int tail_func(int n, int res) 4 { 5 if (n <= 1) return res; 6
7 return tail_func(n - 1, n * res); 8 } 9
10
11 int main() 12 { 13 int dummy[1024*1024]; // 盡可能占用棧。
14
15 tail_func(2048*2048, 1); 16
17 return 1; 18 }
上面這個程序在開了編譯優化和沒開編譯優化的情況下編出來的結果是不一樣的,如果不開啟優化,直接 gcc -o tr func_tail.c 編譯然后運行的話,程序會爆棧崩潰,但如果開優化的話:gcc -o tr -O2 func_tail.c,上面的程序最后就能正常運行。
這里面的原因就在於,尾遞歸的寫法只是具備了使當前函數在調用下一個函數前把當前占有的棧銷毀,但是會不會真的這樣做,是要具體看編譯器是否最終這樣做,如果在語言層面上,沒有規定要優化這種尾調用,那編譯器就可以有自己的選擇來做不同的實現,在這種情況下,尾遞歸就不一定能解決一般遞歸的問題。
我們可以先看看上面的例子在開優化與沒開優化的情況下,編譯出來的匯編代碼有什么不同,首先是沒開優化編譯出來的匯編tail_func:
1 .LFB3:
2 pushq %rbp 3 .LCFI3:
4 movq %rsp, %rbp 5 .LCFI4:
6 subq $16, %rsp 7 .LCFI5:
8 movl %edi, -4(%rbp) 9 movl %esi, -8(%rbp) 10 cmpl $1, -4(%rbp) 11 jg .L4 12 movl -8(%rbp), %eax 13 movl %eax, -12(%rbp) 14 jmp .L3 15 .L4:
16 movl -8(%rbp), %eax 17 movl %eax, %esi 18 imull -4(%rbp), %esi 19 movl -4(%rbp), %edi 20 decl %edi 21 call tail_func 22 movl %eax, -12(%rbp) 23 .L3:
24 movl -12(%rbp), %eax 25 leave
26 ret
注意上面標紅色的一條語句,call 指令就是直接進行了函數調用,它會先壓棧,然后再 jmp 去 tail_func,而當前的棧還在用!就是說,尾遞歸的作用沒有發揮。
再看看開了優化得到的匯編:
1 tail_func:
2 .LFB13:
3 cmpl $1, %edi 4 jle .L8 5 .p2align 4,,7
6 .L9:
7 imull %edi, %esi 8 decl %edi 9 cmpl $1, %edi 10 jg .L9 11 .L8:
12 movl %esi, %eax 13 ret
注意第7,第10行,尤其是第10行!tail_func() 里面沒有函數調用!它只是把當前函數的第二個參數改了一下,直接就又跳到函數開始的地方。此處的實現本質其實就是:下一個函數調用繼續延用了當前函數的棧!
這就是尾遞歸所能帶來的效果: 控制棧的增長,且減少壓棧,程序運行的效率也可能更高!
上面所寫的是 c 的實現,正如前面所說的,這並不是所有語言都擺支持,有些語言,比如說 python, 尾遞歸的寫法在 python 上就沒有任何作用,該爆的時候還是會爆。
def func(n, res): if (n <= 1): return res return func(n-1, n*res) if __name__ =='__main__': print func(4096, 1)
不僅僅是 python,據說 C# 也不支持,我在網上搜到了這個鏈接:https://connect.microsoft.com/VisualStudio/feedback/details/166013/c-compiler-should-optimize-tail-calls,微軟的人在上面回答說,實現這個優化有些問題需要處理,並不是想像中那么容易,因此暫時沒有實現,但是這個回答是在2007年的時候了,到現在歲月變遷,不知支持了沒?我看老趙寫的尾遞歸博客是在2009年,用 c# 作的例子,估計現在 c# 是支持這個優化的了(待考).
尾調用
前面的討論一直都集中在尾遞歸上,這其實有些狹隘,尾遞歸的優化屬於尾調用優化這個大范疇,所謂尾調用,形式它與尾遞歸很像,都是一個函數內最后一個動作是調用下一個函數,不同的只是調用的是誰,顯然尾遞歸只是尾調用的一個特例。
int func1(int a) { static int b = 3; return a + b; } int func2(int c) { static int b = 2; return func1(c+b); }
上面例子中,func2在調用func1之前顯然也是可以完全丟掉自己占有的棧空間的,原因與尾遞歸一樣,因此理論上也是可以進行優化的,而事實上這種優化也一直是程序編譯優化里的一個常見選項,甚至很多的語言在標准里就直接要求要對尾調用進行優化,原因很明顯,尾調用在程序里是經常出現的,優化它不僅能減少棧空間使用,通常也能給程序運行效率帶來比較大的提升。
