从C++取地址操作看对象内存布局
对于一个C++对象,取地址存入一个指针,不同类型的指针拿到的值是一样的吗?
答案是不一定!
我们直接考察带虚函数的单继承和多继承两种场景。
测试样例
示例代码如下:
#include <stdio.h>
#include <stdint.h>
class A {
public:
virtual void funA() {}
int64_t a;
};
class B {
public:
virtual void funB() {}
int64_t b;
};
class C : public A {
public:
virtual void funcC() {}
int64_t c;
};
class D : public A, public B {
public:
virtual void funD() {}
int64_t d;
};
int main() {
{
C c;
void *p = &c;
A *a = &c;
printf("%p %p %p %p %p/n", p, a, &(c.a), &c, &(c.c));
}
{
D d;
void *p = &d;
A *a = &d;
B *b = &d;
printf("%p %p %p %p %p %p %p/n", p, a, &(d.a), b, &(d.b), &d, &(d.d));
}
}
本机运行结果如下:
0x7fffe3d44ec0 0x7fffe3d44ec0 0x7fffe3d44ec8 0x7fffe3d44ec0 0x7fffe3d44ed0
0x7fffe3d44ec0 0x7fffe3d44ec0 0x7fffe3d44ec8 0x7fffe3d44ed0 0x7fffe3d44ed8 0x7fffe3d44ec0 0x7fffe3d44ee0
可以看到:
- 对于单继承,直接取地址、用父类型指针存地址,结果都是一样的;
- 对于多继承,直接取地址、用第一个父类型指针存地址,结果一样,用第二个父类型存地址结果不一样;
我们结合 gcc 和 clang 查看对象内存布局,进一步分析。
内存布局分析
使用 gcc -std=c++17 -fdump-class-hierarchy test-class-layout.cpp
命令,文件输出如下:
Vtable for A
A::_ZTV1A: 3 entries
0 (int (*)(...))0
8 (int (*)(...))0
16 (int (*)(...))A::funA
Class A
size=16 align=8
base size=16 base align=8
A (0x0x7f982b13e000) 0
vptr=((& A::_ZTV1A) + 16)
Vtable for B
B::_ZTV1B: 3 entries
0 (int (*)(...))0
8 (int (*)(...))0
16 (int (*)(...))B::funB
Class B
size=16 align=8
base size=16 base align=8
B (0x0x7f982b13e0c0) 0
vptr=((& B::_ZTV1B) + 16)
Vtable for C
C::_ZTV1C: 4 entries
0 (int (*)(...))0
8 (int (*)(...))0
16 (int (*)(...))A::funA
24 (int (*)(...))C::funcC
Class C
size=24 align=8
base size=24 base align=8
C (0x0x7f982af7d1a0) 0
vptr=((& C::_ZTV1C) + 16)
A (0x0x7f982b13e180) 0
primary-for C (0x0x7f982af7d1a0)
Vtable for D
D::_ZTV1D: 7 entries
0 (int (*)(...))0
8 (int (*)(...))0
16 (int (*)(...))A::funA
24 (int (*)(...))D::funD
32 (int (*)(...))-16
40 (int (*)(...))0
48 (int (*)(...))B::funB
Class D
size=40 align=8
base size=40 base align=8
D (0x0x7f982af8e930) 0
vptr=((& D::_ZTV1D) + 16)
A (0x0x7f982b13e240) 0
primary-for D (0x0x7f982af8e930)
B (0x0x7f982b13e2a0) 16
vptr=((& D::_ZTV1D) + 48)
使用 clang -Xclang -fdump-record-layouts test-class-layout.cpp
命令,终端输出如下:
*** Dumping AST Record Layout
0 | class A
0 | (A vtable pointer)
8 | int64_t a
| [sizeof=16, dsize=16, align=8,
| nvsize=16, nvalign=8]
*** Dumping AST Record Layout
0 | class C
0 | class A (primary base)
0 | (A vtable pointer)
8 | int64_t a
16 | int64_t c
| [sizeof=24, dsize=24, align=8,
| nvsize=24, nvalign=8]
*** Dumping AST Record Layout
0 | class B
0 | (B vtable pointer)
8 | int64_t b
| [sizeof=16, dsize=16, align=8,
| nvsize=16, nvalign=8]
*** Dumping AST Record Layout
0 | class D
0 | class A (primary base)
0 | (A vtable pointer)
8 | int64_t a
16 | class B (base)
16 | (B vtable pointer)
24 | int64_t b
32 | int64_t d
| [sizeof=40, dsize=40, align=8,
| nvsize=40, nvalign=8]
根据输出,可以得出类型D的内存布局情况:
+--------------+ <- ptrA, ptrD
| vtable-A-D |
+--------------+
| members-A |
+--------------+ <- ptrB
| vtable-B |
+--------------+
| members-B |
+--------------+
| members-D +
+--------------+
观察可以发现:
- 父类虚函数表和成员变量排放在子类成员变量之前;
- 从每个父类继承来的虚表和成员变量按照声明顺序依次排列,每个父类的虚表和成员变量紧密排列;
- 成员变量按照声明顺序依次排列;
- 子类型的虚函数追加在第一个父类的虚函数表尾部;
- 子类型地址存入不同父类指针时,指向各自类型对应的虚函数表位置;
- 直接获取子类型地址时,指向对象头部,也就是第一个虚函数表位置。
结论
可以看到,当使用父类指针操作子类对象时,指针指向父类虚函数表坐在偏移位置,此时内存分布和直接操作一个父类对象是一致的。
这种设计,尽可能保证了多态之下成员变量操作的效率。
扩展
会有什么问题吗?
有的!
考虑菱形继承,此时祖先类型的成员变量和虚函数会在子类型中重复出现!
这同样是出于上面所说的操作效率考虑,保证使用父类指针操作时,无论使用哪一个父类都能高效操作祖先类型的成员,因而必须在两个父类各自保存一份祖先类型的信息。
存在解决办法吗?存在。
如果是内存空间非常有限的情况,可以考虑使用虚继承,砍掉重复的成员;代价是操作效率会降低。
对比一下多继承和虚继承,前者时间高效空间低效,后者空间高效时间低效。
不过一般而言,空间都没有那么紧张,所以虚继承很少使用。事实上,就连多继承都是不提倡的,一般只使用单继承……
原创文章,作者:端木书台,如若转载,请注明出处:https://blog.ytso.com/267632.html