扫一扫
分享文章到微信
扫一扫
关注官方公众号
至顶头条
我们先看看这几个类,这是一个典型的菱形继承结构。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],编译器在生成代码时会直接把最后一步运算做掉。
我们再看一个例子,这个例子的继承结构和上一篇中是一样的,也是菱形结构。不同的是,每一个类都重写了顶层类声明的 虚函数 。代码如下: 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。
如果您非常迫切的想了解IT领域最新产品与技术信息,那么订阅至顶网技术邮件将是您的最佳途径之一。
现场直击|2021世界人工智能大会
直击5G创新地带,就在2021MWC上海
5G已至 转型当时——服务提供商如何把握转型的绝佳时机
寻找自己的Flag
华为开发者大会2020(Cloud)- 科技行者