C++箴言:理解inline化的介入和排除(轉)

ba發表於2007-08-15
C++箴言:理解inline化的介入和排除(轉)[@more@]inline 函式——多麼棒的主意啊!它們看起來像函式,它們產生的效果也像函式,它們在各方面都比宏好得太多太多,而你卻可以在呼叫它們時不招致函式呼叫的成本。你還有什麼更多的要求呢?

  實際上你得到的可能比你想的更多,因為避免函式呼叫的成本只是故事的一部分。在典型情況下,編譯器的最佳化是為了一段連續的沒有函式呼叫的程式碼設計的,所以當你 inline 化一個函式,你可能就使得編譯器能夠對函式體實行上下文相關的特殊最佳化。大多數編譯器都不會對 "outlined" 函式呼叫實行這樣的最佳化。

  然而,在程式設計中,就像在生活中,沒有免費午餐,而 inline 函式也不例外。一個 inline 函式背後的思想是用函式本體代替每一處對這個函式的呼叫,而且不必拿著統計表中的 Ph.D. 就可以看出這樣可能會增加你的目的碼的大小。在有限記憶體的機器上,過分熱衷於 inline 化會使得程式對於可用空間來說過於龐大。即使使用了虛擬記憶體,inline 引起的程式碼膨脹也會導致附加的分頁排程,減少指令快取命中率,以及隨之而來的效能損失。

  在另一方面,如果一個 inline 函式本體很短,為函式本體生成的程式碼可能比為一個函式呼叫生成的程式碼還要小。如果是這種情況,inline 化這個函式可以實際上導致更小的目的碼和更高的指令快取命中率! 記住,inline 是向編譯器發出的一個請求,而不是一個命令。這個請求能夠以顯式的或隱式的方式提出。隱式的方法就是在一個類定義的內部定義一個函式:

class Person {
 public:
  ...
  int age() const { return theAge; } // an implicit inline request: age is
  ... // defined in a class definition

 private:
  int theAge;
};

  這樣的函式通常是成員函式,不過我們知道友元函式也能被定義在類的內部,如果它們在那裡,它們也被隱式地宣告為 inline。

  顯式的宣告一個 inline 函式的方法是在它的宣告之前加上 inline 關鍵字。例如,以下就是標準 max 模板(來自 )經常用到的的實現方法:

template // an explicit inline
inline const T& std::max(const T& a, const T& b) // request: std::max is
{ return a < b ? b : a; } // preceded by "inline"

max 是一個模板的事實引出一個觀察結論:inline 函式和模板一般都是定義在標頭檔案中的。這就使得一些程式設計師得出結論斷定函式模板必須是 inline。這個結論是非法的而且有潛在的危害,所以它值得我們考察一下。 inline 函式一般必須在標頭檔案內,因為大多數構建環境在編譯期間進行 inline 化。為了用被呼叫函式的函式本體替換一個函式呼叫,編譯器必須知道函式看起來像什麼樣子。(有一些構建環境可以在連線期間進行 inline 化,還有少數幾個——比如,基於 .NET Common Language Infrastructure (CLI) 的控制環境——居然能在執行時 inline 化。然而,這些環境都是例外,並非規則。inline 化在大多數 C++ 程式中是一個編譯時行為。)

  模板一般在標頭檔案內,因為編譯器需要知道一個模板看起來像什麼以便用到它時對它進行例項化。(同樣,也不是全部如此。一些構建環境可以在連線期間進行模板例項化。然而,編譯期例項化更為普遍。) 模板例項化與 inline 化無關。如果你寫了一個模板,而且你認為所有從這個模板例項化出來的函式都應該是 inline 的,那麼就宣告這個模板為 inline,這就是上面的 std::max 的實現被做的事情。但是如果你為沒有理由要 inline 化的函式寫了一個模板,就要避免宣告這個模板為 inline(無論顯式的還是隱式的)。inline 化是有成本的,而且你不希望在毫無預見的情況下遭遇它們。我們已經說到 inline 化是如何引起程式碼膨脹的,但是,還有其它的成本,過一會兒我們再討論。

  在做這件事之前,我們先來完成對這個結論的考察:inline 是一個編譯器可能忽略的請求。大多數編譯器拒絕它們認為太複雜的 inline 函式(例如,那些包含迴圈或者遞迴的),而且,除了最細碎的以外的全部虛擬函式的呼叫都不會被 inline 化。不應該對這後一個結論感到驚訝。虛擬意味著“等待,直到執行時才能斷定哪一個函式被呼叫”,而 inline 意味著“執行之前,用被呼叫函式取代呼叫的地方”。如果編譯器不知道哪一個函式將被呼叫,你很難責備它們拒絕 inline 化這個函式本體。

  所有這些加在一起,得出:一個被指定的 inline 函式是否能真的被 inline 化,取決於你所使用的構建環境——主要是編譯器。幸運的是,大多數編譯器都有一個診斷層次,在它們不能 inline 化一個你提出的函式時,會導致一個警告。

  有時候,即使當編譯器完全心甘情願地 inline 化一個函式,他們還是會為這個 inline 函式生成函式本體。例如,如果你的程式要持有一個 inline 函式的地址,編譯器必須為它生成一個 outlined 函式本體。他們怎麼能生成一個指向根本不存在的函式的指標呢?再加上,編譯器一般不會對透過函式指標的呼叫進行 inline 化,這就意味著,對一個 inline 函式的呼叫可能被也可能不被 inline 化,依賴於這個呼叫是如何做成的:

