C++-----深度探索对象模型-第三章-Data语意学(三)
1、在C++中一个派生类对象所表现出来的东西其实是自己的成员加上基类的成员的总和,至于派生类成员和基类成员的排列顺序,并不强制指定,大部分编译器基类成员先出现(虚基类除外)。
2、下面讨论四种情况:“单一继承且不含虚函数”,“单一集成并且含有虚函数”,“多重继承”,“虚拟继承”四种情况。
(1)只要继承不要多态
一般来说,具体继承(相对于虚拟继承)并不会增加空间或存取时间上的额外负担。在继承关系下,容易犯一些错误,比如重复设计一些相同操作的函数,因此选择某些函数作为inline函数是设计class的一个重要课题。还有一个容易出现的错误,把一个class分解成两层或更多层,为了表现class体系的抽象化,而导致内存空间增大。下面以一个具体的类来分析:
class Concrete{
public:
//...
private:
int val;
char c1;
char c2;
char c3;
};
在一个32位机器中,每一个Concrete 类对象的的大小是8字节。val占了4字节,char一共占了3字节,再加上一个补齐的字节。现在假设将上面的类分为下面一个三层结构:
class Concrete1{
public:
//...
private:
int val;
char bit1;
};
class Concrete2:public Concrete1{
public:
//...
private:
char bit2;
};
class Concrete3:public Concrete2{
public:
//...
private:
char bit3;
};
这个时候Concrete3 对象的大小变成16,Concrete1大小是8字节,3个字节用来填补。Concrete2的大小是12字节,是在Concrete1填补三个字字节之后,加上一个char,再填补三字节。
如果没有多态机制,那么进行具体继承的对象进行复制时,编译器会将基类对象原来填补的空间拿出来给派生类对象覆盖,这并不是想要的结果。
(2)加上多态
加上多态以后的时间和空间成本:1)导入一个与该类有关的虚函数表,这个表的元素个数一般是虚函数的个数再加上一两个slots(用来支持RTTI)。2)在每一个类对象中导入一个vptyr,虚函数指针,提供执行期连接,是每一个类对象都能找到相对应的虚函数表。3)加强构造函数,使它能够为vptr设定初值,让它指向类所对应的虚函数表。4)加强析构函数,使其能够抹消vptr。这样的时间和空间成本带来的是多态的弹性,也就是指针或引用绑定的类型不确定。
在目前C++编译器领域有一个主要讨论的题目,就是虚函数指针的存放位置,一开始保存在类对象的尾端,这样可以保留C struct的对象格局,但是到了C++2.0开始支持虚拟继承以及抽象基类,随着面对对象范式的兴起,某些编译器开始把vptr放到类对象的开始处。把虚函数指针放到类对象的前端,对于在多重继承下通过指向class member的指针调用虚函数会带来一些帮助。
(3)多重继承
单一继承提供了一种自然多态形式,关于类体系中基类类型和派生类类型之间的转换,基类和派生类都是从相同的地址开始,其中的差异只在于派生类对象比较大,用来容纳自己的nonstatic data member,在这种情况下,类对象从派生类到基类的转换(通过指针或引用)并不需要编译器去改变起始地址。把虚函数指针加上之后,如果基类没有虚函数,而派生类有,这时候把一个派生类对象转换为基类对象,需要编译器介入,来调整地址(vptr)。
多重继承:对于一个多重派生对象,将其地址指定给最左端也就是第一个基类的指针,情况和单一继承相同,两者具有相同的起始地址需要付出的成本只有地址指定操作。至于第二个或后续基类地址指定操作,则需要将地址修改过加上会减去对应的类对象的大小即可。
class point2d{};
class point3d:public point2d{};
class Vertex{};
class Vertex3d:public point3d,public Vertex{};
Vertex3d v3d;
Vertex *pv;
point2d *p2d;
point3d *p3d;
//下面这个操作
pv=&v3d;
//将会转换为
pv=(Vertex*)(((char*)(&v3d))+sizeof(point3d));
//而下面的操作都只需要简单的拷贝其地址就好
p2d=&v3d;
p3d=&v3d;
多重继承的存取操作并不需要付出额外的成本,因为成员的位置在编译时已经确定,存取member只是一个简单的偏移量的运算。
(4)虚拟继承
如下图:

左为多重继承,右为虚拟继承,在左边多重继承下,不管是istream还是ostream都含有一个ios对象,但是在iostream中我们只需要一份ios对象就好,语言层面解决办法就是导入所谓的虚拟继承。
一个类如果有一个或多个虚基类,那么该类会被分割为两部分,一个不变区域和一个共享区域,不变区域中的数据不管后继如何衍化总是拥有固定的offset(从类对象开头开始算起),所以这一部分数据可以直接存取。共享区域,所表现的就是徐基类对象,这部分数据其位置会因为每次的派生操作而有变化,所以他们只可以被间接存取。
一般布局策略是先建立派生类不变部分,然后再建立共享部分。
cfront编译器的做法是:在每一个派生类对象中安插一些指针,每个指针指向一个虚基类,要存取继承得来的虚基类成员可以通过相关指针来间接完成。这样的做法有两个主要的缺点:1)每个对象必须针对每一个虚基类背负一个额外的指针,然而理想上我们却希望类对象有固定的负担,不因为其虚基类的个数有所改变。2)由于虚拟继承串链的家常导致间接存取层次的增加,这里的意思是如果有三层虚拟派生那么就需要三次间接存取,然而理想上希望有固定的存取时间,不因为派生链的深度而改变。
针对第二个缺点,现有的编译器的做法是将所有内置虚基类的指针都拷贝到派生类对象中,虽然付出了一定的空间代价,以空间换时间。针对第一个缺点,一般而言有两种解决办法,微软编译器提出的解决办法是生成一个虚基类表,每一个类对象如果都有一个或多个虚基类就在编译器中安插一个指针,指针指向虚基类表,真正的虚基类指针会放在虚基类表中。另一种方法是在虚函数表中放置虚基类的偏移量,在这种策略下,虚函数表可以经由正值或负值来索引,正值即所引导虚函数指针,负值则所引导虚基类偏移量。
对于虚基类来说,虚基类带来的时间和空间负担以及复杂性也说明它的机制肯定会随着编译器进化而进化。一般而言虚基类最有效的运用形式就是不含有任何数据成员。

浙公网安备 33010602011771号