內聯函數的優缺點

在C++語言的設計中,內聯函數的引入可以說完全是爲了性能的考慮。因此在編寫對性能要求比較高的C++程序時,非常有必要仔細考量內聯函數的使用。

所謂“內 聯”,即將被調用函數的函數體代碼直接地整個插入到該函數被調用處,而不是通過call語句進行。當然,編譯器在真正進行“內聯”時,因爲考慮到被內聯函 數的傳入參數、自己的局部變量,以及返回值的因素,不僅僅只是進行簡單的代碼拷貝,還需要做很多細緻的工作,但大致思路如此。

開發人員可以有兩種方式告訴編譯器需要內聯哪些類成員函數,一種是在類的定義體外;一種是在類的定義體內。

(1)當在類的定義體外時,需要在該成員函數的定義前面加“inline”關鍵字,顯式地告訴編譯器該函數在調用時需要“內聯”處理,如:

class Student

{

public:

        String GetName();

        int     GetAge();

        void        SetAge(int ag);

        ……

private:

        String  name;

        int     age;

        ……

};

inline String GetName()

{

        return name;

}

inline int GetAge()

{

        return age;

}

inline void SetAge(int ag)

{

        age = ag;

}

(2)當在類的定義體內且聲明該成員函數時,同時提供該成員函數的實現體。此時,“inline”關鍵字並不是必需的,如:

class Student

{

public:

        String GetName()       { return name; }

        int     GetAge()        { return age; }

        void        SetAge(int ag) { age = ag; }

        ……

private:

        String  name;

        int     age;

        ……

};

當普通函數(非類成員函數)需要被內聯時,則只需要在函數的定義時前面加上“inline”關鍵字,如:

    inline int DoSomeMagic(int a, int b)

{

        return a * 13 + b % 4 + 3;

}

因爲C++是以 “編譯單元”爲單位編譯的,而一個編譯單元往往大致等於一個“.cpp”文件。在實際編譯前,預處理器會將“#include”的各頭文件的內容(可能會 有遞歸頭文件展開)完整地拷貝到cpp文件對應位置處(另外還會進行宏展開等操作)。預處理器處理後,編譯真正開始。一旦C++編譯器開始編譯,它不會意 識到其他cpp文件的存在。因此並不會參考其他cpp文件的內容信息。聯想到內聯的工作是由編譯器完成的,且內聯的意思是將被調用內聯函數的函數體代碼直 接代替對該內聯函數的調用。這也就意味着,在編譯某個編譯單元時,如果該編譯單元會調用到某個內聯函數,那麼該內聯函數的函數定義(即函數體)必須也包含 在該編譯單元內。因爲編譯器使用內聯函數體代碼替代內聯函數調用時,必須知道該內聯函數的函數體代碼,而且不能通過參考其他編譯單元信息來獲得這一信息。

如果有多 個編譯單元會調用到某同一個內聯函數,C++規範要求在這多個編譯單元中該內聯函數的定義必須是完全一致的,這就是“ODR”(one- definition rule)原則。考慮到代碼的可維護性,最好將內聯函數的定義放在一個頭文件中,用到該內聯函數的各個編譯單元只需#include該頭文件即可。進一步 考慮,如果該內聯函數是一個類的成員函數,這個頭文件正好可以是該成員函數所屬類的聲明所在的頭文件。這樣看來,類成員內聯函數的兩種聲明可以看成是幾乎 一樣的,雖然一個是在類外,一個在類內。但是兩個都在同一個頭文件中,編譯器都能在#include該頭文件後直接取得內聯函數的函數體代碼。討論完如何 聲明一個內聯函數,來查看編譯器如何內聯的。繼續上面的例子,假設有個foo函數:

#include "student.h"

...

void foo()

{

        ...

        Student abc;

        abc.SetAge(12);

        cout << abc.GetAge();

        ...

}

foo函數進入 foo函數時,從其棧幀中開闢了放置abc對象的空間。進入函數體後,首先對該處空間執行Student的默認構造函數構造abc對象。然後將常數12壓 棧,調用abc的SetAge函數(開闢SetAge函數自己的棧幀,返回時回退銷燬此棧幀)。緊跟着執行abc的GetAge函數,並將返回值壓棧。最 後調用cout的<<操作符操作壓棧的結果,即輸出。

內聯後大致如下:

