在C++中,有两种类的数据成员——静态的和非静态的——并且有三种类的成员函数——静态的、非静态的和虚拟的。假设类Point的声明如下:
class Point
{
public:
Point( float xval );
virtual ~Point();
float x() const;
static int PointCount();
protected:
virtual ostream&
print( ostream &os ) const;
float _x;
static int _point_count;
};
在机器内部类Point怎样表示呢?也就是说,我们怎样模塑出不同种类的数据和函数成员呢?
一个简单的对象模型
我们的第一个对象模型无可否认地非常简单。它可能被用在一个被设计在空间代价和运行效率上减小编译器的复杂性的C++实现上。在这个简单模型中,一个对象是一个槽的序列,其中每个槽都指向一个成员。成员们按它们声明的顺序来安排槽。每个数据或函数成员都有一个槽。如图1.1所示。
图 1.1 简单对象模型
在这个简单模型中,成员本身不在对象中。只有用来寻址成员的指针在对象中。这样做避免了成员拥有很不相同的类型并需要不同大小的(并且有时不同类型的)存储空间的问题。对象的成员用槽的索引来寻址。例如,_x的索引是6而_point_count的索引是7。一个类的对象的普通的大小就是一个指针的大小乘以该类所声明的成员的数目。
尽管实际中不用这个模型,索引或槽号的简单概念被开发应用到C++的指向成员的指针的概念中。
一个表驱动的对象模型
为了得到一个使所有类的对象都有一个统一的表示的实现,一个可选的对象模型可能分解出所有特定于成员的信息,而将其放在一对由数据成员和成员函数组成的表中。类的对象包含指向两个成员表格的指针。成员函数表是一个槽的序列,每个槽寻址一个成员。数据成员表直接持有数据。如图1.2所示。
图 1.2. 成员表对象模型
尽管实际上这个模型没有用在C++中,成员函数表的概念是支持有效的运行时虚函数解析的传统实现。
C++对象模型
Stroustrup最初的(并仍然流行的)C++对象模型派生自对空间和访问时间做了优化的简单对象模型。非静态数据成员被直接分配在每个类对象的内部。静态数据成员存储在单个的类对象之外。静态和非静态成员函数也存在类对象之外。虚函数分两步支持:
1.为每个类产生一个指向虚函数的指针的表(这也称虚表)。
2.在每个类对象中插入一个指向相关的虚表的指针(传统上,这称作vptr)。vptr的设置、重设和不设由每个类对象的构造函数、析构函数、拷贝复制操作符函数内产生的代码自动处理。支持运行时类型识别的与每个类相关联的type_info对象同样也从虚表中寻址,通常从该表的第一个槽。
图1.3示出了我们的Point类的一般的C++对象模型。C++对象模型最初的力量在于其空间和运行效率。它最初的缺点是需要重编译利用一个对非静态类数据成员有过增加、删除和修改的类的对象的未修改的代码。(例如两张表的模型,用一层额外的间接索引提供了更好的弹性。但这样做是以空间和运行效率为代价)。
图 1.3 C ++对象模型
增加继承
C++支持单继承:
class Library_materials { ... };
class Book : public Library_materials { ... };
class Rental_book : public Book { ... };
和多继承:
// original pre-Standard iostream implementation
class iostream:
public istream,
public ostream { ... };
此外,继承可以被指定为虚拟的(即,共享的):
class istream : virtual public ios { ... };
class ostream : virtual public ios { ... };
在虚拟继承的情况下,主张基类只出现一次(称为子对象)而忽略这个类在继承链内派生了多少次。例如iostream,只包含一个ios虚基类的实例。
一个派生类在内部会怎样模塑它的基类实例呢?在一个简单的基类对象模型中,在派生类的对象中每个基类会被安排一个槽。每个槽持有基类子对象的地址。这种策略最初的不足是空间和访问时间超过了间接索引。一个好处是类对象的大小不受它相关联的基类的大小的影响。
另一种选择是,可以想象一个基表模型。这里,一个基表的每一个槽包含一个相关基类的地址,就像虚表持有每个虚函数的地址那样。每个类对象包含一个bptr被初始化用来寻址基表。这种策略的主要不足是,当然,是间接引用的空间和访问时间的开销。一个好处是一个每个类对象中继承的统一的表示。每个类对象可以在某个固定位置包含一个指向虚表的指针,而不论它的基类的个数或大小。第二个好处是增大,收缩,或着修改虚表而不用改变类对象本身的大小的能力。
在这两种方案中,间接索引的深度随着继承链的深度而增加;例如,一个Rental_book需要两次间接索引来访问一个从它的Library_material继承来的成员,然而Book只需要一次。可以用复制派生类的指针到继承链中的每个基类来得到一个统一的访问时间。这个折中是用额外的空间存储额外的指针。
C++最初支持的继承模型放弃了所有的间接索引;基类子对象的数据成员直接存储在派生类对象中。这样就提供了对基类成员的最精简和最有效的访问。不足是,当然,基类成员的任何改动,例如增加,删除,或改变一个成员的类型,需要对所有用到该基类或任何其派生类的对象的代码进行重编译。
在Release 2.0时引入虚基类到语言中需要某种形式的间接基类表示。虚基类的最初的模型支持给每个相关的虚基类的对象增加一个指针。可选择的模式已经改进到或着引入一个虚基类表,或者扩展现有的虚表来维护每个虚基类的位置。
对象模型怎样影响程序
实际中,对象模型对程序员意味着什么呢?支持对象模型将导致修改现有程序代码和插入额外代码。例如,给出以下函数,其中类X定义了一个拷贝构造函数,一个虚拟的析构函数,和一个虚函数fun():
X foobar()
{
X xx;
X *px = new X;
// foo() is virtual function
xx.foo();
px->foo();
delete px;
return xx;
};
这个函数的可能的内部变换看起来像这样:
// Probable internal transformation
// Pseudo C++ code
void foobar( X &_result )
{
// construct _result
// _result replaces local xx ...
_result.X::X();
// expand X *px = new X;
px = _new( sizeof( X ));
if ( px != 0 )
px->X::X();
// expand xx.foo(): suppress virtual mechanism
// replace xx with _result
foo( &_result );
// expand px->foo() using virtual mechanism
( *px->_vtbl[ 2 ] )( px )
// expand delete px;
if ( px != 0 ) {
( *px->_vtbl[ 1 ] )( px ); // destructor
_delete( px );
}
// replace named return statement
// no need to destroy local object xx
return;
};
喔,那真是不同啊,不是吗?当然,不期望你在本书的这里理解所有这些变换。在接下来的章节中,我讨论这些是什么和为什么,还有许多。理想地,你将回头来看,折着手指,并说着,“噢,是啊,当然,”奇怪你为什么曾感迷惑。
(本文译自Stanley B. Lippman著《Inside the C++ Object Model》中1.1 The C++ Object Model一文)