關(guān)于C++虛繼承的內(nèi)存模型問題
1、前言
C++虛繼承的內(nèi)存模型是一個經(jīng)典的問題,其具體實現(xiàn)依賴于編譯器,可能會出現(xiàn)較大差異,但原理和最終的目的是大體相同的。本文將對g++中虛繼承的內(nèi)存模型進行詳細解析。
2、多繼承存在的問題
C++的多繼承是指從多個直接基類中產(chǎn)生派生類的能力,多繼承的派生類繼承了所有父類的成員。從概念上來講這是非常簡單的,但是多個基類的相互交織可能會帶來錯綜復(fù)雜的設(shè)計問題,命名沖突就是不可回避的一個,比如典型的是菱形繼承,如圖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
關(guān)鍵字修飾,示例代碼如下(基于前面的例子修改):
#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)了多少次,在派生類中都只包含一份虛基類的成員。本例的繼承關(guān)系如圖3-1所示:
從這個新的繼承體系中我們可以發(fā)現(xiàn)虛繼承的一個特征:必須在虛派生的真實需求出現(xiàn)前就已經(jīng)完成虛派生的操作。在圖3-1中,我們是當(dāng)定義類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ā)中,位于中間層次的基類將其繼承聲明為虛繼承一般不會帶來什么問題。通常情況下,使用虛繼承的類層次是由一個人或者一個項目組一次性設(shè)計完成的。對于一個獨立開發(fā)的類來說,很少需要基類中的某一個類是虛基類,況且新類的開發(fā)者也無法改變已經(jīng)存在的類體系。
4、虛繼承在標準庫中的使用
C++標準庫中的iostream
就是一個虛繼承的典型案例。iostream
是從istream
和ostream
直接繼承而來的,而istream
和ostream
又都繼承自一個名為ios
的類,這個就是一個典型的菱形繼承。此時istream
和ostream
必須采用虛繼承,否則將導(dǎo)致iostream
中保留兩份ios
的成員。
iostream
相關(guān)的源代碼如下(從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
在當(dāng)前編譯器(GCC 4.8.5
)下的內(nèi)存布局如圖5-1所示:
從圖5-1中可以看出這個表和之前這篇文章《一文讀懂C++虛函數(shù)的內(nèi)存模型》講的虛函數(shù)表是差不多的,就多了一個vbase_offset
而已。因為這里的類設(shè)計比較簡單,沒有把虛函數(shù)加進來,有虛函數(shù)的話_vptr.B
或者_vptr.C
下面的內(nèi)存空間存儲的就是指向?qū)?yīng)虛函數(shù)的指針了(以下只講_vptr.B
的相關(guān)內(nèi)容,_vptr.C
同理就不贅述了)。
這里可以看到_vptr.B
指向的是虛函數(shù)的起始地址(因為這里沒有虛函數(shù),所以下面緊接著就是_vptr.C
的內(nèi)容),而不是與它相關(guān)聯(lián)的全部信息的起始地址,事實上從圖5-1中可以看出_vptr.B - 3
~ _vptr.B
這個范圍內(nèi)的數(shù)據(jù)都是類B
虛表的內(nèi)容(不知道編譯器為什么這么設(shè)計,這里也進行揣測了),這三個特殊的內(nèi)存地址存儲的內(nèi)容解析如下:
_vptr.B - 1
:這里存儲的是typeinfo for D
,里面的內(nèi)容其實也是一個指針,指向的是類D
的運行時信息,這些玩意都是為了支持RTTI的。RTTI的相關(guān)內(nèi)容以后會講,這里就先不多分析了。_vptr.B - 2
:這里存儲的是offset_to_top
,這個表示的是當(dāng)前的虛表指針距離類開頭的距離,可以看到對于_vptr.B
來說這個值就是0,因為_vptr.B
就存在于類D
的起始位置,而對于_vptr.C
來說這個值是-16,大家可以算一下_vptr.C
與類D
的起始位置確實是差兩個地址也就是16個字節(jié)(64位系統(tǒng)),至于為什么是負數(shù),這是因為堆內(nèi)存是向下增長的,越往下地址數(shù)值越大。
offset_to_top深度解析:在多繼承中,由于不同基類的起點可能處于不同的位置,因此當(dāng)需要將它們轉(zhuǎn)化為實際類型時,this指針的偏移量也不相同。由于實際類型在編譯時是未知的,這要求偏移量必須能夠在運行時獲取。實體offset_to_top表示的就是實際類型起始地址到當(dāng)前這個形式類型起始地址的偏移量。在向上動態(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
,這個表示的是當(dāng)前虛表指針與其對應(yīng)的虛基類的距離。從圖中可以看出對于_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
的最末尾,不過不用擔(dān)心,運行時可以靠vbase_offset
找到它。
6、總結(jié)
本文先是對虛繼承的概念以及使用場景進行了說明,然后通過一個內(nèi)存模型圖向大家展示了g++下虛繼承的內(nèi)存形態(tài),最后使用GDB查看實際的內(nèi)存情況來驗證內(nèi)存模型圖的正確性。本文為了更直觀地展示虛繼承的內(nèi)存模型,示例設(shè)計得很簡單,類的設(shè)計中只有一個成員變量而沒有成員函數(shù)、虛函數(shù)等其它內(nèi)容。本文與前文《一文讀懂C++虛函數(shù)的內(nèi)存模型》相當(dāng)于拋磚引玉,為下文作鋪墊,在下一篇文章中我將對一些稍微復(fù)雜一點的情景進行分析,看看完整形態(tài)的虛表究竟是什么樣的。
到此這篇關(guān)于關(guān)于C++虛繼承的內(nèi)存模型問題的文章就介紹到這了,更多相關(guān)C++虛繼承的內(nèi)存模型內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
C++ 17轉(zhuǎn)發(fā)一個函數(shù)調(diào)用的完美實現(xiàn)
這篇文章主要給大家介紹了關(guān)于C++ 17如何轉(zhuǎn)發(fā)一個函數(shù)調(diào)用的完美實現(xiàn)方法,文中通過示例代碼介紹的非常詳細,對大家學(xué)習(xí)或者使用C++17具有一定的參考學(xué)習(xí)價值,需要的朋友們下面跟著小編來一起學(xué)習(xí)學(xué)習(xí)吧。2017-08-08詳解VS2010實現(xiàn)創(chuàng)建并生成動態(tài)鏈接庫dll的方法
在某些應(yīng)用程序場景下,需要將一些類或者方法編譯成動態(tài)鏈接庫dll,以便別的.exe或者.dll文件可以通過第三方庫的方式進行調(diào)用,下面就簡單介紹一下如何通過VS2010來創(chuàng)建動態(tài)鏈接庫2022-12-12