前言
想要了解一下虚继承的内部数据结构
(语言: c++, 编译器: g++(4.8.5))
内存布局
考虑以下代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| class A{ public: int _i = 1; };
class B : virtual public A{ public: int _i = 2; };
class C : virtual public A{ public: int _i = 3; };
class D : public B, public C{ public: int _i = 4; };
|
单纯的虚拟继承而已, 我使用了这种方式打印它们的内部数据
1 2 3 4 5 6 7 8
| D a; void *p = &a; int *pi = (int *)p;
for (int i = 0; i < sizeof(a) / 4; ++i) printf("%d\n", pi[i]);
printf("val:%d\n", a._i);
|
然后它们A, B, C, D内部数据情况如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| A: 1 B: 4196632 0 2 1 C: 4196632 0 3 1 D: 4196760 0 2 0 4196784 0 3 4 1 0
|
emmm…
OK, 我可以把前两个 4 字节认为是虚指针(A没有)
那么A, B, C的内存分布就不用看了, 唯一比较特殊的是虚基类在内存的高地址
然后D的内部分布… 这… emmm… 仔细一想, 还好, 重要的是(Q1: 为什么最后补了一个0 ??)
存取方式
我试着访问了一下数据
1 2 3 4 5 6 7 8 9
| D a; A *pa = &a; printf("%d\n", pa->_i); B *pb = &a; printf("%d\n", pb->_i); C *pc = &a; printf("%d\n", pc->_i); D *pd = &a; printf("%d\n", pd->_i);
|
来看一看汇编
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 subq $80, %rsp // 80 栈帧 leaq -80(%rbp), %rax movq %rax, %rdi call _ZN1DC1Ev // 这里是构造函数, 稍后可能需要看看它的构造 leaq -80(%rbp), %rax addq $32, %rax // +32, OK, 向上 32 字节的确是 1 movq %rax, -8(%rbp) movq -8(%rbp), %rax movl (%rax), %eax movl %eax, %esi movl $.LC0, %edi movl $0, %eax call printf // 1 第一次printf leaq -80(%rbp), %rax movq %rax, -16(%rbp) movq -16(%rbp), %rax // B 类型指针并没有经过偏移, 这和预料的一样 movl 8(%rax), %eax // 这里+8, 跳过了虚指针 movl %eax, %esi movl $.LC0, %edi movl $0, %eax call printf // 2 第二次printf leaq -80(%rbp), %rax addq $16, %rax // +16 C类型指针跳过了 B的内存, 这也没什么不对 movq %rax, -24(%rbp) movq -24(%rbp), %rax movl 8(%rax), %eax // 同理 movl %eax, %esi movl $.LC0, %edi movl $0, %eax call printf // 3 第三次printf leaq -80(%rbp), %rax movq %rax, -32(%rbp) movq -32(%rbp), %rax movl 28(%rax), %eax // +28 OK~ movl %eax, %esi movl $.LC0, %edi movl $0, %eax call printf // 4 第四次printf movl $0, %eax leave
|
数据的访问在编译时就已经定好的, 不存在额外效率影响
A指针 偏移了 +32 字节 直接访问数据
B指针 未偏移 访问数据时跳过了虚指针(8字节)
C指针 偏移 +16 字节 访问数据时跳过了虚指针(8字节)
D指针 未偏移 访问数据时跳过了虚指针 + B, C类数据(共28字节)
或许看一下访问虚基类的数据会有所了解?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
| pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 subq $80, %rsp leaq -80(%rbp), %rax movq %rax, %rdi call _ZN1DC1Ev leaq -80(%rbp), %rax addq $32, %rax movq %rax, -8(%rbp) movq -8(%rbp), %rax movl (%rax), %eax movl %eax, %esi movl $.LC0, %edi movl $0, %eax call printf // 基类访问方式和上面的汇编并没有不同 leaq -80(%rbp), %rax movq %rax, -16(%rbp) movq -16(%rbp), %rax movq (%rax), %rax // 取指针指向的值, 也就是D的地址 subq $24, %rax // D地址减去24字节 也就是说, 它前面还有东西 movq (%rax), %rax // 获得那个地址中的值 movq %rax, %rdx movq -16(%rbp), %rax addq %rdx, %rax // 增加了 rdx 大小, movl (%rax), %eax movl %eax, %esi movl $.LC0, %edi movl $0, %eax call printf leaq -80(%rbp), %rax addq $16, %rax movq %rax, -24(%rbp) movq -24(%rbp), %rax movq (%rax), %rax subq $24, %rax movq (%rax), %rax movq %rax, %rdx movq -24(%rbp), %rax addq %rdx, %rax movl (%rax), %eax movl %eax, %esi movl $.LC0, %edi movl $0, %eax call printf leaq -80(%rbp), %rax movq %rax, -32(%rbp) movq -32(%rbp), %rax movq (%rax), %rax subq $24, %rax movq (%rax), %rax movq %rax, %rdx movq -32(%rbp), %rax addq %rdx, %rax movl (%rax), %eax movl %eax, %esi movl $.LC0, %edi movl $0, %eax call printf movl $0, %eax leave
|
emmm… 有一个重大的发现就是, 基类对象前面有数据
这些数据应该是偏移, 这些偏移配合 + 指针本身的地址能够访问到正确的虚基类数据
也就是说, 这是 深入探索对象 中, 对于虚基类的两种实现方式中的第一种
即: 在每个基类中添加虚基类的偏移, 但是又不全对…
越来越糊涂了 (눈_눈), 深吸一口气, 想想自己要干嘛… emmmm….
这样吧, 完整地看一遍构造的过程, 这样应该就明白了
(如果还是晕, 那么就希望下次遇到这个问题的时候能够更从容一些)
(我已经再这个问题上花太多时间, 再这样下去反而不好, 这也不是一个很重要/常用的知识)
完整的构造过程
首先是 main 区块
1 2 3 4 5 6 7 8 9 10 11
| main: .LFB1196: pushq %rbp movq %rsp, %rbp subq $48, %rsp // 48 字节栈帧, 为什么是 48 呢? leaq -48(%rbp), %rax movq %rax, %rdi call _ZN1DC1Ev // 调用构造函数 movl $0, %eax leave ret
|
然后是D的构造函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| _ZN1DC1Ev: .LFB1208: pushq %rbp movq %rsp, %rbp subq $16, %rsp // 它再次开辟了 16 字节的栈帧 movq %rdi, -8(%rbp) // 把原栈顶指针放入了栈中 movq -8(%rbp), %rax addq $32, %rax // 原栈顶指针 +32 偏移, 距栈底还有 16 字节 movq %rax, %rdi call _ZN1AC2Ev // 这里留了 16 字节用于 A 的构造 movl $_ZTT1D+8, %edx // 我很好奇地查了 $_ZTT1D 这东西是多少, 然而事实让我绝望... // 它有非常多的耦合, 短时间内我根本无法计算出这东西是多少!!! // 而且里面的代码是以.开头的, 这意味着是伪指令... (;´༎ຶД༎ຶ`) movq -8(%rbp), %rax movq %rdx, %rsi // rdx 从未被赋值过, 我很好奇它将什么值给了 rsi // 对了, 在上面一句用到了 edx, 这和 rdx 有关 // 暂且把它认为是一个, 编译器施加的魔法: magic movq %rax, %rdi // 原栈顶指针 call _ZN1BC2Ev // 调用 B 构造函数 movl $_ZTT1D+16, %eax movq -8(%rbp), %rdx // 拿出原栈顶指针 addq $16, %rdx // +16 偏移 movq %rax, %rsi movq %rdx, %rdi call _ZN1CC2Ev // 调用 C 构造函数 movl $_ZTV1D+24, %edxl movq -8(%rbp), %rax movq %rdx, (%rax) // 这里与调用 B 时的步骤重复了 :), 覆写 movl $_ZTV1D+48, %edx movq -8(%rbp), %rax movq %rdx, 16(%rax) // 这里与调用 C 时的步骤重复了 :), 覆写 movq -8(%rbp), %rax movl $444, 28(%rax) // 写入值 leave ret
|
A的构造函数:
1 2 3 4 5 6 7 8 9 10 11
| _ZN1AC2Ev: .LFB1199: pushq %rbp movq %rsp, %rbp movq %rdi, -8(%rbp) movq -8(%rbp), %rax movl $111, (%rax) // 将 $111 放入了参数 rdi 指向的内存中 movl: 4字节 popq %rbp ret // 嗯, 没了, 所以编译器仅仅对这 4 字节赋值 // 剩下的 12 字节呢? // 其中8字节没用过, 或者不在 D 的内存中, 因为 sizeof d 是40, 而非48
|
B的构造函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| _ZN1BC2Ev: .LFB1202: pushq %rbp movq %rsp, %rbp movq %rdi, -8(%rbp) // 原栈顶指针 movq %rsi, -16(%rbp) // 未知的原 rdx(magic) movq -16(%rbp), %rax movq (%rax), %rdx // magic的地址 -16 中的值放入 rdx movq -8(%rbp), %rax movq %rdx, (%rax) // 将 rdx 中的值放入了栈顶, 这里是q, 占了 8 字节 movq -8(%rbp), %rax movl $222, 8(%rax) // 栈顶 +8 偏移中, 放入了 $222 popq %rbp ret // 共写入了 12 字节
|
C的构造函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| _ZN1CC2Ev: .LFB1205: pushq %rbp movq %rsp, %rbp movq %rdi, -8(%rbp) movq %rsi, -16(%rbp) movq -16(%rbp), %rax movq (%rax), %rdx movq -8(%rbp), %rax movq %rdx, (%rax) movq -8(%rbp), %rax movl $333, 8(%rax) popq %rbp // 步骤与 B 基本相同 ret
|
也就是说, 在D的构造中, ABC的构造都被调用了
要注意的一点是, BC都未调用A的构造, A的构造仅在D中调用了一次
同时要注意的是, BC会用那个编译器给的数字往自身的内存布局中写值
但是如果其上有D, D会将那个值覆写一次, 假设其值恒定不变, 位移分别为:
8 16 24 48
这是一组有规律的数字, 以 8 为开始, 每个数字是前面数字之和(这个就是偏移)
剩下来最重要的是, D覆写了什么?
再次来看看是如何访问虚基类数据的
1 2 3
| D a; C *pd = &a; printf("%d\n", pd->A::_i);
|
汇编:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| .LFB1196: pushq %rbp movq %rsp, %rbp subq $48, %rsp leaq -48(%rbp), %rax movq %rax, %rdi call _ZN1DC1Ev leaq -48(%rbp), %rax addq $16, %rax // 因为是 C 类型指针, 所以做了 +16 偏移 movq %rax, -8(%rbp) // C *pd = &a; movq -8(%rbp), %rax movq (%rax), %rax // 获得了指针中的值 subq $24, %rax // 该值减去 24 movq (%rax), %rax // 再以该值间接寻址 movq %rax, %rdx movq -8(%rbp), %rax addq %rdx, %rax // rdx 本身的值 + 指针的值 movl (%rax), %eax // 间接寻址得到的值就是虚基类的值 movl %eax, %esi movl $.LC0, %edi movl $0, %eax call printf movl $0, %eax leave ret
|
这次要清晰得多了, 我们以上面D的内存布局为例:
1 2 3 4 5 6 7 8 9 10 11 12
| 4196760 0 2 0 4196784 0 3 4 1 0
|
那么在代码中试一下, 如果没有错误的话, 那个 x 应该是 4
(这个地址明显非常低, 它甚至没达到 8 字节, 这应该是编译的时候准备好的数据)
1 2 3 4 5 6
| D a; long long *pl = (long long *)&a; long long *pl2 = (long long *)&pl; *pl2 = *pl;
printf("%ld\n", *pl);
|
取出来了 16, emmm… 嗯, 没错, 4 个 4字节 4 x 4 = 16 !!!!!!!
PS: 有没有思考过为什么通过双间接才能去到虚基类呢?
这里明显的一个问题是, 虚基类的数据是通过 C 的某个地址索引到 D 的某个地址
然后再用 C 的地址索引到虚基类数据, 有两次的跳转
我再次试了一下有继承三个虚基类的情况(就是D再继承了一个类似 BC的类)
发现它的访问方式是一样的, 也就是说, 每一个自身都会往前寻找固定的字节(编译后固定)
然后用那个地址的数据 + 指针本身的地址(我不知道我又没有表达清楚, 不过我感觉我没有 :) )
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| class E : virtual public A{ public: int _i = 555; };
class D : public B, public C, public E{ public: int _i = 444; };
int main() { D a; long long *pl = (long long *)&a; long long *pl2 = (long long *)&pl; *pl2 = *pl; printf("%ld\n", *pl);
pl = (long long *)&a + 2; pl2 = (long long *)&pl; *pl2 = *pl; printf("%ld\n", *pl);
pl = (long long *)&a + 4; pl2 = (long long *)&pl; *pl2 = *pl; printf("%ld\n", *pl);
|
内存布局:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 4196856 (32) 0 222 0 4196880 (16) 0 333 0 4196904 (0) 0 555 444 111 0
|
这样的内存布局在其他方面可能还会有用
那么为什么会有 0 呢? 我估计是因为要实现这种内存布局, 或者说什么其他原因
summary
我很怀疑下次我看笔记时, 我自己看不看得懂我在说什么…