要了解C++中的面向对象的特性,除了使用方法外,了解其底层的技术细节对我们编程工作非常有帮助。尤其是了解对象的内存映像、vtable(虚函数表)的构造、vptr的插入和初始化时机、构造和析构函数的自动调用时机、对象的构造和析构次序、临时对象的创建和销毁、RTTI(运行阶段类型识别)的底层实现技术等。
对象在内存中的布局
我们首先举一个例子,看一下简单的非多态类Rectangle的对象内存映像:
//Rectangle.h
class Rectangle :
{
public:
Rectangle();
~Rectangle();
float GetLength()const;
void SetLength(float length);
float getWidth()const;
void SetWidth(float width);
void Draw();
static unsigned int GetCount() { return m_count; }
protected:
Rectangle(const Rectangle ©) {};
Rectangle& operator=(const Rectangle &assign) {};
private:
float m_length;
float m_width;
static unsigned int m_count; //对象计数
};
我们可以知道Rectangle这种基本的C++对象模型有下述几个规则
-
非静态数据成员被放在每一个对象体内作为对象专有的数据成员。
-
静态数据成员被提取出来放在程序的静态数据区内为该类所有对象共享,当然也只存在一份。
-
静态和非静态成员函数最终都被提取出来放在程序的代码段中并为该类的所有对象共享,因此每一个成员函数也只存在一份代码实体。
-
类内嵌套定义的各种类型(typedef、class、struct、union、enum等)与放在类外面定义的类型除了作用域不同外,没有本质区别。
综上,构成对象本身的只有数据。任何成员函数都不隶属于任何一个对象,非静态成员函数与对象的关系就是绑定,绑定的中介就是this指针。
现在,我们假设Rectangle派生自抽象基类Shape,Shape有一个属性m_color,并且把Draw()移到Shape中作为纯虚函数,如下:
//shape.h
class Shape
{
public:
Shape();
virtual ~Shape();
float GetColor()const;
void setColor(float color);
virtual void Draw() = 0;
private:
float m_color;
};
//Rectangle.h
class Rectangle : public Shape
{
public:
Rectangle();
~Rectangle();
float GetLength()const;
void SetLength(float length);
float getWidth()const;
void SetWidth(float width);
void Draw();
static unsigned int GetCount() { return m_count; }
protected:
Rectangle(const Rectangle ©) {};
Rectangle& operator=(const Rectangle &assign) {};
private:
float m_length;
float m_width;
static unsigned int m_count; //对象计数
};
增加了继承和虚函数的类的对象模型变得更加复杂,规则如下:
- 派生类继承基类的非静态数据成员,并作为自己对象的专用数据成员。
- 派生类继承基类的非静态成员函数并可以像自己的成员函数一样访问。
- 为每一个多态类创建一个虚函数指针数组(也就是虚函数表vtable),该类的所有虚函数(继承自基类或者新增的)的地址都保存在这张表里。
- 多态类的每一个对象(如果有)中放一个指针成员vptr,其类型为指向函数指针的指针,它总是指向所属类的vtable,也就是说:vptr当前所在的对象是什么类型,那么它就指向这个类型的vtable。vptr是C++对象的隐含成员之一。
- 如果基类已经插入了vptr,则派生类将继承和重用该vptr。
- 如果派生类是从多个基类继承(多继承)或者有多个继承分支(从所有根类开始),而其中若干个继承分支上出现了多态类,则派生类将从这些分支中的每一个分支上继承一个vptr,编译器为它生成多个vtable,有几个vptr就生成几个vtable(每个vptr分别指向其中一个),分别与它的多态基类对应。
- vptr在派生类对象中的相对位置不会随着继承层次的逐渐加深而改变,并且现在的编译器一般都将vptr放在所有数据成员的最前面。
- 为了支持RTTI,为每一个多态类创建一个type_info对象,并把其地址保存在vtable中的固定位置(一般为第一个位置)(这取决于具体编译器的实现技术,标准没有规定)。
现在Rectangle的对象模型如下图所示:
该模型有如下特点:
- 从一个派生类对象入手,可以直接访问到基类的数据成员,因为基类的数据成员被直接嵌入到了派生类对象中(保持基类子对象的完整性)。
- 不论派生层次有多深,派生类对象访问基类对象的数据成员和成员函数时,与访问自己的数据成员和成员函数没有任何效率差异。
- 由于派生类数据成员和基类数据成员的这种紧密关系,当基类定义发生改变(如增删成员)派生类必须重新编译后才能正确使用(这也体现了C++的缺陷,只有全部编译完成才能运行)。
- 派生类新增数据成员和继承来的基类数据成员按照对象的构造顺序来组合,并且每层派生的新增数据成员要么统一放在基类子对象的前面,要么统一放在后面。
- 只有虚函数访问需要经过vptr的间接寻址,增加了议程间接性,因此带来一点额外的开销。
多态的实现
当我们将派生类对象传给基类指针的时候,这个指针除了指向这个对象外,它的vptr会指向原本的派生类所指的函数指针(例子中的Draw_ptr),那么我们通过调用基类中被继承的函数,沿着vptr就能找到派生类的实现方法。(注意,如果基类中有纯虚函数,编译器会不允许这个基类被实例化)
这也解释了为什么析构函数要被声明为虚函数。在实现多态时,当用基类操作派生类,在析构时防止只析构基类而不析构派生类的状况发生。
实例分析
理论挺多,现在来看一下通过VS我们看到的内容:
如图所示,我们先创建了一个单独的基类对象,又用一个基类指针接受了一个派生类对象,然后又单独实例化了一个派生类,通过VS的调试器,可以看到:
- 先看基类对象shape1:
它的vfptr(虚函数指针,即vptr)指向了Shape的vftable(虚函数表,即vtable)表的第一个元素就是Shape的析构函数的指针,第二个元素就是虚函数Draw()。 - 再来看派生类对象rect1:
它没有被继承的函数,所以自己本身是没有vfptr的,但它里面包含有一个基类,这个基类中的vfptr指向的vftable和Shape1是不一样的,它里面所指向的函数是Rectangle的。 - 指向了派生类对象的基类指针shape2:
和rect1不一样的是,它自己是有vfptr的,因为他自己本身就是一个基类的指针,而且我们可以看到, Shape2中的vfptr的内容和Shape2指向的匿名派生类的基类中的vfptr指向的是同一个内存地址(这句话有点绕口)。如果读者再仔细观察,会发现rect1,shape2,shape2所指的匿名派生类中的vfptr都指向的是同一个内存地址。
关于静态成员的问题
上面我们说过,继承只会拷贝非静态成员,在内存中只对象也只拥有非静态成员变量和vptr而已,继承也是只继承这一部分。静态成员是不会被拷贝的永远只有一份,如果是基类的静态成员变量,则所有的继承自它的派生类都共享这个静态成员变量。如果基类还提供了修改这个静态成员的函数,那派生类也能使用。如下例:
#include "Shape.h"
#include "Rectangle.h"
#include <vector>
#include <iostream>
using namespace std;
unsigned int Shape::m_count = 0;
int main()
{
vector<Shape*> shapes;
for (int i = 0; i < 10; i++)
{
Shape* tempShape = new Rectangle(i, i * 2);
shapes.push_back(tempShape);
}
for (auto i : shapes)
{
i->Draw();
cout << i->GetCount() << endl;
}
Rectangle rect1;
cout << rect1.GetCount() << endl;
cout << Shape::GetCount() << endl;
return 0;
}
运行结果:
由此可见,不论Shape::GetCount()、rect1.GetCount()、还是接受了派生类对象的基类指针,使用结果都是一样的。
需要注意的是,静态成成员的实例化时机只能在外部。在线程中实例化的静态成员
关于vtable和vptr
通过上面的分析,我们还可以想到这里有两个关于vtable和vptr的问题:它们的类型,以及初始化的问题。
这个问题的来源是,从语言层面上来讲,只有相同类型的对象才能放入同一个数组或者容器中,这就意味着你无法声明一个同时包含不同类型对象的容器,对函数指针数组也是如此。
我们可以想到,vtable也是一个函数指针数组,按理说也只能存放相同类型的函数指针。可是,一个class中可能有各种各样甚至来源于多个基类的虚函数,它们的原型不可能一样,那这个虚函数表是如何定义的呢?
这和MFC及Qt的消息映射和信号槽有类似的思想。
参考文献
《Inside The C++ Object Model》-Lippman
《高质量程序设计指南》-林锐