多重繼承及虛繼承中物件記憶體的分佈

王慶發表於2013-09-22

轉載:http://www.tbdata.org/archives/878

這篇文章主要講解G++編譯器中虛繼承的物件記憶體分佈問題,從中也引出了dynamic_cast和static_cast本質區別、虛擬函式表的格式等一些大部分C++程式設計師都似是而非的概念。問題拿捏得十分到位,下面是我對原文的翻譯,原文見這裡(By Edsko de Vries, January 2006)。

本文是介紹C++的技術文章,假定讀者對於C++有比較深入的認識,同時也需要一些彙編知識。

本文我們將闡釋GCC編譯器針對多重繼承和虛擬繼承下的物件記憶體佈局。儘管在理想的使用環境中,一個C++程式設計師並不需要了解這些編譯器內部實現細節,實際上,編譯器針對多重繼承(特別是虛擬繼承)的各種實現細節對於我們編寫C++程式碼都或多或少產生一些影響(比如downcasting pointer、pointers to pointers 以及虛基類建構函式的呼叫順序)。如果你能明白多重繼承是如何實現的,那麼你自己就能夠預見到這些影響,進而能夠在你的程式碼中很好地應對它們。再者,如果你十分在意的程式碼的執行效率,正確地理解虛繼承也是很有幫助的。最後嘛,這個hack的過程是很有趣的哦:)

多重繼承
首先我們先來考慮一個很簡單(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”.

參考文獻
[1] CodeSourcery, in particular the C++ ABI Summary, the Itanium C++ ABI (despite the name, this document is referenced in a platform-independent context; in particular, the structure of the vtables is detailed here). The libstdc++ implementation of dynamic casts, as well RTTI and name unmangling/demangling, is defined in tinfo.cc.

[2] The libstdc++ website, in particular the section on the C++ Standard Library API.

[3] C++: Under the Hood by Jan Gray.

[4] Chapter 9, “Multiple Inheritance” of Thinking in C++ (volume 2) by Bruce Eckel. The author has made this book available for download.

相關文章