1. 程式人生 > >C++ 虛函數的內存分配

C++ 虛函數的內存分配

技術 虛指針 title 為什麽 data- 而是 c++ 調試 nbsp

1.無繼承的普通類:

在有虛函數的情況下類會為其增加一個隱藏的成員,虛函數表指針,指向一個虛函數表,虛函數表裏面就是類的各個虛函數的地址了。那麽,虛函數表指針是以什麽模型加入到類裏面的,虛函數表裏面又是怎麽安排的呢。簡單來看下就可以知道了。

技術分享
#include"stdafx.h"  
#pragma pack(8)  
   
class A{  
public:  
       int a; double a2;  
       A() :a(0xaaaaaaaa), a2(0){}  
       virtual void funA2(){}  
       virtual ~A(){}  
       virtual void funA(){}  
};  
   
int _tmain(int argc, _TCHAR* argv[])  
{  
       A a;  
       return 0;  
}   
技術分享

定義一個A的變量,然後看其內存布局:

技術分享

最開始的 4個字節就是虛函數表指針了,類A中有double類型的成員變量 a2,所以類 A的有效字節對齊數是 8,因此可以看到在虛函數表指針後又填充了 4個字節。放完虛函數表指針然後才到類 A 的成員變量。所以在普通類裏面,如果有虛函數的話就會在最開始的地方添加一個隱藏的成員變量,虛函數表指針,然後才到正常的成員變量。然後我們再去看下虛函數表裏面是什麽樣子的:

技術分享

虛函數表也是以4字節為一項,每一項保存一個虛函數的地址。保存的虛函數的地址按照函數聲明的順序排放,第一項存放第一個聲明的虛函數,第二項存放第二個,依此類推。我們看下這個表裏面的每個項都是什麽。

依次選擇:調試 --> 窗口 --> 反匯編,打開匯編窗口,可以看到源程序的匯編代碼。

技術分享

我們先來看第一個虛函數:

virtual void funA2(){}  

由上上圖可知,該函數的地址是:0x00d41028(註意是小端序),在匯編窗口中找到該地址:

技術分享

看到0x00d41028 處放置了一條 jmp 指令,virtual void funA2() 的真正地址是 0x00d41550

我們可以在匯編窗口中找到 0x00d41550地址,結果如下:

技術分享

可以看到這虛函數表中的每一項地址實際上並不是虛函數的直接地址,而是一個跳轉到相應虛函數的地址。

所以在有虛函數的情況下類的安排也是很簡單的,和沒有虛函數的情況相比就是在最前面加一個虛函數表指針而已。其他的東西就和沒有虛函數的類的情況的時候一樣了。然後好像也沒有什麽然後了,復雜的是在後面~

2.單繼承的情況:

單繼承大概又可以分為兩種情況,一種是基類沒有虛函數的情況,一種是基類已經有虛函數表指針的情況。我們分別來看下。

2.1 基類無虛函數的單繼承

技術分享
#include "stdafx.h"  
#pragma pack(8)  
class F2{ public: int f2; double f22; F2() :f2(0xf2f2f2f2), f22(0){} };  
class B : F2{  
public:  
  int b;  
  B() :b(0xbbbbbbbb){}  
  virtual void funB(){}  
};  
   
   
int _tmain(int argc, _TCHAR* argv[])  
{  
  B b;  
  return 0;  
}  
技術分享

B的布局抓數據如下:

技術分享

可以看到虛函數表指針還是放在最開始的地方,也遵循它自己的地址對齊規則,主動填充了4個字節在後面。然後就是F2作為一個整體結構存放在其後,最後才是成員變量b,整個結構也要自身對齊,所以填充了4個字節在最後。虛函數表裏面的就是B的虛函數funB的地址了。因為只有一個虛函數,所以虛函數表裏面也就只有一項。

技術分享

同樣,我們打開反匯編窗口,找到 0x012e1221 地址處:

技術分享

可以看到 0x012e1221處放置了一條 jmp 指令,virtual void funB(){} 的真正地址是 0x012e14e0

我們可以在匯編窗口中找到 0x012e14e0地址,結果如下:

技術分享

果然是 virtual void funB(){} 的起始位置~

所以在基類沒有虛函數的情況下,會產生一個虛函數表指針,而且也還是先存放類的虛函數表指針,然後才到基類等。其實在類有虛函數的情況下(暫不考慮虛繼承),虛函數表指針都是會存放在最開始的。我們再來看下如果繼承的基類已經有了虛函數表指針的情況會是什麽樣子。

2.2 基類有虛函數的單繼承

