《深度探索C++對象模型》讀書筆記

第一章關於對象
C++的模型可以有多種實現方式,例如表驅動,對象模型等,如下是對象模型的實例,其中類的靜態變量和靜態函數單獨放在類之外,包含類的虛函數的函數指針放在一個稱爲virtual table的虛表中,該虛表中的第一個指針通常指向類的類型信息,用於實現RTTI(runtime type information)
第二章 The semantics of Consructors構造函數語義學
2.1 default constructor的建構操作(P39)
根據C++ standard的要求,如果一個類沒有任何用戶定義的constructor,編譯器需要生成一個trivial default constructor就是不做任何事的缺省構造函數,但是在以下幾種情況下需要合成nontrivial default constructor
"帶有Default Constructor"的Member class object
編譯器只有在它需要的時候才合成或者擴張構造函數。
1.如果類沒有任何構造函數,但它內含一個member object,這個object是有default constructor的,那麼編譯器將爲這個class合成一個缺省構造函數,而且這個合成操作只有在構造函數真正被調用的時候纔會發生。
2.一般情況下,合成的函數是inline的,如果合成的函數很複雜,這個函數將會是noninline static的。
3.如果用戶定義了構造函數,而類中又含有memeber object,那麼編譯器會在擴張這個構造函數,在用戶代碼之前,先調用object的構造函數以初始化object。在用於定義了構造函數的情況下,編譯器是不會再生成default constructor,只會擴展現有的構造函數
4.如果類中有多個object,那麼需要根據這些object的聲明順序合成或者擴張構造函數,以調用這些object的構造函數。

"帶有Default constructor"的Base Class
如果一個類派生自另外一個類,基類如果有Default Constructor,那麼派生類分兩種情況:
1.派生類沒有任何的constructor,那麼編譯器會爲派生類合成一個Default Constructor,用於調用父類的構造函數
2.派生類有用戶定義的constructor,那麼編譯器會擴充這些constructor,來調用父類的構造函數

"帶有一個Virtual Function"的class
如果一個類派生自另外一個類,基類或者基類鏈中有一個或者多個virutal class,那麼編譯器需要爲派生類初始化其虛表指針,如果派生類沒有任何constructor,那麼編譯器會合成一個default constructor來做這件事情,如果有constructor,那麼編譯器會擴充這些constructor,來做這件事情

"帶有一個virtual base class"的class
主要是針對多重繼承中的虛擬繼承而言的,對於這種情況,必須保證繼承類對象中的virtual base class object的位置是固定的,所以在初始化的時候,需要編譯器需要在繼承類對象中合成出一個virtual base class指針,並初始化它,這樣用戶代碼中通過繼承類對象訪問虛擬繼承的虛基類的成員時候,才能夠正常訪問。

2.2Copy構造函數的構建操作
使用到copy構造函數的三種情況:
1.直接通過賦值操作符賦值
2.通過傳遞參數對象
3.通過返回對象

如果類沒有提供copy構造函數,那麼編譯器會進行default memberwise initialization,也就是如下的過程:
對當前類中的builtin類型的成員變量,執行賦值操作
對當前類中的class object類型的成員變量,遞歸執行其default memberwise initialization

C++把copy構造函數分爲trivial和nontrivial
如果一個類是具有bitwise copy semantics,那麼它就是trivial的,編譯器不需要合成copy constructor,否則就是nontrivial的,需要合成copy constructor

何爲bitwise copy semantics?
如果一個類對象的成員變量都是builtin類型的,或者它有class object,但是該class object的成員變量也是builtin類型的,或者說編譯器沒有爲它合成一個copy constructor,那麼該對象稱爲bitwise copy semantics

需要合成copy constructor(或者說不具有bitwise copy semantics)的幾種情況:(P53)
1.當一個類中有一個member object,並且這個object有一個copy constructor的時候,編譯器需要合成或者擴展copy constructor來調用這個對象的copy constructor
2.當一個類繼承自一個基類,並且這個基類有copy constructor,不論這個基類的copy constructor是合成的還是聲明的,此時編譯器都需要爲繼承類合成copy constructor
3.當一個類具有一個或者多個virtual function的時候,編譯器需要copy constructor用來初始化對象中的vptr指針,這裏需要特別需要注意,只有在不同類型之間賦值的時候,纔會採用這個合成copy constructor的方式,如果是相同類型的類對象之間的賦值,可以直接bitwise copy
4.當一個類派生自一個繼承鏈,其中有一個或者多個virutal base class的時候,編譯器需要copy constructor來保證virtual base class object實例的位置唯一性,與上面的情況類似,只有在不同類型的對象之間賦值纔會使用這個規則,否則可以直接使用bitwise copy

