天天看點

讀【深度探索C++對象模型】【上】

【書籍資訊】

深度探索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 

int a; 

        double b; 

}; 

class B : public A 

        float t; 

A *a = new B();

它在堆中建立了一個B類型對象,相當于依次存放a, b, t三個資料在堆中。a指針指向B類型對象的首位址,但隻能合法操作屬于它的a和b資料。可以看到這種存放機制,可以很友善的實作向上的轉型。(依照這個例子想象一下引入虛函數,虛标指針放置在前後會産生的不同問題。)

而如果引入多繼承,問題還是類似與單繼承,隻是在基類資料的存放上,需要保證某個編譯器知道的順序。這樣也能可以很友善的實作向上轉型。 比如:

        int a; 

class B 

class C :    

        public A, public B 

        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循環代碼調整帶來的(函數的調用就很難判斷了),可以看出,正确使用内聯可以出乎意料的提升效率(很多編譯器的優化手段都可以使用了)。

本文轉自 duguguiyu 51CTO部落格,原文連結:http://blog.51cto.com/duguguiyu/363369,如需轉載請自行聯系原作者

繼續閱讀