对象模型基础

C++ 在 C 的基础上引入了面向对象的程序设计思想,类的封装、继承和多态使得 C++ 功能更加强大。C++ 的类包含数据成员和成员函数:

数据成员:静态数据成员,非静态数据成员

成员函数:非静态成员函数、静态函数、虚函数

C++ 对象模型研究 C++ 类的数据成员和成员函数在内存中如何布局的问题。假设有个 C++ 类定义如下:

1
2
3
4
5
6
7
8
9
10
class Base
{
public:
int member; // 非静态数据成员
static int smember; // 静态数据成员

void func(); // 非静态成员函数
static void sfunc(); // 静态成员函数
virtual void vfunc(); // 虚函数
};

简单对象模型

在简单对象模型中,类的每个实例化对象在内存中都会申请一块连续内存,在这块内存上保存着指向成员变量和函数的指针。这样做的好处是,每个对象的内存大小是固定的,不管是数据成员还是成员函数,都只对应一个指针,在访问时可以很快计算出指针偏移量。但这样做的弊端也很明显,当访问对象的数据成员时,需要先找到数据成员的指针,然后通过这个指针获取到真实的数据,多了一次寻址,效率较低。

表驱动对象模型

表格驱动对象模型对类中的数据成员和成员函数进行了区分,每个对象保留两个指针,一个指向数据成员表,其中存放数据成员的取值,一个指向成员函数表,其中存放指向成员函数的指针。这种做法的好处是对数据和函数进行分开管理,不好的地方在于访问成员函数时,需要先找到成员函数表,效率更低。

C++ 对象模型

介绍 C++ 对象模型之前,先理解 C++ 中虚函数的概念。C++ 的多态是通过虚函数实现的。在基类中用 virtual 关键字定义虚函数,在派生类中重写虚函数,那么在运行时,传入基类指针时,会根据对象的实际类型来调用正确的函数,实现多态。

不妨试想一下,如果要自己设计 C++ 对象模型,应该怎么做?我是这样想的:C++ 的类实例化为不同的对象,这些对象的静态数据成员是共享的,非静态数据成员是互不影响的,成员函数对各个对象来说是相同的。那么非静态数据成员应该放在每个对象的内存空间中,以保证不同对象相互独立;静态数据成员和成员函数应该放在这个类的公共空间,不用每个对象都存一份,减少存储空间;而虚函数在继承中发挥独特作用,应该与其他成员函数分开。

揭开谜底,以 Base 类为例,C++ 对象模型是长这样的:

可以看出:

  1. C++ 对象模型中有一个指向虚函数表的指针,这个虚函数表中除了存放虚函数的指针之外,还有一个 type_info 的信息,用于在运行时识别对象的正确类型。
  2. C++ 对象的非静态成员变量存放在对象的内存空间中,静态成员变量存放在全局数据区。
  3. C++ 对象的成员函数存放在代码区。

普通继承场景下的对象模型

单一继承场景

以 Base 为基类,定义派生类 Derived 如下:

1
2
3
4
5
6
7
8
9
10
11
class Derived : public Base
{
public:
int member; // 非静态数据成员
static int smember_d; // 静态数据成员

void func(); // 非静态成员函数
static void sfunc_d(); // 静态成员函数
virtual void vfunc(); // 虚函数
virtual void vfunc_d(); // 虚函数
}

这种场景下,Derived 的对象模型如下:

可以看出:

  1. 派生类有自己的虚函数表,从基类那边拷贝一份,然后进行修改:将 type_info 改为派生类的信息;如果派生类重写了基类的虚函数 vfunc,那么虚函数表中用 Derived::vfunc()的指针替换 Base::vfunc()的指针;如果派生类新增了虚函数 vfunc_d,那么虚函数表中新增一个函数指针指向 Derived::vfunc_d()。
  2. 派生类中重写了基类中同名的非虚函数,那么会在代码区中完成同名函数的替换,例如 Derived::func()替换 Base::func()。
  3. 派生类和基类中的同名成员变量均保留。

多重继承场景

单一继承场景下,派生类在基类的虚函数表上进行修改。多重继承场景,有多个基类的虚函数表。定义基类 Base1 和派生类 Derived 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Base1
{
public:
int member1; // 非静态数据成员

virtual void vfunc(); // 虚函数
virtual void vfunc1(); // 虚函数
virtual void vfunc2(); // 虚函数
};

class Derived : public Base, public Base1
{
public:
int member_d; // 非静态数据成员
static int smember_d; // 静态数据成员

void func(); // 非静态成员函数
static void sfunc_d(); // 静态成员函数
virtual void vfunc(); // 虚函数
virtual void vfunc1(); // 虚函数
virtual void vfunc_d(); // 虚函数
};

此时 Derived 的对象模型为:

可以看出:

  1. 继承多个父类时,派生类的对象模型中分别存储一个虚函数表的指针。如果派生类重写的基类同名虚函数,那就在虚函数表中进行替换;如果派生类新增了虚函数,那么在第一个父类的虚函数表中进行新增扩展。

虚继承场景下的对象模型

使用普通继承的菱形继承

多重继承场景中,有一种特殊的情况叫菱形继承,它是这样操作的:B1 继承 B,B2 继承 B,D 继承 B1 和 B2,祖父类 B 被子类 D 重复继承,图示如下:

按多重继承的方式推导 D 的对象模型是这样的:

可以看到,在菱形继承场景中,访问祖父类成员 B::b 时,出现了二义性。

为解决菱形继承中的问题,C++ 发明了虚继承。虚继承的对象模型与普通继承不同:普通继承是子类拷贝并修改父类的虚函数表;虚继承是子类和父类的虚函数表分开保存,将指向父类的虚函数表的指针也加入到子类的对象模型中。

使用虚继承的单一继承

假定单一虚继承场景如下:

此时派生类 Derived 的对象模型为:

可以看到:

  1. 派生类的虚函数表和基类的虚函数表是分开的,派生类重写的虚函数将覆盖基类虚函数表中的同名函数。
  2. 派生类中新增了一个虚基类指针,它指向一个表,表中保存对象模型中各个虚函数表指针的偏移量,第一项为派生类虚函数表指针的偏移量,第二项为虚继承中第一个基类的虚函数表指针的偏移量,以此类推。
  3. 派生类的信息与基类的信息用 0x00000000 隔开。

使用虚继承的菱形继承

假定使用虚继承的菱形继承场景如下:

结合多重继承与单一虚继承,推导派生类 Derived 的对象模型为:

可以看到:

  1. 多重继承的布局基本不变,虚基类的信息被追加到内存布局最后,并用 0x00000000 隔开。此时再访问 B::b 不会出现二义性。
    附注:
    推导时,从子类往祖父类逐步推进,子类与父类适用多重继承,父类与祖父类适用单一虚继承,每一步只决定派生类的数据成员的位置,例如 B::b 的布局应该由单一虚继承决定,如果在多重继承时决定,那推出来也是二义的。

总结

对象模型揭示了 C++ 对象在内存中的存储布局,结合面向对象的三大特性(继承、封装和多态)来理解对象模型,印象会更深一点。