您的位置:首页 > 编程语言 > C语言/C++

C++ 虚继承和虚函数同时存在的对象模型

2016-06-22 20:16 621 查看
如果说没有虚函数的虚继承只是一个噩梦的话,那么这里就是真正的炼狱。这个C++中最复杂的继承层次在VS上的实现其实我没有完全理解,摸爬滚打了一番也算得出了微软的实现方法吧,至于一些刁钻的实现方式我也想不到什么理由来解释它,也只算是知其然不知其所以然吧。

 

分2个阶段来探讨:

 

1.      有虚函数的派生类虚继承了没有虚函数的基类;

 

2.      有虚函数的派生类虚继承了有虚函数的基类;

 

1.  基类无虚函数

 

1.1 虚、实基类都没有虚函数

 

这种情况也还算比较简单。因为虚函数表指针一定是会放在最开始的,所以根据猜测也可以知道其大概布局情况。看下面一个简单的例子。

 

#include "stdafx.h"
#pragma pack(8)
class F1{
public:
int f1;
F1():f1(0xf1f1f1f1){}
};

class A : virtual F1{
public:
inta;
A():a(0xaaaaaaaa){}
virtual void funA2(){}
virtual ~A(){}
virtual void funA(){}
};

int _tmain(int argc, _TCHAR* argv[])
{
A aa;
return 0;
}




 

这是只有一个虚继承的简单情况,可以看到类A的前4个字节就是虚函数表指针,虚函数表里面有3个项,当然就是分别指向类A的那3个虚函数的指针了(注意是小端序):

 


 

可以看到 funA2() 函数的入口地址是0x00f81028;~A() 函数的入口地址是 0x00f811bd;

funA() 函数的入口地址是 0x00f810aa。

 

为了验证 funA2() 函数的入口地址是否正确(~A()和 funA() 可以用相同的方法验证),打开反汇编窗口:



 

找到地址为 0x00f81028 的地方:

 


 

可以看到 0x00f81028 存放着一条 jmp 指令,跳转到A::funA2() 函数的真正地址:0x00f815d0,我们接着到 0x00f815d0 处看看是什么情况:

 


 

可以看到 0x00f815d0 处的确是A::funA2() 函数开始执行的地方,懂汇编的童鞋应该知道前两句指令就是建立A::funA2() 函数的堆栈~

 

虚函数表指针是最大的,要放在第一位,接下来我们看虚基类表指针:

 


 

可以看到虚基类表里面的两个偏移,一个是 -4,说明类A的起始地址位于虚基类表指针偏移-4 个字节的地方,也就是虚函数表指针的地址了。虚基类表里面的第二项就是第一个虚基类( F1 )相对于虚基类表指针的偏移,是8 字节,可以看到刚好也是 f1f1f1f1 的位置。

 

看了一个简单的情况我们再来看一个稍微复杂的继承情况,虚实多继承,所有的基类仍然都没有虚函数。

#include "stdafx.h"
#pragma pack(8)
class F0{ public: char f0; F0() :f0(0xf0){}};
class F1{ public: int f1; F1():f1(0xf1f1f1f1){} };
class F2{ public: int f2; double f22; F2():f22(0), f2(0xf2f2f2f2){} };
class F3 :virtual F2{ public: int f3; F3(): f3(0xf3f3f3f3){} };
class F4 :virtual F2{ public: int f4; F4(): f4(0xf4f4f4f4){} };

class A : virtual F0, F1, virtual F3, F4{
public:
inta;
A():a(0xaaaaaaaa){}
virtual void funA2(){}
virtual ~A(){}
virtual void funA(){}
};

int _tmain(int argc, _TCHAR* argv[])
{
A aa;
return 0;
}






 

我们知道,只要出现虚继承,那么派生类的内存布局就分为实部和虚部,我们来仔细分析一下这 64 个字节:

1.      派生类的实部:

 

1.1、74  68  ad  00:派生类 A 的虚函数表指针;(距派生类 C 的虚基类表指针 -16 个字节)

 

1.2、cc  cc  cc  cc:为了字节对齐而填充的 4个字节;(距派生类 C 的虚基类表指针 -12 个字节)

 

1.3、f1  f1  f1  f1:实基类 F1 的成员变量,int  f1 = 0xf1f1f1f1;(距派生类 C 的虚基类表指针 -8 个字节)

 

