關于C++虛繼承的內(nèi)存模型問題
1、前言
C++虛繼承的內(nèi)存模型是一個經(jīng)典的問題,其具體實現(xiàn)依賴于編譯器,可能會出現(xiàn)較大差異,但原理和最終的目的是大體相同的。本文將對g++中虛繼承的內(nèi)存模型進行詳細解析。
2、多繼承存在的問題
C++的多繼承是指從多個直接基類中產(chǎn)生派生類的能力,多繼承的派生類繼承了所有父類的成員。從概念上來講這是非常簡單的,但是多個基類的相互交織可能會帶來錯綜復雜的設計問題,命名沖突就是不可回避的一個,比如典型的是菱形繼承,如圖2-1所示:

在圖2-1中,類A派生出類B和類C,類D繼承自類B和類C,這個時候類A中的成員變量和成員函數(shù)繼承到類D中變成了兩份,一份來自A–>B–>D這條路徑,另一份來自A–>C–>D這條路徑。
在一個派生類中保留間接基類的多份同名成員,雖然可以在不同的成員變量中分別存放不同的數(shù)據(jù),但大多數(shù)情況下這是多余的,因為保留多份成員變量不僅占用較多的存儲空間,還容易產(chǎn)生命名沖突。假如類A有一個成員變量a,那么在類D中直接訪問a就會產(chǎn)生歧義,編譯器不知道它究竟來自A -->B–>D這條路徑,還是來自A–>C–>D這條路徑。下面是菱形繼承的代碼實現(xiàn):
#include <iostream>
#include <stdint.h>
class A
{
public:
long a;
};
class B: public A
{
public:
long b;
};
class C: public A
{
public:
long c;
};
class D: public B, public C
{
public:
void seta(long v) { a = v; } // 命名沖突
void setb(long v) { b = v; } // 正確
void setc(long v) { c = v; } // 正確
void setd(long v) { d = v; } // 正確
private:
long d;
};
int main(int argc, char* argv[])
{
D d;
}
這段代碼就是圖2-1所示的菱形繼承的具體實現(xiàn),可以看到在類D的seta()方法中,代碼試圖直接訪問間接基類的成員變量a,結(jié)果發(fā)生了錯誤,因為類B和類C中都有成員變量a(都是從類A繼承的),編譯器不知道選用哪一個,所以產(chǎn)生了歧義。
為了消除歧義,我們可以在使用a時指明它具體來自哪個類,代碼如下:
void seta(long v) { B::a = v; }
/* 或 */
void seta(long v) { C::a = v; }
使用GDB查看變量d的內(nèi)存布局,如圖2-2所示:

于是我們可以畫出變量d的內(nèi)存布局,如圖2-3所示:

3、虛繼承簡介
為了解決多繼承時命名沖突和冗余數(shù)據(jù)的問題,C++提出了虛繼承這個概念,虛繼承可以使得在派生類中只保留一份間接基類的成員。使用方式就是在繼承方式前面加上virtual關鍵字修飾,示例代碼如下(基于前面的例子修改):
#include <iostream>
#include <stdint.h>
class A
{
public:
long a;
};
class B: virtual public A
{
public:
long b;
};
class C: virtual public A
{
public:
long c;
};
class D: public B, public C
{
public:
void seta(long v) { a = v; } // 現(xiàn)在不會沖突了
void setb(long v) { b = v; } // 正確
void setc(long v) { c = v; } // 正確
void setd(long v) { d = v; } // 正確
private:
long d;
};
int main(int argc, char* argv[])
{
D d;
}
可以看到這段代碼使用虛繼承重新實現(xiàn)了前面提到的那個菱形繼承,這樣在派生類D中就只保留了一份間接基類A的成員變量a了,后續(xù)再直接訪問a就不會出現(xiàn)歧義了。虛繼承的目的是讓某個類做出聲明,承諾愿意共享它的基類,這個被共享的基類就稱為虛基類(Virtual Base Class),本例中的類A就是一個虛基類。在這種機制下,不論虛基類在繼承體系中出現(xiàn)了多少次,在派生類中都只包含一份虛基類的成員。本例的繼承關系如圖3-1所示:

