目录
0. 前言
1. 普通多继承下,基类和派生类复制转换底层细节(切片)
2. 多继承下的复杂菱形继承
3. 菱形虚拟继承(虚基类)重点
3.1 菱形非虚拟继承对象存储模型
3.2 菱形虚拟继承对象存储模型
3.3 虚拟继承对象存储模型
3.4 多对象继承关系分析其虚基类&虚拟化继承位置
5. 继承的总结和反思
这篇文章主要接上篇文章,从更深层次理解普通继承切片切割以及虚拟继承切片切割,从底部虚拟内存分析,以及分析C++多继承带来的一些问题,和C++解决多继承带来问题采取的方式,并从底层内存观察其逐步实现及原理,最终更深层次感受多继承!并从软件工程分析继承和组合两个概念!!!
派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
那么编译器在普通继承下,处理时如何切片,由上面一题目,看内存分析如下:
可知在编译阶段,对象实例化时,实例化对象只存储成员变量,而成员函数会根据其模板参数、所属类域存储在公共代码段,以便进行调用!
在对象实例化时,通过调试观察其虚拟内存得出,实例化对象会提前在栈区或者堆区开辟好空间,其成员变量在栈区先使用低地址在使用高地址,(如结构体,便于通过偏移量计算成员位置),因此可以绘制出对象d实例化时,内存存储数据模型:
而通过上篇文章可知,默认构造,先构造其基类,在构造子类,而对于多继承其根据继承顺序依次构造,因此先实例化_b1,在实例化_b2,其次实例化_d,因此可以看出由低地址到高地址使用实例化!!!
使用调试,观察其切割切片方式:
- 将&d派生类Derive地址赋值给Base* p1基类指针,此时便会进行切片,切割使用_b1,所以此时p1指向的地址便是原类Derive实例化对象d的地址,但是由于其进行切片,向后只能访问其基类大小个字节,只能访问_b1
- 将&d派生类Derive地址赋值给Base* p2基类指针,此时便会进行切片,切割使用_b2,由于Base2实例化在中间,因此切片时从_b2地址进行切片赋值,向后只能访问其基类大小个字节,只能访问_b2
- 将&d派生类Derive地址赋值给其所属类型的指针变量,此时未发生切片,p3所指向的地址便是整个实例化对象的地址,所以p3的地址便是最开始的地址!!!
最终结果:
p3和p1虽然向后访问数据的偏移量不同,但是所指向同一空间的起始地址&d,_d1,而p2指向同一空间基于Base2实例化的地址,即_b2地址,再根据派生类成员变量内存分布,即可以得出上图结果!!!
总结:
- 对于派生类引用赋值给基类,底层是对指针和解引用的封装,含义不同,内存操作相同!!!
- 对于派生类直接赋值给基类,会直接进行切割赋值
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
菱形继承:菱形继承是多继承的一种特殊情况。
什么是二义性:(多继承和菱形继承都会导致二义性)
如上图:在多继承中class A和class B若是有多个相同数据成员,此时对于class C而言同名的数据成员会产生二义性的问题,需要通过类域对其进行区分,如下代码:
class A { public: A() :_a(1), _same(10) { } int _a; int _same; }; class B { public: B() :_b(1), _same(1) { } int _b; int _same; }; class C : public A,public B{ public: void Print() { //cout << _same << endl;//err _same无法确定是属于哪个类,二义性 } int _c; }; void test() { C c; //cout << c._same << endl;//err _same无法确定是属于哪个类,二义性 }
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。 在Assistant的对象中Person成员会有两份,除了二义性,当间接相同基类成员变量如果占用空间过大,也会浪费内存空间。
class Person { public: string _name; // 姓名 }; class Student : public Person { public: int _num; //学号 }; class Teacher : public Person { public: int _id; // 职工编号 }; class Assistant : public Student, public Teacher { public: string _majorCourse; // 主修课程 }; void Test() { // 这样会有二义性无法明确知道访问的是哪一个 Assistant a; //a._name = "peter"; // 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决 a.Student::_name = "xxx"; a.Teacher::_name = "yyy"; }
我们发现在类Assistant中存在两份的基类Person,分别Assistant存在类Student和类Teacher中,如果数据多则严重浪费空间,也不利于维护, 我们引用基类Person中的数据还需要通过域运算符进行区分。
为了解决以上问题,C++提供了虚基类,也叫做虚拟继承的概念
为了解决上述菱形继承带来的问题,C++中引入了虚基类,其作用是在间接继承共同基类时只保留一份基类成员,虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和Teacher的继承Person时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用。
class Person { public: string _name; // 姓名 }; class Student : virtual public Person { protected: int _num; //学号 }; class Teacher : virtual public Person { protected: int _id; // 职工编号 }; class Assistant : public Student, public Teacher { protected: string _majorCourse; // 主修课程 }; void Test() { Assistant a; a._name = "peter"; }
虚拟继承解决数据冗余和二义性的原理分析
为了研究虚拟继承原理,先给出一个简化的菱形非虚拟继承体系,再借助内存窗口观察对象成员的模型:
class A { public: int _a; }; class B : public A{ public: int _b; }; class C : public A{ public: int _c; }; class D :public B, public C { public: int _d; }; void Test() { D d; d.B::_a = 1; d.C::_a = 2; d._b = 3; d._c = 4; d._d = 5; }
分析上述菱形非虚拟继承对象存储模型,如下:
实例化对象d进行了多继承,其基类B、C为非虚拟继承:
- 首先根据其继承的第一个类调用基类的构造,即调用B的构造,B继承了A,因此B再次调用基类A的构造
- 其实根据其继承的第二个类调用基类的构造,即调用C的构造,C继承了A,因此C再次调用基类A的构造
- 再根据vs内存监视窗口,其虚拟内存,观察其更改顺序,即可的到其非虚拟菱形继承对象存储模型
- 从对象存储模型可观察到非虚拟菱形继承中类A,分别在B类C类各有一份,因此造成二义性和数据冗余
为了研究虚拟继承原理,再给出一个简化的菱形虚拟继承体系,再借助内存窗口观察对象成员的模型:
class A { public: int _a; }; class B : virtual public A{ public: int _b; }; class C : virtual public A{ public: int _c; }; class D :public B, public C { public: int _d; }; void Test() { D d; d.B::_a = 1; d.C::_a = 2; d._a = 0; d._b = 3; d._c = 4; d._d = 5; }
分析上述菱形虚拟继承对象存储模型,如下:
实例化对象d进行了多继承,其基类B、C为虚拟继承,类A为虚基类:
1. 首先根据其继承的第一个类调用其基类的构造,即调用B的构造,B虚拟继承了A,B中指向A的数据就变成了虚基类表的指针,该指针指向一个虚基类表,虚基类表中存储了该指针到公共数据所在内存的偏移量,然后构造B的成员
2. 其次根据其继承的第二个类调用其基类的构造,即调用C的构造,C虚拟继承了A,C中指向A的数据就变成了虚基类表的指针,该指针指向一个虚基类表,虚基类表中存储了该指针到公共数据所在内存的偏移量,然后构造C的成员
3. 最后构造D类的成员变量,此时,实例化对象d,d对象中只有一份数据A,以及其两个基类的虚基类表指针。
简单来说:
- 如果使用非虚拟继承,那么D会从B、C那里继承两份相同的数据。
- 如果使用虚拟继承,那么那两份相同的数据在D类对象中只会存在一份。而D从B、C那里继承的是它们独有的数据以及B和C的虚基类表指针。通过它们各自的虚基类表指针,就可以获取该指针与那份公共数据存储位置的偏移量,进而可以访问它。
当不是菱形状态传递时,有关继承virtual,其虚拟继承的对象存储模型:
class A { public: int _a; }; class B : virtual public A{ public: int _b; }; class C : virtual public A{ public: int _c; }; class D :public B, public C { public: int _d; }; void func(B* bb) { cout << bb->_a << endl; } void Test() { D d; d.B::_a = 1; d.C::_a = 2; d._a = 0; d._b = 3; d._c = 4; d._d = 5; B b; func(&d); func(&b); }
只要是虚拟继承,编译器都会按照虚基类表指针进行编译,才能保证其子类的传递正确性。
当存在多边形对象继承时,其虚基类,一般为最终继承关系中会重复的成员变量所属的类!!!
从上图继承关系可知,构成菱形继承,E类中会造成A类成员变量二义性,因此,需要将类B、C定义虚拟继承解决其二义性和数据冗余。
- 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱 形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设 计出菱形继承。否则在复杂度及性能上都有问题。
- 多继承可以认为是C++的缺陷之一,很多后来的OO语言都没有多继承,如Java。
- 继承和组合
- public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
- 组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
- 优先使用对象组合,而不是类继承 。
- 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称 为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
- 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象 来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复 用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有 些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用 继承,可以用组合,就用组合。
// Car和BMW Car和Benz构成is-a的关系 class Car { protected: string _colour = "白色"; // 颜色 string _num = "陕ABIT00"; // 车牌号 }; class BMW : public Car { public: void Drive() { cout << "好开-操控" << endl; } }; class Benz : public Car { public: void Drive() { cout << "好坐-舒适" << endl; } }; // Tire和Car构成has-a的关系 class Tire { protected: string _brand = "Michelin"; // 品牌 size_t _size = 17; // 尺寸 }; class Car { protected: string _colour = "白色"; // 颜色 string _num = "陕ABIT00"; // 车牌号 Tire _t; // 轮胎 };