携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第7天,点击查看活动详情
一、复杂的菱形继承及菱形虚拟继承
-
单继承:一个子类只有一个直接父类时称这个继承关系为单继承。
-
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承。
上面的测试用例我们都是用单继承演示的。多继承类似现实中,研究生在学校里面既可以是学生,也可以是老师或助教。实际上,多继承是 C++ 的一个坑,越补,坑就越多,可以说,你打开了多继承的大门,各种头痛的问题来了。所以 java 后续都参考了 C++ 的血泪史,直接不支持多继承。多继承当然没有问题,但是支持多继承,就可能会出现下面要学习的菱形继承。
✔ 测试用例一:
#include<iostream>
#include<string>
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;
};
int main()
{
Assistant a;
//a._name = "DanceBit";//err,二义性
a.Student::_name = "DanceBit-学生";//指定域
a.Teacher::_name = "DanceBit-老师";
return 0;
}
-
菱形继承:菱形继承是多继承的一种特殊情况。
如果学生和老师单继承是独立的类,研究生或助教多继承了学生和老师,那都没有问题。但是学生和老师都单继承了人,研究生或助教多继承了学生和老师,就会出现菱形继承。
菱形继承真正的问题在于 Assistant 中会有两份 Person 成员,这会导致数据冗余和二义性。数据冗余就是两份 Person 浪费空间;二义性就是你要访问 _name 时,访问的是谁的 _name。
-
虽然指定域能暂时解决二义性的问题,但是根源上的数据冗余并没有解决,并且每次访问、每次指定那也不是个事呀。
当然菱形继承不一定是这么规则的,只要形状类似,那么它就是菱形继承。
✔ 测试用例二:
#include<iostream>
#include<string>
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.Student::_name = "DanceBit-学生";//指定域
a.Teacher::_name = "DanceBit-老师";
a._name = "DanceBit";
return 0;
}
-
针对于上面多继承中菱形继承的特殊场景所引发的问题,C++ 就引入了虚继承 virtual 以解决数据冗余和二义性,虚继承与后面多态中的虚函数共用一个关键字。
-
注意 virtual 不是在 Assistant 处加,而是在菱形中间腰部 (Student 和 Teacher) 的位置加。
在监视窗口查看,好像更怪了,有三份 Person。其实监视窗口看到的不一定是真实的,基于一些原因,可能是方便观察,VS 对监视窗口做的一些处理。当然监视窗口也是可以看出来的,你可以认为只有一份是真实的,其它两份是引用,这里每次访问 _name,三份都会改变;当然如果想更直观看它的底层,需要使用内存窗口。
-
如果 C++ 学浅一点,那么这块也就到这了。但是这块作为一个大坑,实际上有些地方会要求我们能去了解它到底坑在哪,复杂在哪,如果学语法光学到这,你是不会感觉到这有什么复杂的,因为这里不就是出现了数据冗余和二义性嘛,那使用 virtual 就解决了呀。
实际有时面试问到这块问题时,面试官会往下追问,它底层的原理是什么,虚继承是怎么解决的,出于这个角度,我们还要再往下探索,这是其一;其二,你同事设计了菱形继承,你说:不要用菱形继承,它会出现数据冗余和二义性,同事反驳到:没事,我用虚继承就解决了。那你如何劝退同事不要设计菱形继承,没有了解虚继承的底层,那么你就词穷了,,出于这个角度,我们还要再往下探索。也就是说从实用的角度,已经没必要再往下探索了,因为虽然有虚继承,但实际中是依旧是很不建议也几乎很少设计菱形继承的。
✔ 测试用例三:
#include<iostream>
using namespace std;
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()
{
D d;
d.B::_a = 1;
d.C::_a = 1;
d._b = 3;
d._c = 4;
d._d = 5;
d._a = 0;
return 0;
}
-
这里再使用上面的测试用例去探索就不太合适了,因为这个类太复杂了, 像 string 这样的对象太大了,而且在不同平台下的大小也不一样。所以我们这里设计了四个类,每个类里也只有对应的小写成员变量。
-
不使用 virtual,我们通过内存窗口可以很清晰的看到菱形继承所带来的数据冗余和二义性问题。
-
使用了 virtual,并且在原来的测试代码后加上 d._a = 0; ,此时我们再看内存窗口,可以很清晰的看到 virtual 解决了菱形继承所带来的数据冗余和二义性问题。
此外我们观察对比不使用 virtual 时的内存中成员构成的对象模型:这里把 A 单独拎出来放在最下面,且 BC 中好像还各自多余了 4 个字节的的地址,我们再通过其它内存窗口查看 BC 中那两个所谓多余的地址放的到底是啥 (注意这里是小端机),发现 BC 中各自所谓多余的那 4 个字节指向的空间中存储了 20 和 14,它们一点都不多余,其代表的含义其实是对于 A 的偏移量 —— 0x004FFA14 + 20 = 0x004FFA28;0x004FFA1C + 14 = 0x004FFA28;,官方来讲对于 0x004FFA14 和 0x004FFA1C 所指向的内存2和内存3,称之为虚基表。当然为什么要这要设计的原因也很简单,因为它要解决数据冗余和二义性,那么 A 既不能放到 B 里面也不能放到 C 里面,此时 B 里要找 A,C 里要找 A,只能通过 BC 中对应的指针拿到偏移量 + 当前地址。
什么情况下,会去找 B 的 A、C 的 A 呢 ❓
B b = d;
B* p = &d; p->_a;
B& r = &d; r._a;
这里测试用例四会验证。
为什么需要偏移量 ❓
B b = d; ,切片时,需要去找虚基类的成员;B* p = &d; B* p = &b; ,p 有可能指向 d,也有可能指向 b,d 和 b 中公共的成员的偏移量是不一样的,所以都要取偏移量才能找到公共成员。
为什么不在 0x004FFA14、0x004FFA1C 直接存偏移量 ❓
这样也是可以的,但是使用虚基表来存储的原因是还有其它场景 —— 在引入多态时 0x00DCDB40 和 0x00DCDB48 处还需要在存储一个值。
virtual 已经能解决菱形继承所带来的问题了,为什么还是不建议使用 ❓
在解决问题的同时,也要明白效率降低了,它的这个对象模型更复杂了, 以前是编译器编译完直接就可以找到,因为它们是挨着的;现在是必须得通过指针找到偏移量,再与现在的地址相加才可以找到。
✔ 测试用例三:
#include<iostream>
using namespace std;
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()
{
B b;
b._a = 1;
b._b = 2;
return 0;
}
-
虚继承后,哪怕只给一个 B 对象,它的对象模型也会变,它还是一样把公共的 A 放在最下面。
✔ 测试用例四:
#include<iostream>
using namespace std;
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()
{
B b;
b._a = 1;
b._b = 2;
B* p = &b;
p->_a = 3;
D d;
p = &d;
p->_a = 4;
return 0;
}
-
不管是对象、指针、引用要访问继承的虚基类对象成员 _a,都是取偏移量来计算 _a 的位置,可以看到虚继承后解决了菱形继承所带来的问题,但是同时对象模型更复杂了,以至于访问虚基类成员也付出一定的效率代价。
二、继承的总结和反思
-
很多人说 C++ 语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以最好不要设计出菱形继承,否则在复杂度及性能上都有问题。
-
可以看到 C++ 在这里自已挖了个坑,但是自己含着泪也要填完,这块后面再引入多态,那就更复杂了,多继承可以认为是 C++ 的缺陷之一,很多后来的 OO 语言都没有多继承,如 Java。
-
继承和组合
1、public 继承是一种 is - a 的关系。也就是说每个派生类对象都是一个基类对象。
2、组合是一种 has - a 的关系。假设 B 组合了 A,每个 B 对象中都有一个 A 对象。
继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用 (white-box reuse)。术语 “ 白箱 ” 是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高,类模块之间的独立性低 (10 人跟团旅游。假设 A 类有 80 个保护成员、20 个公有成员,那么派生类中可能都使用了基类的成员,这就意味着基类的改变,会对派生类造成很大的影响)。
对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用 (black-box reuse),因为对象的内部细节是不可见的。对象只以 “ 黑箱 ” 的形式出现。 组合类之间没有很强的依赖关系,耦合度低 (10 人跟团自由旅游。假设 A 类有 80 个保护成员、20 个公有成员,B 类不可能用那 80 个保护成员,所以相对类继承,这的影响就要小很多了),类模块之间的独立性高。优先使用对象组合有助于你保持每个类被封装。
实际尽量多去用组合。组合的耦合度低,代码维护性好,在软件开发中非常推崇 “ 低耦合,高内聚 ”。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承,用组合也不是不能玩多态,但是你就得模拟实现多态的原理。建议类之间的关系是 is - a (司机和人、学生和人),就用继承;类之间的关系是 has - a (脸和眼睛、车和轮胎),就用组合;类之间的关系是既可以是 is - a 也可以是 has - a,就用组合。
//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;
};
三、笔试面试题
- 什么是菱形继承 ?菱形继承的问题是什么 ?
- 什么是菱形虚拟继承 ?如何解决数据冗余和二义性的 ?
- 继承和组合的区别 ?什么时候用继承 ?什么时候用组合 ?