Erlang學習:尾遞歸

先來看一個簡單的問題,輸入一個數N,求出1+2+3+...+N。

這個問題可以使用循環解決

  1. sum = 0; 
  2. for(i = 1; i <= N; i++) 
  3.            sum += i; 

但是在函數式編程語言中,變量是不允許修改的,不能使用這樣的循環,只能用遞歸。

先用C語言的遞歸實現求和

  1. int sum(int N) 
  2.         if(N == 1) 
  3.               return 1;    
  4.         else 
  5.               return N + sum(N-1); 

遞歸存在的一個普遍的問題就是棧溢出,尤其是像上面這個程序,N稍微大一點,堆棧就會溢出。看看下面代碼的執行情況。

  1. //iteration 
  2. long long sum1(long long x) 
  3.        long long i,sum=0; 
  4.       for(i = 1; i <= x; i++) 
  5.              sum += i; 
  6.       return sum; 
  7. //recursion 
  8. long long sum2(long long x) 
  9.        if(x == 1) 
  10.              return 1; 
  11.        else 
  12.             return x + sum2(x-1); 
  13. int main() 
  14.         long long x; 
  15.         scanf("%lld",&x); 
  16.         printf("sum=%lld\n",sum1(x)); 
  17.         printf("sum=%lld\n",sum2(x)); 

可以看到,100000時棧還沒溢出,到了1000000就不行了,使用迭代就不存在這個問題了。

既然遞歸存在這樣的問題,而且函數式編程大量使用遞歸,那函數式編程豈不是一點優勢也沒有?

這就需要用到尾遞歸了。

  1. int sum(int N) 
  2.         if(N == 1) 
  3.                return 1; 
  4.         else 
  5.               return N + sum(N-1); 

我們來看一下這個函數,注意return N + sum(N-1)這條語句,假如調用到了sum(88)

執行 return 88 + sum(87),sum(88)需要將88這個值放在棧裏,等sum(87)返回了再使用88+sum(87),因此sum(88)的棧不能被破壞,同樣,87,86,85...,2,都需要保存在棧裏,這樣就容易造成棧的溢出。

現在修改一下上面的代碼

  1. int sum(int N) 
  2.          return sumX(0,N); 
  3. int sumX(int X, int N) 
  4.          if(N == 1) 
  5.                    return X + 1; 
  6.          else 
  7.                   return sumX(N+X, N-1); 

還是以sum(88)爲例,N就等於88,先調用sumX(0,88),然後是sum(88,87)->sum(175,86),這樣我們把上層函數的狀態都傳給下面的函數,因此以前的棧可以破壞掉然後重新使用。這就是傳說中的尾遞歸。不過即使使用了上面的代碼,C語言編譯器也不會爲尾遞歸優化棧。

其實尾遞歸的本質就是,在函數最後,或者是遞歸結束條件,或者是僅調用函數,而不進行其它操作,比如return 1 + sum(x-1),這就不是尾遞歸,因爲調用了sum(x-1)後,又進行了一個加法操作。

在C語言層實現的尾遞歸並不會得到gcc的優化,我們可以手動修改彙編代碼,實現一個真正的尾遞歸。但是要注意,不能優化成迭代了,必須滿足一個條件,在函數裏面調用自己

在sumX執行第一條語句時,棧格局如上圖。

一般的情況,一個函數的頭兩句是

pushl %ebp

movl %esp, %ebp

棧變成了這樣:

爲了實現尾遞歸,我們不再採用這種傳統的函數調用方式。進入sumX後,不對ebp進行壓棧。在裏面再調用sumX時,不傳遞參數了,讓它繼續使用原來的參數。

判斷n是否爲1,如果爲1,將x加1放入eax,返回。如果不爲1,在原地將x加上n,將n-1,然後繼續調用sumX。

  1. sumX: 
  2.        cmpl $1,8(%esp) 
  3.        jne .next 
  4.        #如果n(也就是8(%esp))是1,那麼將x(也就是4(%esp))放入eax中,加1,然後返回 
  5.        movl 4(%esp),%eax 
  6.        addl $1,%eax 
  7.        #返回 
  8.        pushl %ebp 
  9.        movl %esp,%ebp 
  10.        ret 
  11. .next: 
  12.        #將n加到x上 
  13.        movl 8(%esp),%eax 
  14.        addl %eax,4(%esp) 
  15.        #n-1 
  16.        subl $1,8(%esp) 
  17.        #這個call語句不會引起棧的增長 
  18.        call sumX 

上面的代碼存在一個問題,忘記了call時會將EIP壓棧。

這就有問題了。第一次調用sumX時,需要將EIP壓棧,但是第二次及以後就不能壓棧了。用一個ugly的方法解決這個問題,若x=0,那麼就是第一次,EIP需要壓棧,否則在call之後將EIP再從棧裏彈出來。

代碼如下:

  1. sumX: 
  2.      #先判斷x是否爲0,如果是,就將esp回移4個字節 
  3.      cmpl $0,4(%esp) 
  4.      je .fuck 
  5.      addl $4, %esp 
  6. .fuck 
  7.      cmpl $1,8(%esp) 
  8.      jne .next 
  9.      #如果n(也就是8(%esp))是1,那麼將x(也就是4(%esp))放入eax中,加1,然後返回 
  10.      movl 4(%esp),%eax 
  11.      addl $1,%eax 
  12.      #返回 
  13.      pushl %ebp 
  14.      movl %esp,%ebp 
  15.      ret 
  16. .next: 
  17.      #將n加到x上 
  18.      movl 8(%esp),%eax 
  19.      addl %eax,4(%esp) 
  20.      #n-1 
  21.      subl $1,8(%esp) 
  22.      call sumX 

 

 

 老是容易忘

  leave = movl %ebp,%esp  popl %ebp

  ret = popl %eip

 gdb十進制查看內存 x/u $esp + 4

 

在Erlang中,如果你寫的程序是尾遞歸的,那麼編譯器會自動爲你優化。

  1. -module(sum). 
  2. -export([sum1/1,sum2/1]). 
  3.  
  4. sum1(1)->1; 
  5. sum1(N)-> 
  6.         N + sum1(N-1). 
  7.  
  8. sum2(N)-> 
  9.         sumx(0,N). 
  10.  
  11. sumx(X,0)-> 
  12.           X; 
  13. sumx(X,N)-> 
  14.          sumx(N+X,N-1). 

Erlang在遞歸10000000次時都不溢出,可見Erlang的棧設計的比C大多了,這是必須的,因爲它是函數式編程語言。不過說到底,Erlang在普通的計算機上的棧是用堆模擬的,除非在Erlang的專用硬件上。

我用sum1時,內存耗光了,一直在swap。而sum2使用了尾遞歸,很快就計算出來了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章