1.4、cc  cc  cc  cc:为了字节对齐而填充的 4个字节;(距派生类 C 的虚基类表指针 -4 个字节)

 

1.5、84  68  ad  00:实基类 F4 和派生类 A 共用的虚基类表指针;(距派生类 C 的虚基类表指针 0 个字节)

 

1.6、f4  f4  f4  f4:实基类 F4 的成员变量,int  f4 = 0xf4f4f4f4;(距派生类 C 的虚基类表指针 +4 个字节)

 

1.7、aa  aa  aa  aa:派生类 A 的成员变量,int  a = 0xaaaaaaaa;(距派生类 C 的虚基类表指针 +8 个字节)

 

实部的内存布局图如下:

 


        

2.      派生类实部和虚部的结合处:

 

cc  cc  cc  cc:这四个字节是为了字节对齐而填充的,同时,这四个字节之前的内容是派生类的实部,之后的内容就是派生类的虚部;(距派生类 C 的虚基类表指针 +12 个字节)

 

3.      派生类的虚部:

 

3.1、f0  cc  cc  cc:虚基类 F0 的成员变量,char  f0 = 0xf0,三个 cc 是为了字节对齐而填充的;(距派生类 C 的虚基类表指针 +16 个字节)

 

3.2、cc  cc  cc  cc:为了字节对齐而填充的 4个字节;(距派生类 C 的虚基类表指针 +20 个字节)

 

3.3、f2  f2  f2  f2:虚基类 F2 的成员变量,int  f2 = 0xf2f2f2f2;(距派生类 C 的虚基类表指针 +24 个字节)

 

3.4、cc  cc  cc  cc:为了字节对齐而填充的 4个字节;(距派生类 C 的虚基类表指针 +28 个字节)

 

3.5、00  00  00 00  00  00  00  00:虚基类 F2 的成员变量,double  f22 = 0x00000000;(距派生类 C 的虚基类表指针 +36 个字节)

 

3.6、98  68  ad  00:虚基类 F3 的虚基类表指针;(距派生类 C 的虚基类表指针 +40 个字节)

 

3.7、f3  f3  f3  f3:虚基类 F3 的成员变量,int  f3 = 0xf3f3f3f3;(距派生类 C 的虚基类表指针 +44 个字节)

 

虚部的内存布局图如下:

 



 

以上就是派生类 A 的内存布局

 

然后看下派生类 A 和其基类 F4 共用的虚基类表:

 


 

按照规则这个共用的虚基类表里面的项是这样安排的

 

1.      第一项是0,表示F4的起始地址就是虚基类表指针的起始地址;

 

2.      第二项是F4的虚基类F2相对于虚函数表指针的偏移,是0x18(十进制 24);

 

 

3.      第三项开始就不是F4的虚基类的项了,从第三项开始的各个项是类A的其他虚基类,按照声明的顺序排放,所以第三项就是虚基类F0 相对于虚基类表指针的偏移,是0x10(十进制 16);

 

4.      然后F1没有虚部跳过,再到 F3,因为F3的虚部F2已经包含在了之前的项里面了所以也跳得,所以第四项就到F3 的偏移,是0x28(十进制 40)个字节;

 

接下来我们看看 F3 的虚基类表:

 


 

F3的虚基类表是它自己独享的,先是自己起始地址的偏移,然后是虚基类的偏移,可以看到它的虚基类F2 相对于它这个虚基类表指针的偏移值-16(24 - 40)个字节,也是正确的。

 

1.2虚基类没有虚函数,实基类有虚函数

 

把情况再变得复杂一些,多继承中虚基类仍然没虚函数,但是实基类有虚函数。像下面这个例子其实已经比较复杂了。

#include "stdafx.h"
#pragma pack(8)
class F0{ public: char f0; F0() :f0(0xf0){}};
class F1{ public: int f1; F1():f1(0xf1f1f1f1){} };
class F2{ public: int f2; double f22; F2():f22(0), f2(0xf2f2f2f2){} };
class F3 :virtual F2{ public: int f3; F3(): f3(0xf3f3f3f3){} };
class F4 :virtual F2{ public: int f4; F4(): f4(0xf4f4f4f4){} };

