普通的虚继承
下面我们来看虚继承。首先看看这C020类,它从C010虚继承:
struct C010
{
C010() : c_(0x01) {}
void foo() { c_ = 0x02; }
char c_;
};
struct C020 : public virtual C010
{
C020() : c_(0x02) {}
char c_;
};
运行如下代码,查看对象的内存布局:
PRINT_SIZE_DETAIL(C020)
结果为:
The size of C020 is 6
The detail of C020 is c0 c2 45 00 02 01
很明显对象的起始处是一个指针,然后是子类的成员变量,接下来是父类的成员变量。和以前的讨论不同的是由于使用了虚继承,父类的成员变量被放到了最后面。
运行如下的代码:
C020 c020;
c020.C010::c_ = 0x04;
由于子类中的变量和父类中的变量重名,所以我们必须用这种方式来访问属于父类的成员变量,普通情况下不需要这种写法。我们看看后面这行代码对应的汇编代码:
0042387E mov eax,dword ptr [ebp+FFFFF82Ch]
00423884 mov ecx,dword ptr [eax+4]
00423887 mov byte ptr [ebp+ecx+FFFFF82Ch],4
前面说过对象的起始是一个指针,第1行指令取到这个指针的值,第2行把这个指针指向的地址后移4字节后的值(做为一个4字节的值)取出来。执行完这句我们看看ecx寄存器,可知取出来的值为5。最后一行是真正的赋值指令,它通过在对象的起始处(即[ebp+FFFFF32Ch])加上ecx中的值做偏移值(即5)来得到赋值的目的地址。接合前面的对象布局输出,我们可以发现从对象起始地址开始加5字节的偏移值,刚好得到父类的成员变量的地址。这样我们可以大致分析出直接虚继承的子类的对象布局。
|子类5 |父类1 |
|偏移值指针4,5|子类成员变量1|父类成员变量1|
(注:第一个数字为所在区域的长度(字节数),偏移值指针后的第二个数字为该指针指向的偏移值。后同。)
通过查看内存可以发现偏移值指针指向的内存前4字节为0,我不知道它的具体的用途是什么。接下来的4字节是一个32位的整数,也就是真正的偏移值。即从子类的起始位置到被虚继承的父类的起始位置的偏移值,在我们前面的例子中这个值为5(一个指针加一个char成员变量)。
通过这个分析我们可以看到在虚承继的情况下,通过子类的对象访问父类的普通成员变量的效率是相当低的。如果必须用到虚继承,也应该尽量不要在父类中放置普通成员变量(静态成员变量不受影响)。
另外为什么微软不把偏移值直接放到子类中,而是采用偏移值指针。我想是因为采用指针的方式更为灵活,即使以后需要扩展也不影响类对象的布局。
按下来我们再看看这几行代码:
PRINT_OBJ_ADR(c020);
C010 * pt = &c020;
PRINT_PT(pt);
pt->c_ = 0x03;
第2行声明了一个父类指针,并让它指向一个子类的对象。第3行打印出这个指针的值。运行结果为:
c020's address is : 0012F708
pt's value is : 0012F70D
我们可以看到赋值后的指针的值并不等于赋给它的对象地址值。也就是说在这个赋值过程中编译器进行了额外的工作,即调整了指针的值。我们看看第2行对应的汇编代码,看看编译器究竟做了些什么?
01 004238EA lea eax,[ebp+FFFFF82Ch]
02 004238F0 test eax,eax
03 004238F2 jne 00423900
04 004238F4 mov dword ptr [ebp+FFFFF014h],0
05 004238FE jmp 00423916
06 00423900 mov ecx,dword ptr [ebp+FFFFF82Ch]
07 00423906 mov edx,dword ptr [ecx+4]
08 00423909 lea eax,[ebp+edx+FFFFF82Ch]
09 00423910 mov dword ptr [ebp+FFFFF014h],eax
10 00423916 mov ecx,dword ptr [ebp+FFFFF014h]
11 0042391C mov dword ptr [ebp+FFFFF820h],ecx
喔!比想象的要复杂的多。一行简单的指针赋值语句却产生了这么多的汇编代码。这行代码本身的语义是取对象的地址赋给一个指针,对于编译器来说它把这做为指针到指针的赋值来处理。由于牵涉到了向上的类型转换,同时又有虚继承存在。根据前面的布局分析,在虚继承的情况下,父类位于对象布局的后部。因此在这里要做一个指针位置的调整。由于调整要根据源指针来进行计算,所以先要对源指针的合法性进行检查,以避免运行时的指针异常错误。前3行的汇编指令就是在做这件事,检查源指针是否为NULL。如果为NULL则执行4、5、10、11行,最终给pt赋0。如果不为NULL跳至第6行执行到最后。重要的是第6、7、8行代码,它们通过偏移值指针找到偏移值,并以此来调整指针的位置,让目的指针最终指向对象中的父类部分的数据成员。
对比一下普通的指针赋值,我们可以对上面赋值的复杂性和低效有更深的认识。
C010 * pt1 = NULL;
C010 * pt2 = pt1;
这两行相应的汇编代码为:
0042397D mov dword ptr [ebp+FFFFF814h],0
00423987 mov eax,dword ptr [ebp+FFFFF814h]
0042398D mov dword ptr [ebp+FFFFF808h],eax
第1行是普通的赋值,编译器并不做任何的检查,即使源指针为NULL。因为它不需要根据源指针(本处为NULL)做任何计算。第2个赋值也很直接,只是通过eax做了一个中转。这里我们就可以看到前面的虚继承下的子类指针到父类指针的赋值是我么的低效。在程序中应尽量的避免这种代码。
(未完待继)
潘凯:C++对象布局及多态实现的探索(八)
最新推荐文章于 2024-09-05 17:36:00 发布