这次是这一个系列技术分享的第二部分,主要介绍的是C++对象模型。这里所讲的对象模型,是指如何在内存中(空间上)表达对象的性质和特征,以及如何在运行时(时间上)提供完成面向对象系统中的必要操作的能力。
Table of Content
C++并不是天生地庞大而迟缓
—— Stanley B. Lippman
- 概述
- 对象的内存布局
-
运行期环境
1. 概述
对于传统的C语言程序来讲,数据与算法是分离的:
struct Circle
{
int x, y;
int radius;
};
Circle MakeCircle(int x, int y, int radius) { //.... } void DrawCircle(Circle c) { //.... }
在C++中,将这样的与传统C语言结构体内存布局兼容的对象叫做Plain Old Data(POD)。
此外,我们还可以将数据和算法整合在一起:
class Circle
{
public:
Circle(int x, int y, int radius);
void Draw();
private:
int x, y;
int radius;
};
对于这样的代码,在时空上是否增加了什么负担?同时,C++还支持继承、多重继承等很多机制,它们呢?
class Point {
public:
Point(int x, int y);
private:
int x, y;
};
class Circle : public Point { //... };
我们先从最朴素的模型开始,看一看如何来安排对象的内存空间:
- 简单对象模型
在简单对象模型中,对象中只存储指向成员(包括数据成员和方法成员)的指针,所有操作均通过指针索引来完成。
- (双)表格驱动模型
双表格驱动模型是指在对象中仅存储两个指针:一个指向数据成员table的指针,一个指向方法成员table的指针。这种模型的优点在于所有对象的内存空间占用及内存布局均完全一致。早期的某款C++编译器采用了这一种模型。
以上两种模型都是可以在一定程度上满足需求的,但并未被大多数编译器所采用。目前主流的模型如下:
- 非静态数据存放于对象内部
- 静态数据、非virtual函数存放于对象之外
- 每一个class至少有一张虚函数表
- 每一个object中包含一个指向该表的指针
本文将探讨在单继承、多继承、虚拟继承下C++对象模型是如何应对的。
2. 对象的内存布局
2.1 类
对于如下的代码来说:
class Circle
{
public:
Circle(int x, int y, int radius);
void Draw();
static void Print();
private:
int x, y;
int radius;
static int count;
};
根据前述的对象模型,其其布局与在C语言中使用结构体时是一致的:
同时C++标准保证,在同一个权限声明块中(即同一个public、protected或private块中),后声明的成员在内存中位于高地址(更靠后)。但并没有规定其一定紧邻,其间可以插入其他编译器所需的数据。
2.2 不包含虚函数的单继承
而对于单继承来说:
class Point
{
public:
Point(int x, int y);
protected:
int x, y;
};
class Circle : public Point { protected: int radius; };
其内存布局如下:
可以看出,其占用内存的成本并没有增加。然而是否一定不会增加呢?如果我们有如下代码:
class Point {
public:
//...
protected:
int x, y;
char c1, c2;
};
class Circle : public Point {
public:
//...
protected:
char c3, c4;
int radius;
};
此时的内存布局将为:
由于需要对其内存,编译器很可能会插入padding字节。这时,继承的内存占用将会比数据全部在同一个类中的时候大。不过,我们是否可以取消掉padding呢?
答案自然是否定的。取消padding后,Point对象和Circle对象中Point部分在内存中占用的大小将会不同,此时如果存在以下代码:
void some_func(Point* point) { Point a; *point = a; }
由于Circle和Point存在继承关系,我们不能在编译期确定代码中point的真实类型是什么。如果point一旦为Circle类型,那么在执行到*point=a这一句代码时,将会多覆盖两个字节的内存,即c3和c4将被内容不确定的内存覆盖。
2.3 包含虚函数的单继承
对于包含虚函数的单继承来说,其内存中将会增加一个指向虚表的指针。
class Point {
public:
virtual void Print();
protected:
int x, y;
};
class Circle {
public:
virtual void Print();
protected:
int radius;
};
需要说明的是,虚表指针_vptr可以在Point中的任意位置。上图中其位于Point对象的末尾,也可以位于最前,甚至在中间也可以。在末尾的好处在于其前面的部分仍然与C语言结构体兼容,在最前的好处是可以省去一步计算过程(对象的地址即为虚表指针的位置,不需要额外的计算)。而一般来讲没有编译器选择在中间位置插入虚表指针。
对于一个Point对象来讲,其vptr将指向Point类的虚表;而对于一个Circle对象来讲,虽然图中标明了vptr在Point部分中,但这仅表明它是由Point继承而来,其仍将指向Circle类的虚表,如下图所示:
2.4 包含虚函数的多重继承
假如有如下代码:
class Point {
public:
virtual void Print();
protected:
int x, y;
};
class Text {
public:
virtual void OutputText();
protected:
char buffer[4];
};
class Circle : public Point { protected: int radius; };
class CircleWithText : public Circle, public Text
{ protected: int text_x, text_y; };
在多重继承中,标准并未要求在内存中多个父类之间的顺序。一种可能的内存布局如下:
从上图可以看出,此时CircleWithText中将存在两个虚表指针,分别从两条继承线路中继承而来。也就是说,此时CircleWithText类将包含两张虚表,一张中存放由Circle带来的虚函数,一张存放由Text带来的虚函数。假如CircleWithText自己还有新的虚函数,则可以择一存放。
与单继承相比,多重继承除了内存布局的复杂程度增加了之外,在几种情况下运行时的指针运算同样非常重要:
- 指针的类型转换
- 通过子类调用由第二个父类(内存中靠后的父类)继承而来的虚函数
- 通过指向第二个父类(内存中靠后的父类)的指针调用被子类覆盖的虚函数
让我们逐一观察这几种情况。
1. 指针类型转换
CircleWithText cwt;
Circle *pCircle = &cwt;
Point *pPoint = &cwt;
Text *pText = &cwt;
//pText = (Text*)((char*)&cwt + sizeof(Circle));
对于前两条赋值语句来说,不需要额外的计算,因为在CircleWithText对象中,Circle部分和Point部分的起始地址与cwt一样。而如果要转换为第二个父类的指针,则需要一定的计算。在计算中应当对NULL进行判断,如果需要转换的指针是空指针的话,转换后的指针也应该为空指针。
2. 通过子类调用由第二个父类(内存中靠后的父类)继承而来的虚函数
CircleWithText cwt;
CircleWithText *pcwt = &cwt;
pcwt->OuputText();
上述代码中,使用了子类指针pcwt调用了由Text父类继承而来的虚函数OutputText。由于我们需要传入this指针,这个指针不应当传入pcwt,而是pcwt中Text部分的起始地址。可是很可惜,这只是一种情况;我们无法在编译期确定究竟需要传入哪个this,因为很有可能存在第三种情况:
3. 通过指向第二个父类(内存中靠后的父类)的指针调用被子类覆盖的虚函数
class CircleWithText : public Circle, public Text
{
public:
virtual void OutputText();
};
CircleWithText cwt;
Text *pText = &cwt;
pText->OutputText();
我们首先需要注意这一段代码与上段代码的不同。这段代码中CircleWithText覆盖了Text中的OutputText函数。此时在pText->OutputText()这一调用中,传入的应为cwt的起始地址。
关键的问题终于出现了:第一:如何确定应当传入哪一个this指针?第二,假设已经确定应当传入CircleWithText的起始地址,那么如何从pText指针重新找到CircleWithText的起始地址呢?在编译期,我们无法知晓这些问题,就如我们不知道下面代码中p的真实类型是什么:
void some_func(Text* p) { p->OutputText(); }
因此,我们只能通过一定的运行时机制来解决这个问题。下面介绍两种可以解决这一问题的方法。
方案一:在虚函数表中同时存储函数地址和this指针的偏移
示例图如下:
此时在虚表中同时存储函数地址和所需的this指针偏移(注意,是偏移而不是地址)。因为this指针的“地址”是跟每个对象相关的,而每一个类只有一张vtable。不过偏移却是对于一个类的所有对象都适用的(比如,在一个CircleWithText对象中,想把Text部分的起始地址转换为CircleWithText对象的起始地址所需的偏移是一定的)。这种方法的弊端在于增加了单继承时虚表的成本。
方案二:为函数添加thunk入口
这种方法不改变虚表的结构,而是将原本指向成员函数的指针改为指向一段由编译器自动生成的thunk代码的地址。在这一小段代码中,对指针进行调整,然后再跳转到真正的函数中去。
2.5 虚拟继承
虚拟继承所要解决的问题时在继承树中同一个父类出现多次的情况。有时我们仅需要一个基类,这就需要虚拟继承来解决:
class Drawable { public: virtual void Draw(); };
class Point : virtual public Drawable;
class Text : virtual public Drawable;
实现虚拟继承的难度在于,如何将两个Drawable对象变为一个,同时要解决指针计算的相关问题。下面介绍几种方案:
方案一:虚基类指针
在虚拟继承的子类中,额外存储一个指针用于指向直接虚继承的父类:
然而这一方案的问题在于,如果有多层的虚拟继承,其间接访问的时间成本也将增加。如果存在多个虚继承父类,则需要多个指针。
方案二:对象的虚基类指针表
对于方案一的一种改进是将这些指针都存放在一张表里,通过一个指针来访问:
这一方案解决了方案一中的问题。然而正如之前所说,“地址”是与对象相关的,如果采用了方案二,就是说每一个CircleWithText对象都需要两张虚基类的指针table。
方案三:在虚函数表的负索引处存放偏移
为了解决方案二中的弊端,可以考虑使用偏移,这样的话就不必为每一个对象都生成几个虚基类表了。同时,鉴于虚函数表的负索引还没有被利用,我们可以在这里存放虚基类的偏移。
3. 运行时环境
3.1 运行时类型识别
在虚函数表的第0项中,并不存储函数指针,而是运行时类型信息type_info。dynamic_cast和typeid是与运行时类型相关的操作。
一个典型的type_info 类可能定义如下:
3.2 异常
对于如下代码,我们如何判断应当析构哪些对象?
一种方法是根据程序计数器PC,然而这可能与特定平台相关。另一种方案是使用一个标识变量step,在每一个对象构建或析构之后将step加1。同时存放一张表,用于查询当step为n时应当析构哪些对象。
2.3 C++与动态链接库
对于某一种C++编译器编译产生的动态链接库,将很难其与其他编译器一同使用。原因在于C++标准并没有规定在二进制上的实现方式,编译器在名称改编、成员布局以及异常等机制上均存在着差异。不过,使用抽象类可以一定程度上解决这个问题,详情可以参考一些面向对象系统的实现,例如COM。