C++ 对象内存布局 | C & C++

本文介绍了 C++ 的对象在不同继承方式下的内存布局。

虚函数表

带有虚函数的类在编译时会生成一个虚表,虚表中存放着一堆指针,这些指针指向该类每一个虚函数;编译器会为每个对象生成一个虚表指针,通常在对象的地址空间的最前端,指向该类所对应的虚表。

对于这样一个类:

1
2
3
4
5
6
class Base {
public:
virtual void f();
virtual void g();
virtual void h();
};

内存布局如下:

注:虚函数表的最后多加了一个结点,这是虚函数表的结束结点,就像字符串的结束符 /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
2
3
4
D d;
d.ib = 0; // 二义性错误
d.B1::ib = 1; // 正确
d.B2::ib = 2; // 正确

虚继承

在非虚继承中,VC++ 和 GCC 的表现几乎一样,但是虚继承就不太一样了,后面的内容仍以 VC++ 中的内存布局为例。

  • 继承而来的子类会生成一个的虚基类表指针 vbptr,指向虚基类表,虚基类表保存了指向虚基类的偏移量,而非直接复制了基类的成员,(解决了菱形继承时的二义性问题,因为基类成员只有一个,可以通过不同的派生类中的不同的偏移进行访问)
  • 虚基类表指针总是在虚函数表指针之后,所以对某个类实例来说,如果它有虚基类表指针,那么虚基类表指针可能在实例的 0 字节偏移处(该类没有 vptr 时,vbptr 就处于类实例内存布局的最前面),也可能在类实例的 4 字节偏移处(该类有 vptr时,vptr 处于类实例内存布局的最前面)
  • 一个类的虚基类指针指向的虚基类表,与虚函数表一样,虚基类表也由多个条目组成,条目中存放的是偏移值
    • 第一个条目存放虚基类表指针(vbptr)所在地址到该类内存首地址的偏移值,这个偏移值为 0(类没有虚函数,此时类没有 vptr)或者-4(类有虚函数,此时有vptr)
    • 虚基类表的第二个、第三个、…依次为该类的最左虚继承父类、次左虚继承父类、…的内存地址相对于虚基类表指针的偏移值

单一虚继承

  • 在非虚继承中,子类会复制一份父类的虚表并将自己新定义的虚函数放到其后;但是在虚继承中,子类会单独保留父类的虚表,并生成一个自己的虚表及其指针,以及一个虚基类表及其指针
  • 虚继承的子类单独保留了父类的 vprt 与虚函数表,这部分内容与子类内容以 0 来分界

菱形虚继承

1
2
3
4
class B;
class B1 : virtual public;
class B2 : virtual public;
class D : public B1, public B2;

菱形虚继承下,最派生类 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
2
Base *p_base = new Derive();
delete p_base;

如果基类的析构函数没有声明为虚函数,那么在最后析构的时候派生类的析构函数就不会被调用到。原因如下:

  • 在上面这个例子中,p_base 本质是一个 Base 类型的对象,是无法除了通过虚函数表以外的途径调用到 Derive 的成员函数的
  • Base 的析构函数声明为虚函数之后,Derive 的析构函数也自动成为虚析构函数,这样的话 p_base 就可以通过虚函数表调用到 Derive 的析构函数

参考

C++ 对象的内存布局

图说C++对象模型:对象内存布局详解

C++虚函数的工作原理

C++基类的析构函数为何要声明为虚函数

C++ 对象内存布局 | C & C++

http://www.zh0ngtian.tech/posts/ee737134.html

作者

zhongtian

发布于

2022-03-17

更新于

2023-12-16

许可协议

评论