C++ 内存分布之菱形继承(无虚函数)

菱形继承的定义是:两个子类继承同一父类,而又有子类同时继承这两个子类。例如a,b两个类同时继承c,但是又有一个d类同时继承a,b类。探究的过程还是很有趣的。 菱形继承的内存布局探究花了我几天时间,探究起来还是有点难度的。博文中如果有错误的地方,欢迎大家指正,大家共同进步。

一、继承关系图

图1.菱形类图

二、源代码

在使用g++编译的时候,如果father类和mother不是用虚继承的话,编译会报错的。有网友爆料说window平台是可以编译过的,这里我们就不关心了。下面的情况就会报错

class father: public ancestor //缺少virtual关键字

class mother:public ancestor//缺少virtual关键字

错误log:下面明显说有些成员和方法是模棱两可的。

打印结果:下面打印的数据都是子类er对象的

上面可以看到发现下面几个规律。

1.不管是直接打印子类er对象中c数据的地址,还是将子类对象er转换成father和mother指针,分别打印c数据域地址都是一样的。

2.除了ancestor对象的数据域(菱形的顶端类)在最顶端外,其它数据域仍然是按着继承关系排列的。但是这里共有祖先ancestor的数据域却放到了最后面。

打印的地址没有明显规律,而且和上一篇我们探究的多继承情况很不一样。

这里我们抛出下面几个疑问:

1.为什么ancestor的c数据域,没有放在子类对象首地址。

2.为什么子类对象son数据大小不是father和mother对象的之和。

3.为什么将son对象强制转化成father和mother对象他们的c数据域地址是同一个地址,难道他们没有继承ancestor吗?

……..

带着种种问题,我们开启探究之旅。

三、探究分析

1)调试手段

在使用g++编译C++程序一个强有力的编译选项,下面是它的注释:

-fdump-class-hierarchy-options (C++ only)

Dump a representation of each class’s hierarchy and virtual function table layout to a file.  The file name is made by appending .class

to the source file name, and the file is created in the same directory as the output file.  If the -options form is used, options

controls the details of the dump as described for the -fdump-tree options.

意思就是:带上这个编译选项,可以在生成一个.class后缀文件,里面包含该文件的所有虚表。

这里使用下面的编译命令,来生成我们测试代码的虚表class,在生成虚表class当中,会夹杂许多其它无关紧要的虚表函数,如果你想看看这些无关紧要的虚表,还是自己把例子跑一遍吧,也许会有奇迹发现。这里我们只关心和我们类相关的虚表。有人可能会问,这里根本没有虚函数,怎么就有续表信息了。也许这就是因为我们是虚继承吧。看到下面那条短小精悍的命令了吗,赶紧执行它试试吧!

g++-fdump-class-hierarchy father_jing.cpp

虚表信息:

当C++中出现了虚函数,编译器都会为每一个类生成一个虚表,这个虚表具有可读属性,在ubuntu上它驻留在.rodata段,而且该类所有对象共有这个虚表。在后面会有打印信息,来证明这点。在每一个实例内存空间的最前面会安排一个vptr来指向这个虚表。在后面调试的时候,我会用gdb打出每一个实例的vptr。针对我们这个例子,上面是一张无效的虚表,因为我们类中根本就没有虚函数。原因就是我们采用了虚继承,g++还是按着有虚表的方式来编译。这样的话g++就会把一个虚指针安放在er对象中的father和mother数据开始处。

2)GDB顺藤摸瓜

gdb的调试大家应该很熟悉了吧。不熟悉的请看陈皓大神的GDB调试程序

上面打印的地址和我们在之前的打印结果不一样的,这很正常。每次运行系统分配的地址都是不一样的。上面的打印结果可以看到下面几个现象:

1.子类对象中对应father类的数据域中,没有father继承ancestor的c数据域。

2.子类对象中对应father类的数据起始位置放的是虚表存放地址。