2.3程序轉化語意學
明確的初始化
void foo_bar(){
     X x1(x0);
     X x2 = x0;
     X x3 = X(x0);
}
編譯器轉化後的僞代碼爲:
void foo_bar(){
     X x1;//初始化操作被剝除
     X x2;
     X x3;
     
     x1.X::X(x0);//編譯器安插copy constructor
     x2.X::X(x0);
     x3.X::X(x0);
}

參數的初始化
X xx;
foo(xx);
編譯器轉換後的僞代碼爲:
X __temp0;
__temp0.X::X(xx);
foo(__temp0);
同時還要把foo的函數原型修改爲:
foo(X& x0);
這只是其中一種轉換方法,還有另外一種copy構建的方法

返回值的初始化:
X bar()
{
     X xx;

     return xx;
}
優化一:
void bar(X& __result)
{
     X xx;
     
     xx.X::X();

     __result.X::X(xx);

     return;
}
優化二:
void bar(X& __result)
{
     __result.X::X();

     //processing result
     
     return;
}
這種優化稱爲NRV(named return value)優化,需要聲明X的copy constructor才能觸發,關於這一點實際上是有疑惑的,因爲NRV本身就是爲了避免調用copy constructor的,但是這裏卻需要類聲明copy constructor才能觸發NRV優化不是很奇怪麼,下面的網址給出了一些討論
關於NRV優化的疑惑問答:
其實,說的簡單點,只有在copy constructor是nontrival的時候,編譯器纔會認爲這是一個複雜對象,需要進行這種NRV的優化,不論這個copy constructor是用戶定義的,還是編譯器合成的,都是爲了滿足這個目標。

2.4 Member Initialization list
在下列四種情況下,必須使用初始化列表,否則會編譯失敗:
1.初始化一個reference member
2.初始化一個const member
3.調用基類的constructor,並且它有一組參數
4.調用memeber class的constructor,並且它有一組參數

初始化列表會被編譯器轉換爲語句插入到構造函數的explicit user code之前,並且是按照member在類中聲明的順序進行初始化,而不是按照member在初始化列表中出現的順序

第三章 Data語意學
class X {};
class Y : public virtual X {};
class Z : public virtual X {};
class A : public Y,public Z {};
sizeof(X) = 1
sizeof(Y) = 8或者4
sizeof(Z) = 8或者4
sizeof(A) = 12或者8
因爲class X中沒有任何的member,編譯器爲了區分不同的object,所以需要一個字節用來區分
Y和Z從X虛擬繼承,爲了保證其派生類只有一個X實例,所以他們通過一個4字節指針指向一個X的實例,同時由於Y和Z也沒有member,所以有些編譯器爲他們分配了一個字節用以區分不同的object,同時爲了4字節對齊,其大小就成爲8,但有些編譯器認爲指向X的實例就已經能夠區分這些對象,不需要再分配額外的字節,所以其大小就是4.
A從Y和Z繼承,所以A中先是有Y對象的4字節,然後是Z對象的4字節,如果編譯器爲它添加1字節和3字節的對齊,那麼總大小就是12,否則就是8

3.1Data Member的綁定
C++中有一個member scope resolution rules,其大致含義如下:1.對member function body的分析,在整個class的聲明結束之後才進行,也就是說對於inline member function中的data member的綁定實在整個class聲明完成之後進行的,也就是說,我們可以在inline member function中隨意的使用data member,而不用擔心他們沒有被聲明,因爲編譯器此時並沒有對他們進行分析,在掌握了整個類的聲明之後,纔會對inline member function的body進行分析
2.但是對於member function的argument list,卻是會立即分析的,不會等到class聲明結束,因爲他們是function signature,在開始就要確定下來。
typedef int length;
class Point3d
{
     public:
          void mumble(length val) { _val = val}
          length mumble() {return _val;}
     private:
          typedef float length;
          length _val;
}
這個例子很明顯,mumble中定義的length類型,在開始的時候使用了全局的typedef,在碰到類的typedef的時候,會發生編譯錯誤
3.2 Data Member的佈局
基本符合原來對於C++的理解,有一點疑問的地方是P93中關於template function的指向class member的指針,在3.6節的時候,要反過來看看-----------------------------------------------------------------

