菱形繼承的定義是:兩個子類繼承同一父類,而又有子類同時繼承這兩個子類。例如a,b兩個類同時繼承c,但是又有一個d類同時繼承a,b類。探究的過程還是很有趣的。 菱形繼承的記憶體佈局探究花了我幾天時間,探究起來還是有點難度的。博文中如果有錯誤的地方,歡迎大家指正,大家共同進步。
一、繼承關係圖
圖1.菱形類圖
二、原始碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
#include<iostream> using namespace std; class ancestor{ public: int c; ancestor():c(88){} int* get_c(void){return &c;} }; class father:virtual public ancestor{ public: int cc; father():cc(99){} int* fa_get_anc_c(void){return get_c();} int* get_cc(void){return &cc;} }; class mother:virtual public ancestor{ public: int ccc; mother():ccc(100){} int* ma_get_anc_c(void){return get_c();} int* get_ccc(void){return &ccc;} }; class son :public father,public mother{ public: int d; int e; son():d(2),e(5){} }; int main(){ ancestor *anc= new ancestor(); father *ba = new father(); mother *ma = new mother(); son *er = new son(); ancestor &ancanc =*er; father &baba = *er; mother &mama = *er; int a =baba.c; cout <<"anc_c:"<<ancanc.get_c()<<endl; cout <<"\n"<<endl; cout <<"baba_c:"<<baba.fa_get_anc_c()<<endl; cout <<"baba_cc:"<<baba.get_cc()<<endl; cout <<"\n"<<endl; cout <<"mama_c:"<<mama.ma_get_anc_c()<<endl; cout <<"mama_ccc:"<<mama.get_ccc()<<endl; cout <<"\n"<<endl; cout <<"&er :"<<er<<endl; cout <<"er.c:"<<er->get_c()<<endl; cout <<"er.fa_get_anc_c:"<<er->fa_get_anc_c()<<endl; cout <<"er.get_cc:"<<er->get_cc()<<endl; cout <<"er.ma_get_anc_c:"<<er->ma_get_anc_c()<<endl; cout <<"er.get_ccc:"<<er->get_ccc()<<endl; cout <<"er.d:"<<&er->d<<endl; cout <<"er.e:"<<&er->e<<endl; cout <<"\n"<<endl; cout << "sizeof(ancestor):"<<sizeof(*anc)<<endl; cout << "sizeof(father):"<<sizeof(*ba)<<endl; cout << "sizeof(mother):"<<sizeof(*ma)<<endl; cout << "sizeof( -son-):"<<sizeof(*er)<<endl; } |
在使用g++編譯的時候,如果father類和mother不是用虛繼承的話,編譯會報錯的。有網友爆料說window平臺是可以編譯過的,這裡我們就不關心了。下面的情況就會報錯
class father: public ancestor //缺少virtual關鍵字
class mother:public ancestor//缺少virtual關鍵字
錯誤log:下面明顯說有些成員和方法是模稜兩可的。
1 2 3 4 5 |
father_jing.cpp: In function ‘int main()’: father_jing.cpp:47: error: ‘ancestor’ is an ambiguous base of ‘son’ father_jing.cpp:64: error: request for member ‘get_c’ is ambiguous father_jing.cpp:15: error: candidates are: int* ancestor::get_c() father_jing.cpp:15: error: int* ancestor::get_c() |
列印結果:下面列印的資料都是子類er物件的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
anc_c:0xbdd094 baba_c:0xbdd094 baba_cc:0xbdd078 mama_c:0xbdd094 mama_ccc:0xbdd088 &er :0xbdd070 er.c:0xbdd094 er.fa_get_anc_c:0xbdd094 er.get_cc:0xbdd078 er.ma_get_anc_c:0xbdd094 er.get_ccc:0xbdd088 er.d:0xbdd08c er.e:0xbdd090 sizeof(ancestor):4 sizeof(father):16 sizeof(mother):16 sizeof( -son-):40 |
上面可以看到發現下面幾個規律。
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
虛表資訊:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
Vtable for father //字面意思太明顯,還要我翻譯嗎^_^ father::_ZTV6father: 3u entries //3個入口函式,其實無效 0 12u 8 (int (*)(...))0 16 (int (*)(...))(& _ZTI6father) //這個儲存的是虛表開始地址。 Class father size=16 align=8 //father類大小是16位元組,對齊8個位元組 base size=12 base align=8 //基類大小12位元組,對齊8位元組,這個有點不準確,我們列印結果是基類4位元組。 father (0x7f98f88e6e00) 0 vptridx=0u vptr=((& father::_ZTV6father) + 24u) //虛表首地址加偏移量 ancestor (0x7f98f88e6e70) 12 virtual vbaseoffset=-0x00000000000000018 Vtable for mother mother::_ZTV6mother: 3u entries 0 12u 8 (int (*)(...))0 16 (int (*)(...))(& _ZTI6mother) Class mother size=16 align=8 base size=12 base align=8 mother (0x7f98f89183f0) 0 vptridx=0u vptr=((& mother::_ZTV6mother) + 24u)//虛表首地址加偏移量 ancestor (0x7f98f8918460) 12 virtual vbaseoffset=-0x00000000000000018 Vtable for son son::_ZTV3son: 6u entries 0 36u //這個值就是在將子類物件賦給father指標時,用father指標取c時,根據這個偏移量計算出c的地址,這一系列動作由編譯器在編譯階段完成 8 (int (*)(...))0 16 (int (*)(...))(& _ZTI3son) 24 20u //如上同理 32 (int (*)(...))-0x00000000000000010 40 (int (*)(...))(& _ZTI3son) Construction vtable for father (0x7f98f8921a80 instance) in son //子類物件er中建立father物件虛表. son::_ZTC3son0_6father: 3u entries 0 36u 8 (int (*)(...))0 16 (int (*)(...))(& _ZTI6father) //還是指向父類物件的虛表指標 Construction vtable for mother (0x7f98f8921b60 instance) in son //子類物件er中建立mother物件虛表. son::_ZTC3son16_6mother: 3u entries 0 20u 8 (int (*)(...))0 16 (int (*)(...))(& _ZTI6mother)//同理 Class son size=40 align=8 base size=36 base align=8 //這裡base大小是36位元組,father+mother=36. son (0x7f98f8920b00) 0 vptridx=0u vptr=((& son::_ZTV3son) + 24u) //子類物件中,包含的father資料的虛指標,至於那個vptridx是幹什麼用的,我也沒搞多明白,對照子類虛表 //也許會有什麼發現。 father (0x7f98f8921a80) 0 primary-for son (0x7f98f8920b00) subvttidx=8u ancestor (0x7f98f8921af0) 36 virtual vbaseoffset=-0x00000000000000018 mother (0x7f98f8921b60) 16 subvttidx=16u vptridx=24u vptr=((& son::_ZTV3son) + 48u) //子類物件中,包含的mother資料的虛指標 ancestor (0x7f98f8921af0) alternative-path |
當C++中出現了虛擬函式,編譯器都會為每一個類生成一個虛表,這個虛表具有可讀屬性,在ubuntu上它駐留在.rodata段,而且該類所有物件共有這個虛表。在後面會有列印資訊,來證明這點。在每一個例項記憶體空間的最前面會安排一個vptr來指向這個虛表。在後面除錯的時候,我會用gdb打出每一個例項的vptr。針對我們這個例子,上面是一張無效的虛表,因為我們類中根本就沒有虛擬函式。原因就是我們採用了虛繼承,g++還是按著有虛表的方式來編譯。這樣的話g++就會把一個虛指標安放在er物件中的father和mother資料開始處。
2)GDB順藤摸瓜
gdb的除錯大家應該很熟悉了吧。不熟悉的請看陳皓大神的GDB除錯程式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
Breakpoint 1, main () at father_jing.cpp:43 43 ancestor *anc= new ancestor(); (gdb) n 44 father *ba = new father(); (gdb) n 45 mother *ma = new mother(); (gdb) n 46 son *er = new son(); (gdb) n 47 ancestor &ancanc =*er; (gdb) n 48 father &baba = *er; (gdb) n 49 mother &mama = *er; (gdb) n 51 cout <<"anc_c:"<<ancanc.get_c()<<endl; (gdb) p ((int*)(er)+0) $1 = (int *) 0x603070 (gdb) p *((int*)(er)+0) $2 = 4199096 (gdb) p ((int*)(er)+1) $3 = (int *) 0x603074 (gdb) p *((int*)(er)+1) $4 = 0 (gdb) p ((int*)(er)+2) $5 = (int *) 0x603078 (gdb) p *((int*)(er)+2) //father資料域cc $6 = 99 (gdb) p ((int*)(er)+3) $7 = (int *) 0x60307c (gdb) p *((int*)(er)+3) $8 = 0 //這個地方就是ancestor祖先的資料域,但是這裡居然是0,不是88,why (gdb) p ((int*)(er)+4) $9 = (int *) 0x603080 (gdb) p *((int*)(er)+4) $10 = 4199120 (gdb) p ((int*)(er)+5) $11 = (int *) 0x603084 (gdb) p *((int*)(er)+5) $12 = 0 (gdb) p ((int*)(er)+6) $13 = (int *) 0x603088 (gdb) p *((int*)(er)+6) //mother資料域ccc,那我們的祖先的ancestor資料域c呢。 $14 = 100 (gdb) p ((int*)(er)+7) //這個地方是ancestor祖先的資料域,但是這裡居然是2,已經是子類的資料域了ancestor根本沒有,why $15 = (int *) 0x60308c (gdb) p *((int*)(er)+7) $16 = 2 (gdb) p ((int*)(er)+8) $17 = (int *) 0x603090 (gdb) p *((int*)(er)+8) $18 = 5 (gdb) p ((int*)(er)+9) $19 = (int *) 0x603094 (gdb) p *((int*)(er)+9) //最後我們打出son中基類ancestor資料域c放到了最後,地址為證。 $20 = 88 (gdb) p ((int*)(er)+10) $21 = (int *) 0x603098 (gdb) p *((int*)(er)+10) //到這裡已經獲取到的是一個無效的值了,和我們類例項沒什麼關係了。由son大小是40位元組,也可以知道。 $22 = 135025 (gdb) n |
上面列印的地址和我們在之前的列印結果不一樣的,這很正常。每次執行系統分配的地址都是不一樣的。上面的列印結果可以看到下面幾個現象:
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)。
1 |
int a =baba.c; |
首先,用objdump命令將可執行檔案反彙編成彙編.main函式對應的彙編程式碼。
objdump -S a.out > tttttt.txt
使用這個命令會生成帶有C語言的彙編的程式碼,前提是我們在編譯可執行檔案時,新增了-g選項。為了各位同學能自己計算下面註釋中的一些值,打出了在進入main函式之前暫存器列表資訊:
1 2 3 4 5 6 7 8 9 |
(gdb) info registers all rax 0x7ffff75b8548 140737343358280 rbx 0x0 0 rcx 0x60 96 rdx 0x7fffffffdf58 140737488346968 rsi 0x7fffffffdf48 140737488346952 rdi 0x1 1 rbp 0x7fffffffde60 0x7fffffffde60 rsp 0x7fffffffde10 0x7fffffffde10 |
下面只是主要的main函式反彙編程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
int main(){ 400a34: 55 push %rbp "由1可得,此時rbp=0x7fffffffde60,將rbp暫存器入棧,關於這裡的意思大家可以看看Linux中的區域性變數和棧 400a35: 48 89 e5 mov %rsp,%rbp "此時rsp = 0x7fffffffde10 400a38: 53 push %rbx 400a39: 48 83 ec 48 sub $0x48,%rsp ancestor *anc= new ancestor(); 400a3d: bf 04 00 00 00 mov $0x4,%edi 400a42: e8 f9 fe ff ff callq 400940 <_Znwm@plt> 400a47: 48 89 c3 mov %rax,%rbx "rax返回的就是anc物件例項的地址 400a4a: 48 89 d8 mov %rbx,%rax 400a4d: 48 89 c7 mov %rax,%rdi 400a50: e8 43 05 00 00 callq 400f98 <_ZN8ancestorC1Ev> "呼叫建構函式 400a55: 48 89 5d e0 mov %rbx,-0x20(%rbp) "將anc例項如棧 father *ba = new father(); 400a59: bf 10 00 00 00 mov $0x10,%edi 400a5e: e8 dd fe ff ff callq 400940 <_Znwm@plt> 400a63: 48 89 c3 mov %rax,%rbx "rax返回的就是ba物件例項的地址 400a66: 48 89 d8 mov %rbx,%rax 400a69: 48 89 c7 mov %rax,%rdi 400a6c: e8 73 05 00 00 callq 400fe4 <_ZN6fatherC1Ev> "呼叫建構函式 400a71: 48 89 5d d8 mov %rbx,-0x28(%rbp) "將ba例項如棧 mother *ma = new mother(); 400a75: bf 10 00 00 00 mov $0x10,%edi 400a7a: e8 c1 fe ff ff callq 400940 <_Znwm@plt> 400a7f: 48 89 c3 mov %rax,%rbx "rax返回的就是ma物件例項的地址 400a82: 48 89 d8 mov %rbx,%rax 400a85: 48 89 c7 mov %rax,%rdi 400a88: e8 f5 05 00 00 callq 401082 <_ZN6motherC1Ev> "呼叫建構函式 400a8d: 48 89 5d d0 mov %rbx,-0x30(%rbp) "將ma例項如棧 son *er = new son(); 400a91: bf 28 00 00 00 mov $0x28,%edi 400a96: e8 a5 fe ff ff callq 400940 <_Znwm@plt> 400a9b: 48 89 c3 mov %rax,%rbx "rax返回的就是er物件例項的地址 400a9e: 48 89 d8 mov %rbx,%rax 400aa1: 48 89 c7 mov %rax,%rdi 400aa4: e8 4d 06 00 00 callq 4010f6 <_ZN3sonC1Ev> "呼叫建構函式 400aa9: 48 89 5d c8 mov %rbx,-0x38(%rbp) "將er例項如棧 ancestor &ancanc =*er; 400aad: 48 83 7d c8 00 cmpq $0x0,-0x38(%rbp) "這裡先比較er物件例項是否為空 400ab2: 74 18 je 400acc <main+0x98> 400ab4: 48 8b 55 c8 mov -0x38(%rbp),%rdx 400ab8: 48 8b 45 c8 mov -0x38(%rbp),%rax "取出裡面儲存er物件例項的地址 400abc: 48 8b 00 mov (%rax),%rax 400abf: 48 83 e8 18 sub $0x18,%rax 400ac3: 48 8b 00 mov (%rax),%rax 400ac6: 48 8d 04 02 lea (%rdx,%rax,1),%rax 400aca: eb 05 jmp 400ad1 <main+0x9d> 400acc: b8 00 00 00 00 mov $0x0,%eax 400ad1: 48 89 45 c0 mov %rax,-0x40(%rbp) "這裡將er中儲存的c域地址放到bp-40偏移處 father &baba = *er; 400ad5: 48 8b 45 c8 mov -0x38(%rbp),%rax "直接將er物件的地址放到rbp-0x48偏移處 400ad9: 48 89 45 b8 mov %rax,-0x48(%rbp) mother &mama = *er; 400add: 48 83 7d c8 00 cmpq $0x0,-0x38(%rbp) 400ae2: 74 0a je 400aee <main+0xba> 400ae4: 48 8b 45 c8 mov -0x38(%rbp),%rax 400ae8: 48 83 c0 10 add $0x10,%rax 400aec: eb 05 jmp 400af3 <main+0xbf> 400aee: b8 00 00 00 00 mov $0x0,%eax 400af3: 48 89 45 b0 mov %rax,-0x50(%rbp) "這裡會將mama物件放到rbp-0x50處,具體怎麼算的,請看下面獲取c的過程 int a =baba.c; 400af7: 48 8b 55 b8 mov -0x48(%rbp),%rdx "這裡程式碼留到下面分析 400afb: 48 8b 45 b8 mov -0x48(%rbp),%rax 400aff: 48 8b 00 mov (%rax),%rax 400b02: 48 83 e8 18 sub $0x18,%rax 400b06: 48 8b 00 mov (%rax),%rax 400b09: 48 8d 04 02 lea (%rdx,%rax,1),%rax 400b0d: 8b 00 mov (%rax),%eax 400b0f: 89 45 ec mov %eax,-0x14(%rbp) ....... } |
我們知道每一個區域性變數一般都會儲存到棧中,如果想深入瞭解的話,同學們可以檢視Linux中的區域性變數和棧。這裡在main函式中生成的都是區域性變數,所以為此我們可以根據彙編程式碼,列出物件的分佈圖,如下所示。
圖 3 main函式中臨時變數分佈
當執行到int a = baba.c處,到底呼叫的是子類例項son中的父類資料域的c,還是共有的資料c。為了更清楚方便大家推算,我打出了執行這行程式碼前後的暫存器列表資訊。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
在執行int a = baba.c之前的暫存器狀態 (gdb) info registers all rax 0x603080 6303872 rbx 0x603070 6303856 rcx 0x10 16 rdx 0x603070 6303856 rsi 0x4013b0 4199344 rdi 0x603080 6303872 rbp 0x7fffffffde60 0x7fffffffde60 rsp 0x7fffffffde10 0x7fffffffde10 .............................. int a =baba.c; 400af7: 48 8b 55 b8 mov -0x48(%rbp),%rdx "rdx=0x603070 400afb: 48 8b 45 b8 mov -0x48(%rbp),%rax "rdx=0x603070 400aff: 48 8b 00 mov (%rax),%rax "取出rax中的內容rax=0x401358 --------<1> 400b02: 48 83 e8 18 sub $0x18,%rax "將暫存器rax-0x18,即rax=0x401340 400b06: 48 8b 00 mov (%rax),%rax "取出0x401340的內容,rax=36 -------<2> 400b09: 48 8d 04 02 lea (%rdx,%rax,1),%rax "rax=0x603094 ---------<3> 400b0d: 8b 00 mov (%rax),%eax "0x603094就是c的地址 400b0f: 89 45 ec mov %eax,-0x14(%rbp) "將c的值放到rbp-0x14偏移處。 ........................... 執行int a = baba.c之後的暫存器值列表 (gdb) info registers all rax 0x58 88 "返回值就是88了 rbx 0x603070 6303856 rcx 0x10 16 rdx 0x603070 6303856 rsi 0x4013b0 4199344 rdi 0x603080 6303872 rbp 0x7fffffffde60 0x7fffffffde60 rsp 0x7fffffffde10 0x7fffffffde10 |
擴充套件:
<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資料域