class A : virtual F3{
public:
inta;
A():a(0xaaaaaaaa){}
virtual void funA2(){}
virtual ~A(){}
virtual void funA(){}
};
class B : F1{
public:
intb;
virtual void funB(){}
B():b(0xbbbbbbbb){}
};
class C : virtual F0, F4, B, A {
public:
intc;
virtual void funC(){}
virtual void funB(){}
virtual void funA2(){}
C():c(0x33333333){}
};

int _tmain(int argc, _TCHAR* argv[])
{
A aa;
B bb;
C cc;
return 0;
}


我们先看 A 的内存布局:





由上面的例子推测出A的布局已经是很简单了,需要注意的是成员变量a后面的4个填充字节是不属于A的实部也不属于A的虚部的,而是实部与虚部的结合处,而且A的有效对齐是8字节了。

因为 A 以虚继承的方式继承了 F3,所以F3 会被作为一个整体放入 A 的虚部,这也是为什么出现了F3 的虚基类表指针:0x00e67894(小端序)。

 

 

B的布局更加简单,实继承没有任何虚部,只有一个虚函数表指针而已。





B的布局更加简单,实继承没有任何虚部,只有一个虚函数表指针而已。

 

 

         让我们来看看C吧:





 

C的布局才是我们的看点。可以说一个多继承中的基类终极布局顺序是这样的:先存放实基类中实部有虚函数表指针的基类,然后是普通的实基类,最后是各个虚基类。按照这个逻辑,因为B和A的实部都有虚函数表指针,所以类C的基类布局大概像是这个继承顺序:

 

class C: B, A,F4, virtual F0 {……};


 

实部的安排先是B,然后是A,然后是F4。

让我们一个一个字节分析 C 的内存布局:

1.      C的实部;

 

1.1  b0  78 f2  00:实基类 B 的虚函数表指针;(距派生类 C 的虚基类表指针 -32 个字节)

 

1.2  f1  f1  f1  f1:实基类 F1 的成员变量,int  f1 = 0xf1f1f1f1;(距派生类 C 的虚基类表指针 -28 个字节)

 

1.3  bb  bb bb  bb:实基类 B 的成员变量,int  b = 0xbbbbbbbb;(距派生类 C 的虚基类表指针 -24 个字节)

 

1.4  cc  cc cc  cc:为了字节对齐而填充的 4个字节;(距派生类 C 的虚基类表指针 -20 个字节)

 

1.5  c0  78 f2  00:实基类 A 的虚函数表指针;(距派生类 C 的虚基类表指针 -16 个字节)

 

1.6  e4  78 f2  00:实基类 A 的虚基类表指针;(距派生类 C 的虚基类表指针 -12 个字节)

 

1.7  aa  aa aa  aa:实基类 A 的成员变量,int  a = 0xaaaaaaaa;(距派生类 C 的虚基类表指针 -8 个字节)

 

1.8  cc  cc cc  cc:为了字节对齐而填充的 4个字节;(距派生类 C 的虚基类表指针 -4 个字节)

 

1.9  d0  78 f2  00:实基类 F4 的虚基类表指针;派生类 C与 F4 共用该指针(距派生类 C 的虚基类表指针 0 个字节)

 

1.10   f4  f4 f4  f4:实基类 F4 的成员变量,int  f4 = f4f4f4f4;(距派生类 C 的虚基类表指针 +4 个字节)

 

1.11   33  33 33  33:派生类 C 的成员变量,int  c = 0x33333333;(距派生类 C 的虚基类表指针 +8 个字节)

 

实部的内存布局图如下:



 

2.      实部和虚部的结合处:

cc  cc  cc  cc(距派生类 C 的虚基类表指针 +12 个字节)

 

3.      C 的虚部:

 

3.1  f0  cc cc  cc:虚基类 F0 的成员变量,char  f0 = 0xf0f0f0f0;(距派生类 C 的虚基类表指针 +16 个字节)

 

3.2  cc  cc cc  cc:为了字节对齐而填充的 4个字节;(距派生类 C 的虚基类表指针 +20 个字节)

 

3.3  f2  f2 f2  f2:虚基类 F2 的成员变量,int  f2 = 0xf2f2f2f2;(距派生类 C 的虚基类表指针 +24 个字节)

 