3.3Data member的存取
static data member實際上就是一個全局變量,爲了區分他們,C++使用了name mangling,包括兩個規則:
1.保證能夠推導出獨一無二的名字
2.保證能夠通過推導後的名字,反向得到原來的名字
nonstatic data member的存取
origin._y = 0.0
&origin._y = &origin + (&Point3d::_y - 1)也就是說origin._y的地址等於origin對象的地址加上_y data member在對象中的偏移量,在3.6節中會有指向data member的指針------------------------------------------------------------
看如下兩種表達式的區別:
Point3d origin,*pt=&origin;
origin.x = 0.0//此時可以確定origin的對象類型,從而在編譯期就能知道x的偏移量
pt->x = 0.0//此時對於pt指向一個繼承類,並且其繼承結構中包含虛擬繼承的情況,在編譯期就無法確定x的偏移量,只要在運行期確定其具體的對象類型,才能知道,此時這兩個表達式是有區別的。

3.4 繼承與Data member
只要繼承不要多態
C++保證,出現在派生類中的基類子對象有其完整原樣性。也就是說,在派生類中的基類成員完全保持基類對象的結構。

加上多態
編譯器需要做如下的調整:
1.引入一個virtual function table,用來存儲類的虛函數地址,可能還要加入類的RTTI信息,注意virtual function table是類相關的,每個類只需要有一個,其中的信息是在編譯器就能夠確定的。
2.在每個類的object中加上一個指向virtual function table的指針
3.修改構造函數,在初始化的時候,讓對象的virtual function table的指針指向正確的地址。
4.修改析構函數,在析構的時候,設置virtual function table的指針爲空
此時,編譯器考慮的一個問題是,對象中指向virtual function table的指針在對象的內存佈局中的位置,在早期的cfront編譯器中,爲了在內存佈局上與C的struct兼容,把這個指針放在了對象的最後。但是現代編譯器都是把這個指針放在對象的第一個4字節。
特別需要說明的是,所謂的多態,都是在使用指針的情況下出現的,此時指針指向的對象的類型在編譯期無法確定,只能等到運行期通過不同的虛函數表指針才能確定。對於在編譯期直接使用對象調用虛函數的情況,由於編譯器可以確定對象的類型,可以優化爲直接調用,不用等到運行期,瞭解此點很重要,可以防止多態的濫用。

多重繼承
多重繼承對對象的內存結構的影響是,按照多重繼承的聲明順序,在內存中依次排放base class subobject。
由此導致的問題是派生類對象與第二或者後繼的基類子對象之間的轉換問題
設有如下的派生關係:
class Point2d {};
class Point3d : public Point2d {};
class Vertex {};
class Vertex3d : public Point3d, public Vertex {};

Vertex3d v3d;
Vertex3d *pv3d;
Vertex *pv;
Point2d *p2d;
Point3d *p3d;

pv = &v3d;
Vertex3d是從Vertex繼承的,所以可以這樣賦值,但是在編譯器內部將被轉化爲:
pv = (Vertex*) ((char*)&v3d + sizeof(Point3d))
對於如下的賦值:
pv = pv3d;
編譯器轉化爲:
pv = (pv3d)?(Vertex*)((char*)&v3d + sizeof(Point3d)):0;

虛擬繼承(P116)
虛擬繼承對對象的內存佈局的影響是,由於出現菱形繼承,對象中出現shared virtual base class subject,在對象的內存佈局中需要一個共享的虛基類子對象,這樣在最下方的派生類中存取虛基類的成員的時候,能夠保證唯一的存取。
目前編譯器採取的方式是把virtual base class subject放在了對象的末尾,爲什麼要這樣設計呢,如果放在對象的開始不是可以省很多事情麼?原因如下:
如果對象有多個virtual base class subject,把他們放在對象的開始位置會導致對象模型異常複雜,不僅影響virtual base class subject,而且也影響了派生類subject。所以大部分編譯器採用了,把不變的派生類subject放在對象開始,而把可變的virtual base class subject,放在對象末尾的方式。
由於這種設計方式,在不同的派生類中,virtual base class subject的位置變得不固定,這樣導致編譯期無法通過一個通用的方式處理virtual base class subject的成員變量的存取。cfront採用的方式是在派生類對象中加入一個指針,指向virtual base class subject,但是此方法有兩個缺點:
1.在每個對象中都加入一個指向virtual base class subject指針,導致空間的浪費
2.在出現n多次的虛擬繼承的情況,需要通過這個指針進行n的迭代,導致時間上的浪費
一種推薦的處理方法是:
在派生類的virtual function table中放置virtual base class subject的offset,如果出現n次繼承的情況,在編譯期就可以把這n次繼承的偏移量都放在這個表中。由於virtual function table每個類只有一個,節省了空間,在n次虛擬繼承的情況下,也節省了時間。由此在訪問virtual base class subject的成員變量的時候,需要放過虛擬繼承得到virtual base class subject的offset,這種成本也是可以接受的。

