在C++继承模型中:
- 派生类对象 = 自己的成员 + 基类成员
- 对于派生类成员和基类成员的排序次序:大部分编译器中,基类成员总是先出现,但虚基类除外(一般来说,任何规则在遇上虚基类之后都会被打破)
没有继承
class Point2d{
public:
private:
float x, y;
};
class Point3d{
public:
private:
float x, y, z;
};
对象布局图如下,在没有虚函数的情况下,它们和C struct完全一样
只要继承不要多态
一般来说,具体继承(是相对虚拟继承来说的)并不会增加空间或者存取时间上的额外负担
class 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;
};
单一继承而且没有虚函数时的数据布局:
加上多态
如果要处理一个坐标点,而不在乎它是个Point2d还是Point3d实例,可以在继承关系中添加一个虚函数接口
class Point2d(){
public:
Point2d(float x = 0.0, float y = 0.0)
: _x(x), _y(y) {};
//x和y的存取操作和上面相同。由于对于不同维度的点,这些函数的操作固定不变,所以不必设计为虚函数
float x() { return _x;}
float y() { return _y;}
void x(float newX){_x = newX; }
void y(float newY){_y = newY; }
// 加上z的保留空间
virtual float z(){return 0.0;}
virtual void z(float){}
virtual void operator += (const Point2d & rhs){
_x += rhs._x;
_y += rhs._y;
}
protected:
float _x, _y;
}
只有当我们试图以多态的方式处理2d或者3d坐标点时,在设计中导入一个虚接口才合理。也就是说:
void foo(Point2d &p1, Point2d &p2){
p1 += p2;
}
其中p1和p2可能是2d也可能是3d坐标点。这并不是先前任何设计所能支持的。这样的弹性,就是面向对象设计的中心。支持这样的弹性,势必给Point2d类带来空间和存取时间的额外负担:
- 导入一个和Point2d有关的虚函数表,用来存放它所声明的每一个虚函数的地址,这个表的元素数目一般是被声明的虚函数的数目,在加上一个或者两个slots(用以支持runtime type identification)
- 在每一个类对象中导入一个vptr,提供运行期的链接,使每一个对象能够找到相应的虚函数表
- 加强构造函数,使它能够为
vptr
设定初值,让它指向类所对应的虚函数表。这可能意味着在派生类和每一个基类的构造函数中,重新设定vptr的值。其情况视编译器的优化的积极性而定。 - 加强析构函数,使它能够抹除指向类相关的虚函数表的
vptr
。要知道,vprt很可能已经在派生类析构函数中被设定为派生类的虚函数地址。析构函数的调用次数是反向的:从派生类到基类
class Point3d : public Point2d{
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 Point2d & rhs){ // 注意,这里是Point2d 而不是Point3d
Point2d::operator+=(rhs);
_z += rhs._z;
}
protected:
float _z;
};
上面最大的好处是可以把operator+=运用在一个Point2d和Point3d对象上
Point2d pd2(2.1, 3.0);
Point3d pd3(3.1, 5.2, 3.4);
pd2 += pd3
把
vptr
放置在类对象的哪里会最好
在cfont编译器中,它被放置在类对象的尾端,用以支持下面的继承类型:
- 优点:
- 单一继承提供了一种自然多态的形式,是关于类体系中的基类和派生类之间的准换。把一个派生类指定给基类的指针或者引用时(不管继承深度有多深),并不需要编译器去调停或者修改地址,它可以很自然的发生,而且提供了最佳指向效率。
Point3d p3d; Point2d *p = &p2d;
- 把vptr放在类对象的尾端,可以保留基类C结构体的对象布局
struct no_virts{
int d1, d2;
};
class has_virts : public no_virts{
public:
virtual void foo();
private:
int d3;
};
no_virts *p = new has_virts;
到了C++2.0,开始支持虚拟继承和抽象基类,并且由于OO的兴起,某些编译器开始把vptr放在类对象的头部。
- 优点:帮助”多重继承时,通过指向类成员的指针调用虚函数“
- 缺点:
- 和C不兼容了
- 当把vptr放在类对象的起始处时,如果基类没有虚函数而派生类有,那么单一继承的自然多态就会被打破,此时,把一个派生类转换为基类,就需要编译器调整地址(因为vptr的插入原因)
多重继承
多重继承的复杂度在于派生类和上一个上上一个…基类之间的非自然关系。看个例子:
class Point2d{
public:
// ... 有
protected:
float _x, _y;
};
class Point3d : public Point2d{
public:
// ... 有
protected:
float _z;
};
class Vertex{
public:
protected:
Vertex *next;
};
class Vertex3d :
public Point3, public Vertex{
public:
protected:
float mumble;
};
多重继承的问题主要发生:
- 在派生类对象和其第2或者后继的基类对象之间的转换:
extern void mumble(const Vertex&);
Vertex3d v;
mumble(3); // 将一个Vertex3d 转换为一个Vertex。这是不自然的
- 经由其所制作的虚函数机制做转换
那么对于第一个问题:
- 对一个多重派生对象,将其地址指定给第一个(最左边)基类的指针,情况和单一继承相同,因为二者都指向相同地址,需要付出的只有地址的指定操作而已。
- 对于第二或后继的基类的地址指定操作,需要修改地址:加上(或者减去,如果downcast的话)介于中间的基类子对象大的大小。
比如:
Vertex3d v3d;
Vertex *pv;
Point2d *p2d;
Point3d *p3d;
- 对于:
pv = &v3d;
- 内部转换为:
pv = (Vertex*)((char*)v3d + sizeof(Point3d));
- 对于:
p2d = &v3d;
p3d = &v3d;
- 只需要简单的拷贝地址就可以了。
已经知道:
Vertex3d *pv3d
Vertex *pv;
- 对于下面指定操作:
pv = pv3d;
- 不能够只是简单的被转换为:
pv = (Vertex*)((char*)pv3d + sizeof(Point3d));
- 因为如果pv3d为0,pv将获得sizeof(Point3d)的值。所以,对于指针,内部转换应该有一个条件测试:
pv = pv3d
? (Vertex*)((char*)pv3d + sizeof(Point3d))
:0
至于引用,则不需要针对可能的0值做防卫,因为引用不可能引用到无物(no object)
如果要存取第二个(或者后继)基类中的一个数据成员,将会是怎样的情况?需要付出额外的成本吗?
- 不需要。成员的位置在编译时就固定了,因此存取成员只是一个简单的offset运算,就像单一继承一样简单----不管是经由指针、引用还是对象来存取
虚拟继承
多重继承的一个副作用是,他必须支持某种形式的shared subobject继承
。比如:
class ios{};
class istream : public ios{};
class ostream : public ios{};
class istream :
public istream, public ostream {};
不管istream还是ostream都内含一个ios subobject。然而在iostream的对象布局中,我们只需要单一一份ios subobject就好。解决方法是引入虚拟继承
class ios{};
class istream : public virtual ios{};
class ostream : public virtual ios{};
class istream :
public istream, public ostream {};
虚拟继承技术要求必须能够找到一个有效的方法:
- 将istream和ostream各自维护的一个ios subobject,折叠成一个由iostream维护的单一ios subobject,
- 并且还可以保存基类和派生类的指针(或者引用)之间的多态指定操作。
一般的实现方法如下:
- 类如果内含一个或多个虚基类子对象,将被分割成两部分:一个不变局部和一个共享局部
- 不变局部中的数据,不管后继如何演化,总是有固定的偏移量(从对象的开头算起),所以这一部分的数据可以被直接存取
- 共享局部表现的就是虚基类子对象。这部分的数据,其位置会因为每次的派生操作而变化,所以它们只可以被间接存取。
各家编译器实现技术的差役就在于共享局部间接存取的方法不同。常用的有三种策略。
看个例子:
class Point2d{
public:
protected:
float _x, _y;
};
class Vertex : public virtual Point2d{
public:
protected:
Vertex *next;
};
class Point3d : public virtual Point2d{
public:
protected:
float _z;
};
class Vertex3d : public Vertex, public Point3d{
public:
protected:
float memble;
};
一般的布局策略是先安排好派生类的不变部分,然后再建立其共享部分。
但是这里有一个问题:怎么存取类的共享部分呢?
- cfront编译器会在每一个派生类对象中安插一些指针,每一个指针指向一个虚基类
- 要存取继承到的虚基类成员,可以用指针间接完成。
看个例子:
void Point3d::operator+=(const Point3d &rhs){
_x += rhs._x;
_y += rhs._y;
_z += rhs._z;
};
在cfront策略下,这个运算符会被内部转化为:
__vbcPoint2d->_x += rhs.__vbcPoint2d->_x;
__vbcPoint2d->_y += rhs.__vbcPoint2d->_y;
_z += rhs._z;
而派生类和基类之间的转换:
Point2d *p2d = pv3d;
会变成:
Point2d *p2d = pv3d ? pv3d->vbcPoint2d:0;
这个实现模型有两个缺点:
- 每一个对象必须针对每个虚基类背负一个额外的指针。然而理想上我们却希望类对象有固定的负担,不因为其虚基类的数目而变化。这应该怎么解决呢?
- 由于虚拟继承串链的加长,导致间接存取层次增加。比如如果有3层虚拟衍化,就需要3次间接存取(经由三个虚基类指针)。然而理想上我们希望有固定的存取时间。这应该怎么解决呢?
第一个问题一般有两种解决方法:
- VS编译器引入虚基类表。每一个类对象如果有虚基类,就会由编译器安插一个指针,指向这个虚基类表。
- 在虚函数表中放置虚基类的偏移量。比如下图:
第二个问题的解决方法:
-拷贝所有的nested virtual base class指针,放到派生类对象中。这就解决了“固定存取时间”的间隔,虽然付出了一些空间上的代价
一般来说,虚基类最有效的一种运用形式是:一个抽象的虚基类,没有任何数据成员