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地址,结果如下:
果然是 funA2()的起始位置~
第二个虚函数:
virtual ~A(){}
该虚函数的地址是:0x00d411b8(注意是小端序),在汇编窗口中找到该地址:
可以看到0x00d411b8 处放置了一条 jmp 指令,virtual ~A(){} 的真正地址是 0x00d414e0
我们可以在汇编窗口中找到 0x00d414e0地址,结果如下:
果然是 ~A() 的起始位置~
第三个虚函数:
virtual void funA(){}
该虚函数的地址是:0x00d410aa(注意是小端序),在汇编窗口中找到该地址:
可以看到0x00d410aa 处放置了一条 jmp 指令,virtual void funA(){} 的真正地址是 0x00d41590
我们可以在汇编窗口中找到 0x00d41590地址,结果如下:
果然是 funA()的起始位置~
可以看到这虚函数表中的每一项地址实际上并不是虚函数的直接地址,而是一个跳转到相应虚函数的地址。
所以在有虚函数的情况下类的安排也是很简单的,和没有虚函数的情况相比就是在最前面加一个虚函数表指针而已。其他的东西就和没有虚函数的类的情况的时候一样了。然后好像也没有什么然后了,复杂的是在后面~
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里面的那个函数里面去就是了。其他的项有覆盖的也还是一样都要修改成正确的函数地址。