C++虚函数
C++三大特性:封装、继承、多态
在这里谈下多态,多态指的是在类之间存在继承关系时,有的函数声明为virtual函数,当我们将子类指针或引用转化为父类指针或引用时,调用某个虚函数时调用的是子类的虚函数。
编译器对于这种虚函数是在对象中存放了一个指针,这个指针指向一个表格,表格称之为虚函数表,在x86下,表格中的内容为多个连续的指针,这些指针指向一个个函数,当调用对象的虚函数时,会从这个表格中根据这个函数所在的顺序作为index从表格中取出指针,再调用这个函数。而index是在编译期即可确定的。
抽象模块成为一个类,再继承类的方式,然后在父类函数中有公共方法,在子类函数中有特定方法,在C++中经常用到。
还有一种方式:是虚基类,在抽象的父类中,不实现任何的方法,只保留接口,如下:
class A
{
public:
virtual void virtuFunc1()=0;
};
如下编写代码如 A a;因为类A是个虚函数接口,只能使用A *a 这种方式,因为此时类A是个抽象类接口,没有具体实现。
如上形式中我们不需要也无法实现类A中的函数virtuFunc1,而可以在子类中实现此方法。
class B : public A
{
public:
virtual void virtuFunc1()
{
printf("B\n");
}
};
如上,在B类中实现了virtuFunc1方法,需要注意的是如果在B类中不实现此方法,同样也无法编写B b这样的代码,当前已经编写了,所以可以编写B b的代码。
我们可以在不同的模块,或者在不同的类中抽象出一个抽象类接口,不实现任何的方法。这样可以将不同的模块组合在一起,使用同一个接口去处理代码,方便编写模块。
这些虚函数都是通过虚函数表来实现的。
后续还有V形继承,还有菱形继承。
V形继承如基类A,基类B,类C继承于A与B,如果类A与类B中都存在虚函数,那么类C中就存在两个虚函数表指针,这也是比较简单的结构,如:
class C : public A, public B
{
};
使用如下语句如C* c = new C; A* a = c; 编译器编译这条语句时,a变量值与c变量的值是一样的,直接进行转换,编译器生成的代码中,a变量直接赋值为c变量,如果使用这条语句 B* b = c;编译器编译这条语句时,会将c变量的值加一个偏移值再赋值给b,这个偏移值是编译器计算出来的,偏移值是类A所占的大小,这个大小包括类A所占的局部变量以及一个指向虚函数表的指针的大小,这个大小在32位程序中为4字节。
菱形继承比较复杂,还是按照上面的例子,如果A类与B类都继承自一个Base类,类中存在变量,那么当类D继承自A和B时,而A和B继承自Base类,这种情况如果画图那么就是菱形的,所以叫做菱形继承,出现这种情况,就需要使用虚继承,如下:
class C: virtual public A, virtual public B
{
};
如果不使用虚继承,那么当C继承A和B时,A、B又继承自Base,那么此时C中继承自Base中的变量就存在两份,编译器编译代码时会提示变量重复冲突,因为不知道C中的同名变量来自于A(继承自Base)还是来自于B(来自于Base),所以需要使用虚继承。
虚继承的实现方式是在对象内部使用了相对偏移来指向变量,这个后续补充。接下来使用汇编来探究下VS2013中编写C++代码来探究虚继承的对象结构。
对于不带有虚继承的继承关系,内存结构相对简单
假如C继承自A和B,A和B都有虚函数,那么C的内存结构如下:
+0 C继承自A的虚函数表地址,可能有重写
+4 继承自A的局部变量
+8 C继承自B的虚函数表地址,可能有重写
+C 继承自B的局部变量
之后是C自己的变量,以上是内存结构,
当C类指针转变为A类指针时,编译器自动计算偏移,从上面可以看到C内存结构起始也是A内存结构的起始,偏移为0,所以C类指针转为A类指针时不发生数值上的变化。
当C类指针转变为B类指针时,编译器自动计算偏移,从上面可以看出要从C内存起始地址+8,再转化为B类指针,会发生数值上的变化。
接下来在VS2013上进行调试,查看虚继承的类对象内存结构
class A
{
public:
virtual void virtuFunc1() = 0;
int _aaa1;
int _aaa2;
};
class B : virtual public A
{
public:
virtual void virtuFunc1()
{
printf("B\n");
}
};
class C : virtual public A
{
public:
virtual void virtuFunc1()
{
printf("C\n");
}
};
void callFunc(A* pa)
{
pa->virtuFunc1();
printf("pa->_aaa:%d\n", pa->_aaa1);
}
void callFuncB(B* pb)
{
pb->virtuFunc1();
printf("pb->_aaa:%d\n", pb->_aaa1);
}
void callFuncC(C* pc)
{
pc->virtuFunc1();
printf("pc->_aaa:%d\n", pc->_aaa1);
}
class D : virtual public B, virtual public C
{
public:
virtual void virtuFunc1()
{
printf("D\n");
}
};
int _tmain(int argc, _TCHAR* argv[])
{
int aaSize = sizeof(A); //A对象 大小为12个字节 分别为 虚函数表指针 局部变量1 局部变量2
int bbSize = sizeof(B); //B对象 大小为16个字节 分别为 偏移指针(内部内容为(int)0x0 int(0x4)) 虚函数表指针 局部变量1 局部变量2
int ccSize = sizeof(C); //C对象 大小为16个字节 分别为 偏移指针(内部内容为(int)0x0 int(0x4)) 虚函数表指针 局部变量1 局部变量2
int ddSize = sizeof(D); //D对象 大小为24个字节 分别为 偏移指针(内部内容为(int)0x0 int(0x4) int(0x10) int(0x14)) 虚函数表指针 局部变量1 局部变量2 指针B(转化为B类时传入此指针,欲寻找此指针需要用到之前偏移指针0x10) 指针C(转化为C类时传入此指针,欲寻找此指针需要用到之前偏移指针0x10)
DebugBreak(); //方便调试 可注释掉
B b;
b._aaa1 = 1;
b._aaa2 = 2;
callFunc(&b);
callFuncB(&b);
C c;
c._aaa1 = 3;
callFunc(&c);
callFuncC(&c);
D d;
d._aaa1 = 5; //访问此变量 需要用到之前便宜指针中第二个0x4,然后从D的虚函数表指针+0x4(来源于第一个偏移指针,+0x4取到的值,此次为 【偏移指针+0x04】结果为4) 找到_aaa1的地址 如果是_aaa2,那么寻址方式为对象地址D的虚函数表指针+4+偏移
callFunc(&d); //编译器计算 d的地址+ 偏移量(来源于【第一个偏移指针+4】)
callFuncB(&d); //编译器计算 d的地址+ 偏移量(来源于【第一个偏移指针+8】)
callFuncC(&d); //编译器计算 d的地址+ 偏移量(来源于【第一个偏移指针+c】)
getchar();
return 0;
}
上面内容中我们重点关注下callFuncC(callFuncB也是一样的道理)
0:000> uf callFuncC
46 00471e00 55 push ebp
46 00471e01 8bec mov ebp,esp
46 00471e03 81ecc0000000 sub esp,0C0h
46 00471e09 53 push ebx
46 00471e0a 56 push esi
46 00471e0b 57 push edi
46 00471e0c 8dbd40ffffff lea edi,[ebp-0C0h]
46 00471e12 b930000000 mov ecx,30h
46 00471e17 b8cccccccc mov eax,0CCCCCCCCh
46 00471e1c f3ab rep stos dword ptr es:[edi]
47 00471e1e 8b4508 mov eax,dword ptr [ebp+8] eax为指针
47 00471e21 8b08 mov ecx,dword ptr [eax] 取出指针b的第一个局部变量 用于寻址偏移
47 00471e23 8b5104 mov edx,dword ptr [ecx+4] 取出偏移,edx作为偏移
47 00471e26 8b4508 mov eax,dword ptr [ebp+8]
47 00471e29 8b08 mov ecx,dword ptr [eax] 取出指针b 的第一个局部变量
47 00471e2b 8b4508 mov eax,dword ptr [ebp+8] eax作为指针b
47 00471e2e 034104 add eax,dword ptr [ecx+4] 使用eax+偏移量 作为变量对象 待会赋值给ecx调用
47 00471e31 8b4d08 mov ecx,dword ptr [ebp+8] ecx=b
47 00471e34 8b1411 mov edx,dword ptr [ecx+edx] 取出[b+偏移]作为虚函数表
47 00471e37 8bf4 mov esi,esp
47 00471e39 8bc8 mov ecx,eax eax是变量对象
47 00471e3b 8b02 mov eax,dword ptr [edx] 从虚函数表中取出第一个指针 作为函数指针来调用
47 00471e3d ffd0 call eax
47 00471e3f 3bf4 cmp esi,esp
47 00471e41 e82cf3ffff call TestPolymorphic!ILT+365(__RTC_CheckEsp) (00471172)
48 00471e46 8b4508 mov eax,dword ptr [ebp+8] eax=b
48 00471e49 8b08 mov ecx,dword ptr [eax] ecx=[eax] b的第一个局部变量
48 00471e4b 8b5104 mov edx,dword ptr [ecx+4] edx=[ecx+4] edx作为偏移
48 00471e4e 8bf4 mov esi,esp
48 00471e50 8b4508 mov eax,dword ptr [ebp+8] eax=b
48 00471e53 8b4c1004 mov ecx,dword ptr [eax+edx+4] 【b+4+偏移】是第一个变量的值
48 00471e57 51 push ecx
48 00471e58 680c6a4700 push offset TestPolymorphic!`string' (00476a0c)
48 00471e5d ff151ca14700 call dword ptr [TestPolymorphic!_imp__printf (0047a11c)]
48 00471e63 83c408 add esp,8
48 00471e66 3bf4 cmp esi,esp
48 00471e68 e805f3ffff call TestPolymorphic!ILT+365(__RTC_CheckEsp) (00471172)
49 00471e6d 5f pop edi
49 00471e6e 5e pop esi
49 00471e6f 5b pop ebx
49 00471e70 81c4c0000000 add esp,0C0h
49 00471e76 3bec cmp ebp,esp
49 00471e78 e8f5f2ffff call TestPolymorphic!ILT+365(__RTC_CheckEsp) (00471172)
49 00471e7d 8be5 mov esp,ebp
49 00471e7f 5d pop ebp
49 00471e80 c3 ret
对象地址为eax
eax+[[eax]+4] 作为ecx this指针
虚函数表地址=[ecx]
虚函数func=[[ecx]+0] 函数
004768f8
假如传入对象地址[ebp+8]为0x00b3fcc4
?0x00b3fcc4+poi(poi(0x00b3fcc4)+4) 可以获取this指针
u poi(poi(00b3fcc8)+0) 可以获取虚函数表中第一个函数起始地址。
总结如下:存在虚继承关系时,对象的第一个变量是一个数据结构,这个数据结构中(偏移+4 +8 +C的位置分别为偏移量,代表了要转化为其他类型所需要的偏移量,暂时就这样理解),接下来才是虚函数地址,记下来是局部变量,接下来是转化类型的指针,欲转化为某个父类时会传入此值。
如上面代码在调试时使用windbg查看如下:

可以看到D d对象地址为00b3fca4,查看内容如下:

由于D对象大小为24个字节,所以只看前24个字节:

第一个数据为偏移指针,是个数据结构。
第二个数据为虚函数表地址。
第三个数据为_aaa1,内容为5.
第四个数据为_aaa1,由于没有赋值,所以默认为0xCCCCCCCC,由于Debug调试模式下会对栈上内存默认设置为0xCCCCCCCC,汉字看起来就是烫烫,这是调试器为了及时发现错误,如果代码执行到了栈上,0xCC对应int 3指令,会触发异常中断到调试器中。
第五个数据为D转化为B对象指针需要用到的偏移量
第六个数据为D转化为C对象指针需要用到的偏移量
下面讨论调用callFuncC的情况,调用callFuncB的情况大同小异,稍作修改即可
当调用callFuncC时,需要将D对象转化为C对象指针,D对象地址为00b3fca4

取出第一个偏移量0x476b20,

第一个变量不用,第二个变量用于转化为A类指针,第三个变量转化为B类指针,第四个变量转化为C类指针,此次取出第四个变量0x14,

使用D对象起始地址 00b3fca4+0x14偏移得到00b3fcb8,传入到callFuncC函数中

00b3fcb8中内容就是D对象24个字节的最后四个字节的内容,接下来callFuncC函数内容如下

本次pc为0x00b3fcb8,调用virtuFunc1函数时需要传入this指针,this指针寻址方式如下:

取出0x00b3fcb8内容, 再加上4,再取出内容即为欲转化为C类的偏移,传入的pc为0x00b3fcb8,加上偏移,得到0x00b3fca8,综合前面
D对象地址为00b3fca4,此次得到的this指针对象为0x00b3fca8,也就是刚好越过了那个D对象开头的偏移指针,而此时this指针第一个四字节内容存放的虚函数地址表。
这样获取到了this指针,接着得到虚函数地址表的起始地址,取出表中第一项内容,即为virtuFunc1函数的地址。

要访问pc->_aaa1时,由于已经有了this指针的值,再偏移4,即可越过虚函数表指针,取到类的变量,如下:

?00b3fcb8+fffffff0+4 表达式中 00b3fcb8+fffffff0 得到的是对象的起始地址,即this指针,再加4得到的就是第一个局部变量。
传入*p, p在栈上的地址保存到eax中
eax+[[eax]+4] 作为ecx this指针
虚函数表地址=[ecx]
虚函数func=[[ecx]+0] 函数
类内变量 为 [ecx+4 +8 +c]
如果类C也有自己的虚函数

virtuFuncC是自己的虚函数,

类D也重写了此虚函数,那么sizeof(D)现在是28个字节了,在偏移0x14后面有了两个数据,原来是一个数据,现在又多了指针指向偏移的数据结构。类C也多了四个字节。
对于C++对象的内存布局,内存结构比较复杂,包括
虚函数指针、偏移数据结构的指针、可能还有虚函数指针、实际对应的数据、后面可能有偏移数据结构的指针。
访问类内数据是通过偏移量来计算的,指针转换时也是通过偏移来计算的。

浙公网安备 33010602011771号