技術分享
#include "stdafx.h"  
#pragma pack(8)  
class A  
{  
public:  
    int a; double a2;  
    A() :a(0xaaaaaaaa), a2(0){}  
    virtual void funA2(){}  
    virtual ~A(){}  
    virtual void funA(){}  
};  
  
class B : A{  
public:  
    int b;  
    B() :b(0xbbbbbbbb){}  
    virtual void funB(){}  
    virtual void funA2(){}  
};  
  
  
int _tmain(int argc, _TCHAR* argv[])  
{  
    B b;  
    return 0;  
}  
技術分享

A的布局我們已經知道了,現在B繼承A,而且還有覆蓋了A的虛函數,來看下布局。

技術分享

很明顯,在基類已經有虛函數表指針的情況下派生類不會再主動產生一個虛函數表指針,基類的虛函數表指針是可以和派生類共用的,因為基類的虛函數肯定也是屬於派生類的,如果派生類有虛函數覆蓋掉基類的虛函數的話就會把虛函數表裏面的相應的項改成正確的地址,而且虛函數表指針剛好也是放在類的最開始的位置。所以在這種情況下就是先放基類然後再排放成員變量。我們來看下現在派生類和基類共用的虛函數表是什麽樣子的。

技術分享

虛指針表中共有 4 項,像前面的分析方法一樣,我們結合反匯編窗口,可以得出如下結論(註意是小端序):

技術分享

技術分享

虛函數表有4個項:

1、 第一個項的虛函數已經被B裏面的那個funA2所取代了,因為B裏面的funA2已經覆蓋了基類A裏面的funA2,所以在虛函數表裏面也要相應的改變,這也正是虛函數得以正確調用的前提。

2、 第二個項,也被替換成了B的虛析構函數,我們在代碼裏面沒明寫出B的虛析構函數,編譯器會自動生成一個,而且B的虛析構函數也是會覆蓋掉基類A的虛析構函數的。

3、 第三項還是A裏面的函數funA,因為在派生類裏面沒有被覆蓋,所以還應該是基類裏面的函數。

4、 第四項是基類A沒有的函數funB,所以在這個共用的虛函數表裏面基類A只是用到了前3項而已,後面的項就是沒有覆蓋掉基類的其他虛函數了,而且是按照聲明順序依次排放的。

所以我們暫時可以得出的結論是,有虛函數的類在單繼承的情況下,如果基類沒有虛函數表指針的話會產生一個隱藏的成員變量,虛函數表指針,放在類的最前面,然後才是基類,最後是派生類的各個成員;如果基類已經有了虛函數表指針的話就不需要再產生一個虛函數表指針,派生類可以和基類共用一個虛函數表,此時派生類的布局是先放基類然後再放派生類的各個成員變量。如果派生類有函數覆蓋了基類裏面的虛函數的話,虛函數表裏面的相應項就會改成這個函數的真正地址,其他沒有覆蓋的虛函數按照聲明的順序依次排放在虛函數表的後面各項中。

3.多繼承的情況

鑒於有虛函數的類的第一項都要是虛函數表指針,所以在多繼承的情況下會跟普通情況有所不同。但是有虛函數的類多繼承情況下的對象模型也還是比較簡單和明確的。

大概也有兩種情況,一種是所有的基類都沒有虛函數的情況,一種是基類中有些有虛函數有些又沒有虛函數的混雜情況。

對於第一種情況,內存布局大概是這樣,比如類A的基類都是沒有虛函數的話

class A:F0,F1,F2{int a; (其他成員變量)…… virtual voidfun1(){} ……};  

那麽A肯定也還是要生成一個虛函數表指針的,放在最開始的位置,這種情況下的等價模型大概是這樣 :

class A{void * vf_ptr;F0{};F1{};F2{};int a; (其他成員變量)……};

註意各個的字節對齊就可以了,特別是虛函數表指針。

對於第二種情況,基類是混雜的情況的時候,比如類A:

class A : F0, F1, V0, V1, F2, V2 { int a; (其他成員變量)…… virtual void fun1(){} ……};  

V0、V1、V2是有虛函數的基類,F是沒虛函數的基類,而且繼承的聲明順序隨意。像這種情況的話類A的對象模型大概是這樣的:先排放基類中有虛函數的基類,按照聲明順序,然後再排放基類中沒有虛函數的基類,也是按照聲明順序。比如A此時的對象模型就大概是這樣:

class A{V0{};V1{};V2{};F0{};F1{};F2{};int a; (其他成員變量)……};  

因為基類已經有了虛函數表指針了,所以派生類A也是可以和第一個有虛函數表指針的基類共用一個虛函數表的,這個和單繼承的時候的道理是一樣的,自然派生類就不會在生成一個虛函數表指針了。我們來實際來下這兩種情況的實例。

3.1 基類沒有虛函數

