菱形继承存在的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。在 Assistant
的对象中 Person
的成员变量会有两份。
class Person { public: string _name;//姓名 int _age;//年龄 }; class Teacher : public Person { protected: int _id;//职工号 }; class Student : public Person { protected: int _num;//学号 }; class Assistant : public Teacher, public Student { protected: string _majorCourse;//主修课程 }; void Test() { Assistant a; a._age = 10; }
小Tips:对 _age
访问不明确,其实就是二义性问题。想要解决二义性问题,我们可以通过指定类域去访问,像下面这样。
void Test() { Assistant a; a.Teacher::_age = 10; a.Student::_age = 18; }
小Tips:上面这样虽然解决了二义性问题,但是对于面向对象的语言来说,这样是不符合逻辑的,因为一个人不可能同时拥有两个年龄。并且数据冗余还是没有得到解决。因此下面需要引入虚拟继承,虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在 Student 和 Teacher 继承 Person 时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其它地方去使用。
class Person { public: string _name = "Peter";//姓名 int _age = 0;//年龄 }; class Teacher : virtual public Person { protected: int _id = 0;//职工号 }; class Student : virtual public Person { protected: int _num = 0;//学号 }; class Assistant : public Teacher, public Student { protected: string _majorCourse;//主修课程 };
7.1 虚拟继承解决数据冗余和二义性的原理
为了研究虚拟继承的原理,我们给出了一个简化的菱形继承体系,再借助内存窗口观察对象成员的模型。
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; }; void Test() { D d; d.B::_a = 1; d.C::_a = 2; d._b = 3; d._c = 4; d._d = 5; }
小Tips:上图是普通菱形继承的对象成员在内存中的模型。可以看出内存中存储了两个 _a
成员变量,一个是继承自 B 的,另一个是继承自 C 的。下面再来看看虚拟继承的对象成员在内存中的存储模型。
class A { public: int _a; }; class B : virtual public A //class B : virtual public A { public: int _b; }; class C : virtual public A //class C : virtual public A { public: int _c; }; class D : public B, public C { public: int _d; }; void Test() { D d; d.B::_a = 1; d.C::_a = 2; d._b = 3; d._c = 4; d._d = 5; d._a = 10; }
小Tips:虚拟继承的对象成员在内存中的存储模型发生了很大的变化。首先 A 类型的成员变量 _a
从 B 类型和 C 类型中脱离了出来,普通的菱形继承,_a
会在 B 类型 和 C 类型中各自存储一份,造成数据冗余和二义性,而在菱形继承中,A 类型的成员变量 _a
只会在内存空间中存储一份。另一个变化是,B 类型和 C 类型中分别多出了一个数据,如 B 中的 00ff7bdc
和 C 中的 00ff7be4
,这俩很明显都是地址,根据地址去查找对应内存空间中存储的数据,可以发现, B 类型中的这个地址空间中存储了一个 14,这里的 14 是十六进制,转化成十进制是 20,而 20 正好是 B 类型的首地址 0x00EFFBA0
和 A 类型首地址 0x00EFFBB4
之间的距离。同理,C类中的这个地址空间中存储的 0c
转化成十进制是 12,就是 C 类型的首地址 0x00EFFBA8
和 A 类型的首地址 0x00EFFBB4
之间的距离。这里可能就会有小伙伴想问,为什么不把这个相对距离直接存在 D 类型的对象中,而是单独在内存中开了块空间去存储。首先,因为这里不仅需要存储相对距离,还需要存储一些其它的信息。其次,我们可能同时创建多个 D 对象,对这多个 D 对象来说,它们的偏移量都相同,如果在所有的 D 类型对象中都存储一份,在数据量大的时候会造成极大地浪费,因此我们可以把这部分“固定不变”的信息提取出来,单独在内存中开辟一块空间去存储,然后在 D 类型的对象中存上该空间的地址即可,这样做可以在数据量大的时候,有效的节省空间,提高内存利用率。
小Tips:如上图所示,我们同时创建了两个 D 类型的对象,d 和 d1,它们在内存空间中都存了相同的地址 0x00337bdc
和 0x00337be4
,这两个地址空间中分别存储的是 B 类型 和 A 类型的相对距离,C 类型和 A 类型的相对距离。存储偏移量的这块空间也被形象的叫做虚基表(找基类偏移量的表),但是需要注意虚基表中不止会存储偏移量,还会存储一些其他信息,具体内容将在下一篇关于多态的文章中为大家揭晓,感兴趣的朋友们不妨先点一个关注👀。
7.2 存偏移量的意义
void Test() { D d; d._a = 10; }
如上面代码所示,定义一个普通的 D 类型对象 d,然后去访问它里面的成员变量 _a,这种情况下是用不到偏移量的。偏移量的作用是去找 D 对象中“爷爷类”的成员(即父类的父类,这也就是 A 类),对于普通的 D 类型对象 d 来说,在定义该对象的时候,编译器会根据声明顺序依次为成员创建栈帧(依次分配空间进行存储),所以对编译器来说,它知道 _a 这个成员变量就存储在哪,所以当我们执行 d._a = 10
的时候,编译器会直接找到这块内存空间,并不需要通过虚基表去查找偏移量。存偏移量的真正用途是为了下面这种情况。
void Test() { D d; d._a = 1; d._b = 2; d._c = 3; d._d = 4; B b; b._a = 1; b._b = 2; B* ptr = &b; ptr->_a++; ptr = &d; ptr->_a++; }
小Tips:这里我们首先需要明确一点,在虚继承体系中,B 对象成员在内存中的存储模型相较于普通的继承也发生了改变,它也会涉及到虚基表。
明确了这一点后,我们继续看看上面的代码,我们定义了一个 B 类型的指针 ptr
,该指针可以指向一个 B 类型的对象,也可以指向一个 D 类型的对象(注意,只能访问到 D 类型对象中 B 中的成员)。虽然 ptr
可以指向不同类型的对象,但是 ptr
始终都是 B 类型,这就决定了,无论你 ptr
指向什么类型的对象,你都只能访问到 B 类中有的成员,即 ptr
最多只能访问到 _a
和 _b
这两个成员。ptr
作为一个指针变量,在转换成指令后,它并不知道它指向的是谁,它只是存了一个地址,如果 ptr
存的是一个 B 类型对象的地址,那它的 _a
和 _b
在内存空间上是连续的,但是如果 ptr
存的是一个 D 类对象的地址,那它的 _a
和 _b
在内存空间中并非连续的。中间可能隔着一些其他类型。而指针的工作原理是,首先指针一定存的是一个变量的首地址,其次指针的类型决定了它从该变量的首地址开始,可以访问到多少个连续的空间。以 int
型的指针为例,他可以访问到连续的四个字节,对 int
型的指针 +1,会自动跳过四个字节。再回到这里,当 ptr
存的是一个 D 类对象的首地址,ptr
可以访问到的成员并不连续,那指针就无法找到 _a
了,此时存偏移量的作用就体现出来了,ptr
可以通过虚基表,查找到偏移量,进而找到 _a
成员。此时无论 ptr
是指向 B 类型的对象还是指向 D 类型的对象,当 ptr
要去访问 _a
的时候,都会转化成先取偏移量,再计算 _a
在对象中的地址,再去访问。
7.2 虚继承解决数据冗余问题
还是以上面的代码为例,一个 D 类型对象中和 A 类型有关的成员变量的大小(字节数)在比较小的情况下,那么这个 D 类型对象在虚继承体系下的大小(字节数)可能还会大于普通继承体系下创建的 D 类型对象,上面的代码在普通继承体系下,一个 D 类型对象的大小是 20 字节,在虚继承体系下是 24 字节。
出现这种虚拟继承体系下创建的对象比普通继承体系下创建的对象大的主要原因是 A 类型中的成员变量太少了,所占用的内存空间太小了,导致虚拟继承的支出大于收益,如果 A 中的成员变量比较多,或者是一个大数组,那么,虚拟继承解决普通继承体系下的数据冗余功能就可以体现出来了。
八、继承的总结和反思
很多小伙伴觉得 C++ 语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就存在数据冗余和二义性问题,为了解决数据冗余和二义性问题,又引入了菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。多继承可以认为是 C++ 的缺陷之一,后来很多的 OO 语言都没有多继承,如 Java。
8.1 继承和组合
- public 继承是一种 is-a 的关系。也就是说每个派生类对象都是一个基类对象。
- 组合是一种 has-a 的关系。即在 B 类里面声明一个 A 类型的成员变量。这样以来每个 B 对象中都有一个 A 对象。
- 优先使用对象组合,而不是继承。
- 继承允许我们根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见。继承在一定程度上破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类之间的依赖关系很强,耦合度高。
- 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组长或者组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为“黑箱复用”,因为对象的内部细节是不可见的。对象只是以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助与我们保持每个类被封装。
- 实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。
九、继承常见面试问题
//下面这段代码的输出结果是? class A { public: A(const char* s) { cout << s << endl; } ~A() {} }; class B :virtual public A { public: B(const char* s1, const char* s2) :A(s1) { cout << s2 << endl; } }; class C :virtual public A { public: C(const char* s1, const char* s2) :A(s1) { cout << s2 << endl; } }; class D :public B, public C { public: D(const char* s1, const char* s2, const char* s3, const char* s4) :B(s1, s2) ,C(s1, s3) ,A(s1) { cout << s4 << endl; } }; int main() { D* p = new D("class A", "class B", "class C", "class D"); delete p; return 0; }
小Tips:解决本题的关键在于我们要知道知道以下两点。第一点:对与虚拟继承来说,虽然 D 类看上去只继承了 B 类和 C 类,但是它是一种菱形继承,B 类和 C 类都继承了 A 类,所以从某种意义上讲,D 类也继承了 A 类,又因为这里是菱形虚拟继承,A 类中的成员变量在 D 类中只有一份。这里要求在 D 类构造函数的初始化列表中先去调用 A 类的构造函数,因此如果在 A 类没有默认构造函数的情况下就需要我们自己在初始化列表中显式的去写调用 A 类构造函数的语句。在第四小节中我们提到过:派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。第二点需要我们掌握的是:在派生类构造函数的初始化列表中成员变量的初始化顺序,是按照继承的先后顺序来的,先去调用第一个被继承的类的构造函数,再去调用第二个被继承的类,依此类推,最后再去初始化当前类自己的成员变量,以上面代码为例:class D :public B, public C
,D 类先继承 B,再继承 C,但是需要注意,这是个菱形虚拟继承,B 类和 C 类都继承自 A 类,所以编译器最先去调用 A 类的构造函数,接下来再去调用 B 类的构造函数,然后再去调用 C 类的构造函数,这里还需要注意一点,虽然在 B 类和 C 类构造函数的初始化列表中都显示的写了调用了 A 类构造函数的语句,但是这是菱形虚拟继承,创建 D 对象的第一步就去调用了 A 类的构造函数,所以在调用 B 类和 C 类构造函数的过程中编译器并不会再去调用 A 类的构造函数。虽然在创建 D 对象的过程中编译器并不会去执行 B 类和 C 类构造函数中调用 A 类构造函数的语句,但是我们不能把这条语句给删了(除非 A 类有默认构造),因为这条语句不执行仅仅是在创建 D 类对象的过程中,我们也可能会去创建 B 类对象和 C 类对象,此时该语句就会被执行。有了上面这些知识储备就不难知道上面这段代码的打印结果了。
//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; printf("p1:%p\n", p1); printf("p2:%p\n", p2); printf("p3:%p\n", p3); return 0; }
小Tips:这里的结果和继承顺序有关,p3
作为一个 Derive 类型的指针,它必定是指向 d
对象的首地址,首地址一定是存储当前对象的内存空间中地址编号最小的,即低地址处。从上面的代码中我们可以看出,Derive 对象先继承了 Base1,这就决定了一个 Derive 对象 d
在内存中 Base1 的成员变量一定是存储在最前面的,即低地址处,也就是 Derive 对象的首地址。p1
作为一个 Base1 类型的指针,它只能指向 d
对象中继承自 Base1 的成员变量,而 Base1 的成员变量就存在地址编号最小的那块内存空间上,因此 p1 == p3
。Derive 第二个继承的是 Base2,存储 Base2 成员变量的空间依次往后,自然就不是 d
的首地址,这就导致了 p1 == p3 != p2
。
十、结语
今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,春人的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是春人前进的动力!