先來看一個簡單的問題,輸入一個數N,求出1+2+3+...+N。
這個問題可以使用循環解決
- sum = 0;
- for(i = 1; i <= N; i++)
- sum += i;
但是在函數式編程語言中,變量是不允許修改的,不能使用這樣的循環,只能用遞歸。
先用C語言的遞歸實現求和
- int sum(int N)
- {
- if(N == 1)
- return 1;
- else
- return N + sum(N-1);
- }
遞歸存在的一個普遍的問題就是棧溢出,尤其是像上面這個程序,N稍微大一點,堆棧就會溢出。看看下面代碼的執行情況。
- //iteration
- long long sum1(long long x)
- {
- long long i,sum=0;
- for(i = 1; i <= x; i++)
- sum += i;
- return sum;
- }
- //recursion
- long long sum2(long long x)
- {
- if(x == 1)
- return 1;
- else
- return x + sum2(x-1);
- }
- int main()
- {
- long long x;
- scanf("%lld",&x);
- printf("sum=%lld\n",sum1(x));
- printf("sum=%lld\n",sum2(x));
- }
可以看到,100000時棧還沒溢出,到了1000000就不行了,使用迭代就不存在這個問題了。
既然遞歸存在這樣的問題,而且函數式編程大量使用遞歸,那函數式編程豈不是一點優勢也沒有?
這就需要用到尾遞歸了。
- int sum(int N)
- {
- if(N == 1)
- return 1;
- else
- 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,都需要保存在棧裏,這樣就容易造成棧的溢出。
現在修改一下上面的代碼
- int sum(int N)
- {
- return sumX(0,N);
- }
- int sumX(int X, int N)
- {
- if(N == 1)
- return X + 1;
- else
- 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。
- sumX:
- cmpl $1,8(%esp)
- jne .next
- #如果n(也就是8(%esp))是1,那麼將x(也就是4(%esp))放入eax中,加1,然後返回
- movl 4(%esp),%eax
- addl $1,%eax
- #返回
- pushl %ebp
- movl %esp,%ebp
- ret
- .next:
- #將n加到x上
- movl 8(%esp),%eax
- addl %eax,4(%esp)
- #n-1
- subl $1,8(%esp)
- #這個call語句不會引起棧的增長
- call sumX
上面的代碼存在一個問題,忘記了call時會將EIP壓棧。
這就有問題了。第一次調用sumX時,需要將EIP壓棧,但是第二次及以後就不能壓棧了。用一個ugly的方法解決這個問題,若x=0,那麼就是第一次,EIP需要壓棧,否則在call之後將EIP再從棧裏彈出來。
代碼如下:
- sumX:
- #先判斷x是否爲0,如果是,就將esp回移4個字節
- cmpl $0,4(%esp)
- je .fuck
- addl $4, %esp
- .fuck
- cmpl $1,8(%esp)
- jne .next
- #如果n(也就是8(%esp))是1,那麼將x(也就是4(%esp))放入eax中,加1,然後返回
- movl 4(%esp),%eax
- addl $1,%eax
- #返回
- pushl %ebp
- movl %esp,%ebp
- ret
- .next:
- #將n加到x上
- movl 8(%esp),%eax
- addl %eax,4(%esp)
- #n-1
- subl $1,8(%esp)
- call sumX
老是容易忘
leave = movl %ebp,%esp popl %ebp ret = popl %eip gdb十進制查看內存 x/u $esp + 4 |
在Erlang中,如果你寫的程序是尾遞歸的,那麼編譯器會自動爲你優化。
- -module(sum).
- -export([sum1/1,sum2/1]).
- sum1(1)->1;
- sum1(N)->
- N + sum1(N-1).
- sum2(N)->
- sumx(0,N).
- sumx(X,0)->
- X;
- sumx(X,N)->
- sumx(N+X,N-1).
Erlang在遞歸10000000次時都不溢出,可見Erlang的棧設計的比C大多了,這是必須的,因爲它是函數式編程語言。不過說到底,Erlang在普通的計算機上的棧是用堆模擬的,除非在Erlang的專用硬件上。
我用sum1時,內存耗光了,一直在swap。而sum2使用了尾遞歸,很快就計算出來了。