#include "student.h"

...

void foo()

{

        ...

        Student abc;

        {

            abc.age = 12;

        }

        int tmp = abc.age;

        cout << tmp;

        ...

}

這時,函數調用時的參數壓棧、棧幀開闢與銷燬等操作不再需要,而且在結合這些代碼後,編譯器能進一步優化爲如下結果:

#include "student.h"

...

void foo()

{

        ...

        cout << 12;

        ...

}

這顯然是最好的 優化結果;相反,考慮原始版本。如果SetAge/GetAge沒有被內聯,因爲非內聯函數一般不會在頭文件中定義,這兩個函數可能在這個編譯單元之外的 其他編譯單元中定義。即foo函數所在編譯單元看不到SetAge/GetAge,不知道函數體代碼信息,那麼編譯器傳入12給SetAge,然後用 GetAge輸出。在這一過程中,編譯器不能確信最後GetAge的輸出。因爲編譯這個編譯單元時,不知道這兩個函數的函數體代碼,因而也就不能做出最終 版本的優化。

從上述分析中,可以看到使用內聯函數至少有如下兩個優點。

(1)減少因爲函數調用引起開銷,主要是參數壓棧、棧幀開闢與回收,以及寄存器保存與恢復等。

(2)內聯後編譯器在處理調用內聯函數的函數(如上例中的foo()函數)時,因爲可供分析的代碼更多,因此它能做的優化更深入徹底。前一條優點對於開發人員來說往往更顯而易見一些,但往往這條優點對最終代碼的優化可能貢獻更大。

這時,有必要簡單介紹函數調用時都需要執行哪些操作,這樣可以幫助分析一些函數調用相關的問題。假設下面代碼:

void foo()

{

        ...

        i = func(a, b, c);                                          ①

        ...                                                         ②

}

調用者(這裏是foo)在調用前需要執行如下操作。

(1)參數壓棧:這裏是a、b和c。壓棧時一般都是按照逆序,因此是c->b->c。如果a、b和c有對象,則需要先進行拷貝構造(前面章節已經討論)。

(2)保存返回地址:即函數調用結束返回後接着執行的語句的地址,這裏是②處語句的地址。

(3)保存維護foo函數棧幀信息的寄存器內容:如SP(堆棧指針)和FP(棧幀指針)等。到底保存哪些寄存器與平臺相關,但是每個平臺肯定都會有對應的寄存器。

(4)保 存一些通用寄存器的內容:因爲有些通用寄存器會被所有函數用到,所以在foo調用func之前,這些寄存器可能已經放置了對foo有用的信息。這些寄存器 在進入func函數體內執行時可能會被func用到,從而被覆寫。因此foo在調用func前保存一份這些通用寄存器的內容,這樣在func返回後可以恢 復它們。

接着調用func函數,它首先通過移動棧指針來分配所有在其內部聲明的局部變量所需的空間,然後執行其函數體內的代碼等。

最後當func執行完畢,函數返回時,foo函數還需要執行如下善後處理。

(1)恢復通用寄存器的值。

(2)恢復保存foo函數棧幀信息的那些寄存器的值。

(3)通過移動棧指針,銷燬func函數的棧幀,

(4)將保存的返回地址出棧,並賦給IP寄存器。

(5)通過移動棧指針,回收傳給func函數的參數所佔用的空間。

在前面章節中已經討論,如果傳入參數和返回值爲對象時,還會涉及對象的構造與析構,函數調用的開銷就會更大。尤其是當傳入對象和返回對象是複雜的大對象時,更是如此。

因爲函數調用的準備與善後工作最終都是由機器指令完成的,假設一個函數之前的準備工作與之後的善後工作的指令所需的空間爲SS,執行這些代碼所需的時間爲TS,現在可以更細緻地從空間與時間兩個方面來分析內聯的效果。

