当前位置:首页 » 《休闲阅读》 » 正文

C++大坑之——多继承(菱形继承)

27 人参与  2024年11月02日 18:01  分类 : 《休闲阅读》  评论

点击全文阅读


在这里插入图片描述

文章目录

前言一、多继承是什么?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中,多了一串奇奇怪怪的东西,如下图所示:
在这里插入图片描述
我们分别进入这两个地址,0x002B7B480x002B7B53,如下如所示:
在这里插入图片描述

这个地方红色框框出来的实际上就分别是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!]
在这里插入图片描述


点击全文阅读


本文链接:http://zhangshiyu.com/post/181635.html

<< 上一篇 下一篇 >>

  • 评论(0)
  • 赞助本站

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

关于我们 | 我要投稿 | 免责申明

Copyright © 2020-2022 ZhangShiYu.com Rights Reserved.豫ICP备2022013469号-1