在 C++ 继承模型中,一个 derived class object 所表现出来的东西,是其自己的 members 上其 base class(es) meinbers 的总和。至于 derived class members和 base classies) members 的排列次序并未在 C++ Standard 中强制指定:理论上编译器可以自由安排之。在大部分编译器上头,base class members 总是先出现但属于 virtual base class 的除外 (一般而言,任何一条规则一旦碰上 virtual baseclass 就没辄儿,这里亦不例外)
- 单一继承且不含 virtual
- 单一继承并含 virtual functions
- 多重继承
- 虚拟继承functions
一、只要继承不要多态
图3.1a 就是 Point2d 和 Point3d 的对象布局图,在没有 virtual functions 的情况下(如本例),它们和 C struct 完全一样。
图3.1a 单一继承且不含 virtual
具体继承 (concrete inheritance,译注:相对于虚拟继承 virtual inheritance) 并不会增加空间或存取时间上的额外负担。图3.1b 显示 Point2d 和 Point3d 继承关系的实物布局,其间并没有声明 virtual 接口。
图3.1b 单一继承且不含 virtual
🍌 把两个原本独立不相干的 classes 凑成一对“type/subtype并带有继承关系,会有什么易犯的错误呢?
-
重复设计一些相同操作的函数。
-
空间开销增大。为了“表现 class 体系之抽象化”,把一个 class 分解为两层或更多层,有可能会引入更多的空间开销。因为C++ 语言保证“出现在 derived class 中的 base class subobiect 有其完整原样性”。
🍉考虑下面的例子,在一部 32 位机器中,每-个 Concrete class object 的大小都是 8 bytes,细分如下:
1.val 占用 4 bytes;
2.c1、c2 和 c3 各占用 1bytes;
3.alignment(调整到 word 边界)需要1 bytes.
class Concrete {
public:
// ...
private:int val;char c1;char c2;char c3;
}
但当我们将之拆分为三个拥有继承关系的类时,空间布局便会发生如下变化。可以看到其alignment变的更多了。 Concrete2 的 bit2 实际上却是被放在填补空间所用的 3 bytes 之后于是其大小变成 12 bytes,不是 8 bytes。其中有 6 bytes 浪费在填补空间上。相同的道理使得 Concrete3 obiect 的大小是 16 bytes,其中 9 bytes 用于填补空间。
为什么要这么设计呢?为什么把derived class members捆绑在一起呢?这样不是可以节省更多的空间呢?发明的前辈当然也考虑到了这些你的疑问。只不过,在权衡之后,他们希望牺牲一部分空间代价,让不熟悉OO继承类的c++新手可以避免预期之外的bug。下面是一个错误的例子:
让我们声明以下一组指针:
Concrete2 *pc2;
Concrete1 *pc1_l,*pc1_2
其中 pc1_1和pc1_2 两者都可以指向上述三种 classes objects。下面这个指定操作:
*pc1_2 = *pc1_1;
应该执行一个默认的“memberwise”复制操作(依次复制每个 members对象)。如果 pc1_1 实际指向一个Concrete2 object 或 Concrete3 object,则上述操作应该将复制内容指定给其Concrete1 subobject。
然而,如果 C++ 语言把 derived class members(也就是 Concrete2::bit2 或 Concrete3::bit3)和 Concretel subobject 捆绑在一起,去除填补空间。也就是说,编译器把 base class object 原本的填补空间让出来给derived class members 使用。
那么上述那些语意就无法保留了。因为当发生 Concrete1 subobject 的复制操作时,就会破坏 Concrete2 nembers::
pc1_1 = pc2; // 译注:令 pc1_1 指向 Concrete2 对象
// Oops : derived class subobject 被覆盖掉
// 于是其 bit2 member 现在有了一个并非预期的数值
*pc1_2 = *pc1_1;
所以,通过为每一层对象预留alignment,可以保证指向不同对象的指针,在进行拷贝时,可以保证出现在 derived class 中的 base class subobiect 有其完整原样性。
二、加上多态
🍇 意味着结构体会发生以下的变化:
-
增加一个 virtual table,用来存放它所声明的每一个 virtual functions 的地址。这个 table 的元素数目一般而言是被声明的 virtual functions 的数目,再加上一个或两个 slots (用以支持 runtimetype identification )
-
在每一个 class object 中导人一个 vptr,提供执行期的链接,使每一个object 能够找到相应的 virtual table。
-
加强 constructor,使它能够为 vptr 设定初值,让它指向 class 所对应的 virtual table。这可能意味着在 derived class 和每一个 base class 的constructor 中,重新设定 vptr 的值。
-
加强 destructor,使它能够抹消“指向 class 之相关 virtual table”的vptr。要知道,vptr 很可能已经在 derived class destructor 中被设定为derived class 的 virtual table 地址。记住,destructor 的调用次序是反向的:从 derived class 到 base class。
上述新的(与 p.101比较)Point2d 和 Point3d 声明,最大一个好处是,你可以把 operatort= 运用在一个 Point3d 对象和一个 Point2d 对象身上:
Point2d p2d(2.1,2.2):
Point3d p3d(3.1,3.2,3.3);
p3d += p2d;
得到能 p3d 新值将是 (5.2, 5.4, 3.3);
虽然 class 的声明语法没有改变,但每一件事情都不一样了:
-
两个 z0 member functions 以及 operator+=0运算符都成了虚拟函数;
-
每一个 Point3d class object内含一个额外的 vptr member (继承自 Point2d);
-
多了一个 Point3d virtual table;
-
此外,每一个 virtual member function 的调用也比以前复杂了。
三、多重继承
单一继承提供了一种“自然多态(natural polymorphism)”形式,是关于 classes体系中的 base type 和 derived type 之间的转换。
如果是非虚拟(3.1a)或者是vptr后端放置(3.2b):
-
base class 和 derived class 的 objects 都是从相同的地址开始,其间差异只在于 derived object 比较大,用以多容纳它自己的 nonstatic datamembers。
-
把一个 derived class object 指定给 base class (不管继承深度有多深)的指针或reference。该操作并不需要编译器去调停或修改地址。它很自然地可以发生,而且提供了最佳执行效率。
Point3d p3d;
Point2d *p = &p3d;
如果是vptr前端放置:
-
如果 base class 没有 virtual function 而 derived class 有(译注:正如图 3.2b),那么单一继承的自然多态(natural polymorphism)就会被打破。在这种情况下,把一个 derived object 转换为其 base 类型,就需要编译器的介入,用以调整地址(因 vptr 插人之故)。
-
在既是多重继承又是虚拟继承的情况下,编译器的介人更有必要多重继承既不像单一继承,也不容易模塑出其模型。多重继承的复杂度在于derived class 和其上一个 base class 乃至于上上一个 base class······之间的“非自然”关系。
例如,考虑下面这个多重继承所获得的 class Vertex3d:
对一个多重派生对象,将其地址指定给“最左端(也就是第一个)base class 的指针”,情况将和单一继承时相同,因为二者都指向相同的起始地址。需付出的成本只有地址的指定操作而已 (图 3.4 显示出多重继承的布局) 。
至于第二个或后继的 base class 的地址指定操作,则需要将地址修改过:加上 (或减去,如果downcast 的话)介于中间的 base class subobject(s) 大小,例如下面的指定操作:
Vertex3d *v3d;
Vertex *pv;// 译注:原书命名为*pp,不符合命名原则,改为*p2d 较佳pv = &v3d;
// 虚拟 C++ 码
pv = (Vertex*)(((char*)&v3d) + sizeof( Point3d ));
实际情况可能会更加复杂,因为可能需要对空指针进行保护:
// 虚拟 C++ 码
pv = pv3d ? (Vertex*)((char*)pv3d) + sizeof( Point3d ) : 0;
四、虚继承
多重继承的问题在于无法解决“shared subobjcct 继承”。
一个典型的例子是最早的 iostream library: 不论是 istream 或 ostream 都内含一个 ios subobject。然而在 iostream 的对象布局中,我们只需要单一一份 ios subobiect 就好。语言层面的解决办法是导人所谓的虚拟继承。
class ios { ... }
class istream : public virtual ios { ... }
class ostream : public virtual ios { ... }
class iostream : public istream, public ostream { ...}
一般的实现方法如下所述。Class 如果内含一个或多个 virtual base class subobjects,像 istream 那样,将被分割为两部分:一个不变局部和一个共享局部。
-
不变局部中的数据,不管后继如何衍化,总是拥有固定的 offset(从 object 的开头算起),所以这一部分数据可以被直接存取。
-
至于共享局部,所表现的就是 virtual base class subobject。这一部分的数据,其位置会因为每次的派生操作而有变化所以它们只可以被间接存取。
各家编译器实现技术之间的差异就在于间接存取的方法不同。以下说明三种主流策略。
一般的布局策略是先安排好 derived class 的不变部分,然后再建立其共享部分。
然而,这中间存在着一个问题:如何能够存取 class 的共享部分呢? cfront 编译器会在每一个 derived class obicct 中安插一些指针,每个指针指向一个 virtual base class。要存取继承得来的 virtual base class members,可以使用相关指针间接完成。举个例子,如果我们有以下 derived class 和 base class 的实例之间的转换,在 cfront 实现模型之下,会变成:
Point2d *p2d = pv3d;
Point2d *p2d = pv3d ? pv3d->_vbcpoint2d : 0;
🍓这样的实现模型有两个主要的缺点:
1.每一个对象必须针对其每一个 virtual base class 背负一个额外的指针。然而理想上我们却希望 class object 有固定的负担,不因为其 virtualbase classes 的数目而有所变化。想想看这该如何解决?
2.由于虚拟继承串链的加长,导致间接存取层次的增加。这里的意思是如果我有三层虚拟衍化,我就需要三次间接存取 (经由三个 virtual baseclass 指针)。然而理想上我们却希望有固定的存取时间,不因为虚拟行化的深度而改变。
至于第一个问题,一般而言有两个解决方法。
-
virtual base class table。每一个 class object 如果有一个或多个 virtual base classes就会由编译器安插一个指针,指向 virtual base class table。至于真正的 virtual bas eclass 指针,当然是被放在该表格中。
-
可以在 virtual function table 中放置 virtual base class 的 offset(而不是地址)。将 virtual base class offset 和 virtual function entries 混杂在一起。virtual function table 可经由正值或负值来索引。(在最后的附件里有所体现)
-
如果是正值,很显然就是索引到 virtual functions;
-
如果是负值,则是索引到virtual base class offsets。
-
在这样的策略之下,Point3d 的 operator+= 运算符必须被转换为以下形式 (为了可读性,我没有做类型转换,同时我也没有先执行对效率有帮助的地址预先计算操作)
// 虚拟 C++ 码
(this + __vptr_Point3d[-1])->_x += (&rhs + rhs.__vptr_Point3d[-1])->_x;
(this + __vptr_Point3d[-1])->_y += (&rhs + rhs.__vptr_Point3d[-1])->_y;
_z += rhs._z;
第二个问题,它们经由拷贝操作取得所有的 nested virtual base class 指针,放到 derivedclass object 之中。这就解决了“固定存取时间”的问题,虽然付出了一些空间上的代价。