從這個新的繼承體系中我們可以發(fā)現(xiàn)虛繼承的一個特征:必須在虛派生的真實需求出現(xiàn)前就已經(jīng)完成虛派生的操作。在圖3-1中,我們是當定義類D時才出現(xiàn)了對虛派生的需求,但是如果類B和類C不是從類A虛派生得到的,那么類D還是會保留間接基類A的兩份成員,示例代碼如下:
#include <iostream>
#include <stdint.h>
class A
{
public:
long a;
};
class B: public A
{
public:
long b;
};
class C: public A
{
public:
long c;
};
class D: virtual public B, virtual public C
{
public:
void seta(long v) { a = v; } // 錯誤,不能等到定義類D時再來做虛繼承的工作
void setb(long v) { b = v; } // 正確
void setc(long v) { c = v; } // 正確
void setd(long v) { d = v; } // 正確
private:
long d;
};
int main(int argc, char* argv[])
{
D d;
}
換個角度講,虛派生只影響從指定了虛基類的派生類中進一步派生出來的類,它不會影響派生類本身。在實際開發(fā)中,位于中間層次的基類將其繼承聲明為虛繼承一般不會帶來什么問題。通常情況下,使用虛繼承的類層次是由一個人或者一個項目組一次性設計完成的。對于一個獨立開發(fā)的類來說,很少需要基類中的某一個類是虛基類,況且新類的開發(fā)者也無法改變已經(jīng)存在的類體系。
4、虛繼承在標準庫中的使用
C++標準庫中的iostream就是一個虛繼承的典型案例。iostream是從istream和ostream直接繼承而來的,而istream和ostream又都繼承自一個名為ios的類,這個就是一個典型的菱形繼承。此時istream和ostream必須采用虛繼承,否則將導致iostream中保留兩份ios的成員。
iostream相關的源代碼如下(從gcc-2.95.3版本中摘錄出來的,內(nèi)容有所省略):
struct _ios_fields
{ // The data members of an ios.
streambuf *_strbuf;
ostream* _tie;
int _width;
__fmtflags _flags;
_IO_wchar_t _fill;
__iostate _state;
__iostate _exceptions;
int _precision;
void *_arrays; /* Support for ios::iword and ios::pword. */
};
class ios : public _ios_fields
{...};
class istream : virtual public ios
{...};
class ostream : virtual public ios
{...};
class iostream : public istream, public ostream
{
public:
iostream() { }
iostream(streambuf* sb, ostream*tied=NULL);
};
5、虛繼承下派生類的內(nèi)存布局解析
g++中是沒有所謂的虛基類表的(據(jù)說vs是有單獨一個虛基類表的),只有一個虛表,由于平時用的比較多的是虛函數(shù),所以一般情況下都直接管它叫做虛函數(shù)表,在g++編譯環(huán)境下這種叫法其實是不嚴謹?shù)?。測試程序如下:
#include <iostream>
#include <stdint.h>
class A
{
public:
long a;
};
class B: virtual public A
{
public:
long b;
};
class C: virtual public A
{
public:
long c;
};
class D: public B, public C
{
public:
void seta(long v) { a = v; }
void setb(long v) { b = v; }
void setc(long v) { c = v; }
void setd(long v) { d = v; }
private:
long d;
};
int main(int argc, char* argv[])
{
D d;
d.seta(1);
d.setb(2);
d.setc(3);
d.setd(4);
}
類D在當前編譯器(GCC 4.8.5)下的內(nèi)存布局如圖5-1所示:

