`
weiyinchao88
  • 浏览: 1183958 次
文章分类
社区版块
存档分类
最新评论

C++对象布局及多态实现的探索(五)

 
阅读更多
菱形结构的虚继承

  这次我们看看菱形结构的虚继承。虚继承的引入本就是为了解决复杂结构的继承体系问题。上一篇我们在讨论虚继承时用的是一个简单的继承结构,只是为了打个铺垫。
  我们先看看这几个类,这是一个典型的菱形继承结构。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<C141*>(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字节的语义。有可能是出于兼容的原因,也有可能是为编译器提供一些薄记信息。而且,引入虚继承后的对象继承的拓朴结构可以比我们讨论过的菱形结构要复杂得多。这两个值也可能是用来处理更复杂的继承结构。要想通过表象去揣测出使用它们的动机太困难了。

  (未完待续)


分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics