前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >浅析C++类的内存布局

浅析C++类的内存布局

作者头像
用户6557940
发布2022-07-24 16:43:17
4350
发布2022-07-24 16:43:17
举报
文章被收录于专栏:Jungle笔记Jungle笔记

之前Jungle写过一篇文章《探究C++:虚函数表究竟怎么回事?》,主要通过测试代码来验证虚函数表的存在,进而说明C++的多态机制。但完成文章后仍旧觉得文章云里雾里,并不能很好地说明C++类的内存布局。于是在阅读完3遍《深度探索C++对象模型》之后,重新整理了相关知识点,完成此文。

C++类在有无继承、有无虚函数、有无多重继承或者虚继承时,其内存布局是不一样的。本文将分别阐述各种case。

无继承

1.1. 无虚函数

示例代码如下:

代码语言:javascript
复制
class A
{
private:
  short pri_short_a;
public:
  int i_a;
  double d_a;
  static char ch_a;
  void funcA1() {}
};

A的大小及布局如下:

如上可以说明:

  1. 静态数据成员虽然属于类,但不占用具体类对象的内存。
  2. 成员函数不占用具体类对象内存空间,成员函数存在代码区。
  3. 数据成员的访问级别并不影响其在内存的排布和大小,均是按照声明的顺序在内存中有序排布,并适当对齐。

1.2. 有虚函数

在1.1中的类A里增加一个虚函数:

代码语言:javascript
复制
class A
{
private:
  short pri_short_a;
public:
  int i_a;
  double d_a;
  static char ch_a;
  void funcA1() {}
  virtual void funcA2_v();
};

其内存大小及布局如下:

可以看到,A的起始处存储的是虚指针vptr,指针大小是4字节,这里是为了对齐8字节。为方便观察,之后的讨论中,我们统一把数据成员都改为int类型,占4字节。

现在我们再加一个虚函数funcA_v2():

代码语言:javascript
复制
class A
{
private:
  short pri_short_a;
public:
  int i_a;
  double d_a;
  static char ch_a;
  void funcA1() {}
  virtual void funcA2_v1();
  virtual void funcA2_v2();
};

布局如下:

所以,不论再多虚函数,都只会有一个虚指针vptr,不会改变类的大小。不同之处在于,虚指针所指向的虚表中会多一个项目,即指向另一个虚函数的地址。

单一继承

2.1. 单一继承且无虚函数

如下,我们设计了类A、B和C,其中,B继承自A,C继承自B:

代码语言:javascript
复制
class A
{
public:
  int i_a;
  static char ch_a;
  void funcA1() {}
};

class B : public A
{
public:
  int i_b;
  void funcB1() {}
};

class C :public B
{
public:
  int i_c;
};

内存布局如下:

单一继承的内存布局很清晰,每个派生类中起始位置都是Base class subobject。现在我们在类中增加虚函数,观察在单一继承+有虚函数的情况下,类的内存布局。

2.2. 单一继承且有虚函数

如下:

  • 类A增加了两个虚函数funcA_v1()和funcA_v2()
  • 类B继承自A,覆写funcA_v1()
  • 类C继承自B,重写funcA_v1(),且有自己定义的一个虚函数funcC_v1()
代码语言:javascript
复制
class A
{
public:
  int i_a;
  static char ch_a;
  void funcA1() {}
  virtual void funcA_v1();
  virtual void funcA_v2();
};

class B : public A
{
public:
  int i_b;
  void funcB1() {}
  virtual void funcA_v1();
};

class C :public B
{
public:
  int i_c;
  virtual void funcA_v1();
  virtual void funcC_v1();
};

Class A的内存布局如下,如同1.2,这里不再解释:

Class B的内存布局如下:

B中首先也是基类A subobject,同样含有一个虚指针vptr。由于B覆写了funcA_v1(),故虚表中第一个索引处的函数地址是&B::funcA_v1()

理解了B的内存布局,接下来C的内存布局也就不必赘述:

必须要提及两点:虚析构函数和覆写。虚析构函数在B.3.中详述。怎么才算是覆写?——类的继承里,子类里含有与父类里同名的虚函数,函数名、函数返回值类型和参数列表必须相同,权限可以不同。如上面示例中,B和C都覆写了A的funcA_v1()。下面的例子说明了这一点:

2.3. 虚析构函数

《Effective C++》第三版,Item 07:为多态基类声明virtual析构函数

当一个派生类对象经由一个base class指针被删除,而该base class带着一个non-virtual析构函数,其结果未有定义——实际执行时通常发生的是对象的derived成分没被销毁。所以上述的类设计其实有错误,带多态性质的基类应该声明一个virtual析构函数。如果class带有任何virtual函数,它就应该拥有一个virtual析构函数。