inline void f() {...} // assume compilers are willing to inline calls to f

void (*pf)() = f; // pf points to f
...

f(); // this call will be inlined, because it’s a "normal" call
pf(); // this call probably won’t be, because it’s through
// a function pointer

甚至在你從來沒有使用函式指標的時候,未 inline 化的 inline 函式的幽靈也會時不時地拜訪你,因為程式設計師並不必然是函式指標的唯一需求者。有時候編譯器會生成建構函式和解構函式的 out-of-line 複製,以便它們能得到指向這些函式的指標,在對陣列中的物件進行構造和析構時使用。

  事實上,建構函式和解構函式對於 inline 化來說經常是一個比你在不經意的檢查中所能顯示出來的更加糟糕的候選者。例如,考慮下面這個類 Derived 的建構函式:

class Base {
 public:
  ...

 private:
  std::string bm1, bm2; // base members 1 and 2
};

class Derived: public Base {
 public:
  Derived() {} // Derived’s ctor is empty - or is it?
  ...

 private:
  std::string dm1, dm2, dm3; // derived members 1-3
};

  這個建構函式看上去像一個 inline 化的極好的候選者,因為它不包含程式碼。但是視覺會被欺騙。

  C++ 為物件被建立和被銷燬時所發生的事情做出了各種保證。例如,當你使用 new 時,你的動態的被建立物件會被它們的建構函式自動初始化,而當你使用 delete。則相應的解構函式會被呼叫。當你建立一個物件時,這個物件的每一個基類和每一個資料成員都會自動構造,而當一個物件被銷燬時,則發生關於析構的反向過程。如果在一個物件構造期間有一個異常被丟擲,這個物件已經完成構造的任何部分都被自動銷燬。所有這些情節,C++ 只說什麼必須發生,但沒有說如何發生。那是編譯器的實現者的事,但顯然這些事情不會自己發生。在你的程式中必須有一些程式碼使這些事發生,而這些程式碼——由編譯器寫出的程式碼和在編譯期間插入你的程式的程式碼——必須位於某處。有時它們最終就位於建構函式和解構函式中,所以我們可以設想實現為上面那個聲稱為空的 Derived 的建構函式生成的程式碼就相當於下面這樣:

Derived::Derived() // conceptual implementation of
{
 // "empty" Derived ctor

 Base::Base(); // initialize Base part

 try { dm1.std::string::string(); } // try to construct dm1
 catch (...) { // if it throws,
  Base::~Base(); // destroy base class part and
 throw; // propagate the exception
}

try { dm2.std::string::string(); } // try to construct dm2
catch(...) {
 // if it throws,
 dm1.std::string::~string(); // destroy dm1,
 Base::~Base(); // destroy base class part, and
throw; // propagate the exception
}

try { dm3.std::string::string(); } // construct dm3
catch(...) {
 // if it throws,
 dm2.std::string::~string(); // destroy dm2,
 dm1.std::string::~string(); // destroy dm1,
 Base::~Base(); // destroy base class part, and
throw; // propagate the exception
}
}

這些程式碼並不代表真正的編譯器所生成的,因為真正的編譯器會用更復雜的方法處理異常。儘管如此,它還是準確地反映了 Derived 的“空”建構函式必須提供的行為。不論一個編譯器的異常多麼複雜,Derived 的建構函式至少必須呼叫它的資料成員和基類的建構函式,而這些呼叫(它們自己也可能是 inline 的)會影響它對於 inline 化的吸引力。

  同樣的原因也適用於 Base 的建構函式,所以如果它是 inline 的,插入它的全部程式碼也要插入 Derived 的建構函式(透過 Derived 的建構函式對 Base 的建構函式的呼叫)。而且如果 string 的建構函式碰巧也是 inline 的,Derived 的建構函式中將增加五個那個函式程式碼的複製,分別對應於 Derived 物件中的五個 strings(兩個繼承的加上三個它自己宣告的)。也許在現在,為什麼說是否 inline 化 Derived 的建構函式不是一個不經大腦的決定就很清楚了。類似的考慮也適用於 Derived 的解構函式,用同樣的或者不同的方法,必須保證所有被 Derived 的建構函式初始化的物件被完全銷燬。

  庫設計者必須評估宣告函式為 inline 的影響,因為為庫中的客戶可見的 inline 函式提供二進位制升級版本是不可能的。換句話說,如果 f 是一個庫中的一個 inline 函式,庫的客戶將函式 f 的本體編譯到他們的應用程式中。如果一個庫的實現者後來決定修改 f,所有使用了 f 的客戶都必須重新編譯。這常常會令人厭煩。在另一方面,如果 f 是一個非 inline 函式,對 f 的改變只需要客戶重新連線。這與重新編譯相比顯然減輕了很大的負擔,而且,如果庫中包含的函式是動態連結的,這就是一種對於使用者來說完全透明的方法。
 
  為了程式開發的目標,在頭腦中牢記這些需要考慮的事項是很重要的,但是從編碼期間的實用觀點來看,佔有支配地位的事實是:大多數偵錯程式會與 inline 函式發生衝突。這不應該是什麼重大的發現。你怎麼能在一個不在那裡的函式中設定斷點呢?雖然一些構建環境設法支援 inline 函式的除錯,多數環境還是簡單地為除錯構建取消了 inline 化。

  這就匯出了一個用於決定哪些函式應該被宣告為 inline,哪些不應該的合乎邏輯的策略。最初,不要 inline 任何東西,或者至少要將你的 inline 化的範圍限制在那些必須 inline 的和那些實在微不足道的函式上。透過慎重地使用 inline,你可以使偵錯程式的使用變得容易,但是你也將 inline 化放在了它本來應該在的地位:作為一種手動的最佳化。不要忘記由經驗確定的 80-20 規則,它宣稱一個典型的程式用 80% 的時間執行 20% 的程式碼。這是一個重要的規則,因為它提醒你作為一個軟體開發者的目標是識別出能全面提升你的程式效能的 20% 的程式碼。你可以 inline 或者用其他方式無限期地調節你的函式,但除非你將精力集中在正確的函式上,否則就是白白浪費精力。

  Things to Remember

  ·將大部分 inline 限制在小的,呼叫頻繁的函式上。這使得程式除錯和二進位制升級更加容易,最小化潛在的程式碼膨脹,並最大化提高程式速度的機率。

  ·不要僅僅因為函式模板出現在標頭檔案中,就將它宣告為 inline。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/10617731/viewspace-957346/,如需轉載,請註明出處,否則將追究法律責任。

相關文章