同樣,對於使用一個有虛擬繼承的對象,直接訪問其virtual base class subject的成員變量的情況,在編譯期就可以直接存取,因爲此時就能確定其類型,繼而知道偏移量。對於虛擬繼承的情況,由於virtual base class subject被放置在對象佈局的末端,導致不同的派生類中virtual base class subject的偏移量不同,所以如果採用指針存取virtual base class subject的成員變量這種情況,在編譯期由於無法判斷對象的類型,繼而無法知道存取變量在對象中的偏移量,只能等到運行期,通過virtual function table得到virtual base class subject的offset。

3.5 對象成員的存取效率
方法:
1.從對象的內存結構,判斷存取效率
2.從編譯器轉換後的僞代碼或彙編代碼,判斷存取效率
結果:
1.struct的存取效率是最高的
2.使用inline函數,在優化打開的情況下,可以達到與struct相同的存取效率
3.虛擬繼承的成員對象的存取效率最低

3.6指向Data Member的指針
看如下的代碼:
class Point{
public:
     Point() {x=1;}
     int x;
};

class Point2D: virtual public Point{
public:
     Point2D() {y=2;}
     int y;
};

class Point3D: virtual public Point2D{
public:
     Point3D() {z=3;}
     int z;
};

int _tmain(int argc, _TCHAR* argv[])
{
     printf("&Point::x=%p\n",&Point::x);
     printf("&Point2D::y=%p\n",&Point2D::y);
     printf("&Point3D::z=%p\n",&Point3D::z);

     Point p1;
     Point2D p2;
     Point3D p3;

     printf("sizeof Point=%d\n",sizeof(p1));
     printf("sizeof Point2D=%d\n",sizeof(p2));
     printf("sizeof Point3D=%d\n",sizeof(p3));

     return 0;
}
其中&Point::x得到的是Point的成員變量x在Point對象中的偏移量,使用指針表示爲int Point::*,使用Point對象的指針像如下這樣使用指向成員變量的指針
Point *p;
int Point::*pMem = &Point::x;
p->*pMem = 4;
P132有一個關於多重繼承情況下,使用指向成員變量的指針進行存取的例子,說明編譯器在對指向成員變量的指針進行操作的時候,是要根據對象指針進行衝定義的。
梳理虛擬繼承的內存結構,以上面代碼爲例
Point的內存結構如下:
-------------------
|         x         |
-------------------
Point2D的內存結構如下:
                                  ----------------
                                  |       8        |
-------------------  vptr    ----------------
|               ----+------->|    RTTI      |
-------------------            ----------------
|        y           |
-------------------
|        x           |
-------------------
Point3D的內存結構如下:
                                  ----------------
                                  |       12      |
                                  ----------------
                                  |       8        |
-------------------  vptr    ----------------
|               ----+------->|    RTTI      |
-------------------            ----------------
|        z           |
-------------------
|        x           |
-------------------  vptr   -----------------
|              -----+------>|     RTTI      |
-------------------          ------------------
|        y           |
-------------------

4. Function 語意學
Nonstatic member Functions的轉換步驟:
1.改寫函數的signature,添加this指針作爲第一參數
2.將對nonstatic data member的操作,改爲經過this指針的操作
3.將member function改寫爲一個外部函數,對函數名稱進行mangling,是它在程序中的名字獨一無二。

name mangline
data member name mangling
[member_name]_[class_name]
member function name mangline
[member_name]_[class_name]_[parameter_type_list]

4.2 Virtual Function Members
考慮這樣一個問題,編譯器在遇到ptr->normalize()調用的時候,會如何轉化該調用?按照目前的理解應該是這樣的:
1.編譯器應該在編譯期間維護了各種類型信息,一來自己用,二來對於有虛函數的對象,RTTI要加入到virtual table中。
2.編譯器是知道ptr指針的類型,如果這個類型有virtual function,那麼其內存結構中就會包含vptr,否則就是與C結構兼容的內存結構。
3.根據這些信息,如果normalize是virtual function,那麼它將會被轉化爲(ptr->vptr[n])(ptr)
4.如果normalize不是virtual function,那麼它將會被按照member function的規則轉化爲普通的函數調用,在鏈接期如果沒有相應的函數與之鏈接,則會報錯。

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