3.子类对象中对应mother类的数据域中,没有mother继承ancestor的c数据域。

4.子类对象中对应mother类的数据起始位置放的是虚表存放地址。

5.祖先ancestor放到了子类对象的最后,这也是最大的亮点。

由上面的打印结果,我们可以得到下面这张映射表:

图2 er对象的内存分布

3)子类对象地址赋给父类指针会发生什么

为了探究这个问题,我在代码中添加下面一行代码,就是为了验证子类对象转换成父类指针时,打印father中的c地址到底是它原有的的(0x60307c),还是打印er数据中最下面的c(0x603094)。

首先,用objdump命令将可执行文件反汇编成汇编.main函数对应的汇编代码。

objdump -S a.out > tttttt.txt

使用这个命令会生成带有C语言的汇编的代码,前提是我们在编译可执行文件时,添加了-g选项。为了各位同学能自己计算下面注释中的一些值,打出了在进入main函数之前寄存器列表信息:

下面只是主要的main函数反汇编代码:

我们知道每一个局部变量一般都会保存到栈中,如果想深入了解的话,同学们可以查看Linux中的局部变量和栈。这里在main函数中生成的都是局部变量,所以为此我们可以根据汇编代码,列出对象的分布图,如下所示。

图 3 main函数中临时变量分布

当执行到int a = baba.c处,到底调用的是子类实例son中的父类数据域的c,还是共有的数据c。为了更清楚方便大家推算,我打出了执行这行代码前后的寄存器列表信息。

扩展:

<1>(gdb) p *0x603070
$1 = 4199256 = 0x401358
(gdb) p *er
$2 = {<father> = {<ancestor> = {c = 88}, _vptr.father = 0x401358 <vtable for son+24>,
cc = 99}, <mother> = {_vptr.mother = 0x401370,
ccc = 100}, d = 2, e = 5}
<2>(gdb) p *0x401340
$3 = 36
<3>lea (%rdx,%rax,1),%rax 这句话的意思就是(rdx)+(rax)*1 即
=0x603070+36×1 = 0x603094 ,这里就得出了c的地址。
针对上面的36我们再来看看son的vtable
Vtable for son
son::_ZTV3son: 6u entries
0 36u //这个就是,编译器在虚表中记下了ancestor数据域的偏移
8 (int (*)(…))0
16 (int (*)(…))(& _ZTI3son)
24 20u
32 (int (*)(…))-0x00000000000000010
40 (int (*)(…))(& _ZTI3son)

总结:到现在我们就知道上面一开始抛出的几个问题了吧。

1.为什么ancestor的c数据域,没有放在子类对象首地址。

答:子类对象的首地址存放的是子类的虚表指针。由上面的调试和log打印我们已经发现,在菱形继承中,公共父类的数据域都是公用一份的,并且都是放在子类最下面的数据区。如果我们把子类对象地址赋给父类指针,例如:father *fa;fa=&er;.这里我们可以在上面的调试中发现,编译器会自动将对应father类的那部分数据区的首地址赋给fa。最后访问c域时,即fa->c,也是访问子类0x603094处的共有c数据。

2.为什么子类对象son数据大小不是father和mother对象的之和加son自己的数据域(sizeof(son)=sizeof(father)+sizeof(mother)+son_data)。

答:由于子类对象数据区多出了mother虚表指针,所以大小不一样。

3.为什么将son对象强制转化成father和mother对象他们的c数据域地址是同一个地址,难道他们没有继承ancestor吗?

答:上面一开始我看到子类对象中的father和mother数据域是8字节对齐的,我以为只是续表指针是8字节对齐,所以我将子类对象中对应father数据按4字节地址打印,结果本应该是c数据域的地方打印的是0,所以子类属于father的数据区中没有c数据域

1 2 收藏 1 评论

相关文章

可能感兴趣的话题



直接登录
最新评论
跳到底部
返回顶部