(1)在 空間上,一般印象是不採用內聯,被調用函數的代碼只有一份,調用它的地方使用call語句引用即可。而採用內聯後,該函數的代碼在所有調用其處都有一份拷 貝,因此最後總的代碼大小比採用內聯前要大。但事實不總是這樣的,如果一個函數a的體代碼大小爲AS,假設a函數在整個程序中被調用了n次,不採用內聯 時,對a的調用只有準備工作與善後工作兩處會增加最後的代碼量開銷,即a函數相關的代碼大小爲:n * SS + AS。採用內聯後,在各處調用點都需要將其函數體代碼展開,即a函數相關的代碼大小爲n * AS。這樣比較二者的大小,即比較(n * SS + AS)與(n*AS)的大小。考慮到n一般次數很多時,可以簡化成比較SS與AS的大小。這樣可以得出大致結論,如果被內聯函數自己的函數體代碼量比因爲 函數調用的準備與善後工作引入的代碼量大,內聯後程序的代碼量會變大;相反,當被內聯函數的函數體代碼量比因爲函數調用的準備與善後工作引入的代碼量小, 內聯後程序的代碼量會變小。這裏還沒有考慮內聯的後續情況,即編譯器可能因爲獲得的信息更多,從而對調用函數的優化做得更深入和徹底,致使最終的代碼量變 得更小。

(2)在 時間上,一般而言,每處調用都不再需要做函數調用的準備與善後工作。另外內聯後,編譯器在做優化時,看到的是調用函數與被調用函數連成的一大塊代碼。即獲 得的代碼信息更多,此時它對調用函數的優化可以做得更好。最後還有一個很重要的因素,即內聯後調用函數體內需要執行的代碼是相鄰的,其執行的代碼都在同一 個頁面或連續的頁面中。如果沒有內聯,執行到被調用函數時,需要跳到包含被調用函數的內存頁面中執行,而被調用函數所屬的頁面極有可能當時不在物理內存 中。這意味着,內聯後可以降低“缺頁”的機率,知道減少“缺頁”次數的效果遠比減少一些代碼量執行的效果。另外即使被調用函數所在頁面可能也在內存中,但 是因爲與調用函數在空間上相隔甚遠,所以可能會引起“cache miss”,從而降低執行速度。因此總的來說,內聯後程序的執行時間會比沒有內聯要少。即程序的速度更快,這也是因爲內聯後代碼的空間 “locality”特性提高了。但正如上面分析空間影響時提到的,當AS遠大於SS,且n非常大時,最終程序的大小會比沒有內聯時要大很多。代碼量大意 味着用來存放代碼的內存頁也會更多,這樣因爲執行代碼而引起的“缺頁”也會相應增多。如果這樣,最終程序的執行時間可能會因爲大量的“缺頁”而變得更多, 即程序的速度變慢。這也是爲什麼很多編譯器對於函數體代碼很多的函數,會拒絕對其進行內聯的請求。即忽略“inline”關鍵字,而對如同普通函數那樣編 譯。

綜合上面的分析,在採用內聯時需要內聯函數的特徵。比如該函數自己的函數體代碼量,以及程序執行時可能被調用的次數等。當然,判斷內聯效果的最終和最有效的方法還是對程序的大小和執行時間進行實際測量,然後根據測量結果來決定是否應該採用內聯,以及對哪些函數進行內聯。

如下根據內聯的本質來討論與其相關的一些其他特點。

如前所 述,因爲調用內聯函數的編譯單元必須有內聯函數的函數體代碼信息。又因爲ODR規則和考慮到代碼的可維護性,所以一般將內聯函數的定義放在一個頭文件中, 然後在每個調用該內聯函數的編譯單元中#include該頭文件。現在考慮這種情況,即在一個大型程序中,某個內聯函數因爲非常通用,而被大多數編譯單元 用到對該內聯函數的一個修改,就會引起所有用到它的編譯單元的重新編譯。對於一個真正的大型程序,重新編譯大部分編譯單元往往意味着大量的編譯時間。因此 內聯最好在開發的後期引入,以避免可能不必要的大量編譯時間的浪費。

再考慮這 種情況,如果某開發小組在開發中用到了第三方提供的程序庫,而這些程序庫中包含一些內聯函數。因爲該開發小組的代碼中在用到第三方提供的內聯函數處,都是 將該內聯函數的函數體代碼拷貝到調用處,即該開發小組的代碼中包含了第三方提供代碼的“實現”。假設這個第三方單位在下一個版本中修改了某些內聯函數的定 義,那麼雖然這個第三方單位並沒有修改任何函數的對外接口,而只是修改了實現,該開發小組要想利用這個新的版本,仍然需要重新編譯。考慮到可能該開發小組 的程序已經發布,那麼這種重新編譯的成本會相當高;相反,如果沒有內聯,並且仍然只是修改實現,那麼該開發小組不必重新編譯即可利用新的版本。

