【書籍資訊】
深度探索C++物件模型【Inside The C++ Object Model】 侯捷【Lippman】 華中科技大學出版社:2001
【總體概況】
本書主要是描述編譯器(和連結器)對C++物件模型的處理。詳述了物件導向中繼承、封裝、多態等等重要內容在編譯階段的處理。分析了各種實現的優缺點,並且展示了如何使用“分析-實現-分析...”(個人定義)這種以實踐而不是主觀臆斷為基礎的研究手段。很多深入而細緻的分析是我們從別的書中看不到的(也可能是我太孤陋寡聞了),有些具體的內容可能會有些過時(畢竟這本書寫了有一陣了),但是它包含的架構設計方法和分析問題的手段會令人有終身受益的感覺。
本書的作者和譯者都可謂是大牌了,寫作人有足夠的經驗,譯者有足夠的細心和能力,但好像這本書的影響力不是很大。也許是大多數人覺得此書描述的內容在平時編程中無法用到。但個人感覺本書描述的內容和思想,為我們寫出健壯和高效的代碼打下了基礎。建議每個有C++物件導向編程經驗(甚至是別的語言開發)的人閱讀一下。
【引言】
我打算從基本的C++物件模型開始,首先介紹C++物件模型對物件導向中很多新的元素的處理。比如成員函數、資料、構造和解構函式等等。為了有逐步深入的效果,這部分內容通常不涉及虛繼承。關於虛繼承的內容,會放到本文的最後來說。
接下來會寫一些在執行期的一些特點,最後是異常、RTTI等新增內容。基本的脈絡和原書一樣,只是在細節上有所調整。希望能協助大家快速的瞭解一下C++物件模型的一些相關內容,如果想細緻瞭解,強烈建議閱讀此書。
筆記中加入了很多個人的理解,如果有錯誤,請指正。謝謝。
【C++物件模型】
所有的所謂的物件導向,都是在程式語言一級的。對於編譯器而言,它會將所有物件導向的內容處理成和面向過程的程式一樣。
考慮下面這個類,猜想一下編譯器是如何把這些物件導向的內容翻譯過來的。
class Point
{
public:
Point(float xval);
virtual ~Point();
float x() const;
static int PointCount();
protected:
virtual ostream& print(ostream &os) const;
float _x;
static int _point_count;
};
書中,描述了三種方案。一是簡單物件模型,它會在非配的空間上依次儲存對象的所有資料和函數入口地址;二是表格驅動模式,對象中保留資料和指向一個表格的指標,表格中存放函數入口資訊。第一種方法邏輯簡單,但時間和空間(個人感覺特別是空間,有100個同類的對象就需要重複儲存100次所有函數地址)複雜度都比較高;第二種方法空間利用和靈活的都很好,但訪問函數的時間開銷增加(加了一層)。所以在很多編譯器中,都採用了第三種折衷的方案,就是所謂的C++物件模型。
如所示,編譯器區別對待資料,普通函數,虛函數,靜態函數等元素,在時間、空間、靈活性中尋求一個平衡點。書中所有的後續內容都是基於該模型的,而我們有必要瞭解編譯器在建立這個模型中做的很多事情,有利於我們寫出更好的代碼。
【資料處理】
編譯器在處理C++對象中的資料時,考慮了與C的相容性和存取速度。通常一個非待用資料被放在對象空間的開始,而待用資料被放置在一個全域資料段中,並保證在調用之前被初始化。
非待用資料按照許可權集中放置,並保證較晚出現的放置在較高的地址。資料與資料之間通常是一個挨一個放置,但出於齊位的需求,在資料中間可能會被插入一些補空的資料。整個對象的大小基本等價與資料大小的總和(和齊位需求的資料)和為了保證虛函數機制引入的指標。
在一般的單繼承體系中(即不考慮虛繼承,下同),子物件的資料是挨著父物件資料存放的(在高址),而且父物件資料的存放不會被子物件影響(連用於補齊的資料也保持原樣)。上面描述的是不引入虛函數的前提下,如果引入,虛表指標通常放置在對象資料的前端(低址)或尾端(高址)。兩種方式各有其好處(放在前端有利於對象的向上轉型,而放在尾端對資料地址的處理比較簡單),其實現依具體編譯器而定。
依照上述的存放方式,考慮如下一段代碼:
class A
{
public:
int a;
double b;
};
class B : public A
{
public:
float t;
}
A *a = new B();
它在堆中建立了一個B類型對象,相當於依次存放a, b, t三個資料在堆中。a指標指向B類型對象的首地址,但只能合法操作屬於它的a和b資料。可以看到這種存放機制,可以很方便的實現向上的轉型。(依照這個例子想象一下引入虛函數,虛標指標放置在前後會產生的不同問題。)
而如果引入多繼承,問題還是類似與單繼承,只是在基類資料的存放上,需要保證某個編譯器知道的順序。這樣也能可以很方便的實現向上轉型。 比如:
class A
{
public:
int a;
}
class B
{
public:
double b;
}
class C :
public A, public B
{
public:
float c;
}
C *c = new C();
A *a = c;
B *b = c;
同樣的,堆中放置一個C類型對象,依次包括a, b, c三個資料。在轉型成為A類型指標時,指標指向開始地址(即int a的位置),可以操作a。在轉型成為B類型指標是,指標指向double b的位置,可以操作b。
從上面的敘述可以看出:其實,封裝、繼承(普通繼承)等物件導向機制的引入,只是增加了編譯器的負擔,並不會影響到資料的存取的效率。從書中實際測試來看,情況也確實如此,對對象中的資料操作和對非對象中的資料操作,速度基本一致。
【函數處理】
C++物件導向模型中的函數可以視為有三類組成:一是非靜態成員函數;二是靜態成員函數;三是虛函數。
考慮類型A中有這樣一個非靜態成員函數void Test()。這個函數經過編譯器處理後,就會變成如下的格式:extern Test__xxAxx(register A* const this)。也就是編譯器做了兩件事。一是為函數添加了一個參數,該參數為該類型的一個常指標,這樣在函數中就可以使用該類型對象的資料了;二是為函數取了個獨一無二的名字,該技術被稱為Name Mangling。簡單的可以認為是一個資料方程。輸入是函數名、類型名等相關因素,輸出了一個獨一無二的函數名。這樣當我們調用obj.Test()的時候,就相當於在寫Test_xxAxx(&obj),所謂物件導向的內容被抹乾淨了。
在非靜態成員函數中,還有一種情況就是內嵌函式。眾所周知,內嵌函式不算是函數,它會在所有調用該內嵌函式處展開該函數的代碼(而不是一個調用)。通常我們會把少量代碼的函數設定為inline。編譯器可以忽視你的請求(書上說會變成一個static的函數,不解ing...),同樣編譯器也可能把一個不是inline的函數提升為inline。而inline帶來的好處和壞處一樣明顯。好處就是效率的提升,壞處就是代碼的膨脹和臨時變數的堆積。所以使用時要仔細考慮,不要泛濫使用內嵌函式(不要太依靠編譯器了)。
靜態成員函數的轉換更為簡單。只是被做了一個簡單的name mangling。因為靜態函數並不能調用類型中對象的非待用資料,所以它不需要傳入一個對象指標。因此,靜態函數可以視為類擁有的東西,它只能夠操作屬於類的資料(待用資料)。
虛函數的轉換從前面的那副物件模型圖表中可以略見一斑。在每一個有虛函數的對象中(不管是繼承至基類還是自己定義的)都會被安插一個被稱為vptr的指標,該指標指向一個被稱為vtbl的表格。vtbl被分成若干個等大的slot,第一個slot放置了關於物件類型的資訊,其他每一個slot中都放置了一個虛函數的入口地址。地址的具體函數可能不同,但繼承樹中同一個(指名字和參數都相同)的虛函數會被固定放置在某個slot中。也就是如果前面所述的A中那個函數Test()是虛函數,obj->Test()的調用可能就被轉換成(*obj->vptr[1])(obj)。不難看出,這相當於一個函數指標的調用。由此可知,虛函數之所以可以表現出執行期的變化,是因為它有兩個固定的內容。一是雖然物件類型不同,但它們肯定都有一個虛指標指向虛表;二是雖然具體的函數地址不知道,但是它在虛表中的位置是固定的。
這裡所述的虛函數的實現機制,只符合單繼承模型,在多繼承和虛繼承的情況下,會有很大的不同。
最後來看下效率。書中比較了友元(相當於普通調用)、內聯、非靜態成員、靜態成員、單繼承虛函數、多繼承虛函數和虛擬繼承虛函數的效率。拋開多繼承不說,虛函數的引入,確實帶來了少量的效能損失(資料顯示10%左右),而除內聯外其他調用方式效率一致。內聯的效率出奇的好,高出了近百倍。事後作者發現,這種提升不只是內聯本身帶來的,而是伴隨著內聯的for迴圈代碼調整帶來的(函數的調用就很難判斷了),可以看出,正確使用內聯可以出乎意料的提升效率(很多編譯器的最佳化手段都可以使用了)。