多重繼承及虛繼承中對象內存的分佈

多重繼承及虛繼承中對象內存的分佈

絕對經典----感覺以前瞭解的太膚淺了,中國大地人才真多呀!!!--原文字太小了,給放大了!嘿嘿!!
多重繼承

首先我們先來考慮一個很簡單(non-virtual)的多重繼承。看看下面這個C++類層次結構。

1 class Top
2 {
3      public:
4           int a;
5 };
6
7 class Left : public Top
8 {
9        public:
10           int b;
11 };
12
13 class Right : public Top
14 {
15       public:
16            int c;
17 };
18
19 class Bottom : public Left, public Right
20 {
21       public:
22            int d;
23 };
24
用UML表述如下:

注意到Top類實際上被繼承了兩次,(這種機制在Eiffel中被稱作repeated inheritance),這就意味着在一個bottom對象中實際上有兩個a屬性(attributes,可以通過bottom.Left::a和 bottom.Right::a訪問) 。

那麼Left、Right、Bottom在內存中如何分佈的呢?我們先來看看簡單的Left和Right內存分佈:

[Right 類的佈局和Left是一樣的,因此我這裏就沒再畫圖了。]

注意到上面類各自的第一個屬性都是繼承自Top類,這就意味着下面兩個賦值語句:

1 Left* left = new Left();
2 Top* top = left;

left和top實際上是指向兩個相同的地址,我們可以把Left對象當作一個Top對象(同樣也可以把Right對象當Top對象來使用)。但是Botom對象呢?

GCC是這樣處理的:

但是現在如果我們upcast 一個Bottom指針將會有什麼結果?

1 Bottom* bottom = new Bottom();
2 Left* left = bottom;

這段代碼運行正確。這是因爲GCC選擇的這種內存佈局使得我們可以把Bottom對象當作Left對象,它們兩者(Left部分)正好相同。但是,如果我們把Bottom對象指針upcast到Right對象呢?

1 Right* right = bottom;

如果我們要使這段代碼正常工作的話,我們需要調整指針指向Bottom中相應的部分。

通過調整,我們可以用right指針訪問Bottom對象,這時Bottom對象表現得就如Right對象。但是bottom和right指針指向了不同的內存地址。最後,我們考慮下:

1 Top* top = bottom;

恩,什麼結果也沒有,這條語句實際上是有歧義(ambiguous)的,編譯器會報錯: error: `Top’ is an ambiguous base of `Bottom’。其實這兩種帶有歧義的可能性可以用如下語句加以區分:

1 Top* topL = (Left*) bottom;
2 Top* topR = (Right*) bottom;

這兩個賦值語句執行之後,topL和left指針將指向同一個地址,同樣topR和right也將指向同一個地址。

虛擬繼承

爲了避免上述Top類的多次繼承,我們必須虛擬繼承類Top。

1 class Top
2 {
3         public:
4               int a;
5 };
6
7 class Left : virtual public Top
8 {
9         public:
10            int b;
11 };
12
13 class Right : virtual public Top
14 {
15      public:
16            int c;
17 };
18
19 class Bottom : public Left, public Right
20 {
21       public:
22            int d;
23 };
24

上述代碼將產生如下的類層次圖(其實這可能正好是你最開始想要的繼承方式)。

對於程序員來說,這種類層次圖顯得更加簡單和清晰,不過對於一個編譯器來說,這就複雜得多了。我們再用Bottom的內存佈局作爲例子考慮,它可能是這樣的:

這種內存佈局的優勢在於它的開頭部分(Left部分)和Left的佈局正好相同,我們可以很輕易地通過一個Left指針訪問一個Bottom對象。不過,我們再來考慮考慮Right:

1 Right* right = bottom;

這裏我們應該把什麼地址賦值給right指針呢?理論上說,通過這個賦值語句,我們可以把這個right指針當作真正指向一個Right對象的指針(現在指向的是Bottom)來使用。但實際上這是不現實的!一個真正的Right對象內存佈局和Bottom對象Right部分是完全不同的,所以其實我們不可能再把這個upcasted的bottom對象當作一個真正的right對象來使用了。而且,我們這種佈局的設計不可能還有改進的餘地了。這裏我們先看看實際上內存是怎麼分佈的,然後再解釋下爲什麼這麼設計。

上圖有兩點值得大家注意。第一點就是類中成員分佈順序是完全不一樣的(實際上可以說是正好相反)。第二點,類中增加了vptr指針,這些是被編譯器在編譯過程中插入到類中的(在設計類時如果使用了虛繼承,虛函數都會產生相關vptr)。同時,在類的構造函數中會對相關指針做初始化,這些也是編譯器完成的工作。Vptr指針指向了一個“virtual table”。在類中每個虛基類都會存在與之對應的一個vptr指針。爲了給大家展示virtual table作用,考慮下如下代碼。

