深入探究內聯函數

 

內聯函數——多麼振奮人心的一項發明!它們看上去與函數很相像,它們擁有與函數類似的行爲,它們要比宏(參見第 2 條)好用的多,同時你在調用它們時帶來的開銷比一般函數小得多。可謂“內聯在手,別無他求。” <?xml:namespace prefix = o ns = "urn:schemas-microsoft-com:office:office" />

你得到的遠遠比你想象的要多,因爲節約函數調用的開銷僅僅是冰山一角。編譯器優化通常是針對那些沒有函數調用的代碼,因此當你編寫內聯函數時,編譯器就會針對函數體的上下文進行優化工作。然而大多數編譯器都不會針對“外聯”函數調用進行優化。

然而,在你的編程生涯中,“沒有免費的午餐”這句生活哲言同樣奏效,內聯函數不會倖免。內聯函數背後蘊含的理念是:用代碼本體來取代每次函數調用,這樣做很可能會是目標代碼的體積增大不少,這一點並不是非要統計學博士才能看得清。對於內存空間有限的機器而言,過分熱衷於使用內聯則會造成函數佔用過多的空間。即使在虛擬內存中,那些冗餘的內聯代碼也會帶來不少無謂的分頁,從而使緩存讀取命中率降低,最終帶來性能的犧牲。

另一方面,如果一個內聯函數體非常的短,那麼爲函數體所生成代碼的體積就會比爲函數調用生成的代碼小一些。此時,內聯函數才真正做到了減小目標代碼和提高緩存讀取命中率的目的。

我們要時刻保持清醒, Inline 是對編譯器的一次請求,而不是一條命令。這種請求可以顯式提出也可以隱式提出。隱式請求的途徑就是:在類定義的內部定義函數:

class Person {

public:

  ...

  int age() const { return theAge; }// 隱式內聯請求 :

  ...                              // 年齡 age 在類定義中做出定義

 

private:

  int theAge;

};

這樣的函數通常是成員函數,但是類中定義的函數也可以是友元(參見第 46 條),如果函數是友元,那麼也應隱式將它們定義爲內聯函數。

顯式聲明內聯函數的方法爲:在函數定義之前添加 inline 關鍵字。比如說,下面是標準 max 模板(來自 <algorithm> )通常的定義方式:

template<typename T>            // 顯式內聯請求:

inline const T& std::max(const T& a, const T& b)

{ return a < b ? b : a; }       // std::max 的前邊添加 ”inline”

max 是一個模板這一事實,讓我們不免得出這樣的推論:內聯函數和模 板都應該在頭文件中定義。這就使一些程序員做出“函數模板必須爲內聯函數”的論斷。這一結論不僅不合法,而且也存在潛在的害處,所以這裏我們還是要大略的瞭解一下。

由於大多數構建環境都是在編譯過程中進行內聯,因此內聯函數一般情況下都應該定義在頭文件中。編譯器必須首先了解函數的大致情況,以便於用所調用函數體來代替這次函數調用。(一些構建環境在連接過程中進行內聯,還有個別基於 .NET 通用語言基礎結構( CLI )的託管環境甚至是在運行時進行內聯。這樣的環境僅僅屬於例外,而不是守則。在大多數 C++ 程序中,內聯是一個編譯時行爲。)

模板通常保存在頭文件中,但是編譯器還需要了解模板的大致情形,以便於在用到時進行正確的實例化。(然而,這並不是一成不變的。一些構建環境在連接時進行模板實例化。但是編譯時實例化纔是更通用的方式。)

模板實例化相對於內聯是獨立的。如果你正在編寫一個模板,而你又確信由這個模板所實例化出的所有函數都應該是內聯的,那麼這個模板就應該添 inline 關鍵字;這也就是上文中 std::max 現的做法。但是如果你正在編寫的模板並不需要實例化內聯函數,那麼就不需要聲明內聯模板(無論是顯式還是隱式)。內聯也是有開銷的,不假思索就引入內聯的開銷的做法並不明智。我們已經介紹過了內聯是如何使代碼膨脹起來的(對於模板的作者而言,還應該做更周密的考慮——參見第 44 條),但是內聯還會帶來其他的開銷,這就是下文中我們將要討論的問題。