因爲內聯的本質就是用函數體代碼代替對該函數的調用,所以考慮遞歸函數,如:

[inline] int foo(int n)

{

        ...

        return foo(n-1);

}

如果編譯器編譯某個調用此函數的編譯單元,如:

void func()

{

        ...

        int m = foo(n);

        ...

}

考慮如下兩種情況。

(1)如果在編譯該編譯單元且調用foo時,提供的參數n不能知道其實際值,則編譯器無法知道對foo函數體進行多少次代替。在這種情況下,編譯器會拒絕對foo函數進行內聯。

(2)如果在編譯該編譯單元且調用foo時,提供的參數n能夠知道其實際值,則編譯器可能會視n值的大小來決定是否對foo函數進行內聯。因爲如果n很大,內聯展開可能會使最終程序的大小變得很大。

如前所 述,因爲內聯函數是編譯期行爲,而虛擬函數是執行期行爲,因此編譯器一般會拒絕對虛擬函數進行內聯的請求。但是事情總有例外,內聯函數的本質是編譯器編譯 調用某函數時,將其函數體代碼代替call調用,即內聯的條件是編譯器能夠知道該處函數調用的函數體。而虛擬函數不能夠被內聯,也是因爲在編譯時一般來說 編譯器無法知道該虛擬函數到底是哪一個版本,即無法確定其函數體。但是在兩種情況下,編譯器是能夠知道虛擬函數調用的真實版本的,因此虛擬函數可以被內 聯。

其一是通過對象,而不是指向對象的指針或者對象的引用調用虛擬函數,這時編譯器在編譯期就已經知道對象的確切類型。因此會直接調用確定的某虛擬函數實現版本,而不會產生“動態綁定”行爲的代碼。

其二是雖 然是通過對象指針或者對象引用調用虛擬函數,但是編譯時編譯器能知道該指針或引用對應到的對象的確切類型。比如在產生的新對象時做的指針賦值或引用初始 化,發生在於通過該指針或引用調用虛擬函數同一個編譯單元並且二者之間該指針沒有被改變賦值使其指向到其他不能確切知道類型的對象(因爲引用不能修改綁 定,因此無此之虞)。此時編譯器也不會產生動態綁定的代碼,而是直接調用該確定類型的虛擬函數實現版本。

在這兩種情況下,編譯器能夠將此虛擬函數內聯化,如:

inline virtual int x::y (char* a)

{

    ...

}

void z (char* b)

{

    x_base* x_pointer = new x(some_arguments_maybe);

    x x_instance(maybe_some_more_arguments);

    x_pointer->y(b);

    x_instance.y(b);

當然在實際開發中,通過這兩種方式調用虛擬函數時應該非常少,因爲虛擬函數的語義是“通過基類指針或引用調用,到真正運行時才決定調用哪個版本”。

從上面的 分析中已經看到,編譯器並不總是尊重“inline”關鍵字。即使某個函數用“inline”關鍵字修飾,並不能夠保證該函數在編譯時真正被內聯處理。因 此與register關鍵字性質類似,inline僅僅是給編譯器的一個“建議”,編譯器完全可以視實際情況而忽略之。

另外從內 聯,即用函數體代碼替代對該函數的調用這一本質看,它與C語言中的函數宏(macro)極其相似,但是它們之間也有本質的區別。即內聯是編譯期行爲,宏是 預處理期行爲,其替代展開由預處理器來做。也就是說編譯器看不到宏,更不可能處理宏。另外宏的參數在其宏體內出現兩次或兩次以上時經常會產生副作用,尤其 是當在宏體內對參數進行++或--操作時,而內聯不會。還有,預處理器不會也不能對宏的參數進行類型檢查。而內聯因爲是編譯器處理的,因此會對內聯函數的 參數進行類型檢查,這對於寫出正確且魯棒的程序,是一個很大的優勢。最後,宏肯定會被展開,而用inline關鍵字修飾的函數不一定會被內聯展開。

最後順帶提及,一個程序的惟一入口main()函數肯定不會被內聯化。另外,編譯器合成的默認構造函數、拷貝構造函數、析構函數,以及賦值運算符一般都會被內聯化。


轉自:http://blog.sina.com.cn/s/blog_4a0e545d01000c4e.html


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