文章目录
- 前言
- 一、多继承是什么?
- 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 A
class B : virtual public A
{
public:int _b;
};// class C : public A
class 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!]