《深度探索 C++ 对象模型》第一章重点梳理。主要内容包括:
- C++ 封装对象的布局成本
- C++ 对象模型,详述 C++ 如何组织类对象及其成员
- C++ 继承简述,简述 C++ 三种继承关系和三种继承方式,关于继承布局模型会在之后的章节详述
- C++ 支持多态的三种方式
- 指针类型和多态的实现原理
1 C++ 封装对象的布局成本
C++ 将数据和对数据的操作封装为一个类,相比于 C 语言不封装,并没有增加成本。因为类的数据成员直接内含在每一个实例化的对象中,而方法成员虽然含在类的声明中,但却不出现在每一个对象中,每一个非内联(non-inline)的成员函数只产生一个函数实体,而内联函数自然在使用的地方产生函数实体,因此不会有任何空间或执行的不良效应。
C++ 在布局以及存取时间上的主要额外负担是由 virtual 引起的,包括:
- virtual function 机制:用以支持一个高效的“执行期绑定”(runtime binding)
- virtual base class:用以实现“多次出现在继承体系中的 base class,只存在一个被共享的实体”
后面将会对这两种情况进行解释。
2 C++ 对象模型
C++ 对象模型按照以下方式组织类对象及其成员:
- 非静态成员变量(non-static data member)存放于类对象内部
- 静态成员变量(static data member)存放于所有类对象之外
- 静态和非静态的成员函数(member function)存放于所有类对象之外
- 虚函数(virtual function)通过虚函数表(virtual table)管理:每一个类产生出一堆指向虚函数的指针,放在表格之中构成虚函数表,接下来每一个类对象拥有一个指向虚函数表的指针,称为 vptr,vptr 的设定(set)和重置(reset)都由类的构造函数、析构函数、拷贝构造等完成。并且每一个类所关联的 type_info 对象(用来支持 runtime type identification,简称 RTTI)也经由虚函数表指出,通常放在虚函数表的第一位。
什么是 RTTI ?
RTTI 即运行阶段类型识别(Runtime Type Identification),旨在为程序在运行阶段确定对象类型提供一种标准方式。
RTTI 可以在只有一个指向基类的指针或引用时,确定所指对象的准确类型。C++ 有两种支持 RTTI 的运算:
- dynamic_cast 运算符,如果可能的话,dynamic_cast 运算符将使用一个指向基类的指针来生成一个指向派生类的指针,否则该运算符返回空指针。
- typeid 运算符,返回一个指出对象类型的 type_info 对象的引用,其中存储了有关对象类型的信息,比如类名等。
需要注意的是,只能将 RTTI 用于包含虚函数的类层次结构,原因在于只有对于这种类层次结构,才会将派生类对象的地址赋给基类指针(多态),即 RTTI 只适用于包含虚函数的类(实现多态)。
以下面的类为例:
1 | class Point { |
其对象模型如下图所示:

C++ 这样构建对象模型的主要优点在于空间和存取时间效率高,但缺点在于如果程序代码本身没有更改,但所用到的类对象的非静态成员变量有所修改,那么代码就需要重新编译。
另一种没有应用在 C++ 中的对象模型——双表格模型可以解决这个问题,双表格模型把所有成员变量放在数据表格中,把所有指向函数的指针放在函数表格中,每一个类对象只包含指向这两个表格的指针。这样虽然更灵活,但也因此付出了空间和执行效率两方面的代价。
3 C++ 继承简述
C++ 支持单一继承、多重继承和虚拟继承:
1 | // 单一继承 |
为什么需要虚拟继承?
虚拟继承是为了防止多重继承中,一个派生类继承自两个相同的基类的情况。
如上面的例子,类 iostream 继承自类 istream 和类 ostream,这两个类都继承自基类 ios,如果不使用虚拟继承,则类 iostream 中会出现两个基类 ios 的对象,而在虚拟继承中,无论基类在继承链中被派生多少次,都只产生一个实体(称为子对象,subobject),即在类 iostream 中,istream 对象和 ostream 对象共享一个 ios 基类对象,从而避免了重复继承的情况。
之后的章节中我们会具体讨论这些继承情况分别对应的继承模型,即派生对象和基类对象的组织关系和内存管理。
顺便讨论一下类成员的三种访问级别和三种继承方式。众所周知 C++ 类成员可以拥有三种访问级别:
- public:可以被该类中的函数、子类的函数、其友元函数访问,也可以由该类的对象访问
- protected:可以被该类中的函数、子类的函数、其友元函数访问,但不能被该类的对象访问
- private:只能由该类中的函数、其友元函数访问,不能被任何其他访问,该类的对象也不能访问
在类继承中,也包含三种继承方式,不同的继承方式会导致类中成员的访问级别变化:
- public 继承:父类中的成员属性不发生改变
- protected 继承:父类的 protected 和 public 成员在子类中变为 protected,private 成员不变
- private 继承:父类的所有成员在子类中变为 private
4 C++ 支持多态的三种方式
C++ 通过以下三种方式支持多态:
- 隐式转换:将派生类指针转化为一个指向其基类的指针:
1 | shape *ps = new circle(); |
- 虚函数机制:基类中定义虚函数(纯虚函数),派生类进行重载,在运行时决定调用哪个函数对象:
1 | ps->rotate(); |
- dynamic_cast 和 type_id 运算符,在上面的虚拟继承提到过,用来将指向基类的指针转化为指向派生类对象的指针,或者获取对象的 type_info
5 指针类型和多态原理
指向类的指针和指向其他变量类型(比如 int, string)的指针有什么不同?
以内存需求的观点来说,没有什么不同,它们都需要有足够的内存来放置一个机器地址,指向不同类型的指针之间的差异既不在其声明方法不同,也不在其内容(代表一个地址)不同,而是在其所寻址出来的 object 类型不同。也就是说,”指针类型”会告诉编译器如何解释某个特定地址中的内存内容及其大小。
例如下面的类:
1 | class ZooAnimal { |
那么指向类 ZooAnimal 的对象的指针将会包含:
- 指向 int 类型的指针,在 32 位计算机上,int 占 4 字节
- 指向 string 类型的指针,占 8 字节(4 字节的字符指针和 4 字节的表示字符串长度的整数)
- 指向虚函数表的指针,占 4 字节
因此类 ZooAnimal 的对象一共占用 16 字节,如果 ZooAnimal 对象存放在内存地址 1000,那么一个指向 ZooAnimal 对象的指针将会知道它需要涵盖内存的内存范围是 1000~1015.

如果不告诉编译器这个指针指向什么类型,比如泛型指针 void*,那么编译器将不知道指针涵盖的地址范围,也就不能通过指针访问到正确的内存内容。
现在我们定义一个 Bear 类继承自 ZooAnimal 类:
1 | class Bear : public ZooAnimal { |
那么指向 Bear 对象的指针覆盖的内存大小是其基类子对象的大小(16字节)加上该对象成员变量的大小(4 + 4 字节):

假设 Bear 对象存放在内存地址 1000 处,那么指向基类 ZooAnimal 的指针 pz 和指向派生类 Bear 的指针 pb 都指向内存地址 1000,区别在于 pz 指针涵盖的内存范围只包含基类成员的 16 字节,而 pb 指针涵盖的内存范围包含整个 Bear 对象。
除了 ZooAnimal 对象中出现的成员,我们不能使用 pz 来处理 Bear 对象的任何成员,除非使用 virtual 机制:
1 | // 不可以,虽然 pz 指向一个 bear 对象的地址 |
现在来看这种情况:
1 | Bear b; |
为什么 za 调用的是 ZooAnimal 的 rotate 方法?这是由于当一个基类对象直接被初始化为一个派生类对象时,会发生切割(sliced),以塞入较小的基类内存中,而无法体现出任何派生类的痕迹。这时多态就“失效”了,实际上这是由于多态不能够发挥在“直接存取对象”这件事情上,因为多态机制是依靠指针(pointer)或引用(reference)完成的。
举例来说明,比如有下面的类继承关系:

然后我们使用下面一组定义:
1 | { |
这组定义可能的内存布局如下图:

将对象 za 或者 b 的地址,或者指针 pp 的内容(也是个地址)赋给指针 pza,是完全没有问题的,后续还可以通过各种转换得到想要的不同派生类的对象,也就实现了多态。一个指针或者引用之所以支持多态,就是因为他们并不会引发内存配置的资源量的改变,会受到改变的只是它们所指向的内存的“大小和解释方式”而已。
但如果直接对对象进行操作,就会改变内存中的资源需求量,比如之前的例子,把整个 Bear 对象指定给 za,就会溢出它所配置得到的内存,自然也就无法得到正确的结果。
总之,多态是一种强大的机制,允许你继一个抽象的 public 接口之后,封装相关的类型。需要付出的代价就是额外的间接性——不论是在“内存的获得”或是在“类型的决断”上。C++ 通过 class 的 pointers 和 references 来支待多态,这种程序设计风格就称为“面向对象”。