對普通函數、宏函數、內聯函數的做用機制的探索

     此次咱們來分析的是C/C++程序員常常遇到的問題,如何在普通函數、宏函數、內聯函數之間作取捨,其實它們三者之間並無什麼絕對的你好我差的說法,只要掌握了三者的做用機制的話,結合實際狀況通常都能作出正確的選擇。下面咱們一個個介紹上面的三個方法:ios

一、普通函數程序員

    就和它的名字同樣,它表明着千千萬萬在普通不過的函數,說它普通並非由於它負責的工做很普通,而是相較於宏定義和內聯來講的,這樣的函數有可能存在於類中,那時候咱們叫它成員函數,而若是不在類中,咱們通常都是叫它···函數,因此在這裏我把它們統統叫作普通函數了。這類函數在程序的執行過程當中是如何被識別並調用的呢?咱們如下面的樣例程序來進行分析:
 1 #include <iostream>
 2  
 3 using namespace std;  4  
 5 int foo( int a, int b)  6 {  7     return a + b;  8 }  9  
10 int main() 11 { 12     int x = foo (1, 2); 13  
14     printf("Add = %d\n" , x); 15     cout << "Add = " << x << endl ; 16  
17     return 0; 18 } 19  

 

    首先要明白的是,程序通常有這幾個狀態:預處理階段、編譯階段、連接階段和執行階段,而這類函數須要在後三個階段進行不一樣的操做才能保證程序的正確運行
     
     (1)編譯階段:
     編譯階段開始時,編譯器首先要對函數進行名稱修飾,像上面的foo函數,C++編譯器會將名字翻譯成?foo@@YGHHH@Z(這個根據不一樣的編譯器有不一樣的翻法)。大多數程序在Link的時候判斷函數調用對應的是哪一個函數時,通常是靠函數名稱、參數數目和類型以及返回值來決定調用哪一個函數的,而這樣的命名很是方便連接器識別並作出最佳的選擇
 
     (2)連接階段:
     編譯器在編譯的過程當中是對不一樣的文件獨立編譯的,它們之間的引用狀況編譯器並不瞭解,這時候須要連接器站出來爲各個文件之間的關聯指路。以前編譯器進行的名稱修飾也是爲了連接階段順利進行而提早作準備。在這個階段,foo函數的調用與foo函數的代碼本體相鏈接,保證調用時可以正確的找到foo函數。
 
     (3)執行階段:
     由於在編譯階段中已經對代碼進行了必定的處理,咱們在main中進行的foo(1,2)的調用連接器也已經找到了對應的函數,那麼在執行到那裏的時候,程序會作些什麼?你們都知道程序在運行時會動態的維護一個運行棧來輔助程序的執行,下面咱們來分析一下foo(1,2)這句話執行的時候棧進行了怎樣的變化。首先根據__stdcall(C++的標準函數調用,C用的是__cdecl)的規則,要將foo函數的參數從右向左壓入棧頂,所以該句話的彙編語句爲:
 
1 push 2
2 push 1
3 call foo // 壓入當前EIP(代碼執行指針)後跳轉
 
(下面的內容須要必定的彙編基礎再進行閱讀,目的是爲了以後的某個結果作理論論證,若是讀不懂的話能夠先放一放看看結論,再來慢慢理解這部分)
     再進一步的分析,事實上堆棧不止是放入了這兩個參數,爲了可以更好的控制這個棧,程序使用esp和ebp這兩個指針寄存器來存儲當前堆棧指針和棧頂指針,在函數調用發生時,ebp首先會被壓入棧內用於函數調用完成的恢復,緊接着esp將會賦值給ebp來存儲當前的棧頂,以後在新的函數中,ebp將做爲基址存在。而後是對esp進行一次減法運算,此次減小的值正是局部變量所須要佔用的總空間,其實減法操做就是在申請空間了。函數主要部分運行完畢後將會把運行結果保存在eax中,在這裏須要注意一下,函數返回值在不一樣的操做系統上,咱們先不談這個問題,若是想提早了解翻到這篇文章最後就能夠看到了。保存完返回值結果後,首先要釋放申請的局部變量空間,因此咱們又對esp進行了一次加法運算以釋放空間,如今咱們能夠把ebp的值賦予esp了,同時彈出ebp(這時esp指向的正是以前保存的那個主函數的棧頂指針)恢復以前的epb,最後調用ret返回主函數,提取以前壓入的EIP繼續執行下一句代碼,這樣整個棧在函數調用先後保持了棧平衡,順利完成了調用。這樣一來咱們再看看該函數對應的彙編代碼:
     
 1 pusb ebp // ebp入棧以保存調用前的棧基址,等待函數調用完畢後出棧  2 mov ebp,esp // 將esp給ebp,這時ebp表明着新的一段程序(函數內部)的棧的基址  3 sub esp, Size // Size不固定,表明函數內部的局部變量總大小,目的在於申請局部變量的空間  4 ······ // 局部變量初始化  5 mov eax, [ebp + 8H]   // 將1交給寄存器eax等待運算  6 add eax, [ebp + 0CH] // 將2加在eax上獲得計算結果  7 add esp,Size // 釋放局部變量佔用的空間  8 mov esp,ebp // 函數即將結束,首先恢復esp  9 pop ebp // 經過pop操做將以前保存的ebp再交還給ebp 10 ret                        // 彈出EIP返回主調用函數執行下一句命令

 

 
     
