明智地使用內聯
內嵌函式------多妙的主意啊!它們看起來象函數,運作起來象函數,比宏(macro)要好得多(參見條款1),使用時還不需要承擔函數調用的開銷。你還能對它們要求更多嗎?
然而,你從它們得到的確實比你想象的要多,因為避免函數調用的開銷僅僅是問題的一個方面。為了處理那些沒有函數調用的代碼,編譯器最佳化程式本身進行了專門的設計。所以當內聯一個函數時,編譯器可以對函數體執行特定環境下的最佳化工作。這樣的最佳化對"正常"的函數調用是不可能的。
我們還是不要扯得太遠。程式世界和現實生活一樣,從來就沒有免費的午餐,內嵌函式也不例外。內嵌函式的基本思想在於將每個函數調用以它的代碼體來替換。用不著統計專家出面就可以看出,這種做法很可能會增加整個目標代碼的體積。在一台記憶體有限的電腦裡,過分地使用內聯所產生的程式會因為有太大的體積而導致可用空間不夠。即使可以使用虛擬記憶體,內聯造成的代碼膨脹也可能會導致不合理的頁面調度行為(系統顛簸),這將使你的程式運行慢得象在爬。(當然,它也為磁碟控制卡提供了一個極好的鍛煉方式)過多的內聯還會降低指令快取的命中率,從而使取指令的速度降低,因為從主存取指令當然比從緩衝要慢。
另一方面,如果內嵌函式體非常短,編譯器為這個函數體產生的程式碼就會真的比為函數調用產生的程式碼要小許多。如果是這種情況,內聯這個函數將會確實帶來更小的目標代碼和更高的快取命中率!
要牢記在心的一條是,inline指令就象register,它只是對編譯器的一種提示,而不是命令。也就是說,只要編譯器願意,它就可以隨意地忽略掉你的指令,事實上編譯器常常會這麼做。例如,大多數編譯器拒絕內聯"複雜"的函數(例如,包含迴圈和遞迴的函數);還有,即使是最簡單的虛函數調用,編譯器的內聯處理常式對它也愛莫能助。(這一點也不奇怪。virtual的意思是"等到運行時再決定調用哪個函數",inline的意思是"在編譯期間將調用之處用被調函數來代替",如果編譯器甚至還不知道哪個函數將被調用,當然就不能責怪它拒絕產生內聯調用了)。以上可以歸結為:一個給定的內嵌函式是否真的被內聯取決於所用的編譯器的具體實現。幸運的是,大多數編譯器都可以設定診斷級,當聲明為內聯的函數實際上沒有被內聯時,編譯器就會為你發出警告資訊(參見條款48)。
假設寫了某個函數f並聲明為inline,如果出於什麼原因,編譯器決定不對它內聯,那將會發生些什麼呢?最明顯的一個回答是將f作為一個非內嵌函式來處理:為f產生代碼時就象它是一個普通的"外聯"函數一樣, 對f的調用也象對普通函數調用那樣進行。
理論上來說確實應該這樣發生,但理論和現實往往會偏離,現在就屬於這種情況。因為,這個方案對解決"被外聯的內聯"(outlined inline)這一問題確實非常理想,但它加入到C++標準中的時間相對較晚。較早的C++規範(比如ARM------參見條款50)告訴編譯器製造商去實現的是另外不同的行為,而且這一舊的行為在現在的編譯器中還很普遍,所以必須理解它是怎麼一回事。
稍微想一想你就可以記起,內嵌函式的定義實際上都是放在標頭檔中。這使得多個要編譯的單元(源檔案)可以包含同一個標頭檔,共用標頭檔內定義的內嵌函式所帶來的益處。下面給出了一個例子,例子中的源檔案名稱以常規的".cpp"結尾,這應該是C++世界最普遍的命名習慣了:
// 檔案example.h
inline void f() { ... } // f的定義
...
// 檔案source1.cpp
#include "example.h" // 包含f的定義
... // 包含對f的調用
// 檔案source2.cpp
#include "example.h" // 也包含f的定義
... // 也調用f
假設現在採用舊的"被外聯的內聯"規則,而且假設f沒有被內聯,那麼,當source1.cpp被編譯時間,產生的目標檔案中將包含一個稱為f的函數,就象f沒有被聲明為inline一樣。同樣地,當source2.cpp被編譯時間,產生的目標檔案也將包含一個稱為f的函數。當想把兩個目標檔案連結在一起時,編譯器會因為程式中有兩個f的定義而報錯。
為了防止這一問題,舊規則規定,對於未被內聯的內嵌函式,編譯器把它當成被聲明為static那樣處理,即,使它局限於當前被編譯的檔案。具體到剛才看到的例子中,遵循舊規則的編譯器處理source1.cpp中的f時,就象f在source1.cpp中是靜態一樣;處理source2.cpp中的f時,也把它當成在source2.cpp中是靜態一樣。這一策略消除了連結時的錯誤,但帶來了開銷:每個包含f的定義(以及調用f)的被編譯單元都包含自己的f的靜態拷貝。如果f自身定義了局部靜態變數,那麼,每個f的拷貝都有此局部變數的一份拷貝,這必然會讓程式員大吃一驚,因為一般來說,函數中的"static"意味著"只有一份拷貝"。
具體實現起來也會令人吃驚。無論新規則還是舊規則,如果內嵌函式沒被內聯,每個調用內嵌函式的地方還是得承擔函數調用的開銷;如果是舊規則,還得忍受代碼體積的增加,因為每個包含(或調用) f的被編譯單元都有一份f的代碼及其靜態變數的拷貝!(更糟糕的是,每個f的拷貝以及每個f的靜態變數的拷貝往往處於不同的虛擬記憶體頁面,所以兩個對f的不同拷貝進行調用有可能導致多個分頁錯誤。)
還有呢!有時,可憐的隨時準備為您效勞的編譯器即使很想內聯一個函數,卻不得不為這個內嵌函式產生一個函數體。特別是,如果程式中要取一個內嵌函式的地址,編譯器就必須為此產生一個函數體。編譯器怎麼能產生一個指向不存在的函數的指標呢?
inline void f() {...} // 同上
void (*pf)() = f; // pf指向f
int main()
{
f(); // 對f的內聯調用
pf(); // 通過pf對f的非內聯調用
...
}
這種情況似乎很荒謬:f的調用被內聯了,但在舊的規則下,每個取f地址的被編譯單元還是各自產生了此函數的靜態拷貝。(新規則下,不管涉及的被編譯單元有多少,將只產生唯一一個f的外部拷貝)
即使你從來不使用函數指標,這類"沒被內聯的內嵌函式"也會找上你的門,因為不只是程式員會使用函數指標,有時編譯器也這麼做。特別是,編譯器有時會產生建構函式和解構函式的外部拷貝,這樣就可以通過得到那些函數的指標,方便地構造和析構類的對象數組(參見條款M8)。
實際上,隨便一個測試就可以證明建構函式和解構函式常常不適合內聯;甚至,情況比測試結果還糟。例如,看下面這個類Derived的建構函式:
class Base {
public:
...
private:
string bm1, bm2; // 基類成員1和2
};
class Derived: public Base {
public:
Derived() {} // Derived的建構函式是空的,
... // ------但,真的是空的嗎?
private:
string dm1, dm2, dm3; // 衍生類別成員1-3
};
這個建構函式看起來的確象個內聯的好材料,因為它沒有代碼。但外表常常欺騙人!僅僅因為它沒有代碼並不能說明它真的不含代碼。實際上,它含有相當多的代碼。
C++就對象建立和銷毀時發生的事件有多方面的規定。條款5和M8介紹了當使用new時,動態建立的對象怎樣自動地被它們的建構函式初始化,以及當使用delete時解構函式怎樣被調用。條款13說明了當建立一個對象時,對象的每個基類以及對象的每個資料成員會被自動地建立;當對象被銷毀時,會自動地執行相反的過程(即析構)。這些條款告訴你,C++規定了哪些必鬚髮生,但沒規定"怎麼"發生。"怎麼發生"取決於編譯器的實現者,但要弄清楚的是,這些事件不是憑空自己發生的。程式中必然有什麼代碼使得它們發生,特別是那些由編譯器的實現者寫的、在編譯其間插入到你的程式中的代碼,必然也藏身於某個地方------有時,它們就藏身於你的建構函式和解構函式。所以,對於上面那個號稱為空白的Derived的建構函式,有些編譯器會為它產生相當於下面的代碼:
// 一個Derived建構函式的可能的實現
Derived:erived()
{
// 如果在堆上建立對象,為其分配堆記憶體;
// operator new的介紹參見條款8
if (本對象在堆上)
this = :perator new(sizeof(Derived));
Base::Base(); // 初始化Base部分
dm1.string(); // 構造dm1
dm2.string(); // 構造dm2
dm3.string(); // 構造dm3
}
別指望上面這樣的代碼可以通過編譯,因為它在C++中是不合法的。首先,在建構函式內無法知道對象是不是在堆上。(想知道如何可靠地確定一個對象是否在堆上,請參見條款M27)另外,對this賦值是非法的。還有,通過函數調用訪問建構函式也是不允許的。然而,編譯器工作起來沒這些限制,它可以隨心所欲。但代碼的合法性不是現在要討論的主題。問題的要點在於,調用operator new(如果需要的話)的代碼、構造基類部分的代碼、構造資料成員的代碼都會神不知鬼不覺地添加到你的建構函式中,從而增加建構函式的體積,使得建構函式不再適合內聯。當然,同樣的分析也適用於Base的建構函式,如果Base的建構函式被內聯,添加到它裡面的所有代碼也會被添加到Derived的建構函式(Derived的建構函式會調用Base的建構函式)。如果string的建構函式恰巧也被內聯,Derived的建構函式將得到其代碼的5個拷貝,每個拷貝對應於Derived對象中5個string中的一個(2個繼承而來,3個自己聲明)。現在你應該明白,內聯Derived的建構函式並非可以很簡單就決定的!當然,類似的情況也適用於Derived的解構函式,無論如何都要清楚這一點:被Derived的建構函式初始化的所有對象都要被完全銷毀。剛被銷毀的對象以前可能佔用了動態分配的記憶體,那麼這些記憶體還需要釋放。
程式庫的設計者必須預先估計到聲明內嵌函式帶來的負面影響。因為想對程式庫中的內嵌函式進行二進位代碼升級是不可能的。換句話說,如果f是庫中的一個內嵌函式,使用者會將f的函數體編譯到自己的程式中。如果程式庫的設計者後來要修改f,所有使用f的使用者程式必須重新編譯。這會很令人討厭(參見條款34)。相反,如果f是非內嵌函式,對f的修改僅需要使用者重新連結,這就比需要重新編譯大大減輕了負擔;如果包含這個函數的程式庫是被動態連結的,程式庫的修改對使用者來說完全是透明的。
內嵌函式中的靜態對象常常表現出違反直覺的行為。所以,如果函數中包含靜態對象,通常要避免將它聲明為內嵌函式。具體介紹參見條款M26。
為了提高程式開發品質,以上諸項一定要牢記在心。但在具體編程時,從純實際的角度來看,有一個事實比其餘的因素都重要:大多數調試器遇上內嵌函式都會無能為力。
這不是什麼新鮮事。你想,怎麼在一個不存在的函數裡設定斷點呢?怎麼逐步執行到這樣一個函數呢?怎麼俘獲對它的調用呢?除非你是個百年一遇的怪才,或者用了暗渡陳倉之類的伎倆,否則是不可能做到的。讓人高興的是,這一點倒是可以作為決定該不該對函式宣告inline的決策依據之一。
一般來說,實際編程時最初的原則是不要內聯任何函數,除非函數確實很小很簡單,象下面這個age函數:
class Person {
public:
int age() const { return personAge; }
...
private:
int personAge;
...
};
謹慎地使用內聯,不但給了調試器更多發揮作用的機會,還將內聯的作用定位到了正確的位置:它是一個根據需要而使用的最佳化工具。不要忘了從無數經驗得到的這條80-20定律(參見條款M16):一個程式往往花80%的時間來執行程式中20%的代碼。這是一條很重要的定律,因為它提醒你,作為程式員的一個很重要的目標,就是找出這20%能夠真正提高整個程式效能的代碼。你可以選擇內聯你的函數,或者沒必要就不內聯,但這些選擇只有作用在"正確"的函數上才有意義。
一旦找出了程式中那些重要的函數,以及那些內聯後可以確實提高程式效能的函數(這些函數本身依賴於所在系統的體繫結構),就要毫不猶豫地聲明為inline。同時,要注意代碼膨脹帶來的問題,並監視編譯器的警告資訊(參見條款48),看看是否有內嵌函式沒有被編譯器內聯。