3.4  cc  cc cc  cc:为了字节对齐而填充的 4个字节;(距派生类 C 的虚基类表指针 +28 个字节)

 

3.5  00  00 00  00  00 00  00  00:虚基类 F2 的成员变量,double  f22 = 0;(距派生类 C 的虚基类表指针 +36 个字节)

 

3.6  f4  78 f2  00:虚基类 F3 的虚基类表指针;(距派生类 C 的虚基类表指针 +40 个字节)

 

3.7  f3  f3 f3  f3:虚基类 F3 的成员变量,int  f3 = 0xf3f3f3f3;(距派生类 C 的虚基类表指针 +44 个字节)

 

虚部的内存布局如下图所示:



 

所以最开始是B的实部,B没有虚部所以也就是整个B类了,然后是A的实部,由实部的字节对齐规则可以知道A的有效对齐是8字节,其实部的起始地址就是代表了A的起始地址,所以他的起始地址偏移要是8字节的整数倍,所以在B后填充了4个字节,才到A的实部。A的实部之后就是F4的实部,同样F4的有效对齐参数也是8字节,所以也还是要填充,然后因为已经从基类中继承了有虚基类表指针了,所以就不需要再产生一个了,就直接开始存放C的成员变量小c,之前的这几个元素就组成了C的实部,C的实部进行自身对齐还要在后面填充4个字节,所以C
的实部的大小就是0x30个字节。然后到虚部,虚部按照声明的顺序一个个的来就得了,先是F0,然后是F4的虚部F2,再是B的虚部,因为B没有虚部所以跳过,就到了A的虚部了,也就是F3,因为F3的虚部F2已经有了所以就不能再重复了,然后就只在最后存放了F3的实部。

这就是整个类C的布局了。也不是太困难。

 

和B共用的虚函数表:b0  78  f2  00(注意是小端序):



 

打开反汇编窗口,看看  0x00f21159 和 0x00f211e5 (小端序)这两个地址的内容:

 


 


 

在C的最开始就是和B共用的虚函数表指针,指向同一个表。因为C里面的虚函数覆盖了B里面的虚函数,所以表里面的对应的项也是要用C里面的那个函数来覆盖掉的,而C里面的虚函数funC没有被任何基类覆盖,所以要在这里追加上去,所以这个表就有两项。

 

A的虚函数表:

 


 

 


 



 

 


 

A的虚基类表:      



    A的虚基类表在这里不是和类C一起共用,类C是和按照声明顺序的实基类中的第一个有虚基类表指针的基类共用一个的,虽然A在内存里面排放在前面但是声明却是在F4的后面,所以C是和F4共用一个虚基类表而不是和A共用。A的虚基类表还是它自己独享的。里面的各项分别是:

1.      fc  ff ff  ff:小端序,转换为十进制是-4,也就是A 自己到A 的虚基类表指针偏移:(-16) - (-12) = - 4

2.      24  00 00  00:转换为十进制是 36,A 的虚基类表指针到虚基类F2的偏移:24 - (-12) = 36;

3.      34  00 00  00:转换为十进制是52,A 的虚基类表指针到虚基类F3的偏移:40 - (-12) = 52

 

C 和 F4共用的虚基类表:



这个就是C和F4共用的虚基类表了。里面的各项是

1.      F4自己的起始地址偏移;

2.      18  00 00  00:小端序,转换为十进制是24,虚基类表指针到F2的偏移:24 – 0 = 24;

3.      10  00 00  00:转换为十进制是16,虚基类表指针到F0的偏移:16 – 0 =16;

4.      28  00 00  00:转换为十进制是40,虚基类表指针到F3的偏移:40 – 0 =40;

 

F3的虚基类表:



1.      00  00 00  00:F3 的虚基类表指针到 F3 的距离:0

2.      F0  ff ff  ff:F3 的虚基类表指针到 F2 的偏移:24 – 40 =-16

 

到此我们就可以推断出派生类有虚函数和无虚函数时的统一继承布局顺序。就是先安排实继承的基类,然后是虚继承。其中实继承的基类中实部有虚函数表指针的又要先被安排。同样优先级的基类按照继承声明顺序存放。

 

 

2.虚基类有虚函数

我们可以开始进入到第二个大块,虚基类有虚函数的情况。