下面是運行棧的狀態圖:
······ (主函數棧)
參數 2
參數 1
EIP
EBP
······ (函數局部變量)
 
  
 
     綜上咱們可以得出一個結論:普通函數在調用的過程當中,須要進行壓棧、出棧等操做,同時還要維護一個運行棧,進行這些操做都是要付出必定時間代價的。若是咱們約定函數體核心程序運行時間爲TC,入棧出棧以及其餘運行棧操做爲TS,那麼TC/TS越大,說明函數工做效率越高,反而若是函數體執行的時間遠遠小於維護棧的時間(即TC/TS -> 0),那麼函數的實際效率會變的至關不樂觀。一旦出現效率不佳的狀況,咱們就能夠考慮用宏函數或內聯函數來進行替換了,由於它們不須要付出函數調用和堆棧操做的代價。
 

 
 
二、宏函數
 
     宏你們都並不陌生,學過C/C++的朋友們大多都有所接觸,雖然許多書籍上都並不推薦你們使用宏定義,主要緣由是考慮到宏替換是徹底忽略語言特性和規則、忽略做用域、忽略類型系統的替換(來自《C++編程規範》)。確實,這種徹底不考慮後果的替換頗有可能帶來很是可怕的後果,因此在這裏提一句,若是能夠的話儘量用const、enum或者是inline代替#define,但這麼一說豈不是就表明着宏是一個很可怕的怪物了麼?不是的,看待事物不能帶着有色眼鏡,宏也有他好用的一面。宏在對於簡單函數上的替換方面就能夠作的很好,C程序員們也是常常用到這個手段的。咱們經過下面這個例子來分析一下宏函數:
 1 #include <iostream>
 2 using namespace std;  3  
 4 #define imax(a,b) ((a) > (b)) ? (a) : (b)
 5  
 6 int main()  7 {  8         int x = imax(1 ,2);  9         printf ("MaxInt = %d\n", imax(1 ,2)); 10         cout << "MaxInt = " << imax(1 ,2); 11         return 0 ; 12 }
     上面這段簡短的樣例程序實現了一個比較最大值並返回的宏函數,那麼這段程序在執行的整個過程當中發生了什麼?宏起做用是在預處理階段。預處理器在運行的過程當中,將與imax(a,b)這樣格式匹配的段落直接替換成咱們定義的格式,也就是((a) > (b)) ? (a) : (b)。在替換的過程當中,預處理器進行的是純文本替換,徹底忽略語言特性和規則、做用域、類型系統(再強調一遍以示重要性)。預處理階段結束後,程序依次進入編譯、連接、執行階段,最終完成執行。
     與普通函數不一樣的是,宏函數在執行過程當中不涉及運行棧的操做和函數調用,實際上就至關於用於維護動態棧的時間TS = 0,這樣一來咱們的效率就是100%了,這對於追求效率的編程人員來講但是一件很不錯的事情,尤爲是在如單片機這樣的領域,硬件機能的限制咱們須要追求儘量的高效率,所以用宏函數替代普通函數提升效率是個不錯的選擇。而高效隨之帶來的反作用也是顯而易見的,若是替換內容過長,會致使整個程序的代碼量激增,只要有一處替換,就會多出一塊代碼,這種複製代碼式的替換若是控制不當會帶來代碼膨脹,佔用更多的空間。除此以外,因程序員宏定義的疏忽致使的一些不容易發現的錯誤也是頗有可能的,畢竟宏替換不會檢查任何合法性,少打一對括號就有可能惹來麻煩,好比,imax(a,b)的宏定義咱們寫成:
     #define imax(a,b) a > b ? (a) : (b)
會是什麼樣子呢?
     考慮這樣一句話:
    int x = 3 + imax(1 ,2);
這句話若是是以前的imax毫無問題··· 可是如今他就很是的神奇了,咱們看看他替換以後會生成什麼:
     int x = 3 + 1 > 2 ? ( 1) : ( 2)