在接下来的示例中,我们将加上虚析构函数。

多重继承

3.1. 多重继承

如下是一个简单的继承关系,class C同时继承自A和B:

代码语言:javascript
复制
class A
{
public:
  int i_a;
  void funcA1() {}
  virtual ~A() {}
};

class B
{
public:
  int i_b;
  void funcB1() {}
  virtual ~B() {};
};

class C :public A, public B
{
public:
  int i_c;
  virtual ~C() {}
};

类A和B的内存布局如同1.2。而类C的内存布局如下:

可见,派生类C中依其继承的基类的顺序,存放了各个基类subobject及各自的vptr,然后才是Class C自己的数据成员。需要解释上图中的thunk

Thunk解释:所谓thunk是一小段assembly代码,用来(1)以适当的offset值调整this指针,(2)跳到virtual function去。例如,经由一个Base2指针调用Derived destructor,其相关的thunk可能看起来是下面这个样子: //虚拟C++代码 pbase2_dtor_thunk: this += sizeof( base1 ); Derived::~Derived( this );

根据上面的解释,经由class A的指针调用C的析构函数,其offset等于0;而经由class B调用C的析构函数,其offset等于8,如同上图所示:this-=8。

同时也可以想到,随着base class的数量增多,派生类里也会首先顺序存放各个基类subobject。而派生类中也会记录其到各个base subobject的offset。如下图是类D同时继承类A、B、C:

3.2. 菱形继承

如上图是一个菱形继承的示意图,类B和C均继承自类A,类D同时继承类B和C,代码如下:

代码语言:javascript
复制
class A
{
public:
  int i_a;
  virtual ~A() {}
};

class B :public A
{
public:
  int i_b;
  virtual ~B() {};
};

class C :public A
{
public:
  int i_c;
  virtual ~C() {}
};

class D :public B, public C
{
public:
  int i_d;
  virtual ~D() {}
};

类A的内存布局很简单,如1.2。类B和C的内存布局如2.2。接下来看类D的内存布局:

如上图,D中依次存放基类B subobject和基类C subobject。其中B和C中均存放一份class A subobject

3.3. 虚拟继承

从菱形继承的most-derived class(即3.2.中的class D)的内存布局可以看出,subobject A有两份,所以A的data member也存了两份,但实际上对于D而言,只需要有一份subobject A即够了。菱形继承不仅浪费存储空间,而且造成了数据访问的二义性。虚拟继承可以很好地解决这个问题。

同样以3.2.中的继承关系为例,不过这次我们B和C对A的继承都加上了关键字virtual。

代码语言:javascript
复制
class A
{
public:
  int i_a;
  virtual ~A() {}
};

class B :virtual public A
{
public:
  int i_b;
  virtual ~B() {};
};

class C :virtual public A
{
public:
  int i_c;
  virtual ~C() {}
};

class D :public B, public C
{
public:
  int i_d;
  virtual ~D() {}
};

接下来看看各个类的内存布局。

A的内存布局同1.2。类B和C的内存布局如2.2?是吗?不是!如下图:

可以看到,class B中有两个虚指针:第一个指向B自己的虚表,第二个指向虚基类A的虚表。而且,从布局上看,class B的部分要放在前面,虚基类A的部分放在后面。在class B中虚基类A的成分相对内存起始处的偏移offset等于class B的大小(8字节)。C的内存布局和B类似。

这个布局与之前的不一样:为什么基类subobject反而放到后面了

Class如果内含一个或多个virtual base subobjects,将被分割成两部分:一个不变区域和一个共享区域。不变区域中的数据,不管后继如何衍化,总有固定的offset(从object的开头算起),所以这一部分可以直接存取。而共享区域所表现的就是virtual base class subobject。这部分数据的位置会因为每次的派生操作而发生变化,所以它们只可以被间接存取。

接下来看class D的内存布局:直接的基类B和C按照声明的继承顺序,在D的内存中顺序安放。紧接着是D的data member。然后是共享区域virtual base class A。

总结

可以看到,C++类在有无继承、有无虚函数、有无多重继承或者虚继承时,其内存布局大不一样,多重继承或者菱形继承下,内存布局甚至很复杂。大致理清之后,可以对C++类的内存布局有个清晰认识。

本文也整理到了我的Github,感兴趣的可以去看看。谢谢支持!

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-06-25,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Jungle笔记 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1.2. 有虚函数
  • 3.2. 菱形继承
  • 3.3. 虚拟继承
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档