微博上看到有人在討論尾遞歸,想起之前曾看過老趙寫的一篇相關的博客,介紹的比較詳細了,相信不少人都看過,我也在下面留了言,但挑了個刺,表示文章在關鍵點上一帶而過了,老趙天然是懂的,但看的人若是不深刻思考,未必真正的明白,下面我說說個人理解。python
什麼是尾遞歸呢?(tail recursion), 顧名思議,就是一種「不同的」遞歸,說到它的不同,就得先說說通常的遞歸。對於通常的遞歸,好比下面的求階乘,教科書上會告訴咱們,若是這個函數調用的深度太深,很容易會有爆棧的危險。c#
// 先不考慮溢出問題
int func(int n) { if (n <= 1) return 1; return (n * func(n-1)); }
緣由不少人的都知道,讓咱們先回顧一下函數調用的大概過程:函數
1)調用開始前,調用方(或函數自己)會往棧上壓相關的數據,參數,返回地址,局部變量等。優化
2)執行函數。spa
3)清理棧上相關的數據,返回。code
所以,在函數 A 執行的時候,若是在第二步中,它又調用了另外一個函數 B,B 又調用 C.... 棧就會不斷地增加不斷地裝入數據,當這個調用鏈很深的時候,棧很容易就滿 了,這就是通常遞歸函數所容易面臨的大問題。blog
而尾遞歸在某些語言的實現上,能避免上述所說的問題,注意是某些語言上,尾遞歸自己並不能消除函數調用棧過長的問題,那什麼是尾遞歸呢?在上面寫的通常遞歸函數 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)前,徹底就能夠先銷燬本身放在棧上的東西。get
這就是爲何尾遞歸若是在獲得編譯器的幫助下,是徹底能夠避免爆棧的緣由:每個函數在調用下一個函數以前,都能作到先把當前本身佔用的棧給先釋放了,尾遞歸的調用鏈上能夠作到只有一個函數在使用棧,所以能夠無限地調用!編譯器
尾遞歸的調用棧優化特性
相信讀者都注意到了,我一直在強調,尾遞歸的實現依賴於編譯器的幫助(或者說語言的規定),爲何這樣說呢?先看下面的程序:
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以前顯然也是能夠徹底丟掉本身佔有的棧空間的,緣由與尾遞歸同樣,所以理論上也是能夠進行優化的,而事實上這種優化也一直是程序編譯優化裏的一個常見選項,甚至不少的語言在標準裏就直接要求要對尾調用進行優化,緣由很明顯,尾調用在程序裏是常常出現的,優化它不只能減小棧空間使用,一般也能給程序運行效率帶來比較大的提高。