這個結果本該是5,但卻成了2,由於運算符"+"的優先級高於">"和"?:",所以這句話實際上變成了:
      int x = (3 + 1) > 2 ? ( 1 ) : ( 2 )
因此這個結果固然是2了··· 爲了避免讓你們辛辛苦苦的花大量時間去調這樣的bug,建議在使用宏定義時,每一個成員和運算保險起見最好用括號括起來以保證正確的優先級
 
     最後這個代碼其實還有一個問題,cout輸出的那句話你認爲是多少,是2麼?其實這個輸出的結果是0,不要吃驚,咱們來看看程序運行時到底發生了什麼。首先咱們先將替換後的cout語句展開看看:
  cout << "MaxInt = " << ((1) > ( 2)) ? ( 1) : ( 2);
     注意到了麼,cout函數在判斷輸出內容時只識別了?以前的部分,也就是((1 ) > ( 2 ))這一部分了,這個的結果是false,天然就輸出0了,然後面的東西去哪了呢,經過跟蹤這段代碼咱們發現,cout在輸出時由於沒法與任何一個<<重載類型相匹配,所以進入了錯誤處理而不是輸出:
    
 __CLR_OR_THIS_CALL operator void *() const { // test if any stream operation has failed
         return ( fail() ? 0 : (void *)this); }
而若是使用普通函數或內聯函數的話就徹底沒有問題了,這個問題正是由於imax的宏聲明缺乏一個最外層的括號,若是寫成
     #define imax(a,b) (((a) > (b)) ? (a) : (b))
就沒有問題了
 

三、內聯函數
 
     安排內聯函數做爲最後一種類型登場是有必定緣由的,一方面內聯函數有點像宏函數採用替換的方式,另外一方面內聯函數還和普通函數同樣考慮了語言特性、做用域和類型系統等內容,單從這方面來看,內聯函數好像成爲了解決問題的銀彈,它簡直是棒極了,擁有着宏函數和普通函數優勢,是否是巴不得把全部的函數都inline化?若是你真的是這麼想的,但願在你抱着這個念頭開始編程以前先把後面的部分仔細閱讀完,以防止被inline美麗的容貌所誤導(固然並非說inline很糟糕簡直像一個騙子,恰當的使用inline纔是關鍵)。
 
     使用一個工具以前最好了解它,這每每可以讓你更加熟練的使用它創造,而不是被它緊緊拴住動彈不得。接下來咱們來分析一下內聯函數的運行機理:
     
      在編譯階段,和普通函數相似的,內聯函數也要進行名字修飾、合法性檢查等等,但要注意的是,內聯函數在通過檢查後不只會保存函數名稱、參數類型和返回值類型,還會把內聯函數的本體也一併保存起來,在以後的編譯過程當中一旦遇到該函數的調用時首先會檢查調用是否合法,經過編譯器檢查後便直接將函數代碼嵌入在調用出替代調用語句。內聯函數的替換相較於宏函數的替換在這個時候就顯現出它的優點了:
 
          a.      內聯函數的替換是要進行類型檢查的,而宏替換隻是簡單的字符串替換,別的是無論的
          b.      由於宏替換是文本替換,可能致使沒法預料的後果,所以要注意宏內部的計算順序
          c.      宏替換沒法發現編譯錯誤,而內聯函數是真正意義上的函數,一旦有語法錯誤編譯器會報錯
          d.      宏替換錯誤很難調試,由於是文本替換,而內聯函數調試起來就容易得多了
          e.      從編程思路角度上來講,內聯函數通常更加有意義
 
      相比於普通函數,內聯函數直接嵌入代碼這樣的作法也省去了運行棧維護和函數調用的開銷。同時這裏面還有一個好處,編譯器對於一些順序代碼是進行優化的,而咱們不多看到編譯器對有函數調用的部分進行優化。若是咱們使用內聯函數代替了函數調用,也就意味着編譯器能夠對這一部分的代碼進行優化,這對於提高總體運行速度也是有很大的幫助的。
     不過,做爲犧牲,內聯函數也有「反作用」,當你在使用內聯函數時發現境況與下面的內容有些類似時,就該考慮一下是否真的應該使用內聯函數了:
          a.      替換帶來的代碼膨脹是不可避免的,尤爲是函數體比較複雜時,空間代價會至關的大,甚至會超過內聯帶來的收益,所以內聯函數體不宜太長太複雜,所謂複雜          就是不可以包判斷、循環等語句,更復雜的就不用說了
          b.      內聯函數會致使頁面開銷變大,當函數體內容較多時甚至有可能會下降命中率從而致使程序效率下降
          c.      內聯函數會破壞封裝,由於它是直接採用代碼替換,也就意味着能被看到,所以pimpl模式是不可以和inline一塊使用的
          d.      將內聯函數定義在頭文件後,一旦修改了內聯函數,整個頭文件都要從新編譯,在大型程序中這樣的代價可能不小
 
     除去上面的狀況,咱們就能夠考慮使用內聯函數,看看下面這個例子,這是一個比較適合使用內聯的範例:
 
 1 #include <iostream>
 2 using namespace std ;  3  
 4 inline int foo (int a , int b )  5 {  6      return a + b;  7 }  8  
 9 int main() 10 { 11      int x = foo ( 1, 2 ); 12     printf ("Count = %d\n" , x); 13      return 0 ; 14 }

 

     相比於普通函數,內聯函數在函數最開始以inline關鍵字做爲提示。要注意的是,inline並非聲明,而是一種提示,它告訴編譯器這個函數要之內聯的方式進行處理,所以在頭文件的函數定義中,是不須要要添加inline的。在樣例代碼中,foo函數體很是簡單,僅僅是返回加法運算結果,所以像這樣的函數就比較適合inline化了。
     如今對於內聯函數的優缺點和原理也有必定的認識了,咱們再來看看如何構造inline函數,像上面的樣例程序是一種方法,它還有一種等效的方法是這樣:
1 int foo( int a, int b ); 2  
3 inline int foo (int a , int b ) 4 { 5      return a + b; 6 }
     這就是剛纔說的inline特性,它並非聲明而是一種提示,所以在定義時不須要使用inline,就算是在class中定義inline也是同樣的,以下代碼:
 1 class A  2 {  3 public :  4      int foo (int a , int b );  5 };  6  
 7 inline int A ::foo (int a , int b )  8 {  9      return a + b; 10 }
     除此以外,直接在class中實現的類也會被編譯器當作inline函數進行處理,如:
 
1 class A 2 { 3 public : 4      int foo (int a , int b ) 5  { 6          return a + b ; 7  } 8 };

     
  要注意的是,如下狀況就算咱們讓編譯器去生成inline函數,編譯器也會拒絕:
     a.      當咱們顯示的用inline提示,但函數內部使用了諸如判斷語句、循環語句等複雜的表達方式時,編譯器會拒絕函數inline化
     b.      當在class內部直接實現函數時,若是函數內部較爲複雜,編譯器同樣會拒絕函數inline化
     c.      虛函數是不會被inline化的,由於虛函數意味着直到執行時才肯定,而inline函數則是在執行前完成全部工做,這二者是相矛盾的,所以任何爲虛函數inline化的操做編譯器都會拒絕
 
     目前大多數的編譯器都具有了診斷能力,只要編譯器認爲它太複雜,就會堅定的拒絕程序員的請求。另外儘可能不要對構造函數和析構函數進行inline化,雖然看起來它們多是很是簡單,但實際上編譯後最終造成的樣子每每會出乎意料,由於編譯器爲了可以爲class實現各類功能每每會在編譯時向構造和析構函數添加大量的其餘內容。好比說vtbl的構造,繼承的調用,this指針的添加,類成員初始化序列的補充等等(具體內容能夠參考《深度探索C++對象模型》的第五章,構造、析構、拷貝語義學),因此除非你對他們背後實際的樣子瞭如指掌,仍是儘可能避免構造、析構、拷貝函數inline化。
     
          

 
附:函數返回值的處理
 
     函數在返回值是藉助寄存器進行傳遞的,使用哪些寄存器以及怎樣的形式傳回與返回值的類型有關。
     若是返回值類型是32位可承受的,如int、char、short、指針這樣的類型,經過eax寄存器傳遞就行了;若是是64爲可承受的,如_int64這樣的則能夠用edx+eax的方式返回,其中edx保存高32位,eax保存低32位;若是是浮點數返回值類型,如float、double等,將採用一個專用的浮點數寄存器的棧頂返回;若是是返回struct或class,編譯器將會以引用的形式返回該參數,採用eax返回。本文中的foo函數返回值爲int,所以採用的即是eax寄存器返回了。
 
     這裏還有一點值得咱們注意一下,由於函數返回值藉助寄存器而非棧空間,這意味着返回值的代價很低,所以在c89規範中聲明,凡是沒有顯示的聲明函數返回值類型的通通都默認爲int類型的返回值,而在C++的標準中任何函數沒有返回值類型是被報錯的,沒有返回類型就是void,並且在void返回值類型的函數中也是不許許return任何值的,所以爲了規範化,建議函數最好顯示的聲明返回值,而不是放着不寫讓編譯器本身去猜。
相關文章
相關標籤/搜索