C++虛繼承原理與類布局深度分析
引言
在開(kāi)始深入了解虛繼承之前,我們先要明白C++引入虛繼承的目的。C++有別于其他OOP語(yǔ)言最明顯的特性就是類的多繼承,而菱形繼承結(jié)構(gòu)則是多繼承中最令人頭疼的情況。
我們都知道,當(dāng)派生類繼承基類時(shí),派生類內(nèi)部會(huì)保存一份基類數(shù)據(jù)的副本。在D->B|C, B|C->A的菱形繼承結(jié)構(gòu)中,B、C各自存有一份A成員變量的副本,這導(dǎo)致D繼承B、C后同時(shí)保存了兩份A成員變量,這就導(dǎo)致了空間浪費(fèi)和語(yǔ)法二義性的問(wèn)題。
所以C++引入了虛繼承,用于解決菱形繼承導(dǎo)致的數(shù)據(jù)冗余。
本文的目標(biāo)是探究虛繼承的實(shí)現(xiàn)方式和類布局(Class Layout)的具體規(guī)則,主要內(nèi)容源自于本人對(duì)C++: Under the Hood的解讀和提煉。
不過(guò)在開(kāi)始之前,我們需要先熟悉一下普通繼承下的類布局,方便與之后的虛繼承進(jìn)行對(duì)比。
請(qǐng)注意,以下用于分析的數(shù)據(jù)皆來(lái)自于MSVC的編譯結(jié)果。C++標(biāo)準(zhǔn)定義了一些基本規(guī)范,但不同編譯器的實(shí)現(xiàn)方式可能會(huì)有所差異,所以內(nèi)容僅具有一定的參考性。
單繼承
以下是由A類派生B類的單繼承例子:
none
class A
{
public:
int a1;
int a2;
};none
class B : public A
{
public:
int b1;
int b2;
};通過(guò)在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://www.dbjr.com.cn/article/208240.htm。
看起來(lái)可能有點(diǎn)抽象,它其實(shí)是等價(jià)于下圖中的內(nèi)容:

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