1 Bottom* bottom = new Bottom();
2 Left* left = bottom;
3 int p = left->a;
第二條的賦值語句讓left指針指向和bottom同樣的起始地址(即它指向Bottom對象的“頂部”)。我們來考慮下第三條的賦值語句。下面是它彙編結果:

1 movl left, %eax # %eax = left
2 movl (%eax), %eax # %eax = left.vptr.Left
3 movl (%eax), %eax # %eax = virtual base offset
4 addl left, %eax # %eax = left + virtual base offset
5 movl (%eax), %eax # %eax = left.a
6 movl %eax, p # p = left.a

總結下,我們用left指針去索引(找到)virtual table,然後在virtual table中獲取到虛基類的偏移(virtual base offset, vbase),然後在left指針上加上這個偏移量,這樣我們就獲取到了Bottom類中Top類的開始地址。從上圖中,我們可以看到對於Left指針,它的virtual base offset是20,如果我們假設Bottom中每個成員都是4字節大小,那麼Left指針加上20字節正好是成員a的地址。

我們同樣可以用相同的方式訪問Bottom中Right部分。

1 Bottom* bottom = new Bottom();
2 Right* right = bottom;
3 int p = right->a;

right指針就會指向在Bottom對象中相應的位置。

 

這裏對於p的賦值語句最終會被編譯成和上述left相同的方式訪問a。唯一的不同是就是vptr,我們訪問的vptr現在指向了virtual table另一個地址,我們得到的virtual base offset也變爲12。我們畫圖總結下:

當然,關鍵點在於我們希望能夠讓訪問一個真正單獨的Right對象也如同訪問一個經過upcasted(到Right對象)的Bottom對象一樣。這裏我們也在Right對象中引入vptrs。

OK,現在這樣的設計終於讓我們可以通過一個Right指針訪問Bottom對象了。不過,需要提醒的是以上設計需要承擔一個相當大的代價:我們需要引入虛函數表,對象底層也必須擴展以支持一個或多個虛函數指針,原來一個簡單的成員訪問現在需要通過虛函數表兩次間接尋址(編譯器優化可以在一定程度上減輕性能損失)。

Downcasting

如我們猜想,將一個指針從一個派生類到一個基類的轉換(casting)會涉及到在指針上添加偏移量。可能有朋友猜想,downcasting一個指針僅僅減去一些偏移量就行了吧。實際上,非虛繼承情況下確實是這樣,但是,對於虛繼承來說,又不得不引入其它的複雜問題。這裏我們在上面的例子中添加一些繼承關係:

1 class AnotherBottom : public Left, public Right
2 {
3       public:
4            int e;
5            int f;
6 };

這個繼承關係如下圖所示:

那麼現在考慮如下代碼

1 Bottom* bottom1 = new Bottom();
2 AnotherBottom* bottom2 = new AnotherBottom();
3 Top* top1 = bottom1;
4 Top* top2 = bottom2;
5 Left* left = static_cast(top1);
下面這圖展示了Bottom和AnotherBottom的內存佈局,同時也展示了各自top指針所指向的位置。