inline 是對編譯器的一次請求,但編譯器可能會忽略它。在我們的討論開 始之前,我們首先要弄清這一點。大多數編譯器如果認爲當前的函數過於複雜(比如包括循環或遞歸的函數),或者這個函數是虛函數(即使是最平常的虛函數調用),就會拒絕將其內聯。後一個結論很好理解。因爲 virtual 意味着“等到運行時再指出要調用哪個程序,”而 inline 意味着“在執行程序之前,使用要調用的函數來代替這次調用。”如果編譯器不知道要調用哪個函數,那麼它們拒絕內聯函數體的做法就無可厚非了。

綜上所述,我們得出下面的結論:一個給定的函數是否得到內聯,取決於你正在使用的構建環境——主要是編譯器。幸運的是,大多數編譯器擁有診斷機制,如果編譯器在內聯函數時失敗了,那麼它們將會做出警告(參見第 53 條)。

有些時候,即使編譯器認爲某個函數非常適合進行內聯,可是還是會爲它提供一個函數體。舉例說,如果你的程序要取得某個內聯函數的地址,那麼編譯器必須用典型的方法爲其創建一個外聯的函數體。那麼編譯器又怎樣讓一個指針去指向一個不存在的函數呢?再加上編譯器一般不會通過對函數指針的調用進行內聯這一事實,更能肯定這一結論:對於一個內聯函數的調用是否應該得到內聯,取決於這一調用是如何進行的:

inline void f() {...}  // 假設編譯器樂意於將 f 的調用進行內聯

 

void (*pf)() = f;      // pf 指向 f

 

...

 

f();                   // 此調用將被內聯,因爲這是一次“正常”的調用

 

pf();                     // 此調用很可能不會被內聯,

                       // 因爲它是通過一個函數指針進行的

即使你從未使用函數指針,未得到內聯的函數依然“陰魂不散”,這是因爲需求函數指針的不僅僅是程序員。比如,編譯器在爲對象的數組進行構造或析構時,也會生成構造函數和析構函數的外聯副本,從而使它們可以得到這些函數的指針以便使用。

實際上,爲構造函數和析構函數進行內聯通常不是一個好的選擇,這兩者甚至不如一些隨意挑選的“選手”。請看下面示例 Derived 類的構 造函數:

class Base {

public:

 ...

 

private:

   std::string bm1, bm2;     // 基類成員 1 2

};

 

class Derived: public Base {

public:

  Derived() {}                // 派生類的構造函數爲空 還有別的可能 ?

 

  ...

 

private:

  std::string dm1, dm2, dm3;// 派生類成員 1–3

};

乍看上去,將這個構造函數進行內聯再適合不過了,因爲它不包含任何代碼。其實你的眼睛欺騙了你。

C++ 對於在創建和銷燬對象的過程中發生的事件進行了多方面的保證。比如,當你使用 new 時,你動態創建的對象的構造函數就會自動將其初始化;當你使用 delete 時,將調用相關的析構函數。當你創建一個對象時。每個基類和該對象中的每個數據成員將自動得到構造,在銷燬這個對象時,針對兩者的析構過程將會自動進行。如果在對象的構造過程中有異常拋出,那麼對象中已經得到構造的部分將統統被自動銷燬。在所有這些場景中, C++ 告訴你什麼一定會發生,但它沒有說明如何發生。這一點取決於編譯器的實現者,但是必須要清楚的一點是,這些事情並不是自發的。你必須要在程序中添加一些代碼來實現它們。這些代碼一定存在於某處,它們由編譯器代勞,用於在編譯過程中插入你的程序中。一些時候它們就存在於構造函數和析構函數中,所以,對於上文中 Derived 的空構造函數,我們可以將具體實現中生成的代碼等價看作:

Derived::Derived()                  // Derived 空構造函數的抽象實現

