C++ 記憶體分佈之菱形繼承(無虛擬函式)

發表於2016-12-01

菱形繼承的定義是:兩個子類繼承同一父類,而又有子類同時繼承這兩個子類。例如a,b兩個類同時繼承c,但是又有一個d類同時繼承a,b類。探究的過程還是很有趣的。 菱形繼承的記憶體佈局探究花了我幾天時間,探究起來還是有點難度的。博文中如果有錯誤的地方,歡迎大家指正,大家共同進步。

一、繼承關係圖

圖1.菱形類圖

二、原始碼

在使用g++編譯的時候,如果father類和mother不是用虛繼承的話,編譯會報錯的。有網友爆料說window平臺是可以編譯過的,這裡我們就不關心了。下面的情況就會報錯

class father: public ancestor //缺少virtual關鍵字

class mother:public ancestor//缺少virtual關鍵字

錯誤log:下面明顯說有些成員和方法是模稜兩可的。

列印結果:下面列印的資料都是子類er物件的

上面可以看到發現下面幾個規律。

1.不管是直接列印子類er物件中c資料的地址,還是將子類物件er轉換成father和mother指標,分別列印c資料域地址都是一樣的。

2.除了ancestor物件的資料域(菱形的頂端類)在最頂端外,其它資料域仍然是按著繼承關係排列的。但是這裡共有祖先ancestor的資料域卻放到了最後面。

列印的地址沒有明顯規律,而且和上一篇我們探究的多繼承情況很不一樣。

這裡我們丟擲下面幾個疑問:

1.為什麼ancestor的c資料域,沒有放在子類物件首地址。

2.為什麼子類物件son資料大小不是father和mother物件的之和。

3.為什麼將son物件強制轉化成father和mother物件他們的c資料域地址是同一個地址,難道他們沒有繼承ancestor嗎?

……..

帶著種種問題,我們開啟探究之旅。

三、探究分析

1)除錯手段

在使用g++編譯C++程式一個強有力的編譯選項,下面是它的註釋:

-fdump-class-hierarchy-options (C++ only)

Dump a representation of each class’s hierarchy and virtual function table layout to a file.  The file name is made by appending .class

to the source file name, and the file is created in the same directory as the output file.  If the -options form is used, options

controls the details of the dump as described for the -fdump-tree options.

意思就是:帶上這個編譯選項,可以在生成一個.class字尾檔案,裡面包含該檔案的所有虛表。

這裡使用下面的編譯命令,來生成我們測試程式碼的虛表class,在生成虛表class當中,會夾雜許多其它無關緊要的虛表函式,如果你想看看這些無關緊要的虛表,還是自己把例子跑一遍吧,也許會有奇蹟發現。這裡我們只關心和我們類相關的虛表。有人可能會問,這裡根本沒有虛擬函式,怎麼就有續表資訊了。也許這就是因為我們是虛繼承吧。看到下面那條短小精悍的命令了嗎,趕緊執行它試試吧!

g++-fdump-class-hierarchy father_jing.cpp

虛表資訊:

當C++中出現了虛擬函式,編譯器都會為每一個類生成一個虛表,這個虛表具有可讀屬性,在ubuntu上它駐留在.rodata段,而且該類所有物件共有這個虛表。在後面會有列印資訊,來證明這點。在每一個例項記憶體空間的最前面會安排一個vptr來指向這個虛表。在後面除錯的時候,我會用gdb打出每一個例項的vptr。針對我們這個例子,上面是一張無效的虛表,因為我們類中根本就沒有虛擬函式。原因就是我們採用了虛繼承,g++還是按著有虛表的方式來編譯。這樣的話g++就會把一個虛指標安放在er物件中的father和mother資料開始處。

2)GDB順藤摸瓜

gdb的除錯大家應該很熟悉了吧。不熟悉的請看陳皓大神的GDB除錯程式

上面列印的地址和我們在之前的列印結果不一樣的,這很正常。每次執行系統分配的地址都是不一樣的。上面的列印結果可以看到下面幾個現象:

1.子類物件中對應father類的資料域中,沒有father繼承ancestor的c資料域。

2.子類物件中對應father類的資料起始位置放的是虛表存放地址。

3.子類物件中對應mother類的資料域中,沒有mother繼承ancestor的c資料域。

4.子類物件中對應mother類的資料起始位置放的是虛表存放地址。

5.祖先ancestor放到了子類物件的最後,這也是最大的亮點。

由上面的列印結果,我們可以得到下面這張對映表:

圖2 er物件的記憶體分佈

3)子類物件地址賦給父類指標會發生什麼

為了探究這個問題,我在程式碼中新增下面一行程式碼,就是為了驗證子類物件轉換成父類指標時,列印father中的c地址到底是它原有的的(0x60307c),還是列印er資料中最下面的c(0x603094)。

首先,用objdump命令將可執行檔案反彙編成彙編.main函式對應的彙編程式碼。

objdump -S a.out > tttttt.txt

使用這個命令會生成帶有C語言的彙編的程式碼,前提是我們在編譯可執行檔案時,新增了-g選項。為了各位同學能自己計算下面註釋中的一些值,打出了在進入main函式之前暫存器列表資訊:

下面只是主要的main函式反彙編程式碼:

我們知道每一個區域性變數一般都會儲存到棧中,如果想深入瞭解的話,同學們可以檢視Linux中的區域性變數和棧。這裡在main函式中生成的都是區域性變數,所以為此我們可以根據彙編程式碼,列出物件的分佈圖,如下所示。

圖 3 main函式中臨時變數分佈

當執行到int a = baba.c處,到底呼叫的是子類例項son中的父類資料域的c,還是共有的資料c。為了更清楚方便大家推算,我打出了執行這行程式碼前後的暫存器列表資訊。

擴充套件:

<1>(gdb) p *0x603070
$1 = 4199256 = 0x401358
(gdb) p *er
$2 = {<father> = {<ancestor> = {c = 88}, _vptr.father = 0x401358 <vtable for son+24>,
cc = 99}, <mother> = {_vptr.mother = 0x401370,
ccc = 100}, d = 2, e = 5}
<2>(gdb) p *0x401340
$3 = 36
<3>lea (%rdx,%rax,1),%rax 這句話的意思就是(rdx)+(rax)*1 即
=0x603070+36×1 = 0x603094 ,這裡就得出了c的地址。
針對上面的36我們再來看看son的vtable
Vtable for son
son::_ZTV3son: 6u entries
0 36u //這個就是,編譯器在虛表中記下了ancestor資料域的偏移
8 (int (*)(…))0
16 (int (*)(…))(& _ZTI3son)
24 20u
32 (int (*)(…))-0x00000000000000010
40 (int (*)(…))(& _ZTI3son)

總結:到現在我們就知道上面一開始丟擲的幾個問題了吧。

1.為什麼ancestor的c資料域,沒有放在子類物件首地址。

答:子類物件的首地址存放的是子類的虛表指標。由上面的除錯和log列印我們已經發現,在菱形繼承中,公共父類的資料域都是公用一份的,並且都是放在子類最下面的資料區。如果我們把子類物件地址賦給父類指標,例如:father *fa;fa=&er;.這裡我們可以在上面的除錯中發現,編譯器會自動將對應father類的那部分資料區的首地址賦給fa。最後訪問c域時,即fa->c,也是訪問子類0x603094處的共有c資料。

2.為什麼子類物件son資料大小不是father和mother物件的之和加son自己的資料域(sizeof(son)=sizeof(father)+sizeof(mother)+son_data)。

答:由於子類物件資料區多出了mother虛表指標,所以大小不一樣。

3.為什麼將son物件強制轉化成father和mother物件他們的c資料域地址是同一個地址,難道他們沒有繼承ancestor嗎?

答:上面一開始我看到子類物件中的father和mother資料域是8位元組對齊的,我以為只是續表指標是8位元組對齊,所以我將子類物件中對應father資料按4位元組地址列印,結果本應該是c資料域的地方列印的是0,所以子類屬於father的資料區中沒有c資料域

相關文章