從圖5-1中可以看出這個表和之前這篇文章《一文讀懂C++虛函數(shù)的內(nèi)存模型》講的虛函數(shù)表是差不多的,就多了一個vbase_offset而已。因為這里的類設計比較簡單,沒有把虛函數(shù)加進來,有虛函數(shù)的話_vptr.B或者_vptr.C下面的內(nèi)存空間存儲的就是指向?qū)摵瘮?shù)的指針了(以下只講_vptr.B的相關內(nèi)容,_vptr.C同理就不贅述了)。
這里可以看到_vptr.B指向的是虛函數(shù)的起始地址(因為這里沒有虛函數(shù),所以下面緊接著就是_vptr.C的內(nèi)容),而不是與它相關聯(lián)的全部信息的起始地址,事實上從圖5-1中可以看出_vptr.B - 3 ~ _vptr.B這個范圍內(nèi)的數(shù)據(jù)都是類B虛表的內(nèi)容(不知道編譯器為什么這么設計,這里也進行揣測了),這三個特殊的內(nèi)存地址存儲的內(nèi)容解析如下:
_vptr.B - 1:這里存儲的是typeinfo for D,里面的內(nèi)容其實也是一個指針,指向的是類D的運行時信息,這些玩意都是為了支持RTTI的。RTTI的相關內(nèi)容以后會講,這里就先不多分析了。_vptr.B - 2:這里存儲的是offset_to_top,這個表示的是當前的虛表指針距離類開頭的距離,可以看到對于_vptr.B來說這個值就是0,因為_vptr.B就存在于類D的起始位置,而對于_vptr.C來說這個值是-16,大家可以算一下_vptr.C與類D的起始位置確實是差兩個地址也就是16個字節(jié)(64位系統(tǒng)),至于為什么是負數(shù),這是因為堆內(nèi)存是向下增長的,越往下地址數(shù)值越大。
offset_to_top深度解析:在多繼承中,由于不同基類的起點可能處于不同的位置,因此當需要將它們轉(zhuǎn)化為實際類型時,this指針的偏移量也不相同。由于實際類型在編譯時是未知的,這要求偏移量必須能夠在運行時獲取。實體offset_to_top表示的就是實際類型起始地址到當前這個形式類型起始地址的偏移量。在向上動態(tài)轉(zhuǎn)換到實際類型時(即基類轉(zhuǎn)派生類),讓this指針加上這個偏移量即可得到實際類型的地址。需要注意的是,由于一個類型即可以被單繼承,也可以被多繼承,因此即使只有單繼承,實體offset_to_top也會存在于每一個多態(tài)類型之中。
(這里要注意一點就是offset_to_top只存在于多態(tài)類型中,所以我們可以看到在第二小節(jié)那個例子中,根本就沒有什么所謂的虛表之類的東西,它也就不支持RTTI,最簡單的大家可以使用dynamic_cast去試試,會報錯說該類型不具備多態(tài)性質(zhì)的。那么問題來了,怎樣才能以最簡短的方式讓它具備多態(tài)的性質(zhì)呢?很簡單,定義一個析構(gòu)函數(shù),用virtual修飾即可)
_vptr.B - 3:這里存儲的是vbase_offset,這個表示的是當前虛表指針與其對應的虛基類的距離。從圖中可以看出對于_vptr.B來說這個值是40,算一下剛好是_vptr.B與a的差距,_vptr.C同理。
vbase_offset深度解析:以測試程序為例,對于類型為B的引用,在編譯時,無法確定它的虛基類A它在內(nèi)存中的偏移量。因此,需要在虛表中額外再提供一個實體,表明運行時它的基類所在的位置,這個實體稱為vbase_offset,位于offset_to_top上方。
接下來我們通過GDB來驗證一下前面講的內(nèi)容,先打印出變量d的內(nèi)存信息,如圖5-2所示:

從圖5-2中可以看到變量d的內(nèi)容與前面分析的差不多,接下來我們來看一下這兩個虛表的內(nèi)容,如圖5-3所示:

從圖5-3中可以看出前面的內(nèi)存圖是正確的,接下來就再看一下變量d自身的內(nèi)存布局,如圖5-4所示:

圖5-4顯示出的結(jié)果和前面圖5-1的完全一致,到這里調(diào)試就結(jié)束了,由調(diào)試結(jié)果可以知道圖5-1的內(nèi)存模型是正確的。
這里要補充一點,就是對于虛繼承下的類
D,和第二節(jié)那個沒有虛繼承的相比,基類A的位置被移動到了類D的最末尾,不過不用擔心,運行時可以靠vbase_offset找到它。
6、總結(jié)
本文先是對虛繼承的概念以及使用場景進行了說明,然后通過一個內(nèi)存模型圖向大家展示了g++下虛繼承的內(nèi)存形態(tài),最后使用GDB查看實際的內(nèi)存情況來驗證內(nèi)存模型圖的正確性。本文為了更直觀地展示虛繼承的內(nèi)存模型,示例設計得很簡單,類的設計中只有一個成員變量而沒有成員函數(shù)、虛函數(shù)等其它內(nèi)容。本文與前文《一文讀懂C++虛函數(shù)的內(nèi)存模型》相當于拋磚引玉,為下文作鋪墊,在下一篇文章中我將對一些稍微復雜一點的情景進行分析,看看完整形態(tài)的虛表究竟是什么樣的。
到此這篇關于關于C++虛繼承的內(nèi)存模型問題的文章就介紹到這了,更多相關C++虛繼承的內(nèi)存模型內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
C++ 17轉(zhuǎn)發(fā)一個函數(shù)調(diào)用的完美實現(xiàn)
這篇文章主要給大家介紹了關于C++ 17如何轉(zhuǎn)發(fā)一個函數(shù)調(diào)用的完美實現(xiàn)方法,文中通過示例代碼介紹的非常詳細,對大家學習或者使用C++17具有一定的參考學習價值,需要的朋友們下面跟著小編來一起學習學習吧。2017-08-08
詳解VS2010實現(xiàn)創(chuàng)建并生成動態(tài)鏈接庫dll的方法
在某些應用程序場景下,需要將一些類或者方法編譯成動態(tài)鏈接庫dll,以便別的.exe或者.dll文件可以通過第三方庫的方式進行調(diào)用,下面就簡單介紹一下如何通過VS2010來創(chuàng)建動態(tài)鏈接庫2022-12-12

