继承与 Data Member(2)
加上多态的情况
如果我要处理一个坐标点, 而不在意这是一个 Point2d 或 Point3d 实例, 那么就需要在继承关系中提供一个 virtual function 接口:
class Point2d { public: Point2d(float x = 0.0, float y = 0.0) :_x(x), _y(y){}; //x 和 y 的存取函数与前一个博客中相同 //由于对不同维度的点, 这些函数操作固定不变, 所以不必设为 virtual virtual float Z()(float){} //设定以下的运算符为 virtual virtual void operator+=(const Point2d &rhs) { _x += rhs.X(); _y += rhs.X(); } //...more operations protected: float _x, _y; };
这样的好处就是可以让不同维度之间的点进行运算, 当然具有这些弹性的代价就是空间和存取时间的额外负担:
1. 导入一个和 Point2d 有关的 virtual table, 用来存放它所声明的每一个 virtual functions 的地址。这个 table 的元素数目一般而言是被声明的 virtual functions 的数目, 再加上一个或两个 slots(用以支持 runtime type identification)
2. 在每一个 class object 中导入一个 vptr, 提供执行期的链接, 使每一个 object 能够找到相应的 virtual table。
3. 加强 constructor, 使它能够为 vptr 设定初值, 让它指向 class 所对应的 virtual table。 这可能意味着在 derived class 和每一个 base class 的 constructor 中, 重新设定 vptr 的值, 其情况视编译器的优化的积极性而定。
4加强 destructor, 使它能够抹掉指向 class 的相关 virtual table 的 vptr。因为 vptr 很可能已经在 derived class destructor 中被设定为 derived class 的 virtual table 地址。值得注意的是, destructor 的调用是反向的, 与栈的行为类似。一个积极的优化编译器可以压抑那些大量的制定操作。
这些额外负担带来的冲击程度视被处理的 Point2d objects 的数目和生命期而定, 也视对这些 objects 做多态程序设计所带来的利益而定, 要是一个应用程序知道它所能使用的 point objects 只限于二维坐标点或三维坐标点, 那么这种设计所带来的负担就让人无法接受。
//新版本的 Point3d 声明 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() override {return _z;} void Z(float z) override {_z = z;} void operator+=(const Point3d &rhs) { Point2d+=(rhs); _z += rhs.Z(); } //...more members protected: float _z; };
上述声明做的最大好处就是可以在无视点的类型的前提下进行自增运算, 同时代价就是多了许多虚拟函数, 每一个 Point3d class object 内含一个 vptr member, 每一个 virtual member function 的调用也比以前复杂了。
目前的 C++ 编译器的领域里有一个主要讨论的题目:把 vptr 放置在 class object 的哪里会最好? 在 cfront 编译器里, 它被放在 class object 的尾端, 用以支持以下的继承类型:
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;
其数据布局如图:
把 vptr 放在编译器尾端, 可以保留 base class C struct 的对象布局, 因而允许在 C 程序代码中也能使用。 这种做法在 C++ 最初问世时, 被许多人采用, 但是由于 OOP 的兴起, 某些编译器开始把 vptr 放到 class obejct 的开头处,这对于在多重继承下, 通过指向 class members 的指针调用 virtual function会带来一些帮助。否则, 不仅从 class object 起始点开始量起的 offset 必须在执行期备妥, 甚至与 class vptr 之间的 offset 也必须备妥。 当然, 其代价就是放弃了与 C 的兼容性。
放在开头的数据布局:
多重继承
单一继承提供了一种自然多态的形式, 是关于 class 体系中的 base type 和derived type 之间的转换, 观察上一篇博客的图可以发现, base class 和 derived class 的 objects 都是从相同的地址开始, 其间差异只在于 derived object 比较大, 用以多容纳它自己的 nonstatic data members。下面这样的制定操作:
Point3d *p3d;
Point2d *p = &p3d;
把一个 derived class 指定给一个 derived class 的指针或 reference, 该操作并不需要编译器去调停或修改地址, 他很自然地发生, 并提供了最佳执行效率。
之前的一种编译器把 vptr 放在 class object 的起始处, 如果 base class 没有 virtual function 而 derived class 有, 那么单一继承的自然多态就会被打破, 这种情况下, 把一个 derived object 转换为其 base 类型就需要编译器的介入, 用以调整地址(vptr 插入之故)。 在既是多重继承又是虚拟继承的情况下, 编译器的介入更为重要。
多重继承不像单一继承, 也不容易画出其模型, 多重继承的复杂度在于 derived class 和其上一个 base class 乃至于上上个 base class 之间的非自然联系, 例如:
class Point2d { public: //... protected: float _x, _y; }; class Point3d: Point2d { public: //... protected: float _z; }; class Vertex { public: //... protected: Vertex *next; }; class Vertex3d: public Point3d, public Vertex { public: //... protected: float memble; };
而多重继承的问题主要发生于 derived class objects 和其第二或后继的 base class objects 之间的转换, 无论是直接转换如下:
extern void memble(const Vertex);
Vertex2d v;
...
mumble(v);
或者经由其所支持的 virtual function 机制做转换。
对一个多重派生对象, 将其地址指定给第一个 base class 的指针, 情况将和单一继承时相同, 因为二者都指向相同的起始地址, 需付出的成本只有地址的制定操作而已。 至于第二个或后继的 base class 的地址制定操作, 则需要降低至修改过: 加上或减去介于中间的 base class subobject(s) 大小, 如:
Vertex3d v3d; Vertex *pv; Point2d *p2d; Point3d *p3d; 那么下面的这个操作: pv = &v3d; //需要的转化 pv = (Vertex*)(((char*)&v3d) + sizeof(Point3d)); //更多需要转化的操作 p3d = &v3d; p2d = &v3d; 就只需要简单的拷贝其他地址就行了, 但如果有两个指针如下: Vertex3d *pv3d; Vertex *pv; 那么下面的指定操作: pv = pv3d; 不能够只是简单的转换为: pv = (Vertex*)((char*)pv3d) + sizeof(Point3d); 因为如果 pv3d 为 0,之前的代码中 pv = (Vertex*)(sizeof(Point3d)), 显然不成立。所以这就需要一个条件测试: //更为严谨的可能代码 pv = pv3d ?(Vertex*)((char*)pv3d + sizeof(Point3d)) :0;
当然你可能会问, 之前的 reference 为什么不需要防卫? 因为 reference 不可能引用“无”。
多重继承的数据布局可以表述如下图:
C++ standard 实际上并未要求 Vertex3d 中的 base classes Point3d 和 Vertex 有特定的排列次序。 原始的 cfront 编译器是根据声明的次序来排列它们。 因此 cfront 编译器制作出来的 Vertex3d 对象可以被视为是一个 Point3d 的子类对象(subobject, 其中实际还有一个 Point2d subobject) 加上一个 Vertex object, 最后再加上自己的部分。 目前的各个编译器仍然以此方式完成多重 base class 布局(当然, 是在没有虚继承的前提下)
某些编译器(如 MetaWare) 设计有一种优化技术, 只要第二个(或后继) base class 声明了一个 virtual function, 而第一个没有, 就把多个 base class 的次序调换。 这样可以在 derived class object 中少产生一个 vptr。 但这项优化技术未得到全球厂商的认可,因此并不普及。
那如果要存取第二个(或后继) base class 中的一个 data member, 将会是怎样的情况? 需要额外的付出吗? 不, 因为 members 的位置在编译期就固定了, 因此存取 members 只是一个简单的 offset 运算, 就像单一继承一样简单, 不管是经由一个指针, 一个 reference 或是一个 object 来存取。