Data 语义学(二)
“继承”与 Data Member
-
只要继承不要多态(我认为这里翻译为 没有多态的继承 会更好一点)(Inheritance without Polymorphism)
一般来说,继承带来的影响是 可以共享“数据本身”以及“数据的处理方法”,并将之局部化。(这里怎么理解这个局部化呢?我的理解就是,一般来说,一个类的成员变量都是私有的,但是类会提供访问这些成员变量的接口,这些接口是公开的,但一个类的继承了另一个类之后,派生类虽然在实际上拥有了基类的成员变量,但是逻辑上,派生类对基类成员变量的访问时受到接口的限制的,同时,每一个接口的作用范围都被限定在了声明该接口的类中,并不能对其他类的成员变量进行修改,这应该差不多就是这个意思了。)
一般而言,具体继承(concrete inheritance,非虚拟继承)并不会增加空间或存取时间上的额外负担。
下面来看看常见的两个错误:
-
把原本独立不相干的 classes 凑成一对 “type/subtype”, 并带有继承关系。
一些经验不足的人可能会重复设计一些相同操作的函数,(比如,constructor 或者 operator 之类的),它们并没有被做成 inline 函数。
-
把一个 class 分解为两层或更多层,有可能会为了“表现 class 体系的抽象化” 而膨胀所需的空间。
记住这一很重要的点:C++语言保证 “出现在 derived class 中的 base class object 有其完整性”。
如何理解呢?我们看个例子吧。
// 第一种实现 class test { public: private: int val; // 4 btyes char c1; // 1 btye char c2; // 1 btye char c3; // 1 byte } // 第二种实现 class test1 { private: int val; char c1; } class test2 :public test1 { private: char c2; } class test3 : public test2 { private: char c3; }
我们在看看这两种实现最终的布局
这样,就能够很明显地看出来 从开始的 8字节的 class object 变成了 12 字节的 class object。
那可能有会有人问了,之间把它们拼到一起不行吗?那我们在来看看(通过第二种实现来展示)
现在我们考虑一个场景:
test1 t1 = new test1{}; test2 t2 = new test2{}; // 现在将t1拷贝到t2 t2 = t1; // 这种情况下才用的是 bitwise copy,不了解的可以去看我的前几期的文章
因为采用的 bitwise copy,所以编译器会将 test1 中的所有数据都拷贝到 test2 上,如果是前面的实现方式并不会出现问题,因为在那种布局下,class 与 class 中间是隔离的。但是,现在 test1 和 test2 都是 8字节,拷贝的时候也都是拷贝 8 字节,这就导致了 test1 的数据会覆盖 test2 中的数据,导致原本的
char c2
变成了无效数据了。
-
-
加上多态(Adding Polymorphism)
现在考虑这样一种情况,我们想要在一个继承体系中的不同的类中实现同一个接口,但是这个接口在不同的类中能够体现出不同的行为。比如:
class Point2d { public: void func1(); private: int x, y; } class Point3d : public Point2d { public: void func2(); private: int z; }
在这种情况下,我们想要在
class Point2d
和class Point3d
中通过一样的接口来对 point 进行操作,而不是像上面那样实现一个func1()
和一个func2()
。一种方法是通过重载的方法来实现,另一种是通过虚函数的方法来实现(virtual member function)// 方法一 class Point2d { public: void func1(int, int); private: int x, y; } class Point3d : public Point3d { public: void func(int, int, int); private: int z; }
算了,这里直接上点真的代码会更好一点
class test { public: auto _test(int i) -> void { std::cout << "this is _test(int)\n"; } private: }; class test1 : public test { public: auto _test(int i, int j) -> void { test::_test(1); std::cout << "this is _test(int, int)\n"; } private: }; int main() { test1 t{}; t._test(1,1); test t1{}; t1._test(1); return 0; }
看看输出
可以看到,跟上面一样,如果使用函数重载的话,在重载的函数中(也就是
Pointer3d::func()
中)先调用Point2d::func()
进行二维处理,再处理三维的。其实使用虚函数的话也可以这样子做。现在看看如何用虚函数来实现这个功能吧。
先看看例子是怎么实现的
class Point2d { public: virtual void func(); private: int x, y; } class Point3d : public Point2d { public: virtual void func(); private: int z; }
再看看真正的代码,这里我就不跑了,大家也可以自己 copy 一下跑跑
#if 1 #include <iostream> class test { public: virtual auto _test(int i) -> void { std::cout << "this is _test(int)\n"; } private: }; class test1 : public test { public: #if 0 // 第一种实现方法,跟之前的重载的方式差不多 virtual auto _test(int i) -> void { test::_test(1); std::cout << "this is _test(int, int)\n"; } #else // 我们现在看看第二种吧 // 这种的话是直接重写,不复用基类中的方法 virtual auto _test(int i) -> void { std::cout << "this is _test(int)\n"; std::cout << "this is _test(int, int)\n"; } #endif private: }; int main() { test1 t{}; t._test(1,1); test t1{}; t1._test(1); return 0; } #endif
但是大家注意到 重载 和 虚函数重写 的不同:函数重载需要重载的函数与原函数拥有不同的函数标签,但是 虚函数重写 它们的函数标签是相同的,只是实现不同。
现在我们进入正题:
我们引入了虚函数,这样无论我们是处理二维的点还是三维的点都可以直接调用同一的接口,这使我们的客户使用起来非常简便。这样也使我们的程序拥有了一定的弹性,这种弹性正是面向对象程序设计的核心。既然我们引入了虚函数,那么势必会给我们的 class 带来了额外的负担,究竟有什么负担呢?
- 编译器需要导入一个虚函数表(virtual function table),用来存放该 class 所声明的每一个 virtual function 的地址。这个虚函数表元素的个数一般是被声明的虚函数的个数,再加上上一个或两个 slots (用来支持 runtime type identification)就是 type_info。
- 在每一个 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。
这些都是书上的原话,现在我们再看看作者对于 vptr 的放置的看法吧。
- 放在 class 的开端。(现在流行的方法)。这种方式,对于 在多重继承之下,通过指向 class members 的指针调用 virtual function,会带来一些帮助。否则,不仅 “从 class object 起始点开始量起” 的偏移位置 (offset)必须在执行期就准备妥当,甚至与 class vptr 之间的 offset 也必须准备妥当。简单来说就是,在执行期,编译器会做多很多额外的工作。
- 放在 class 的结尾。可以保留 base class struct c 的对象布局,因而允许在c程序中使用。
-
多重继承(Virtual Inheritance)
单一继承提供了一种“自然多态”(natural polymorphism)形式,是关于 classes 体系中的 base type 和 derived type 之间的转换关系。你会看到 base class 和 derived class 的objects 都是从相同的地址开始的,期间差异只在于 derived object 比较大,用以多容纳它自己的 nonstatic data members。
Point3d p3d; Point2d *p = &p3d;
像上面一样,把一个 derived class object 指定给 base class 的指针或引用(reference)。这个操作并不需要编译器去调停或修改地址。所以它可以很自然地发生,而且提供了最佳的执行效率。
但是,多重继承即不像单一继承,也不容易模塑出其模型。多继承的复杂度在于 derived class 和 其上一个 base class 乃至于上上一个 base class 之间的 “非自然”关系。
看个例子
class Point2d { public: // 拥有virtual接口 private: float _x, _y; }; class Point3d : public Point2d { public: // ... private: float _z; } class Vertex { public: // 拥有virtual接口 private: Vertex *next; }; class Vertex2d : public Point3d, public Vertex { public: // protected: float mumble; } // 它们之间的继承关系 Point2d Vertex | | Point3d | | | Vertex3d
**多重继承的主要问题发生在 派生类对象和它第二或后继的基类对象之间的转换。**不论是直接转换还是通过其所支持的virtual function 机制做转换。
对于一个多重派生类对象,将其地址指定给第一个基类的指针,情况将和单一继承相同,因为它们都用相同的起始地址。但是,从第二个开始,就需要将地址进行修改(需要编译器进行)
Vertex3d v3d; Vertex *pv; Point2d *p2d; Point3d *p3d; // 1. pv = &v3d; // 需要编译器在内部进行转换 pv = (Vertex*)(((char*)&v3d) + sizeof(Point3d)); // 2. p2d = &v3d; p3d = &v3d; // 它们只需要简单地拷贝就行了。 // 3. Vertex3d *pv3d; Vertex *pv; pv = pv3d; // 不能够简单地被转化为 pv = (Vertex*)((char*)pv3d + sizeof(Point3d)); // 需要进行判空 pv = pv3d ? (Vertex*)((char*)pv3d + sizeof(Point3d)) : 0;
还剩最后一个 虚拟继承 ,因为内容有点多,需要时间梳理,所以明天在写了。
v3d;
p3d = &v3d;
// 它们只需要简单地拷贝就行了。
// 3.
Vertex3d pv3d;
Vertex pv;
pv = pv3d;
// 不能够简单地被转化为
pv = (Vertex)((char)pv3d + sizeof(Point3d));
// 需要进行判空
pv = pv3d ? (Vertex*)((char*)pv3d + sizeof(Point3d)) : 0;
还剩最后一个 虚拟继承 ,因为内容有点多,需要时间梳理,所以明天在写了。