關於函數調用和尾遞歸的一點認識


函數調用

在大多數支持塊結構的程序設計語言都支持函數或者子程序(函數和子程序的區別在於函數有返回值而子程序沒有,在這里我們不區分這兩個概念)。在進行函數調用和從函數返回時通常由一個被稱為控制棧的運行時刻棧進行管理。每一個活躍的函數在控制棧中都會有一個相對應的活動記錄,有時也稱為棧幀。活動記錄存儲着函數調用時傳遞的參數信息和從函數返回時返回值與控制跳轉的信息。

 

函數的活動記錄需要包括下面的信息

  • 控制鏈(control link):指向控制棧中前一個活動記錄的指針;
  • 訪問鏈(access link):指向源程序中最近的外層塊對應的活動記錄,用於維護靜態作用域(本文中不討論);
  • 返回地址:函數調用結束后被執行的第一條指令地址(本文討論中將忽略);
  • 返回結果地址:存放函數返回值位置的地址;
  • 實際參數:函數調用時傳遞的參數值;
  • 局部變量:函數體中聲明的局部變量;
  • 臨時存儲區:在函數執行過程中產生的臨時結果。

不同語言的不同實現中對上述信息的存放順序和存放方式可能不一樣。在這里我們按照上面說明的順序來進行討論。

 

每當一個函數被調用時,就創建一個相對應的活動記錄,並壓入控制棧中;而當從一個函數調用結束時,在控制棧中與該函數對應的活動記錄(即棧頂的活動記錄)就會被彈出控制棧。每個函數所對應的活動記錄的大小是不一致的,而堆棧寄存器每次記錄的都只是棧頂的活動記錄,所以在每個活動記錄中需要一個控制鏈來記錄前一個活動記錄。

 

下面我們通過一個例子來簡單演示一下函數的活動記錄如何被壓棧和彈出棧的。

1 int fact (int n)
2 {
3 if (n == 1) {
4 return 1;
5 }else {
6 return n * fact(n – 1);
7 }
8 }

上面是計算一個正整數的階乘的C語言代碼。現在設想要計算表達式fact(3),導致對fact函數的一次調用,則此時將函數調用fact(3)的活動記錄壓入控制棧中。該活動記錄如圖1所示,其中

圖1 

  • 控制鏈,指向執行函數調用fact(3)之前棧頂的活動記錄;
  • 返回結果地址,指向用於存儲函數調用fact(3)的結果的位置;
  • 實際參數3;
  • 一塊臨時區域,用於存放n>0時表達式fact(n-1)的中間結果。

當這個活動記錄進入棧中之后,計算階乘的代碼開始運行。因為n>0,所以會遞歸調用fact(2),然后遞歸調用fact(1)。因此會產生一系列的活動記錄,如圖2所示。 

圖 2

在這里,返回值地址指針指向上層活動記錄中分配的位置,這樣當從fact(1)返回時,返回值會被存儲在fact(2)的活動記錄中,fact(2)會將該返回值與2相乘返回給fact(3)。

尾遞歸

尾遞歸就是在函數調用之后再沒有其他計算的遞歸調用。尾遞歸調用的返回值可以直接當作包含該尾遞歸調用的函數的返回值。設想函數f和函數g,f和g可能是不同的函數,也可能是相同的函數。如果函數g調用函數f,並且g不作任何修改直接返回f的返回值,則稱g對f的調用是尾遞歸調用。

 

典型的尾遞歸調用的例子是,求兩個正整數的最大公約數的歐幾里得算法,下面是該算法的一個Scheme語言實現:

1 (define (gcd a b)
2 (if (= b 0)
3 a
4 (gcd b (remainder a b))))

其中,函數remainder是計算其第一個參數除於第二個參數所得到的余數。

 

前面我們給出的那個計算階乘的C語言函數則不是一個尾遞歸調用,因為在遞歸調用fact(n-1)的返回值還需要乘以n來得到函數調用fact(n)的返回值。其實,我們可以寫出一個尾遞歸調用的階乘函數,如下所示:

1 int fact_tr(int n, int a)
2 {
3 if (n <= 1) {
4 return a;
5 }else {
6 return fact_tr(n-1, n*a);
7 }
8

尾遞歸的好處在於所有的遞歸調用可以用一個活動記錄來表示,這樣無論遞歸調用的深度有多深,其所占用的內存空間就只是常量的,而不會隨着遞歸深度增加而增加。下面我們將基於函數調用fact_tr(3,1)進行分析,如圖3所示是在采用常規活動記錄處理方式時,函數調用fact_tr(3,1)運行時某一時刻控制棧的一部分內容:這三個活動記錄從上到下分別是函數調用fact_tr(3,1)、fact_tr(2,3)和fact_tr(1,6)的活動記錄。

 

圖 3

可見當函數調用fact_tr(1.6)將結果6返回給函數調用fact_tr(2,3),然后fact_tr(2,3)會直接將其返回給fact_tr(3,1),而函數調用fact_tr(3,1)也會將該結果直接返回給前一個活動記錄。所以,函數調用fact_tr(1,6)可以跳過fact_tr(2,3)和fact_tr(3,1)的活動記錄直接將結果返回。也就是說,此時函數調用fact_tr(2,3)和函數調用fact_tr(3,1)的活動記錄沒有起作用。實際上,當調用fact_tr(2,3)時,函數調用fact_tr(3,1)的活動記錄已經沒作用,所以在壓入fact_tr(2,3)的活動記錄之前,就可將fact_tr(3,1)的活動記錄彈出,實際更好的處理是將fact_tr(3,1)的活動記錄當作fact_tr(2,3)的活動記錄。同理,在調用fact_tr(1,6)時,將fact_tr(2,3)的活動記錄當作fact_tr(1,6)的活動記錄。

 

現在很多語言的編譯器都采用了一些優化技術,其中包括將尾遞歸轉化為一段循環代碼來進行處理,這樣就可以避免很多函數調用所消耗的時間和空間,而只是采用修改某些變量的值來實現。例如,在scheme語言的標准中要求scheme的實現必須要支持尾遞歸機制。

 

其實,很多非尾遞歸的函數調用都可以轉化為尾遞歸來實現,不過需要在函數參數傳遞中添加一些參數來記錄某些狀態信息,例如上面計算階乘的非尾遞歸函數fact轉化為尾遞歸函數fact_tr需要在函數參數中加一個累積器。如果編譯器采用了尾遞歸優化技術,通過這種轉化可以在一定程度上實現效率的提升,但同時會增加編程的復雜性而降低程序的易讀性。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM