文章目录
前言一、多继承是什么?1. 多继承概念2. 多继承语法 二、菱形继承1. 为什么会有菱形继承问题?2. 代码感受菱形继承3. 虚拟继承1)虚拟继承概念及语法2)虚拟继承的原理 4. 为什么要有虚基表?5. 为什么要有偏移量?6. 关于解决数据冗余 三、小试牛刀四、库里的菱形继承❤️继承的总结和反思
前言
前面学习了继承的概念与语法,今天我们一起来看看C++中的大坑——菱形继承?
一、多继承是什么?
1. 多继承概念
多继承是指一个类可以同时继承多个父类的特性。在这种情况下,子类能够访问和使用其所有父类的方法和属性。
这样理解:现实生活中,子类可能会继承多个父类,比如骡子是由马和驴所生的,他同时继承了马和驴的一些特征。
这种特性在一些面向对象编程语言(如C++)中是允许的,但在其他语言(如Java)中则被限制为单继承。
我们再通过下面这个例子区分一下单继承和多继承:
这是单继承,一个子类只有一个直接的父类,他也只有这一个直接父类的成员
这是多继承,及子类同时具有两个及以上的直接父类,他有所有直接父类的成员
2. 多继承语法
多继承的基本语法是:class 子类 : 继承方式 父类1,继承方式 父类2…
现在有这样一种情况:
#include<iostream>using namespace std;class Student{public:int _num; //学号int _age; //年龄};class Teacher{public:int _id; // 职工编号int _age; //年龄};class Assistant : public Student, public Teacher{public:string _majorCourse; // 主修课程int _age; //年龄};int main(){Assistant as;as.Student::_age = 18;as.Teacher::_age = 30;as._age = 19;return 0;}
他是这样继承的,如下图所示:
也可以很清楚的看到,这里有三份年龄都不一样,这就是多继承
二、菱形继承
1. 为什么会有菱形继承问题?
假设有这样一个继承的样子:
两个类同时继承一个父类,他们呢又有同样一个子类,就会形成菱形继承
我们来看一下菱形继承的对象模型:
这会导致什么现象呢?其实刚刚多继承的那个例子已经有所铺垫了,作为子类,他有两个基类的成员,就造成了(1)数据冗余,(2)二义性
在Assistant的对象中Person成员会有两份。
这三种形式都属于菱形继承,
也就是说继承只要形成了闭环,就是菱形继承!
2. 代码感受菱形继承
class Person{public:string _name; // 姓名};class Student : public Person{protected:int _num; //学号};class Teacher : public Person{protected:int _id; // 职工编号};class Assistant : public Student, public Teacher{protected:string _majorCourse; // 主修课程};
直观的感受,这里有一个很大的问题:
假设我要使用a._name,就会有二义性,无法具体确定_name访问的是哪个父类成员的_name,但是二义性好解决,指定作用域就可以。但是,对于数据冗余的问题依然解决不了。
3. 虚拟继承
1)虚拟继承概念及语法
因此,就出现了虚拟继承!
虚拟继承是一种在C++中解决菱形继承问题的机制。当一个子类通过多个父类继承同一个祖先类时,会导致潜在的二义性(即“钻石问题”)。虚拟继承通过确保只有一份祖先类的实例存在,来避免这种问题。
主要特点:
语法:在继承时使用关键字virtual
来声明父类。例如:
class A {};class B : virtual public A {};class C : virtual public A {};class D : public B, public C {};
注意,这里是在腰部进行virtual关键字,最下面的儿子以及祖先都不写!
共享实例:虚拟继承确保无论通过哪个路径继承,只有一个A的实例存在于D中。
构造顺序:虚拟基类的构造函数在所有派生类构造之前被调用,确保它的成员被初始化。
访问:在虚拟继承中,派生类可以通过虚拟基类来访问祖先类的成员,避免了命名冲突。
优点:
消除了菱形继承带来的二义性,以及数据冗余提高了代码的可维护性和可读性。2)虚拟继承的原理
为了研究虚拟继承原理,我们给出了一个简化的菱形继承继承体系,再借助内存窗口观察对象成员的模型。
class A{public:int _a;};// class B : public Aclass B : virtual public A{public:int _b;};// class C : public Aclass C : virtual public A{public:int _c;};class D : public B, public C{public:int _d;};
这个继承方式是这样的:A里有_a,B里有_b,C、D同理:
对于它内部内存的管理:
先来进行初始化:
int main(){D d;d.B::_a = 1;d.C::_a = 2;d._b = 3;d._c = 4;d._d = 5;return 0;}
对于普通菱形继承,不使用virtual关键字是这样的:
可以看到,它没有什么不同,就是按照顺序连续存储的,有两个A就造成了数据冗余与二义性。
对于带virtual关键字的菱形继承:
首先,我们可以发现,A只有一份了,然后这个公有的A被放到了最下面,这样就解决了二义性的问题
其次,在B和C中,多了一串奇奇怪怪的东西,如下图所示:
我们分别进入这两个地址,0x002B7B48
与0x002B7B53
,如下如所示:
这个地方红色框框出来的实际上就分别是B到A与C到A的偏移量!
我们叫它虚基表!!!
虚基表中红框部分存了偏移量,第一行是预留的,目前第二行是有效的。使用白框中的地址就可以找到偏移量,最终可以定位到A类中去!
4. 为什么要有虚基表?
为什么要有一个虚基表呢?下面这里白框的部分难道不能直接存A的地址吗?
原因有一下两个场景:
场景一:我们这里共同的只有一个A类,因此对于这里来说看不出差别,但是假设我还有其他的值要存呢?假设我还有EFG…要存在这里呢?
因此我们引入了虚基表,这些偏移量全部存到一个虚基表里边去,子类对象里只存虚基表的地址,利用偏移量来寻找所需的A。
场景二:我们这里只定义了一个d对象,假设我还有一个d1呢?这两个对象是一模一样的结构,它们相对偏移量的关系也是相等的,有了虚基表就可以传同一份虚基表的地址,通过相同的偏移量来找到A。
如下图:我们可以看到d与d1虚基表的地址是一样的!!!
5. 为什么要有偏移量?
那为什么又需要偏移量来找呢?
请问下面这段代码需不需要用到偏移量?
D d;d._a = 1;
答案:不需要。
作为虚拟继承的它,编译器直到它的A在最下面,找到时候就直接去最下面找就可以了。
那什么时候会用到偏移量呢?
下面我也给出两个场景:
场景一:切片。假如有这样一段代码。
D d;
B b = d;
那么这个b作为父类就要去找d中相应的部分进行切片,但是d中是这样存的:
B的部分除了最上面蓝色的框还有最下面的A,因此找A就需要进行偏移量来找到。
场景2:
假设有这样一串代码:
D d;d._a = 1;B b;b._a = 2;b._b = 3;B* ptr = &b;ptr->_a++;ptr = &d;ptr->_a++;
首先我们要知道,作为虚拟继承,不只是D的模型,连B的模型结构都变了,他变得与C保持一致。
如图:
b的模型已经不再是纯粹的,他也有虚基表,它的A也在最下面。
那么就会引发出一个问题,假设有这样的代码:
单看这里两个蓝色框里的代码,从表面看没有任何差异,对于编译器来说他并不知道实在调用b还是在调用d,因此只要我们取出偏移量,就可以根据偏移量来计算找到A。
我们可以来看一下汇编,这里是一模一样的,唯独偏移量不同:
因此,虚基表和偏移量都是必须的!!!
6. 关于解决数据冗余
但从下面这张图来说,好像没有解决数据冗余的问题。
但是,假设 _a是个数组呢?_a[10086],那么普通继承会继承很多份_a[10086],但是虚拟继承只继承一份,所以还是解决了数据冗余的问题。
三、小试牛刀
请问p1, p2, p3的关系是什么?class Base1 { public: int _b1; };class Base2 { public: int _b2; };class Derive : public Base2, public Base1 { public: int _d; };int main() {Derive d;Base1* p1 = &d;Base2* p2 = &d;Derive* p3 = &d;return 0;}
分析如图:
谁先继承谁在上面L:
p1与p3所指向位置是一样的,但是p1与p3含义不同,p2在它们下面。
因此p1 = p3 != p2
请问下面代码打印顺序是什么?
#include<iostream>using namespace std;class A {public:A(const char* s) { cout << s << endl; }~A() {}};class B :virtual public A{public:B(const char* sa, const char* sb) :A(sa) { cout << sb << endl; }};class C :virtual public A{public:C(const char* sa, const char* sb) :A(sa) { cout << sb << endl; }};class D :public B, public C{public:D(const char* sa, const char* sb, const char* sc, const char* sd) :B(sa, sb), C(sa, sc), A(sa){cout << sd << endl;}};int main() {D* p = new D("class A", "class B", "class C", "class D");delete p;return 0;}
答案是:
这里考了两个点:
虚拟继承只继承一份初始化列表顺序与构造顺序无关,谁先声明谁先构造。四、库里的菱形继承
其实我们iostream就是一种菱形继承,库里的大佬驾驭得住,我们在实战中还是要尽量避免使用。
❤️继承的总结和反思
组合与继承的关系
多继承的复杂性
很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
多继承的缺陷
多继承可以认为是C++的缺陷之一,很多后来的OO语言都没有多继承,如Java。
继承和组合
继承
public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
组合
组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
优先使用对象组合,而不是类继承。
继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。
组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。
到这里就结束啦,创作不易,佬们三连支持一波??????<( ̄︶ ̄)↗[GO!]