技術分享
#include"stdafx.h"  
#pragma pack(8)  
class F0{ public:char f0; F0() :f0(0xf0){} };  
class F1{ public:int f1; F1() :f1(0xf1f1f1f1){} };  
   
class C : F1, F0{  
public:  
       int c;  
       virtual void funC(){}  
       virtual void funB(){}  
       virtual void funA2(){}  
       C() :c(0x33333333){}  
};  
   
int _tmain(int argc, _TCHAR* argv[])  
{  
       C c;  
       return 0;  
}   
技術分享

技術分享

在派生類有虛函數而基類都沒有虛函數的情況下,派生類仍然會產生一個虛函數表指針放在最開始,然後才到各個基類,最後就是成員變量了。結合反匯編窗口,來看下虛函數表裏面是些什麽。

技術分享

虛函數表指針共有 3 項,像前面的分析方法一樣,我們結合反匯編窗口,可以得出如下結論(註意是小端序):

技術分享

可以看到由於派生類的虛函數沒有覆蓋任何基類裏面的虛函數所以虛函數表裏面的各項就是各個虛函數按照聲明的順序的地址了。然後再來看下基類有虛函數而且派生類還有覆蓋掉基類的虛函數的情況。

3.2 基類中有虛函數

技術分享
#include"stdafx.h"  
#pragma pack(8)  
class F0{ public:char f0; F0() :f0(0xf0){} };  
class F1{ public:int f1; F1() :f1(0xf1f1f1f1){} };  
   
class A  
{  
public:  
       int a; double a2;  
       A() :a(0xaaaaaaaa), a2(0){}  
       virtual void funA2(){}  
       virtual ~A(){}  
       virtual void funA(){}  
};  
   
class B : A{  
public:  
       int b;  
       B() :b(0xbbbbbbbb){}  
       virtual void funB(){}  
       virtual void funA2(){}  
};  
   
class C : F1, A,F0, B{  
public:  
       int c;  
       virtual void funC(){}  
       virtual void funB(){}  
       virtual void funA2(){}  
       C() :c(0x33333333){}  
};  
   
   
int _tmain(int argc, _TCHAR* argv[])  
{  
       C c;  
       return 0;  
}  
技術分享

技術分享

類C的模型大概是這樣:

技術分享
class C{  
public:  
   A a;  
   B b;  
   F1 f1;  
   F0 f0;  
   int c;  
};  
技術分享

很明顯,雖然F1聲明在基類的最前面但是存放順序還是先存放有虛函數的基類A然後到也是有虛函數的基類B,再才是各個沒有虛函數的基類F1、F0。最後才是派生類C的成員變量。C的虛函數funB 覆蓋了基類B裏面的虛函數,而另一個虛函數funA2既覆蓋了基類A裏面的虛函數也覆蓋了基類B繼承自基類A裏面的虛函數funA2,理論上基類A和基類B裏面被覆蓋掉的虛函數其在各自虛函數表裏面的對應項都要被改變成正確的函數地址,也就是C裏面的虛函數的真實地址。然後我們看下A和B的虛函數表是什麽樣子的。

A和C共用的虛函數表:技術分享

虛函數表指針共有 4項,像前面的分析方法一樣,我們結合反匯編窗口,可以得出如下結論(註意是小端序):

技術分享

B的虛函數表:

技術分享

虛函數表指針共有 4項,像前面的分析方法一樣,我們結合反匯編窗口,可以得出如下結論(註意是小端序):

技術分享

可以看到派生類和基類A共享的虛函數表裏面的各個項已經修改成了函數的真正的地址,在最後還加了一個沒有覆蓋掉任何基類虛函數的虛函數地址項。而基類B裏面的項就有點意外了,它並不是直接修改成跳轉到正確的地址上去,而是使用了一個調整塊的東西,把EAX寄存器減去相應的值,然後再跳轉到正確的函數裏面去,這個暫時不在這裏贅述,反正最後還是跳轉到了C裏面的那個函數裏面去就是了。其他的項有覆蓋的也還是一樣都要修改成正確的函數地址。

****************************************************************************** 技術分享
    virtual void funA2(){}  
       virtual ~A(){}  
       virtual void funA(){}  

         virtual void funB(){}  
       virtual void funA2(){}  

         virtual void funC(){}  
       virtual void funB(){}  
       virtual void funA2(){}  
c::funA2
C::~C
A:funA
c:func
至於上面為什麽沒有C:FUNB,因為B類中有的A類中沒有,所有c類不需要替換,
相反對於funA2,A,B,C三個類中都有,那麽我們優先選著A類的 c:funa2 c:~c A:funA C:funB
技術分享

C++ 虛函數的內存分配