1. 程式人生 > >說說尾遞迴(轉載) 說說尾遞迴

說說尾遞迴(轉載) 說說尾遞迴

轉載:https://www.cnblogs.com/catch/p/3495450.html

說說尾遞迴

微博上看到有人在討論尾遞迴,想起以前曾看過老趙寫的一篇相關的部落格,介紹的比較詳細了,相信很多人都看過,我也在下面留了言,但挑了個刺,表示文章在關鍵點上一帶而過了,老趙自然是懂的,但看的人如果不深入思考,未必真正的明白,下面我說說我的理解。

什麼是尾遞迴

什麼是尾遞迴呢?(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之前顯然也是可以完全丟掉自己佔有的棧空間的,原因與尾遞迴一樣,因此理論上也是可以進行優化的,而事實上這種優化也一直是程式編譯優化裡的一個常見選項,甚至很多的語言在標準裡就直接要求要對尾呼叫進行優化,原因很明顯,尾呼叫在程式裡是經常出現的,優化它不僅能減少棧空間使用,通常也能給程式執行效率帶來比較大的提升。 

微博上看到有人在討論尾遞迴,想起以前曾看過老趙寫的一篇相關的部落格,介紹的比較詳細了,相信很多人都看過,我也在下面留了言,但挑了個刺,表示文章在關鍵點上一帶而過了,老趙自然是懂的,但看的人如果不深入思考,未必真正的明白,下面我說說我的理解。

什麼是尾遞迴

什麼是尾遞迴呢?(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之前顯然也是可以完全丟掉自己佔有的棧空間的,原因與尾遞迴一樣,因此理論上也是可以進行優化的,而事實上這種優化也一直是程式編譯優化裡的一個常見選項,甚至很多的語言在標準裡就直接要求要對尾呼叫進行優化,原因很明顯,尾呼叫在程式裡是經常出現的,優化它不僅能減少棧空間使用,通常也能給程式執行效率帶來比較大的提升。