潘凯:C++对象布局及多态实现的探索(8-12)

来源:百度文库 编辑:神马文学网 时间:2024/04/27 21:13:01
普通的虚继承
下面我们来看虚继承。首先看看这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做了一个中转。这里我们就可以看到前面的虚继承下的子类指针到父类指针的赋值是我么的低效。在程序中应尽量的避免这种代码。
(未完待继)
菱形结构的虚继承
这次我们看看菱形结构的虚继承。虚继承的引入本就是为了解决复杂结构的继承体系问题。上一篇我们在讨论虚继承时用的是一个简单的继承结构,只是为了打个铺垫。
我们先看看这几个类,这是一个典型的菱形继承结构。C100和C101通过虚继承共享同一个父类C041。C110则从C100和C101多重继承而来。
struct C041
{
C041() : c_(0x01) {}
virtual void foo() { c_ = 0x02; }
char c_;
};
struct C100 : public virtual C041
{
C100() : c_(0x02) {}
char c_;
};
struct C101 : public virtual C041
{
C101() : c_(0x03) {}
char c_;
};
struct C110 : public C100, public C101
{
C110() : c_(0x04) {}
char c_;
};
运行如下代码:
PRINT_SIZE_DETAIL(C110)
结果为:
The size of C110 is 16
The detail of C110 is 28 c3 45 00 02 1c c3 45 00 03 04 18 c3 45 00 01
我们可以象上一篇一样,画出对象的内存布局。
|C100,5 |C101,5 |C110,1 |C041,5 |
|ospt,4,11 |m,1 |ospt,4,6 |m,1 |m,1 |vtpt,4 |m1 |
(注:为了不折行,我用了缩写。ospt代表偏移值指针、m代表成员变量、vtpt代表虚表指针。第一个数字是该区域的大小,即字节数。只有偏移值指针有第二个数字,第二个数字就是偏移值指针指向的偏移值的大小。)
可以看到对象的内存布局中只有一个C041,即祖父类的部分只有一份,且放在最后面。这就是菱形继承。对比前面几篇的讨论,我们可以知道,如果没有用虚继承机制,那么在C041对象的内存布局中会出现两份C041部分,这也就是所谓的V型继承。相应的对象布局为:C041+C100+C041+C101+C110。在V型继承中是不能直接从C110,即孙子类,直接转型到C041,即祖父类的。因为在对象的布局中有两份祖父类的实体,一份从C100而来,一份从C101而来。编译器在决议时会存在二义性,它不知道转型后到底用哪一份实体。虽然可以通过先转型到某一父类,然后再转型到祖父类来解决。但使用这种方法时,如果改写了祖父类的成员变量的内容,runtime是不会同步两个祖父类实体的状态,因此可能会有语义错误。
我们再分析一下上面的内存布局。普通继承的布局,顶层类在前面。多重继承时则按从左到右的顺序排。从C100和C101到C110的继承是普通继承,所以遵循这个原则,先是左父类再右父类,接下去是子类。而虚继承则要求将共享的父类放到整个对象布局的最后(即使虚父类没有被真正的共享也是如此,前在一篇的C020类就是这样。不知道打开优化开关后会不会有变化。)所以在上例中的祖父类也是被置于最后的。
我们再看看对成员的访问情况。运行以下代码并查看相应的汇编代码。
C110 c110;
c110.c_ = 0x51;
c110.C100::c_ = 0x52;
c110.C101::c_ = 0x52;
c110.C041::c_ = 0x53;
c110.foo();
对应的汇编代码为:
01 00423993 push 1
02 00423995 lea ecx,[ebp+FFFFF7F0h]
03 0042399B call 0041DE60
04 004239A0 mov byte ptr [ebp+FFFFF7FAh],51h
05 004239A7 mov byte ptr [ebp+FFFFF7F4h],52h
06 004239AE mov byte ptr [ebp+FFFFF7F9h],52h
07 004239B5 mov eax,dword ptr [ebp+FFFFF7F0h]
08 004239BB mov ecx,dword ptr [eax+4]
09 004239BE mov byte ptr [ebp+ecx+FFFFF7F4h],53h
10 004239C6 mov eax,dword ptr [ebp+FFFFF7F0h]
11 004239CC mov ecx,dword ptr [eax+4]
12 004239CF lea ecx,[ebp+ecx+FFFFF7F0h]
13 004239D6 call 0041DF32
前3行是对象的初始化,调用了对象的构造函数。4、5、6行是对子类、左右父类的成员变量的赋值。我们可以看到是直接写的,因为这一层的继承是普通继承。第7、8、9行是对祖父类成员变量的赋值,和上篇讨论过的一样,是通过偏移值指针指向的偏移值来间接访问的。最后的4行指令是对成员函数的调用。我们可以看到调用的函数地址是直接给出的(最后一行),因为我们是通过对象来调用,即使是虚函数调用也不会有多态的行为。但是得到this指针的方式却是颇为间接,即第10、11、12行。因为这个函数在祖父类中定义,那么它操作的数据成员应该是祖父类的。因此编译器要调整this指针的位置。而祖父类又是被虚继承,因此要通过偏移值指针指向的偏移值来进行调整。
再观察一下第9行和第12行,可以看到计算出来的地址值是不一样的。这是因为第9行为给祖父类的成员变量赋值,而祖父类中有虚表指针存在,所以在得到对象的起始地址后,编译器给它加了4字节的偏移量以跳过虚指针。实际的得到地址的运算为:[ebp+ecx+FFFFF7F0h+4h],编译器在生成代码时会直接把最后一步运算做掉。
(未完待续)
菱形结构的虚继承(2)
我们再看一个例子,这个例子的继承结构和上一篇中是一样的,也是菱形结构。不同的是,每一个类都重写了顶层类声明的虚函数。代码如下:
struct C041
{
C041() : c_(0x01) {}
virtual void foo() { c_ = 0x02; }
char c_;
};
struct C140 : public virtual C041
{
C140() : c_(0x02) {}
virtual void foo() { c_ = 0x11; }
char c_;
};
struct C141 : public virtual C041
{
C141() : c_(0x03) {}
virtual void foo() { c_ = 0x12; }
char c_;
};
struct C150 : public C140, public C141
{
C150() : c_(0x04) {}
virtual void foo() { c_ = 0x21; }
char c_;
};
首先我们运行下面的代码,看看它们的内存布局。
PRINT_SIZE_DETAIL(C041)
PRINT_SIZE_DETAIL(C140)
PRINT_SIZE_DETAIL(C141)
PRINT_SIZE_DETAIL(C150)
结果为:
The size of C041 is 5
The detail of C041 is f0 c2 45 00 01
The size of C140 is 14
The detail of C140 is 48 c3 45 00 02 00 00 00 00 44 c3 45 00 01
The size of C141 is 14
The detail of C141 is 58 c3 45 00 03 00 00 00 00 54 c3 45 00 01
The size of C150 is 20
The detail of C150 is 74 c3 45 00 02 68 c3 45 00 03 04 00 00 00 00 64 c3 45 00 01
和前面的布局不同之处在于,共享部分和前面的非共享部分之间多了4字节的0值。只有共享部分有虚表指针,这是因为派生类都没有定义自己的虚函数,只是重写了顶层类的虚函数。我们分析一下C150的对象布局。
|C140,5         |C141,5         |C150,1 |zero,4 |C041,5     |
|ospt,4,15 |m,1 |ospt,4,10 |m,1 |m,1    |4      |vtpt,4 |m1 |
(注:为了不折行,我用了缩写。ospt代表偏移值指针、m代表成员变量、vtpt代表虚表指针。第一个数字是该区域的大小,即字节数。只有偏移值指针有第二个数字,第二个数字就是偏移值指针指向的偏移值的大小。)
再看函数的调用:
C150 obj;
PRINT_OBJ_ADR(obj)
obj.foo();
输出的对象地址为:
obj's address is : 0012F624
最后一行函数调用的代码对应的汇编代码为:
00423F74  lea         ecx,[ebp+FFFFF757h]
00423F7A  call        0041DCA3
单步执行后,我们可以看到ecx中的值为:0x0012F633,这个地址也就是obj对象布局中的祖父类部分的起始地址。通过上面的布局分析我们知道C150起始的偏移值指针指向的值为15,即对象起始到共享部分(祖父类部分)的偏移值。上面输出的obj起始地址为0x0012F624加上十进制的15后,正好是我们看到的ecx中的值0x0012f633。
由于函数调用是作用于对象上,我们看到第二行的call指令是直接到地址的。
在这里令人困惑的问题是,我们知道ecx是用来传递this指针的。在前一篇中,我们分析了在C110对象上的foo方法调用。在那个例子中,由于foo是顶层类中定义的虚函数,并且没有被下面的派生类重写,因此通过子类对象调用这个方法时,编译器产生的代码是通过子类起始的偏移指针指向的偏移值来计算出祖父类部分的起始地址,并将这个地址做为this指针所指向的地址。但是在C150类中,foo不再是从祖父类继承的,而是被子类自己所重写。照理这时的this指针应该指向子类的起始地址,也就是0x0012F62E,而不是ecx中的值0x0012F633。
我们跟进去看看C150::foo()的汇编代码,看它是怎样通过指向祖父类部分的this指针,来定位到子类的成员变量。
01 00426C00  push        ebp
02 00426C01  mov         ebp,esp
03 00426C03  sub         esp,0CCh
04 00426C09  push        ebx
05 00426C0A  push        esi
06 00426C0B  push        edi
07 00426C0C  push        ecx
08 00426C0D  lea         edi,[ebp+FFFFFF34h]
09 00426C13  mov         ecx,33h
10 00426C18  mov         eax,0CCCCCCCCh
11 00426C1D  rep stos    dword ptr [edi]
12 00426C1F  pop         ecx
13 00426C20  mov         dword ptr [ebp-8],ecx
14 00426C23  mov         eax,dword ptr [ebp-8]
15 00426C26  mov         byte ptr [eax-5],21h
16 00426C2A  pop         edi
17 00426C2B  pop         esi
18 00426C2C  pop         ebx
19 00426C2D  mov         esp,ebp
20 00426C2F  pop         ebp
21 00426C30  ret
果然,由于此时指针指向的不是子类的起始部分(而是祖父类的起始部分),因为是通过减于一个偏移值为向前定位成员变量的地址的。注意第15行,这时eax中存放的是this指针的值,写入值的地址是[eax-5],结合前面的对象布局和对象的内存输出,我们可以知道this指针的值(此时指向祖父类C041的起始部分)减去5个字节(4字节的0值和1字节的成员变量值)后,刚好是子类C150的起始地址。
为什么不直接用子类的地址而是通过祖父类的起始地址间接的进行定位?这牵涉到编译内部的实现限制和对一系统问题的全面的理解。只是通过分析现象很难找到答案。
我们再通过指针来调用一次。
C150 * pt = &obj;
pt->foo();
第二行代码对应的汇编指令为:
01 00423F8B  mov         eax,dword ptr [ebp+FFFFF73Ch]
02 00423F91  mov         ecx,dword ptr [eax]
03 00423F93  mov         edx,dword ptr [ecx+4]
04 00423F96  mov         eax,dword ptr [ebp+FFFFF73Ch]
05 00423F9C  mov         ecx,dword ptr [eax]
06 00423F9E  mov         eax,dword ptr [ebp+FFFFF73Ch]
07 00423FA4  add         eax,dword ptr [ecx+4]
08 00423FA7  mov         ecx,dword ptr [ebp+FFFFF73Ch]
09 00423FAD  mov         edx,dword ptr [ecx+edx]
10 00423FB0  mov         esi,esp
11 00423FB2  mov         ecx,eax
12 00423FB4  call        dword ptr [edx]
13 00423FB6  cmp         esi,esp
14 00423FB8  call        0041DDF2
喔!更加迂回了。这段代码非常的低效,里面很多明显的冗余指令,如第1、4、6行,2、5行等,如果打开了优化开关可能这段指令的效率会好很多。
第9行通过祖父类的虚表指针得到了函数地址,第11行同样把祖父类部分的起始地址0x0012F633做为this指针指向的地址存入ecx。
最后我们做个指针的动态转型再调用一次:
C141 * pt1 = dynamic_cast(pt);
pt1->foo();
第1行代码对应的汇编指令如下:
01 00423FBD  cmp         dword ptr [ebp+FFFFF73Ch],0
02 00423FC4  je          00423FD7
03 00423FC6  mov         eax,dword ptr [ebp+FFFFF73Ch]
04 00423FCC  add         eax,5
05 00423FCF  mov         dword ptr [ebp+FFFFF014h],eax
06 00423FD5  jmp         00423FE1
07 00423FD7  mov         dword ptr [ebp+FFFFF014h],0
08 00423FE1  mov         ecx,dword ptr [ebp+FFFFF014h]
09 00423FE7  mov         dword ptr [ebp+FFFFF730h],ecx
这里实际做了一个pt是否为零的判断,第4条指令把pt指向的地址后移了5字节,最后赋给了pt1。这样pt1就指向了右父类部分的地址位置,也就是C141的起始位置。
第2行代码对应的汇编指令为:
01 00423FED  mov         eax,dword ptr [ebp+FFFFF730h]
02 00423FF3  mov         ecx,dword ptr [eax]
03 00423FF5  mov         edx,dword ptr [ecx+4]
04 00423FF8  mov         eax,dword ptr [ebp+FFFFF730h]
05 00423FFE  mov         ecx,dword ptr [eax]
06 00424000  mov         eax,dword ptr [ebp+FFFFF730h]
07 00424006  add         eax,dword ptr [ecx+4]
08 00424009  mov         ecx,dword ptr [ebp+FFFFF730h]
09 0042400F  mov         edx,dword ptr [ecx+edx]
10 00424012  mov         esi,esp
11 00424014  mov         ecx,eax
12 00424016  call        dword ptr [edx]
13 00424018  cmp         esi,esp
14 0042401A  call        0041DDF2
由于是通过偏移值指针进行运算,最后在调用时ecx和edx的值和前面通过pt指针调用时是一样的,这也是正确的多态行为。
(未完待续)
菱形结构的虚继承(3)
最后我们看看,如果在上篇例子的基础上,子类及左、右父类都各自定义了自己的虚函数,这时的情况又会怎样。
struct C140 : public virtual C041
{
C140() : c_(0x02) {}
virtual void foo() { c_ = 0x11; }
char c_;
};
struct C160 : public virtual C041
{
C160() : c_(0x02) {}
virtual void foo() { c_ = 0x12; }
virtual void f160() { c_ = 0x12; }
char c_;
};
struct C161 : public virtual C041
{
C161() : c_(0x03) {}
virtual void foo() { c_ = 0x13; }
virtual void f161() { c_ = 0x13; }
char c_;
};
struct C170 : public C160, public C161
{
C170() : c_(0x04) {}
virtual void foo() { c_ = 0x14; }
virtual void f170() { c_ = 0x14; }
char c_;
};
首先运行如下的代码,看看内存的布局。
PRINT_SIZE_DETAIL(C041)
PRINT_SIZE_DETAIL(C160)
PRINT_SIZE_DETAIL(C161)
PRINT_SIZE_DETAIL(C170)
结果为:
The size of C041 is 5
The detail of C041 is f0 b2 45 00 01
The size of C160 is 18
The detail of C160 is 84 b3 45 00 88 b3 45 00 02 00 00 00 00 80 b3 45 00 01
The size of C161 is 18
The detail of C161 is 98 b3 45 00 9c b3 45 00 03 00 00 00 00 94 b3 45 00 01
The size of C170 is 28
The detail of C170 is b0 b3 45 00 c8 b3 45 00 02 ac b3 45 00 bc b3 45 00 03 04 00 00 00 00 a8 b3 45 00 01
C170对象的布局为:
|C160,9             |C161,9             |C170,1 |zero,4 |C041,5   |
|vp,4 |op,4,19 |m,1 |vp,4 |op,4,10 |m,1 |m,1    |       |vp,4 |m1 |
(注:为了不折行,我用了缩写。op代表偏移值指针、m代表成员变量、vp代表虚表指针。第一个数字是该区域的大小,即字节数。只有偏移值指针有第二个数字,第二个数字就是偏移值指针指向的偏移值的大小。)
左右父类由于各自定义了自己的新的虚函数,因此都拥有了自己的虚表指针。奇怪的是子类虽然也定义了自己的新的虚函数,我们在上面的布局中却看到它并没有自己的虚表指针。那么它应该是和顶层类或是某一父类共用了虚表。我们可以在后面通过对调用的跟踪来找到答案。
另一个奇怪的地方是在左右父类中的偏移值指针指向的偏移值不再是到祖父类的偏移量,而是变成了到祖父类之前的4字节0值的偏移量。同时在前面第八篇中我们说过偏移值指针指向的地址的前4个字节为零,接下来的4个字节才是真正的偏移量。在这个例子中,前4个字节不再为0,而是0xFFFFFFFC,即整数-4。
照例我们先通过对象来调用一下。
C170 obj;
PRINT_OBJ_ADR(obj);
obj.foo();
结果为:
obj's address is : 0012F54C
最后一行调用对应的汇编指令为:
004245B8  lea         ecx,[ebp+FFFFF687h]
004245BE  call        0041D122
ecx中的值(即this指针的值)为0x0012F563,和前面一样是指向祖父类的起始部分。同样函数中的指令也是通过将this-5字节来定位到正确的成员变量的地址,这里不再列出函数的汇编指令。
再看看调用它自己新定义的虚函数。
obj.f170();
对应的汇编指令为:
004245C3  lea         ecx,[ebp+FFFFF670h]
004245C9  call        0041D127
让我非常惊奇的是这次this指针的值居然是0x0012F54C。和前面的对象地址输出是一样的,也就是指向了整个对象的起始位置。这就让人非常的奇怪了,在同一个对象上调用的两个虚函数,编译器为它们传递的this指针却是不同的。
让我们跟到函数中,看它怎样取得正确的成员变量的地址。
01 00426F80  push        ebp
02 00426F81  mov         ebp,esp
03 00426F83  sub         esp,0CCh
04 00426F89  push        ebx
05 00426F8A  push        esi
06 00426F8B  push        edi
07 00426F8C  push        ecx
08 00426F8D  lea         edi,[ebp+FFFFFF34h]
09 00426F93  mov         ecx,33h
10 00426F98  mov         eax,0CCCCCCCCh
11 00426F9D  rep stos    dword ptr [edi]
12 00426F9F  pop         ecx
13 00426FA0  mov         dword ptr [ebp-8],ecx
14 00426FA3  mov         eax,dword ptr [ebp-8]
15 00426FA6  mov         byte ptr [eax+12h],14h
16 00426FAA  pop         edi
17 00426FAB  pop         esi
18 00426FAC  pop         ebx
19 00426FAD  mov         esp,ebp
20 00426FAF  pop         ebp
21 00426FB0  ret
看看第15行可以知道,是直接在this指针上加了18字节(即16进制的12h)来定位到子类的成员变量。
由于函数中的指令是以这种方式来定位子类成员变量,所以即使我们是通过指针来调用,不同的只是怎样定位函数地址,而this指针的值是肯定不会变的。我们来验证一下。
C170 * pt = &obj;
pt->f170();
第二行代码对应的汇编指令如下:
01 004245DA  mov         eax,dword ptr [ebp+FFFFF664h]
02 004245E0  mov         edx,dword ptr [eax]
03 004245E2  mov         esi,esp
04 004245E4  mov         ecx,dword ptr [ebp+FFFFF664h]
05 004245EA  call        dword ptr [edx+4]
06 004245ED  cmp         esi,esp
07 004245EF  call        0041DDF2
第一行把整个对象的起始地址放到eax中,第2行把eax当指针,并把所指地址放到edx中。对象的起始地址正好也是左父类中的虚表指针,第5行进行调用的时候果然是把edx指向的地址后移了4字节后取值,做为函数地址。这也就回答了前面的一个问题,子类没有虚表,它的虚表实际合并到了左父类的虚表中,左父类定义了一个自己的虚函数,占用了虚函数表的第一个条目,子类的虚函数则占用了第二个条目。因此在寻址时要加上4个字节。ecx中的this指针值和我们前面估计一样,是整个对象的起始地址。
最后我们看看怎样得到祖父类地址。
pt->C041::c_ = 0x33;
对应的汇编指令为:
01 004245F4  mov         eax,dword ptr [ebp+FFFFF664h]
02 004245FA  mov         ecx,dword ptr [eax+4]
03 004245FD  mov         edx,dword ptr [ecx+4]
04 00424600  mov         eax,dword ptr [ebp+FFFFF664h]
05 00424606  mov         byte ptr [eax+edx+8],33h
首先把对象的起始地址赋给eax。第2行把eax+4字节后得到的指针指向的地址赋给ecx,这个值就是偏移值指针指向的地址。果然第3行把它+4字节后取值,再赋给edx。这时edx的值为13h,照理这应该是到祖父类区域的偏移值,但实际是只到我们在对象布局中列出的4字节0值,也就是真正的祖父类起始地址的前4个字节。我们在前面讨论C170的对象布局时已经提到这个问题。所以我们看到第5行定位到成员变量时再加了8字节,以跳过4字节的0值为4字节的祖父类的虚表指针,而不是只加4字节跳过虚表指针。在C150对象中我们可以看到偏移值是直接跳过4字节0值,定位到祖父类起始地址的。
我们始终没有清楚的解释过祖父类之前的4字节0值及偏移值指针指向地址的前4字节的语义。有可能是出于兼容的原因,也有可能是为编译器提供一些薄记信息。而且,引入虚继承后的对象继承的拓朴结构可以比我们讨论过的菱形结构要复杂得多。这两个值也可能是用来处理更复杂的继承结构。要想通过表象去揣测出使用它们的动机太困难了。
(未完待续)
后记
结合前面的讨论,我们可以看到,只要牵涉到了虚继承,在访问父类的成员变量时生成的代码相当的低效,需要通过很多间接的计算来定位成员变量的地址。在指针类型转换,动态转型,及虚函数调用时,也需要生成很多额外的代码来调整this指针。象前一篇中对C170对象的obj.foo()和obj.f170()两次调用,传递到两个函数中的this指针居然是不一样的。
前面我们碰到过的怪异行为还有很多,比如偏移值指针指向地址的前4字节,及C150、C170对象中的4字节0值的语义,为什么对C150和C170调用foo函数时,this指针指向的不是子类部分的起始位置而是祖父类的起始位置,等等。去彻底的探究这些问题的意义并不是很大。虚继承的实现属于编译器厂商的行为,厂商出于不同的考虑,实现的方法也会大相径庭。
对于传统的C程序员,他们可能会认为C++的效率低。其实效率低是低在多态部分,因为这要在运行时通过虚表来决议出函数的地址。但对于设计而言,多态是一个非常强大的武器。多态也是面向对象设计的核心技术之一。虽然在执行的效率上有所损失,但对于大规模的程序设计,对于问题域到模型的映射,使用以多态为核心的面向对象设计技术可以提高设计、实现及维护的效率,对于大部分的应用,总体来说得大于失。
但是对于虚继承,个人感觉只是为了解决菱形继承及更复杂继承问题不得已而引入的一项机制,而且没有完美的解决方案,不但大幅的损失效率,而且带来了巨大的复杂性,使得继承结构晦涩难懂。如非万不得已,且在自己清楚一切后果的情况下,建议不要使用。尤其是不要在被虚继承的基类中声明非静态的成员变量。
C++支持多种编程范型,面向过程式的、数据抽象及封装、面向对象、现在又多了一种基于模板技术的泛型编程。我们以一个优秀的开源C++编程环境ACE(之所以叫编程环境,因为它提供了从类库到框架的多层次的支持)为例,看看在设计时的衡量及各种编程范型的运用契机。ACE分几个层次,依次为:OS适配层、wrapper facade层、框架层、服务组件层。OS适配层、wrapper facade层主要运用了数据抽象及封装,没有用到多态及虚机制。因为这两层的关键是效率。而且在这两层中的类的成员方法尽量的内联。其实不使用多态及虚机制的话,C++的效率和C应该是差不多的,但对象封装导致了大量的琐碎方法(如对成员变量的访问封装,即set,get方法),而方法调用的成本也是相当高昂的(需要保存及恢复当前的执行环境上下文,参数的传递及返回值可能产生很多的临时变量及对象等)。所以这两层通过内联来减少函数调用的开销,提高执行效率。在框架层则使用了大量的设计模式,大量使用了多态机制及泛型技术。在这一层的主要关注点是结构的清晰,及实现设计上的语义。在这时多态机制在执行效率上的损失是可以忽略不计的。
最后,我用Lippman在他的经典书籍《Inside the C++ Object Model》中关于描述虚成员函数章节中的一段话来做为这系列文章的结束。“Although I have a folder full of examples worked out and more than one algorithm for determining the proper offsets and adjustments, the material is simply too esoteric to warrant discussion in this text. My recommendation is not to declare nonstatic data members within a virtual base class. Doing that goes a long way in taming the complexity.”大意为在虚继承时用以确定偏移地址及进行this指针调整的可行算法很多,而且大都非常的诡异(这个我们已经见识了:))。同时他建议不要在被虚继承的基类中声明非静态的成员变量,这样做纯属自取烦恼。
另,感谢张水松和张建业这两个土人,在写这些文章时和他们进行了一些非常有益的探讨。最后也是他们提醒我,不要再深入下去,以免走火入魔。
(全文完)