C++ 对象内存布局 | C & C++
本文介绍了 C++ 的对象在不同继承方式下的内存布局。
虚函数表
带有虚函数的类在编译时会生成一个虚表,虚表中存放着一堆指针,这些指针指向该类每一个虚函数;编译器会为每个对象生成一个虚表指针,通常在对象的地址空间的最前端,指向该类所对应的虚表。
对于这样一个类:
1 | class Base { |
内存布局如下:
注:虚函数表的最后多加了一个结点,这是虚函数表的结束结点,就像字符串的结束符 /0
一样,其标志了虚函数表的结束。这个结束标志的值在不同的编译器下是不同的。在 VC++ 中是 NULL。在 GCC 中,这个值是 1 或 0。如果是 1,表示还有下一个虚函数表,如果是 0,表示是最后一个虚函数表。
单一继承
注:10、100、1000 分别为 Parent.iparent、Child.ichild、GrandChild.igrandchild 的值。
- 虚函数表在最前面的位置
- 成员变量根据其继承和声明顺序依次放在后面,父类的虚函数在子类的虚函数前面
- 在单一的继承中,被 overwrite 的虚函数在虚函数表中得到了更新,没有被覆盖的函数依旧
多重继承
- 每个父类都有自己的虚表
- 子类的成员函数被放到了第一个父类的表中
- 内存布局中,其父类布局依次按声明顺序排列
- 每个父类的虚表中的 f() 函数都被 overwrite 成了子类的 f(),这样做就是为了解决不同的父类类型的指针指向同一个子类实例,而能够调用到实际的函数
菱形继承
最顶端的父类 B 其成员变量存在于 B1 和 B2 中,并被 D 给继承下去了。而在 D 中,其有 B1 和 B2 的实例,于是 B 的成员在 D 的实例中存在两份,一份是 B1 继承而来的,另一份是 B2 继承而来的。所以,如果使用以下语句,则会产生二义性编译错误:
1 | D d; |
虚继承
在非虚继承中,VC++ 和 GCC 的表现几乎一样,但是虚继承就不太一样了,后面的内容仍以 VC++ 中的内存布局为例。
- 继承而来的子类会生成一个的虚基类表指针 vbptr,指向虚基类表,虚基类表保存了指向虚基类的偏移量,而非直接复制了基类的成员,(解决了菱形继承时的二义性问题,因为基类成员只有一个,可以通过不同的派生类中的不同的偏移进行访问)
- 虚基类表指针总是在虚函数表指针之后,所以对某个类实例来说,如果它有虚基类表指针,那么虚基类表指针可能在实例的 0 字节偏移处(该类没有 vptr 时,vbptr 就处于类实例内存布局的最前面),也可能在类实例的 4 字节偏移处(该类有 vptr时,vptr 处于类实例内存布局的最前面)
- 一个类的虚基类指针指向的虚基类表,与虚函数表一样,虚基类表也由多个条目组成,条目中存放的是偏移值
- 第一个条目存放虚基类表指针(vbptr)所在地址到该类内存首地址的偏移值,这个偏移值为 0(类没有虚函数,此时类没有 vptr)或者-4(类有虚函数,此时有vptr)
- 虚基类表的第二个、第三个、…依次为该类的最左虚继承父类、次左虚继承父类、…的内存地址相对于虚基类表指针的偏移值
单一虚继承
- 在非虚继承中,子类会复制一份父类的虚表并将自己新定义的虚函数放到其后;但是在虚继承中,子类会单独保留父类的虚表,并生成一个自己的虚表及其指针,以及一个虚基类表及其指针
- 虚继承的子类单独保留了父类的 vprt 与虚函数表,这部分内容与子类内容以 0 来分界
菱形虚继承
1 | class B; |
菱形虚继承下,最派生类 D 的对象模型又有不同的构成了。在 D 对象的内存构成上,有以下几点:
- 在 D 对象内存中,基类出现的顺序是:先是 B1(最左父类),然后是 B2(次左父类),最后是 B(祖父类)
- D 对象的数据成员放在 B 类前面,两部分数据依旧以 0 来分隔
- 编译器没有为 D 类生成一个它自己的 vptr,而是覆盖并扩展了最左父类的虚基类表,与简单继承的对象模型相同
- 超类 B 的内容放到了 D 类对象内存布局的最后
其他相关知识点
动态绑定
对于下面这种用法:
1 | Base *p_base = new Derive(); |
指针 p_base
是可以调用到 Derive
的成员函数的,当然前提是该成员函数在 Base
中是虚函数,而且在 Derive
中有实现。原因如下:
- 派生类有一个虚函数表,这个虚函数表复制自基类
- 每个单一继承的派生类对象会有一个虚指针,指向其对象的虚函数表
- 如果派生类实现了某个虚函数,那么派生类虚函数表里面的相应内容会被替换掉
- 如果派生类实现了新的虚函数,那么这些虚函数将会加在这个虚函数表后面
- 在上面这个例子中,
p_base
的虚指针其实是指向Derive
的虚函数表,那其自然可以调用到Derive
中实现的虚函数
基类的析构函数要声明为虚函数
对于下面这种用法:
1 | Base *p_base = new Derive(); |
如果基类的析构函数没有声明为虚函数,那么在最后析构的时候派生类的析构函数就不会被调用到。原因如下:
- 在上面这个例子中,
p_base
本质是一个Base
类型的对象,是无法除了通过虚函数表以外的途径调用到Derive
的成员函数的 - 将
Base
的析构函数声明为虚函数之后,Derive
的析构函数也自动成为虚析构函数,这样的话p_base
就可以通过虚函数表调用到Derive
的析构函数
参考
C++ 对象内存布局 | C & C++