現在我們來考慮考慮從top1到left的static_cast,注意這裏我們並不清楚對於top1指針指向的對象是Bottom還是AnotherBottom。這裏是根本不能編譯通過的!因爲根本不能確認top1運行時需要調整的偏移量(對於Bottom是20,對於AnotherBottom是24)。所以編譯器將會提出錯誤: error: cannot convert from base `Top’ to derived type `Left’ via virtual base `Top’。這裏我們需要知道運行時信息,所以我們需要使用dynamic_cast:

1 Left* left = dynamic_cast(top1);

不過,編譯器仍然會報錯的 error: cannot dynamic_cast `top’ (of type `class Top*’) to type `class Left*’ (source type is not polymorphic)。關鍵問題在於使用dynamic_cast(和使用typeid一樣)需要知道指針所指對象的運行時信息。但是,回頭看看上面的結構圖,我們就會發現top1指針所指的僅僅是一個整數成員a。編譯器沒有在Bottom類中包含針對top的vptr,它認爲這完全沒有必要。爲了強制編譯器在Bottom中包含top的vptr,我們可以在top類裏面添加一個虛析構函數。

1 class Top
2 {
3        public:
4            virtual ~Top() {}
5            int a;
6 };

這就迫使編譯器爲Top類添加了一個vptr。下面來看看Bottom新的內存佈局:

是的,其它派生類(Left、Right)都會添加一個vptr.top,編譯器爲dynamic_cast生成了一個庫函數調用。

1 left = __dynamic_cast(top1, typeinfo_for_Top, typeinfo_for_Left, -1);

__dynamic_cast定義在libstdc++(對應的頭文件是cxxabi.h),有了Top、Left和Bottom的類型信息,轉換得以執行。其中,參數-1代表的是類Left和類Top之間的關係未明。如果想詳細瞭解,請參看tinfo.cc的實現。

總結

最後,我們再聊聊一些相關內容。

二級指針

這裏的問題初看摸不着頭腦,但是細細想來有些問題還是顯而易見的。這裏我們考慮一個問題,還是以上節的Downcasting中的類繼承結構圖作爲例子。

1 Bottom* b = new Bottom();
2 Right* r = b;

(在把b指針的值賦值給指針r時,b指針將加上8字節,這樣r指針才指向Bottom對象中Right部分)。因此我們可以把Bottom*類型的值賦值給Right*對象。但是Bottom**和Right**兩種類型的指針之間賦值呢?

1 Bottom** bb = &b;
2 Right** rr = bb;

編譯器能通過這兩條語句嗎?實際上編譯器會報錯: error: invalid conversion from `Bottom**’ to `Right**’
爲什麼? 不妨反過來想想,如果能夠將bb賦值給rr,如下圖所示。所以這裏bb和rr兩個指針都指向了b,b和r都指向了Bottom對象的相應部分。那麼現在考慮考慮如果給*rr賦值將會發生什麼。

1 *rr = b;

注意*rr是Right*類型(一級)的指針,所以這個賦值是有效的!

這個就和我們上面給r指針賦值一樣(*rr是一級的Right*類型指針,而r同樣是一級Right*指針)。所以,編譯器將採用相同的方式實現對*rr的賦值操作。實際上,我們又要調整b的值,加上8字節,然後賦值給*rr,但是現在**rr其實是指向b的!如下圖

呃,如果我們通過rr訪問Bottom對象,那麼按照上圖結構我們能夠完成對Bottom對象的訪問,但是如果是用b來訪問Bottom對象呢,所有的對象引用實際上都偏移了8字節——明顯是錯誤的!

總而言之,儘管*a和*b之間能依靠類繼承關係相互轉化,而**a和**b不能有這種推論。

虛基類的構造函數

編譯器必須要保證所有的虛函數指針要被正確的初始化。特別是要保證類中所有虛基類的構造函數都要被調用,而且還只能調用一次。如果你寫代碼時自己不顯示調用構造函數,編譯器會自動插入一段構造函數調用代碼。這將會導致一些奇怪的結果,同樣考慮下上面的類繼承結構圖,不過要加入構造函數。

1 class Top
2 {
3      public:
4            Top() { a = -1; }
5            Top(int _a) { a = _a; }
6            int a;
7 };
8
9 class Left : public Top
10 {
11 public:
12            Left() { b = -2; }
13            Left(int _a, int _b) : Top(_a) { b = _b; }
14            int b;
15 };
16
17 class Right : public Top
18 {
19      public:
20            Right() { c = -3; }
21            Right(int _a, int _c) : Top(_a) { c = _c; }
22            int c;
23 };
24
25 class Bottom : public Left, public Right
26 {
27     public:
28            Bottom() { d = -4; }
29            Bottom(int _a, int _b, int _c, int _d) : Left(_a, _b), Right(_a, _c)
30            {
31                       d = _d;
32            }
33            int d;
34 };
35
先來考慮下不包含虛函數的情況,下面這段代碼輸出什麼?

1 Bottom bottom(1,2,3,4);
2 printf(“%d %d %d %d %d\n”, bottom.Left::a, bottom.Right::a, bottom.b, bottom.c, bottom.d);
你可能猜想會有這樣結果:

1 1 2 3 4
但是,如果我們考慮下包含虛函數的情況呢,如果我們從Top虛繼承派生出子類,那麼我們將得到如下結果:

-1 -1 2 3 4
如本節開頭所講,編譯器在Bottom中插入了一個Top的默認構造函數,而且這個默認構造函數安排在其他的構造函數之前,當Left開始調用它的基類構造函數時,我們發現Top已經構造初始化好了,所以相應的構造函數不會被調用。如果跟蹤構造函數,我們將會看到

Top::Top()
Left::Left(1,2)
Right::Right(1,3)
Bottom::Bottom(1,2,3,4)
爲了避免這種情況,我們應該顯示地調用虛基類的構造函數

1 Bottom(int _a, int _b, int _c, int _d): Top(_a), Left(_a,_b), Right(_a,_c)
2 {
3            d = _d;
4 }

到void* 的轉換

1 dynamic_cast(b);

最後我們來考慮下把一個指針轉換到void *。編譯器會把指針調整到對象的開始地址。通過查vtable,這個應該是很容易實現。看看上面的vtable結構圖,其中offset to top就是vptr到對象開始地址。另外因爲要查閱vtable,所以需要使用dynamic_cast。

指針的比較

再以上面Bottom類繼承關係爲例討論,下面這段代碼會打印Equal嗎?

1 Bottom* b = new Bottom();
2 Right* r = b;
3
4 if(r == b)
5      printf(“Equal!\n”);
先明確下這兩個指針實際上是指向不同地址的,r指針實際上在b指針所指地址上偏移8字節,但是,這些C++內部細節不能告訴C++程序員,所以C++編譯器在比較r和b時,會把r減去8字節,然後再來比較,所以打印出的值是”Equal”.


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