本章是从整个对象的全局观来审视C++,并对比C++和C,讨论出一个较为高效和简明的C++对象模型,最后详细介绍了继承如何对程序造成影响。
主要分为以下几个内容:
- 引入。C++如何基于C构建对象
- C++简单对象的内存布局模型讨论
- Class 和 Struct 间的关系和影响
- 详细讨论对象的继承
关于C++的强大以及C的简洁之间的讨论已经很多,因而我不打算介绍C++是如何基于C构建对象的。而是直接进入C++对象的内存布局模型的讨论。
由于本章是C++对象模型的概述,也是全书的一个热身。我打算比较详细的介绍,故本文将只介绍2. C++简单对象的内存布局模型讨论。
今后再继续后面的讨论。
对象布局
我经常听人说,利用函数指针,他可以利用C实现面向对象。
诚然如此,但用C实现面向对象,以及C++内建的面向对象之间的执行效率有何区别?
如果不深入底层,我们是无法讨论出个结果的,现在,我就将简要的介绍书中提到的三种内存布局模型。
注:以如下类定义为例:
class Point
{
public:
Point( float xval ) : _x(xval) {}
virtual ~Point();
float x() const { return _x; }
static int PointCount() { return _point_count; }
protected:
virtual ostream& print( ostream &os ) const {
os << _x;
return os;
}
float _x;
static int _point_count;
};
简单对象模型
这个模型非常简单,每一个对象有自己独立的存储空间,而存储的内容是一列指针(书中称存储此指针的空间为slot),每个指针指向该对象的一个成员变量或成员函数。如下图:
由此图可以清楚的看到这种模型的实现方式。
这种模型很简单,每个slot都是指针,因而有固定的大小。
但问题是,每次访问其成员,都必须加一层指针访问。这将极大的影响模型的效率。
表格驱动对象模型
这种模型相对于前一个模型,更具扩展性,它是在对象的存储空间中,只存储两个指针:一个指向成员变量表,一个指向成员函数表。成员变量表直接存储变量,函数表存储函数指针:
这种模型的好处在于,每个对象的存储空间都有相同的表现方式,扩展性非常强。
但实际上,这种模型比前一种模型效率更低。但是,在后面可以看到,成员函数表的模型将被应用于虚函数的实现上。
C++对象模型
这种模型是C++编译器实际使用的模型(不同的编译器只有细节差别,基本模型是一致的)。它对空间、时间都进行了优化,当然,这种优化也必然导致它的复杂度有些许增加。
书中讲述的比较简洁,可能有些难懂,我在这里分步骤来介绍这个模型。
首先,暂时不考虑虚函数的实现,那么其实现方式如下:
- 每个对象直接存储非静态变量,这与C中的struct完全一致
- 对象的静态变量存储在一块共享区域,每个对象都可以访问
- 对象的静态、非静态函数的执行体都存储在对象之外,每个对象都可以访问
- 静态及非静态函数的区别在于,非静态函数将多传递一个this指针,以便函数访问this指针指向的内容
其模型如下图:
图中可以清晰的看到,实际的动态内存需求,仅仅只是每个对象内部的非静态成员变量所占内存大小的总和。
并且,编译器内部访问对象的成员函数的方式,实际上是同C的函数访问完全一致的。
我们可以这样理解C++编译器的实现:
它将C++代码转换成了如下的C代码(暂时忽略虚函数):
// Point类定义,只有非静态成员变量
struct Point
{
float _x;
};
// 静态成员变量定义在Point类之外
static int _Class_Point_point_count;
// 构造函数初始化成员变量
void _Class_Point_Constructor(struct Point* this, float xval) {
this->_x = xval;
}
float _Class_Point_x(struct Point* this) {
return this->_x;
}
// 静态成员函数直接访问静态成员变量
static int _Class_Point_PointCount() {
return _Class_Point_point_count;
}
之后,再进行编译。
这样,一个不含虚函数的对象的存储空间,就与此对象成员变量组成的结构体毫无区别。
并且,对成员函数的访问效率,与直接访问一个C函数的效率等同。这一点,是使用函数指针模拟对象的C结构体无法比拟的。
C++对象模型(加虚函数)
下面我们再来看看加上虚函数的情形,它将在前文提到的实现上再加上几条:
- 一个包含虚函数的类,将定义一个虚函数列表,称为vtbl。此列表的每一个slot都指向该类的一个虚成员函数。
- 每个对象增加一个指针,指向对应类的虚函数列表。此指针称为vptr。这个vptr相当于一个变量。但只由编译器自己管理(在构造、析构和复制函数中设定和重置),用户不能访问。同一个类的不同对象,将包含指向同一个vtbl的vptr,并且,不会随着对象指针类型的变化而变化(后文再详细讨论)。
其模型结构如下图:
相对于前文的非虚函数类,增加了一个__vptr__Point的指针以及虚函数列表。图中所示的type_info for Point用于RTTI,暂不考虑。
此模型,翻译成c语言实现,将有如下定义:
struct Point;
// Point类的虚函数定义
ostream& _Class_Point_print(struct Point*, ostream&);
void _Class_Point_Destructor( struct Point* );
// Point类的虚函数表vtbl
static struct __vtbl__Point_Tag{
ostream& (*_p_Class_Point_print)( struct Point*, ostream& os );
void (*_p_Class_Point_De_Point)( struct Point* );
} __vtbl__Point = { _Class_Point_print, _Class_Point_Destructor };
// Point类(只有非静态成员变量)
struct Point
{
float _x;
struct __vtbl__Point_Tag *__vptr;
};
// Point类的静态成员变量
static int _Class_Point_point_count;
// Point类的非静态成员函数,加上了this指针。
// 在构造函数中初始化变量以及vptr
void _Class_Point_Constructor(struct Point* this, float xval) {
this->_x = xval;
this->__vptr = &__vtbl__Point;
}
float _Class_Point_x(struct Point* this) {
return this->_x;
}
// Point类的静态成员函数,直接返回静态成员变量
static int _Class_Point_PointCount() {
return _Class_Point_point_count;
}
// Point类的虚函数实现
ostream& _Class_Point_print(struct Point* this, ostream& os) {
os << this->_x;
return os;
}
void _Class_Point_Destructor( struct Point* this ) {
}
需要注意的是,C语言没有访问控制(const、private、protect、public等),这些访问控制是在C++编译器编译时检查的。
下面我用一个简单的例子来说明,虚函数是如何做到正确的访问成员函数的。
首先,看看不用虚函数的情况:
class Animal {
void bark() { cout << "。。妈妈没教我叫。。。" << endl; }
};
class Dog {
void bark() { cout << "汪汪!" << endl; }
};
int main() {
Animal *a = new Dog;
a->bark();
return 0;
}
输出的结果是“。。妈妈没教我叫。。。”。
而如果是使用虚函数的情况呢:
class Animal {
virtual void bark() = 0;
};
class Dog {
virtual void bark() { cout << "汪汪!" << endl; }
};
int main() {
Animal *a = new Dog;
a->bark();
return 0;
}
输出当然就是“汪汪!”了。并且,使用虚函数可以强制Animal的bark被覆盖。相当于Animal成为一个接口,这也可以避免出现“妈妈没教它叫”的窘境。
那么,虚函数是如何实现这一效果的?我们还是从底层来看看。
先看非虚函数的C语言翻译版:
struct Animal {
// 没有成员变量,定义空结构体
};
// Animal构造函数,什么都不做
void _Class_Animal_Constructor(struct Animal* this) {
}
void _Class_Animal_bark(struct Animal* this) {
cout << "。。妈妈没教我叫。。。" << endl;
}
class Dog {
// 没有成员变量,定义空结构体
};
// Dog构造函数,什么都不做
void _Class_Dog_Constructor(struct Dog* this) {
}
void _Class_Dog_bark(struct Dog* this) {
cout << "汪汪!" << endl;
}
int main() {
Animal *a = new Dog;
// new 的是Dog,调用Dog的构造函数(此函数什么都没做)
_Class_Dog_Constructor(a);
_Class_Animal_bark(a); // a->bark();
return 0;
}
再看看虚函数版本:
// Animal类的虚函数为纯虚函数,没有实现
// Animal类的虚函数表vtbl
static struct _Class_Animal__vtbl_Tag{
void (*_p_Class_Animal_bark)( struct Animal* );
} _Class_Animal__vtbl = { 0 };
struct Animal {
// 只有一个vptr的变量
struct _Class_Animal__vtbl_Tag *__vptr;
};
// 在构造函数中初始化变量以及vptr
void _Class_Animal_Constructor(struct Animal* this) {
this->__vptr = &_Class_Animal__vtbl;
}
// Animal类的虚函数为纯虚函数,没有实现
/
// Dog类的虚函数定义
void _Class_Dog_bark( struct Dog* );
// Dog类的虚函数表vtbl
static struct _Class_Animal__vtbl_Tag _Class_Dog__vtbl = { _Class_Dog_bark };
struct Dog {
// 只有一个vptr的变量
struct _Class_Dog__vtbl_Tag *__vptr;
};
// 在构造函数中初始化变量以及vptr
void _Class_Dog_Constructor(struct Dog* this) {
this->__vptr = &_Class_Dog__vtbl;
}
// Dog类的虚函数实现
void _Class_Dog_bark(struct Dog* this) {
cout << "汪汪!" << endl;
}
int main() {
Animal *a = new Dog;
// new 的是Dog,调用Dog的构造函数(此函数将a->__vptr赋值为_Class_Dog__vtbl
_Class_Dog_Constructor(a);
a->__vptr->_p_Class_Animal_bark(a); // a->bark();
return 0;
}
代码中的关键在于虚函数版本中,Dog类的构造函数将a->__vptr赋值为_ClassDog__vtbl,而_ClassDog__vtbl->_p_Class_Animal_bark是赋值为_Class_Dog_bark的。因此,同样是a->__vptr->_p_Class_Animal_bark()的调用,初始化为Dog的Animal指针将访问的是_Class_Dog_bark,而没有使用虚函数的版本是直接调用了_Class_Animal_bark。这就是虚函数的实现方式。
虚函数的使用虽然多加上了一层指针访问,但其对动态访问的支持使得程序的设计可以更加抽象,有利于大型程序的设计。