C++虛繼承原理與類布局深度分析
引言
在開始深入了解虛繼承之前,我們先要明白C++引入虛繼承的目的。C++有別于其他OOP語言最明顯的特性就是類的多繼承,而菱形繼承結(jié)構(gòu)則是多繼承中最令人頭疼的情況。
我們都知道,當(dāng)派生類繼承基類時,派生類內(nèi)部會保存一份基類數(shù)據(jù)的副本。在D
->B
|C
, B
|C
->A
的菱形繼承結(jié)構(gòu)中,B
、C
各自存有一份A
成員變量的副本,這導(dǎo)致D
繼承B
、C
后同時保存了兩份A
成員變量,這就導(dǎo)致了空間浪費和語法二義性的問題。
所以C++引入了虛繼承,用于解決菱形繼承導(dǎo)致的數(shù)據(jù)冗余。
本文的目標(biāo)是探究虛繼承的實現(xiàn)方式和類布局(Class Layout)的具體規(guī)則,主要內(nèi)容源自于本人對C++: Under the Hood的解讀和提煉。
不過在開始之前,我們需要先熟悉一下普通繼承下的類布局,方便與之后的虛繼承進(jìn)行對比。
請注意,以下用于分析的數(shù)據(jù)皆來自于MSVC的編譯結(jié)果。C++標(biāo)準(zhǔn)定義了一些基本規(guī)范,但不同編譯器的實現(xiàn)方式可能會有所差異,所以內(nèi)容僅具有一定的參考性。
單繼承
以下是由A
類派生B
類的單繼承例子:
none
class A { public: int a1; int a2; };
none
class B : public A { public: int b1; int b2; };
通過在VS中啟用Class Layout的輸出,我們可以得到以下內(nèi)容:
none
class A size(8): +--- 0 | a1 4 | a2 +--- class B size(16): +--- 0 | +--- (base class A) 0 | | a1 4 | | a2 | +--- 8 | b1 12 | b2 +---
Visual Studio中查看類布局的方法可以參考http://chabaoo.cn/article/208240.htm。
看起來可能有點抽象,它其實是等價于下圖中的內(nèi)容:
由于派生類繼承了其基類的所有屬性和行為,因此派生類的每個實例都將包含基類實例數(shù)據(jù)的完整副本。在B
中,A
的成員數(shù)據(jù)擺放在B
的成員數(shù)據(jù)之前。雖然標(biāo)準(zhǔn)并沒有如此規(guī)定,但是當(dāng)我們需要將B
類的地址嵌入A
類的指針時(例如:A *p = new B();
),這種布局不需要再添加額外的位移,就可以使指針指向A
數(shù)據(jù)段的開頭(在接下來的多繼承中更能體現(xiàn)這么做的好處)。圖中A*
、B*
指針指向的位置也體現(xiàn)了這一點。
因此,在單繼承的類層次結(jié)構(gòu)中,每個派生類中引入的新實例數(shù)據(jù)只是簡單地附加到基類的布局末尾。
多繼承
none
class A { public: int a1; int a2; };
none
class B { public: int b1; int b2; };
none
class C : public A, public B { public: int c1; int c2; };
類C
多重繼承自A
和B
,與單繼承一樣,C
包含每個基類實例數(shù)據(jù)的副本,并且置于類的最前方。與單繼承不同是,多繼承不可能使每個基類數(shù)據(jù)的起始地址都位于派生類的開頭。從圖中也可以看出,在基類A
占據(jù)起始位置后,基類B
只能保存在偏移量為8的位置。這就使得將C*
轉(zhuǎn)換為A*
和B*
時的操作出現(xiàn)了差異。
none
C c; (void *)(A *)&c == (void *)&c (void *)(B *)&c > (void *)&c (void *)(B *)&c == (void*)(sizeof (A) + (char *)&c)
這幾個判斷語句的結(jié)果都為true
,因此可以看出當(dāng)C*
轉(zhuǎn)為B*
時,會在原地址的基礎(chǔ)上進(jìn)行偏移。這也是多繼承帶來的開銷之一。
編譯器實現(xiàn)可以采用任何順序布置基類實例和派生類實例數(shù)據(jù)。MSVC通常的做法是先按聲明順序布局基類實例,然后按聲明順序布置派生類的新數(shù)據(jù)成員。 不過在后續(xù)的例子中我們將會看到,當(dāng)部分基類具有虛基類表(或虛函數(shù)表)而其他基類沒有時,情況就不一定如此了。
菱形繼承
現(xiàn)在就搬出我們在文章開頭提到的菱形繼承的例子,來看看具體的布局是怎么樣的。
none
class A { public: int a1; int a2; };
none
class B : public A { public: int b1; int b2; };
none
class C : public A { public: int c1; int c2; };
none
class D : public B, public C { public: int d1; int d2; };
類B
和C
都繼承了A
,因此也都保存了一份基類A
的實例數(shù)據(jù)副本。
當(dāng)類D
同時繼承了類B
和C
之后,也完整地保存了B
和C
的實例數(shù)據(jù)副本,也就導(dǎo)致D
中出現(xiàn)了兩份A
的實例數(shù)據(jù)副本。
編譯器不能確定我們究竟是要訪問從B
繼承來的A
成員,還是從C
繼承來的A
成員,從D*
轉(zhuǎn)換到A*
的偏移量也無法確定。因此,下面這些操作都是具有二義性的,不能成功編譯:
none
D d; d.a1 = 1; // E0266 "D::a1" 不明確 A *p_a = (A *)&d; // C2594 “類型強(qiáng)制轉(zhuǎn)換”: 從“D *”到“A *”的轉(zhuǎn)換不明確
想要成功執(zhí)行的話,就必須顯式地聲明訪問路徑,以消除二義性:
none
D d; d.B::a1 = 1; // 或者d.C::a1 A *p_a = (A *)(B *)&d; // 或者(A *)(C *)&d
虛繼承
為了解決這一問題,C++引入了虛繼承的概念。在僅保留一份重復(fù)的實例數(shù)據(jù)副本的情況下,通過虛基類表(vbtable)來訪問共享的實例數(shù)據(jù)。聽起來有些難以理解,所以接下來我會通過分析虛繼承下的類布局來解釋虛繼承語法的實現(xiàn)。
我們先來分析單繼承情況下,虛繼承與普通繼承之間的類布局差異。
none
class A { public: int a1; int a2; };
none
class B : public A { public: int b1; int b2; };
none
class C : virtual public A { public: int c1; int c2; };
A
為基類,B
繼承于A
,C
虛繼承于A
。
通過對比B
和C
的類布局我們可以發(fā)現(xiàn)兩個明顯的差異:
- 虛繼承中,派生類布局的起始位置增加了
vbptr
指針,該指針指向vbtable
- 虛繼承中,基類的實例數(shù)據(jù)副本被放置在了派生類的末尾
而vbtable
中的兩個條目也很好理解,我們首先要知道XdYvbptrZ
表示的是在X
類中,Y
的vbptr
到Z
類入口的偏移量。因此:
- 第一條記錄
CdCvbptrC = 0
表示,C
類中,C
的vbptr
到C
類入口的偏移量為0
。 - 第二條記錄
CdCvbptrA = 16
表示,C
類中,C
的vbptr
到A
類入口的偏移量為16
。從圖中也可以看出C
類中,C::vbptr
的保存位置為0
,A
類的入口位于16
,因此偏移量為16
。
在數(shù)據(jù)訪問的過程中,需要用到vbtable
中的偏移量來計算訪問地址,這就涉及到了查表+偏移的操作。因此,虛繼承的訪問開銷會比前面在多繼承中提到的固定偏移計算來得更大,與此同時vbptr
和vbtable
也造成了額外的內(nèi)存開銷。
從單繼承的例子來看,虛繼承帶來了更大的時間和內(nèi)存開銷,但卻沒有體現(xiàn)出任何的額外優(yōu)勢。并且也看不出vbptr
和vbtable
存在的必要性,畢竟為什么我們不直接讓A* = C* + 16
呢?
而接下來通過菱形繼承的例子,我們就會明白這種做法的必要性。
虛繼承——菱形繼承
none
class A { public: int a1; int a2; };
none
class B : virtual public A { public: int b1; int b2; };
none
class C : virtual public A { public: int c1; int c2; };
none
class D : public B, public C { public: int d1; int d2; };
需要注意,在這個例子中B
和C
虛繼承于A
,而D
則是普通繼承于B
和C
。
在為菱形繼承添加上虛繼承之后,我們可以明確地看到B
和C
結(jié)尾的A
實例數(shù)據(jù)副本,在D
的結(jié)尾被合并成了一份。與此同時,編譯器根據(jù)D
的布局結(jié)構(gòu)創(chuàng)建了新的vbtable
,B
和C
的vbptr
也被修改為指向新的vbtable
。
現(xiàn)在我們就可以解答前面提出的問題:“為什么不直接讓`A* = C* + 16呢?”
從圖中就可以看出,在C
類的布局中,C* + 16 == A*
是成立的,因此以下代碼的運(yùn)行結(jié)果是1
none
C* p_c = new C(); A* p_a = p_c; // 編譯器自動轉(zhuǎn)換的結(jié)果 printf("%d", (void*)p_a == (void*)(16 + (char*)p_c)); // 返回1
而在D
類之中,C* + 16
訪問的就是D::d1
的地址了,這種做法明顯是錯誤的,因此代碼的運(yùn)行結(jié)果是0
none
C* p_c = new D(); // 注意:這里的C*來源于類型D A* p_a = p_c; printf("%d", (void*)p_a == (void*)(16 + (char*)p_c)); // 返回0
所以根本的問題在于,不同類中的A*
相對于C*
的位置是不固定的,在運(yùn)行時多態(tài)的情況下,我們無法僅在編譯階段計算出確定的偏移量。
但有了vbptr
和vbtable
之后,無論是C
類的C*
還是D
類的C*
,我們都可以訪問當(dāng)前vbptr
所指向的vbtable
獲取偏移量。而vbptr
和vbtable
都是可以在編譯時根據(jù)類布局來確定的。所以下面的代碼中,無論C*
的來源是C
類還是D
類,運(yùn)行的結(jié)果始終為1
none
C* p_c = new D(); A* p_a = p_c; int* vbptr_c = *(int**)p_c; // 這里根據(jù)C類的布局知道vbptr位于C*的起始位置(編譯時確定) printf("%d", (void*)p_a == (void*)(*(vbptr_c + 1) + (char*)p_c)); // vbptr_c + 1是因為A*偏移量位于vbtable[1](編譯時確定)
虛表指針(vbptr)的位置
關(guān)于虛繼承的實現(xiàn)方式已經(jīng)解釋的差不多了,接下來我們再介紹幾種類布局的情況,以幫助你更好地理解這些概念。
讓我們先復(fù)習(xí)一下上一個章節(jié)中的例子來說明:
none
class A { public: int a1; int a2; }; class C : virtual public A { public: int c1; int c2; };
我們已經(jīng)介紹過了這個布局,C
虛繼承A
后,在起始位置添加了vbptr
,并將A
的實例數(shù)據(jù)副本布置在了末尾。
讓我們把情況弄得稍微復(fù)雜一些:
none
class A { public: int a1; int a2; }; class B // 注意,這次B沒有繼承A { public: int b1; int b2; }; class C : virtual public A, public B { public: int c1; int c2; };
我們讓C
虛繼承A
的同時,再普通繼承B
。這次C
發(fā)生了兩個變化:
vbptr
的位置從0
變?yōu)榱?code>8,也就是說vbptr
的行為似乎和普通成員變量一樣,被布置在基類的成員之后。注意我這里說的是"似乎",因為下一章節(jié)我們就會找到特例。- 第二個變化則是
vbtable
中的CdCvbptrC
的值從0
變?yōu)榱?code>-8,這其實就是受到vbptr
位置變化的影響。
共用虛基類表(vbtable)
介紹完“正常情況”后,我們再來看一個特殊情況。
none
class A { public: int a1; int a2; };
none
class B : virtual public A { public: int b1; int b2; };
none
class C : virtual public A, public B { public: int c1; int c2; };
這次我們讓B
虛繼承于A
,然后和上一章一樣,讓C
虛繼承A
的同時,再普通繼承B
。
可以看到,由于B
和C
都有vbptr
,并且具有公共的虛基類A
,導(dǎo)致二者的vbptr
合并到了起始位置,并且共用一個vbtable
。
后續(xù)我經(jīng)過幾次測試后發(fā)現(xiàn)一個規(guī)律,當(dāng)派生類同時進(jìn)行虛繼承和非虛繼承的情況下,只要非虛繼承的基類中存在vbptr
指針,那么派生類的虛繼承就會與之共用一個vbptr
和vbtable
。
參考資料
How virtual inheritance is implemented in memory by c++ compiler?
到此這篇關(guān)于C++虛繼承原理與類布局分析的文章就介紹到這了,更多相關(guān)C++虛繼承原理內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
使用C++實現(xiàn)類似Qt的信號與槽機(jī)制功能
信號與槽機(jī)制是 Qt 框架中的核心設(shè)計,用于實現(xiàn)對象之間的解耦通信,在純 C++ 中,我們也可以設(shè)計出類似的機(jī)制,利用模板、函數(shù)指針和哈希表,實現(xiàn)高效且靈活的信號與槽功能,本文給大家介紹了如何使用C++實現(xiàn)類似Qt的信號與槽機(jī)制功能,需要的朋友可以參考下2025-01-01C語言科學(xué)計算入門之矩陣乘法的相關(guān)計算
這篇文章主要介紹了C語言科學(xué)計算入門之矩陣乘法的相關(guān)計算,文章中還介紹了矩陣相關(guān)的斯特拉森算法的實現(xiàn),需要的朋友可以參考下2015-12-12C語言之實現(xiàn)棧的基礎(chǔ)創(chuàng)建
這篇文章主要介紹了C語言之實現(xiàn)棧的基礎(chǔ)創(chuàng)建,本篇文章通過簡要的案例,講解了該項技術(shù)的了解與使用,以下就是詳細(xì)內(nèi)容,需要的朋友可以參考下2021-07-07C++實現(xiàn)LeetCode(769.可排序的最大塊數(shù))
這篇文章主要介紹了C++實現(xiàn)LeetCode(769.可排序的最大塊數(shù)),本篇文章通過簡要的案例,講解了該項技術(shù)的了解與使用,以下就是詳細(xì)內(nèi)容,需要的朋友可以參考下2021-07-07