2.1 没有虚函数的派生类继承一个有虚函数的虚基类

#include "stdafx.h"
#pragma pack(8)
class B {
public:
intb;
virtualvoid funB(){}
B():b(0xbbbbbbbb){}
};

class F1 : virtual B{
public: int f1;
F1():f1(0xf1f1f1f1){}
};

int _tmain(int argc, _TCHAR* argv[])
{
F1f1;
return0;
}


 


 

 

虚基类表:



虚函数表:

 


 

 


 

注意,虚基类会作为一个整体放入派生类的虚部。

这个还比较简单,类 F1 的布局就是先虚基类表指针,然后是成员 f1 一起组成实部,然后是整个B 类作为虚部。可见在虚基类有虚函数表指针的情况下,这个虚函数表指针是不需要放在派生类的最开始的,因为它是虚继承的。它应该和整个虚基类作为一个整体被放在派生类的虚部里面。虚基类表和虚函数表的值也很好理解,就不赘述了。

 

2.2 有虚函数的派生类继承一个有虚函数的虚基类

现在再复杂一点,派生类也有虚函数,虚继承的虚基类也有虚函数,但是派生类的虚函数并没有覆盖虚基类里面的虚函数的情况。如下

 

#include "stdafx.h"
#pragma pack(8)
class B {
public:
intb;
B():b(0xbbbbbbbb){}
virtual void funB(){}
};

class C : virtual B{
public:
intc; double c2;
virtual void funC(){}
C():c(0x33333333), c2(0){}
};

int _tmain(int argc, _TCHAR* argv[])
{
Cc;
return0;
}


 

C的布局也可以按照我们常规的思想来猜测,C本身有虚函数,所以最开始肯定是C的虚函数表指针了,因为它都是会放在第一位的。然后因为有虚继承,还会有虚基类表指针,然后才到C的成员变量,实部就到这里了,之后就到虚部,也就是整个B了。看下抓包的数据。

 


C的虚函数表:





 

C的虚基类表:



1.      f8  ff ff  ff:小端序,转换为十进制是-8,C的虚基类表指针到 C 起始位置的偏移是 -8 个字节,这是正确的;

2.      18  00 00  00:小端序,转换为十进制是24,C的虚基类表指针到虚基类  B 的偏移是24个字节,这也是正确的;

 

 

B的虚函数表:

 


 


 

得到的结果也还算正常,和预期的一样,可以看到排在第一位的确实就是虚函数表指针,然后才是存放虚基类表指针,也可以看到因为这两个特殊的隐藏成员需要主动对齐的原因都在其后填充了4个字节,c和c2之间的4个字节就是常规的填充了。

 

派生类的虚函数不覆盖虚基类的虚函数时,会有两个虚函数表指针出现,而不会再和虚基类共用一个虚函数表了,因为虚基类要放在后面,在这里没办法很好的共享,因为如果还和虚基类共享一个虚函数表的话即使访问一个和虚基类没有任何联系的虚函数也需要先找到虚基类的位置然后再找到对于的虚函数,这样的开支就没必要了。

 

那么我们看下当派生类的虚函数全部把虚基类的虚函数覆盖掉的时候,这种情况下是不是就可以和虚基类共享一个虚函数表了呢?理论上是可以的,而且也是合理的。

#include "stdafx.h"
#pragma pack(8)
class B {
public:
__int64b;
B():b(0xbbbbbbbbbbbbbbbb){}
virtual void funB(){}
virtual void funB2(){}
virtual void funB3(){}
};

class C : virtual B{
public:
intc;
virtual void funB(){}
virtual void funB3(){}
C():c(0x33333333){}
};

int _tmain(int argc, _TCHAR* argv[])
{
Cc;
return0;
}




虚基类表:



1.      00  00 00  00:C 的虚基类表指针到 C 的头部的偏移是0;

2.      10  00 00  00:转化为十进制是16,,C 的虚基类表指针到虚基类 B的偏移是 16 个字节

 

 

虚函数表:

 


 


 




 

可以看到,当派生类C的虚函数都覆盖掉了虚基类的虚函数的时候,是可以和虚基类共享一个虚函数表的,而且也是很合逻辑的。所以只会产生虚基类表,也就是第一个4字节,可以看到表里面的东西确实就是虚基类表。而和B共用的虚函数表里面的B被C覆盖的项也已经改成了正确的函数地址了。

 