{

 

  Base::Base();                        // 初始化 Base 部分

 

  try { dm1.std::string::string(); }   // 嘗試構造 dm1

 catch (...) {                        // 如果拋出異常 ,

   Base::~Base();                     // 銷燬基類部分 ,

   throw;                             // 並且傳播該異常

  }

 

  try { dm2.std::string::string(); }   // 嘗試構造 dm2

 catch(...) {                         // 如果拋出異常 ,

    dm1.std::string::~string();        // 銷燬 dm1,

   Base::~Base();                     // 銷燬基類部分 ,

    throw;                             // 並且傳播該異常

 }

 

 try { dm3.std::string::string(); }  // 嘗試構造 dm3

 catch(...) {                         // 如果拋出異常 ,

   dm2.std::string::~string();        // 銷燬 dm2,

   dm1.std::string::~string();        // 銷燬 dm1,

   Base::~Base();                     // 銷燬基類部分 ,

   throw;                             // 並且傳播該異常

 }

}

這段代碼並不能完全真實反映出編譯器所做的事情,因爲真實的編譯器採用的做法更加複雜。然而,上面的代碼可以較爲精確地反映出 Derived 的“空”構造函數必須要提供的內容。無論編譯器處理異常的實現方式多麼複雜, Derived 的構造函數必須至少爲其數據成員和基類調用構造函數,這些調用(可能就是內聯的)會使 Derived 顯得不那麼適合進行內聯。

一推理過程對於 Base 的構造函數同樣適用,因此如果將 Base 內聯,所有添加進其中的代碼同樣也會添加進 Derived 的構造函數中(通過 Derived 構造函數調用 Base 構造函數的過程)。同時,如果 string 的構造函數恰巧被內聯了,那麼 Derived 的構造函數將爲其複製出五份副本,分別對應 Derived 對象中包含的五個字符串(兩個繼承而來,另外三個系對象本身包括)。現在,“ Derived 的構造函數是否應該內聯不是一個純機械化問題”就很容易理解了。對於 Derived 的析構函數也一樣,你必須親自關注 Derived 的構造函數初始化的對象是否全部恰當的得到銷燬,這一點機器無法代替。

庫設計者必須估算出將函數內聯所帶來的影響,因爲你根本無法爲庫中客戶端程序員可見的內聯函數提供底層的升級。換句話說,如果 f 是庫中的一個內聯函數,那麼庫的客戶端程序員就會將 f 的函數體編譯進他們的程序中。隨後,如果一個庫實現者修改了 f 的內容,那麼所有曾經使用過 f 的客戶端程序員必須要重新編譯他們的代碼。這一點是我們所不希望看到的。另一個角度講,如果 f 不是內聯函數,那麼修改 f 只需要客戶端程序員重新連接一下就可以了。這樣要比重新編譯減少很多繁雜的工作,並且,如果庫中需要使用的函數是動態鏈接的,那麼它對於客戶端程序員就是完全透明的。

我們的目標是開發優質的程序,因此要將這些重要問題牢記在心。但是以編寫代碼實際操作的角度來說,這一個事實將淹沒一切:大多數調試人員面對內聯函數時會遇到麻煩。這並不會令人意外,因爲你無法爲一個尚不存在的函數設定一個跟蹤點。一些構建環境試圖支持內聯函數的調試,但是幾乎都失敗了,大多數環境都是在調試過程中直接禁止內聯。

對於“哪個函數應該聲明爲 inline 而哪些不應該”這一問題,我們可以由上文中引出一個邏輯上的策略。起初,不要內聯任何內容,或者僅挑選出那些不得不內聯的函數(參見第 46 條)或者那些確實是很細小的程序(比如本節開篇處出現的 Person::age )進行內聯。謹慎引入內聯,你就爲調試工作提供了方便,但是你仍然要爲內聯擺正位置:它屬於手工的優化操作。不要忘記 80-20 經驗決定主義原則:一個典型的程序將花去 80% 的時間僅僅運行 20% 的代碼。這是一個非常重要的原則,因爲它時時刻刻提醒我們,軟件開發者的目標是:找出你的代碼中 20% 的這部分進行優化,從而從整體上提高程序的性能。你可以花費很長的時間進行內聯、修改函數等等,但如果你沒有鎖定正確的目標,那麼你做再多的努力也是徒勞。

銘記在心

l  僅僅對小型的、調用頻率高的程序進行內聯。這將簡化你的調試操作,爲底層更新提供方便,降低潛在的代碼膨脹發生的可能,並且可以讓程序獲得更高的速度。

l  不要將模板聲明爲 inline 的,因爲它們一般在頭文件中出現。

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