前言
在C++继承模型中,derived class object所表现出来的东西,是其自身members与其base classes’s members的总和。它们出现的次序没有强制性约定,一般来说,base class members将先出现,但属于virtual base class的除外(virtual继承总能带来各种各样的意外)。
下面提出一个问题:假设我们单独为Point2d与Point3d建立class,而不是令他们派生自某个固有继承体系(如Inside the C++ object model 关于对象一章):1
2
3
4
5
6
7
8
9
10
11
12class Point3d{
public:
...
private:
float x,y;
};
class Point2d{
public:
...
private:
float x,y,z;
};
下述的各个小节将依次讨论不同情况下的区别。在没有virtual functions的情况下,我们可以认为上述两种写法的对象布局与C struct一致:
Inheritance without Polymorphism
一般来说,C++中有将非尾端类设计为抽象类的准则,假设我们暂且忽略这一点,将Point3d derived from Point2d,从而保证共享数据和操作数据的方法。一般来说,具体继承不会增加空间或时间的开销,有继承体系如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27class Point2d{
public:
Point2d(float x=0.0,float y=0.0):_x(x),_y(y) {};
float x() {return _x;}
float y() {return _y;}
void x(float newX) {_x=newX;}
void y(float newY) {_y=newY;}
void operator+=(const Point2d& rhs){
_x+=rhs._x;
_y+=rhs._y;
}
protected:
float _x,_y;
};
class Point3d:public Point2d{
public:
Point3d(float x=0.0,float y=0.0,float z=0.0)
:Point2d(x,y),_z(z) {};
float z() {return _z;}
void z(float newZ) {_z=newZ;}
void operator+=(const Point3d& rhs){
Point2d::operator+=(rhs);
_z+=rhs.z();
}
protected:
float _z;
};
它们的对象模型如下所示:
空间膨胀
将一个class分为多层,可能会因为“表现class体系的抽象化”而扩张所需空间。C++语言保证,出现在derived class中的base class subobject具备完整性,具体如下述实例所示:1
2
3
4
5
6
7
8
9
10class Concrete{
public:
...
private:
int val;
char c1;
char c2;
char c3;
char c4;
};
图中左侧的数字表明在32位机器上,这一部分占用了多少bytes(padding表示alignment带来的影响)。
现假设出于某些需要,我们将该Concrete分为三层:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19class Concrete1{
public:
...
private:
int val;
char bit1;
};
class Concrete2:public Concrete1{
public:
...
private:
char bit2;
}
clss Concrete3:public Concrete2{
public:
...
private:
char bit3;
};
可能从设计的角度来说该结构比较合理,但我们会发现现在的Concrete3已经扩充到了16bytes,足足多了一倍,原因便是subobject的原样性必须保持。现在的对象布局如下所示:
保证subobject一致性的原因
看起来很蠢,那么为什么我们非要保证base class subobject的原样性呢?
考虑下述指针:1
2Concrete2 *pc2;
Concrete1 *pc1_1,*pc1_2;//可以指向任意的concrete object
当我们通过指针来完成复制操作:1
*pc1_2=*pc1_1;
会执行默认的memberwise复制,对象是被指的object的Concrete2或Concrete3 object。如果pc1_1实际上指向的是Concrete2或Concrete3 object,则上述操作实际上应该复制pc1_1指向对象的Concrete1 subobject部分。
如果,C++将derived class members与subobject捆绑(失去一致性),那么会直接导致复制操作出现各种未定义行为:
Adding Polymorphism
如果我们在现有的继承体系中加入virtual function接口:1
2
3
4
5
6
7
8class Point2d{
public:
...
virtual void operator+=(const Point2d& rhs);
...
protected:
...
};
显然,virtual function仅仅发生于多态情况下:1
2
3
4
5void foo(Point2d& p1.Point2d& p2){
...
p1+=p2;
...
}
其中p1与p2既可能指向Point2d object也可能指向Point3d object。多态是面向对象程序设计的核心,支持这样的弹性势必需要给class带来空间与存取时间上的负担。
多态带来的开销
- vtbl
vtbl将会被生成,其内部存放着所有virtual function的地址,显然,该table的大小与virtual function的数目正相关。内部可能还会有别的slots,用以支持RTTI; - vptr
每一个class object内部都被置入一个vptr,提供执行期链接,保证每一个object能够找到对应的vtbl。 - 加强constructor
constructor需要为vptr设定初值,这可能意味着需要在derived class和每一个base class的constructor中重新设定vptr。 - 加强destructor
destructor需要能够抹除指向vbtl的vptr,需要记住的是,destructor的调用次序是反向的:从derived class到base class。
vptr的位置
在设计C++编译器时的一个经典问题是:把vptr置于class object的哪一个位置最佳?
置于尾部
假设我们将其置于object的尾部:1
2
3
4
5
6
7
8
9
10
11struct no_virts{
int d1,d2;
}
class has_virts:public no_virts{
public:
virtual void foo();
//...
private:
int d3;
};
no_virts *p = new has_virts;
把vptr置于class object的尾端可以保留base class C struct的对象布局,从而确保即使在C程序中也能使用。
置于头部
自C++2.0起,虚继承与抽象基类特性被引入,且OO兴起,部分编译器开始把vptr放在object的头部:
将vptr置于前端,对于“在多重继承下,通过指向class members的指针调用member function”会带来帮助。但代价是丧失了与C struct的语言兼容性
下图展示了Point2d与Point3d加上了virtual function之后的内存布局(vptr位于尾端):
Multiple Inheritance
在单一继承下,base class与derived class objects均从相同地址开始,只是其大小不同,1
2Point3d p3d;
Point2d* p = &p3d;
上述操作并不需要编译器去修改地址,且具备最佳执行效率。
上文曾叙述某些编译器将vptr置于class object的起始处。如果base class不具备virtual function而derived class具备的话,此时无法再保持对象起始地址的相同,需要编译器的介入以调整地址(因为vptr插入的缘故)。
多重继承不同于单一继承,也难以塑造模型,其复杂度在于derived class和其上一个base class乃至于上上个base class….之间的“非自然”关系,下文将以实例说明。
问题实例
1 | class Point2d{ |
多重继承的主要问题在于derived class objects和其第二或后继的base class objects之间的转换,例如:1
2
3
4extern void mumble(const Vertex&);
Vertex3d v;
...
mumuble(v);//将v转化为Vertex对象
问题剖析
对于一个多重派生对象,将其地址指定给“最左端base class的指针”时,情况与单一继承情况一致。但如果你需要指定后继的base class地址,则需要编译器修改地址:加减介于中间的base class subobjects的大小:1
2
3
4Vertex3d v3d;
Vertex *pv;
Point2d *p2d;
Point3d *p3d;
对于上述声明,下面的赋值操作将引发内部地址转换如下:1
2pv = &v3d;
pv = (Vertex*)(((char*)&v3d)+sizeof(Point3d));
而下面的assignment则只需要简单地拷贝地址即可:1
2p2d = &v3d;
p3d = &v3d;
如果现有两个指针如下:1
2Vertex3d *pv3d;
Vertex *pv;
那么下面的assignment无法像上面那样简单地移位:1
2pv = pv3d;
pv = (Vertex*)(((char*)&pv3d)+sizeof(Point3d));
原因在于如果pv3d==0,那么pv将获得sizeof(Point3d),这不可取,因此需要修改为:1
pv = pv3d?(Vertex*)(((char*)&pv3d)+sizeof(Point3d)):0;
引用在赋值时不需要考虑这么多,因为引用始终具备指向性(More Effective C++ 1)。下图给出了当前继承体系下的内存布局:
存取后继base class中的data member无需付出额外的成本,因为members的位置在编译期间便已经固定。
Virtual Inheritance
多重继承的一大副作用在于,它必须支持某种形式的“shared subobject继承”。其中一个很典型的例子就是iostream library:1
2
3
4class ios {..};
class istream:public ios {...};
class ostream:public ios {...};
class iostream:public istream,public ostream {...};
显然,无论是istream还是ostream内部都含有一个ios subobject。我们希望避免在iostream中出现两个2个ios subobject,因此可以引入虚继承:1
2
3
4class ios {..};
class istream:public virtual ios {...};
class ostream:public virtual ios {...};
class iostream:public istream,public ostream {...};
虚继承实现的难点
实现虚继承的一大难点在于需要找到一个足够有效的方法。将istream与ostream各自维护的一个ios subobject,折叠成为一个由iostream维护的单一ios subobject,并且保存pointer与reference对应的多态指定操作。
虚继承的具体实现
我们将class分为两部分:不变局部、共享局部。
- 不变局部
不变局部中的数据不管后继如何衍化,总是拥有固定的offset,因此这一部分数据可以被直接存取。 - 共享局部
所谓的共享局部则是virtual base class subobject这一部分的数据,其位置会由于派生操作而发生改变,所以它们只能被间接存取。
有三种主流策略来实现虚继承,我们将在实例中一一介绍。
问题实例
现有Vertex3d虚继承体系如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24class Point2d{
public:
...
protected:
float _x,_y;
};
class Point3d:public virtual Point2d{
public:
...
protected:
float _z;
};
class Vertex:public virtual Point2d{
public:
...
protected:
Vertex *p;
};
class Vertex3d:public Vertex,public Point3d{
public:
...
protected:
float mumble;
};
一般布局策略是先安置好不变局部,再建立其共享局部。关键在于如何去读取class的共享部分?
实现模型
部分编译器会在每一个derived class object中安插一些指针,每个指针指向一个virtual base class。存取继承得到的virtual base class members可以通过这些指针来完成,其对象布局大致如下:
举例而言,1
2
3
4
5
6
7
8
9
10//原有函数
void Point3d::operator+=(const Point3d &rhs){
_x+=rhs._x;
_y+=rhs._y;
_z+=rhs._z;
}
//真正实现
_vbcPoint2d->_x += rhs._vbcPoint2d->_x;//vbc means virtual base class
_vbcPoint2d->_y += rhs._vbcPoint2d->_y;
_z += rhs._z;
而一个derived class和一个base class的实例之间的转换如下所示:1
2Point2d *p2d = pv3d;
Point2d * p2d = pv3d?pv3d->__vbcPoint2d:0;
这种实现模型主要有两个缺点:
- 每一个virtual base class都存在一个与之对应的指针,但我们希望空间开销固定,不能因为virtual base class数目的增长而增长。
- 继承体系发生纵向增长后,间接存取的层次也将提高,我们希望时间开销固定,不随着继承体系扩张而增长。
第二个问题的解决方案是:拷贝所有的virtual base指针放在derived class object内部,保证一次间接访问即可获取data member,但这种方法增加了空间支出。
第一个问题有两种解决策略:
- virtual base class table
Microsoft编译器采纳此法,如果一个class object存在一个或多个virtual base class,即会由编译器生成一个指向virtual base class table的指针,该table内部存放着访问各个virtual base class的指针。 - 在vbtl中放置virtual base class的offset
其实现模型大致如下:
vbtl由正值或负值来索引,正值则索引到virtual functions,负值则索引至virtual base class offsets。在该模型下,derived class实体与base class实体之间的转换操作如下所示:1
2
3Point2d *p2d = pv3d;
//转换后的程序
Point2d *p2d = pv3d? pv3d + pv3d->_vptr_Point3d[-1]:0;
最佳运用方式
一般而言,virtual base class最有效的一种运用方式是:一个抽象的virtual base class,没有任何data members(类似于Java的Interface)。