最后看到蓝色的那4个0,这个东西是比较蹊跷的地方,至于为什么会产生这4个字节的东西,他的理由是什么,我是没有办法搞懂了,跟进了汇编码也没有什么实际的收获,它在汇编码里面使用了一个常量,这个常量的意义是什么无从得知,总之我测试了几种情况,使用的常量都不一样但是最终导致的这4个字节就是都是为0,说白了就是使用虚基类表里面的这个虚基类的偏移值减去一个和这个偏移值相等的常量,所以导致这个值最后都是0,没有测试到为其他值的情况,实在是搞不懂为什么了~……

 

后来我查了一下MSDN,这4个字节是一个叫做vtordisp 的东西,我不知道怎么读也不知道是什么意思。勉强找到了一些资料,但是谜底始终没有解开,即使MSDN也没提供太多有意义的解释。我们其实可以用编译指令禁止产生这个东西,MSDN上面是这么说的:

 

#pragma vtordisp({on | off} )

The vtordisp pragma is applicableonly to code that uses virtual bases. If a derived class overrides a virtualfunction that it inherits from a virtual base class, and if a constructor ordestructor for the derived class calls that function using a pointer
to thevirtual base class, the compiler may introduce additional hidden"vtordisp" fields into classes with virtual bases.

The vtordisp pragma affects thelayout of classes that follow it. The /vd0 and /vd1 options specify the samebehavior
for complete modules. Specifying off suppresses thehidden vtordisp members. Specifying on, the default, enablesthem where they are necessary. Turn off vtordisp only if there is nopossibility that the class's constructors and destructors call virtualfunctions
on the object pointed to by the this pointer.

 

大概的意思就是说,vtordisp 这个东西只使用于虚基类里面,如果一个派生类的虚函数覆盖了虚基类里面的虚函数,而且派生类的构造函数或者析构函数干嘛干嘛了的话,编译器就会为虚基类生成这个隐藏的字段。翻译不准确,自己看原文哈~······ 

 

而当派生类的虚函数只是部分覆盖了虚基类里面的虚函数的话,其实大概我们也可以想到了,那就是还是要产生一个派生类独立的虚函数表指针,而且当派生类有显示的构造或者析构函数的时候,被覆盖了虚函数的虚基类要在其起始地址前面开辟4个字节,也就是vtordisp字段。

#include "stdafx.h"
#pragma pack(8)
class B {
public:
doubleb;
B():b(0xbbbbbbbbbbbbbbbb){}
virtual void funB(){}
virtual void funB2(){}
virtual void funB3(){}
};
class C : virtual B {
public:
intc;
virtual void funB(){}
virtual void funC(){}
virtual void funB3(){}
C():c(0x33333333) {}
};

int _tmain(int argc, _TCHAR* argv[])
{
Cc;
return0;
}


C的独立虚函数表:

 


 


虚基类表:



1.      fc  ff ff  ff:小端序,转换为十进制为-4,C 的虚基类表指针到C 起始位置的偏移是 -4 个字节;

2.      0c  00 00  00:小端序,转换为十进制为12,C 的虚基类表指针到虚基类 B 的偏移是 12 个字节;

 

B的虚函数表:

 


 


 

 




 

可以看到类C会产生一个独立的虚函数表来存储那些没有覆盖掉基类虚函数的虚函数项;而当派生类有显示的构造或者析构函数的时候,被覆盖了虚函数的虚基类就会在他的起始地址事情开辟4个字节的空间。

 

 

类的各种继承状况的布局模型大概就这样了。知道了这些我们就可以预测出很复杂层次的类的每一个字节。

 

还是小总结一下吧,推断类的内存布局先得把类的继承层次的排放次序弄好,类的继承层次的一个终极排放次序就是如上所说的,先是实继承中实部有虚函数表指针的基类,然后是普通的实继承基类,整个派生类组成了实部之后再排放虚部,继承都是按照实继承和虚继承的本质意义来继承的,然后注意字节对齐的规则和一些特殊对齐规则,还有隐藏成员的产生等就差不多了。

 

其实也没什么好总结的,太多东西了就算总结出来没实践过也是没用的,毕竟这个东西实在是太复杂了。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: