[C++进阶]多继承和菱形继承

上一篇我们讲的继承其实是C++中的单继承

即一个子类只有一个直接父类的时候称这个继承关系为单继承。

一、多继承

一个子类有两个或以上直接父类时称这个继承关系为多继承‘

多继承即认为一个对象可能同时有其他两个或以上对象的属性所设计出来的。

class Student
{
protected:
	int _num; //学号
};
class Teacher
{
protected:
	int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse; // 主修课程
};
int main()
{
	Assistant at;
	return 0;
}

如上代码所示,Assistant继承了Student,Teacher两个类的属性

二、菱形继承

虽然多继承看似很合理,但是多继承引发了一种新的问题——菱形继承

菱形继承是多继承的一种特殊情况

举个例子:

这里我们会发现西红柿最后有两个植物的标签

从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。在Assistant的对象中会有两份Person成员

菱形继承导致的问题也正是如此。如下代码所示,就会产生二义性

#include<iostream>
using namespace std;
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; // 主修课程
};

void Test()
{
	Assistant a;
	a._name = "peter";
}

此处的二义性,我们还可以通过类域指定访问去处理

我们还要注意这种情形:我们如果指定的是父类的父类的话,编译器是可以通过的。

此时的结果究竟指向哪个Student里的父类还是Person的父类呢?不妨我们测试一下 

当我们运行到这时Student内为zhangsan,这时如果王五运行完了,

Student内变为王五。

其实是跟继承的顺序有关的,我们写多继承的时候先是继承了Student,所以Student里面的Perosn中的_name会被修改为王五

三、菱形虚拟继承

在前面,我们得知了由多继承导致的菱形继承中的一些问题:数据冗余和二义性
为了解决这个问题,C++又出来了一个菱形虚拟继承

菱形虚拟继承只需要在菱形继承中的腰部位置(即Student和Teacher类)添加关键词virtual即可

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; // 主修课程
};

有了菱形虚拟继承,我们可以不需要指定类域去访问_name,我们的Person在监视窗口看好像是存了三份,并且我们修改数据的时候三份同时进行修改。

#include<iostream>
using namespace std;
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; // 主修课程
};
int main()
{
	Assistant a;
	a._name = "lier";
	a._name = "zhangsan";
	return 0;
}

虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和Teacher的继承Person时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用。

四、菱形虚拟继承的底层原理

在菱形虚拟继承中:

  • Assistant的对象中Person成员只存在一份,并且存在于对象的最底部
  • Student和Teather部分会存在各自的指针,该指针(虚基表指针)指向一份表(虚基表),其中记录了各自相对于Person部分偏移量(通过偏移量就能找到下方的Person部分)

我们可以用如下代码进行研究:

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;
};
int main()
{
	D d;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;
	return 0;
}

上面是一个普通的菱形继承,它的运行结果应为如下所示

这里我们要注意,我们看似这里好像是给包了起来。实际中是连续的内存进行存放的

如上是菱形继承的底层内存分布

接下来看菱形虚拟继承的底层分布
我们还是上面的例子,只不过将其改为菱形虚拟继承

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;
};
int main()
{
	D d;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;
	return 0;
}

这样的话,我们从监视窗口看好像是有三份,其实不然,这里并非三份。而是一份

我们从内存窗口可以更加精确的观测到内存的变化,我们发现,a似乎只有一份,且对象模型发生非常大的变化,也就是说,现在的A既不在B也不在C。这里倒是还可以理解,因为为了解决数据冗余二义性,它需要放到其他位置上,具体方最上面还是最下面是取决于编译器自己规定的。但是B和C里面似乎又多了一些东西,这些东西又是什么呢?

我们注意到我们的偏移量是存在第二个里面,第一个里面是0,这个0是为其他值预留的。如果还有其他值的话,可以直接存进来。 因为菱形虚拟继承的,可能不止这一个。
而且这里仅仅只是一个类型,我们可能要实例化很多个对象,每个对象如果都是直接存储的话,那么代价太大了,不如直接开辟一个空间将偏移量全部放进去,然后每个对象只有一个指针指向即可。这里的映射,我们有时候也称之为虚基表(寻找基址偏移量的表)

具体的思路大家可惜去看看其他大佬的讲解

总之,编译器始终先取到偏移量,然后计算出_a的地址,最后在访问。

五、菱形虚拟继承对于空间的优化

当我们不使用菱形虚拟继承的时候

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;
};

int main()
{
	cout << sizeof(D) << endl;
	return 0;
}

运行结果:

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;
};

int main()
{
	cout << sizeof(D) << endl;
	return 0;
}

运行结果:

我们会发现,为什么菱形虚拟继承所消耗的空间比虚拟继承消耗的空间还大呢?菱形虚拟继承不是都已经解决了数据冗余了吗?

我们先分析一下我们的对象模型
下面是菱形继承的
刚好是五个整型,所以是20符合我们的计算结果

5*4=20

下面是菱形虚拟继承的
可以看到,果然是24

不过这里其实是节省了的,总体而言相当于我们是节省四字节,但是花费了八字节
不过我们这里的八字节是恒定的,因为用的是指针,节省的四字节是B和C中的A是四字节的,但是又因为要在最底层多出一个A,所以总体就是节省了一个A类的对象。也就是节省了四字节

那么如果我们将A所消耗的空间变大,那么是不是从商业的角度来看,就开始盈利了。

在菱形虚拟继承下
我们先看当A的成员变量为100个元素的大小的时候,消耗420个空间,为8+8+4+400

class A
{
public:
	int _a[100];
};
//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;
};

int main()
{
	cout << sizeof(D) << endl;
	return 0;
}

运行结果:

在菱形继承下,为812个空间,为4+400+4+400+4

class A
{
public:
	int _a[100];
};
//class B : public A
	class B : public A
{
public:
	int _b;
};
//class C : public A
	class C : public A
{
public:
	int _c;
};
class D : public B, public C
{
public:
	int _d;
};

int main()
{
	cout << sizeof(D) << endl;
	return 0;
}

运行结果:

可见确实是由8个字节换取了A的成员变量的大小

六、多继承和菱形继承中的一些细节

我们看如下代码。试问p1、p2、p3之间的关系是什么

class Base1 { public:  int _b1; };
class Base2 { public:  int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };

int main() {
	Derive d;
	Base1* p1 = &d;
	Base2* p2 = &d;
	Derive* p3 = &d;
	return 0;
}

要知道这个问题,我们得先知道的一点是,多继承的对象存放中,先继承的在上面。

所以他们的指向关系为如下所示。首先前两个是切片,所以他们看到的就是他们类里面的部分,所以都指向自己的部分,而p3不是切片,它关心的是整个对象。所以也在最上面起始处。

所以最终为p1==p3!=p2

那么如果我们将继承顺序换一下,先继承Base2,然后继承Base1

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;
}

最终结果如下所示

我们再来看这样一个题:
思考输出的结果是什么?

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;
}

对于这道题,它是一个菱形虚拟继承,我们要清楚它的对象模型

我们先猜一猜这个A会调用几次构造?是三次吗?其实不是的,它只调用一次构造。因为这是一个菱形虚拟继承,在这里面其实只有一份A,所以它也只能构造一次。因为一个对象不可能构造三次。

那么这个A对象何时调用呢,我们知道这个A它既不在B也不在C,它在D里面。
而我们new的时候调用的是D的构造函数,它先走的是初始化列表,这个初始化列表它走的顺序是声明的顺序。声明的顺序中,A是第一个,所以A虽然在最下面,但是它却是第一个先执行的,所以先打印A类,然后走B,在走B的时候并不会走这个A的构造,编译器会处理干净的。然后就是C,最后打印D

那么既然B和C的构造函数不会走A的构造,能否将其给去掉呢?

其实是不可以的,因为我们有可能会单独调用B对象。

七、菱形继承在库里面的应用

虽然菱形继承很坑,我们一般不建议使用菱形继承,但是在库里面是用的

我们的IO流就是这样的。

如下所示,下面的箭头都是继承,我们就会发现中间出现了菱形继承,iostream继承了istream和ostream。

八、继承和组合

我们已经知道什么是继承了,那么什么是组合呢?如下所示就是组合与继承的区别,组合其实就是一个自定义类型的成员变量

class A
{};
//继承
class B : public A
{};
//组合
class C
{
private:
	A _a;
};

继承和组合都完成了对对象的复用:

  1. public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
  2. 组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象
  3. 优先使用对象组合,而不是类继承 。
  4. 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
  5. 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装
  6. 实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。
  7. 一般符合is-a关系的使用继承,如植物和花。符合has-a的就使用组合,如轮胎和车。既符合继承又符合组合的一般使用组合。因为组合耦合度更低。

九、继承总结

  1. 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
  2. 多继承可以认为是C++的缺陷之一,很多后来的OO(OO是面向对象,OOP是面向对象程序设计)语言都没有多继承,如Java。

本篇的内容到此结束,如有错误感谢指正。

  • 26
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值