什么叫菱形继承
菱形继承是多继承的一种特殊情况。什么叫多继承呢?
多继承又称多重继承。是指一个子类 拥有两个或两个以上直接父类时称这个继承关系为多继承。
当我们将继承称为is a的关系,即子类is a父类时,多继承就显得"合乎情理"。
一个assistant类能不能同时是student类和teacher的子类呢?换句话说,一个助理能不能既是学生又是老师呢?番茄能不能既是水果又是蔬菜呢?青蛙能不能既是陆生动物又是水生动物呢?
我们发现生活中有很多多继承的例子。今天我们在语言的层面上来谈谈一种特殊的多继承情况–菱形继承。
下面给出一个菱形继承的结构图:
根据图片,菱形继承指在继承体系中形成了一个“菱形”图形。这种结构通常涉及四个组件:一个基类、两个继承自该基类的中间类,以及一个从这两个中间类进一步继承的派生类。这种继承模式可以引起多种问题,尤其是关于数据的重复和访问路径的不明确。
而下面我们将 探究菱形 继承模式所带来的一些"麻烦"。
菱形继承带来的问题
首先给出一个菱形继承的代码样例:
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; // 主修课程 };
根据以上代码我们先思考Assistant类的类体结构包含了哪些东西。
既然student和teacher类都继承复用了基类person的成员_name,那么同样的,Assistant类也会复用student与teacher的成员。这样一来,person中的成员_name就被复用了两次!
打开调试窗口观察Assistant实例化对象的内部成员
我们发现,Assistant的实例化对象确实有两个_name,并且这两个_name分别在自己的作用域中。
于是我们就能发现菱形继承所带来的问题:
- 数据冗余
- 二义性
数据冗余容易理解,二义性是指同一个成员可能指向不同的数据,从而发生歧义。
下面我们来观察二义性:
当我们尝试直接访问造成冗余的成员,编译器会报错。因为他也不知道你是访问student域中的_name,还是teacher域中的_name(每个基类的成员集合在派生类中都有独立的域)。这就产生了歧义。
因为两个_name有自己的域,那么这种歧义也很容易解决,即声明在哪个域中。比如:
既是是这样,也还是无法解决数据冗余的问题,且每次使用都要声明命名空间,显得过于傻。
由于菱形继承是多继承的一种情况,只要存在多继承,那么就很难避免的出现菱形继承。
那么我们有什么办法解决菱形继承带来的数据冗余和二义性问题呢?
虚拟继承
C++ 提供了一种称为“虚拟继承”的机制来解决这种问题。通过虚拟继承,可以确保无论一个类被继承了多少次,基类的数据成员在内存中只有一份拷贝。
在上面的菱形继承问题中,可以通过将Student 和 Teacher 对 Person 的继承声明为虚拟的(使用 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; // 主修课程 };
虚拟继承的内存布局
在C++中,使用虚拟继承时,虚拟基类的成员不是由直接派生类控制,而是被推迟到最底层的派生类来初始化和控制。这种机制确保了虚拟基类的数据只有一份实例存在于对象的内存布局中,即使它被多个中间派生类间接继承。
Assistant类的内存分布:
在这个例子中,student 和 teacher都虚拟继承自 person,而 Assistant 从 student 和 teacher 继承。在虚拟继承的情况下,person 的实例只会在Assistant中存在一次,无论通过 student 还是 theacher 的路径。
虚拟继承内存布局解释:
在虚拟继承中,每个对象通常会包含一个或多个虚拟表指针(vptr),指向虚拟表(vtable)。此外,还有一个或多个虚拟基类表指针(vbptr),指向一个虚拟基类表(vbtable)。这个表包含了从当前类到其虚拟基类的偏移量。
注意这里的虚拟表指的是虚表,虚拟基表指的是虚基表,这两者是完全不一样的东西。虚表存的是派生类的虚函数指针,虚基表存的才是当前类到虚基类的偏移量。什么是虚函数指针,在后面的章节会讲。
为了更好的理解虚拟继承内存分布,给出以下代码做实验:
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 = 2; d._b = 3; d._c = 4; d._d = 5; return 0; }
根据以上代码,调用内存窗口,观察d对象内部的成员分布情况
- 创建一个d对象,并在父类B的作用域里找到成员_a,使其赋值为1:
- 为了证明在父类C中的_a和父类B中的_a是同一个,在C的作用域中,再将_a赋值为2:
得出结论1:虚拟继承的虚拟基类成员确实只会实例一份,在B中找到的_a和在C中找到的_a在同一个地址空间中。
下面继续调试,观察d中B、C作用域的位置。
- .分别赋值给d._d,d._c,d_d。观察这些成员的相对位置:
得出结论2:父类B和C的域中都没有直接存放虚拟基类的成员_a。那么我们之前是如何通过d.B::_a=1
赋值的呢?
可以很容易想到,既然_a没有在B的域中我们还能找到它,说明B的作用域中应该存在着类似指针的机制帮助B找到_a.
同时我们也发现B的域中除了自己的成员_b外还有一个地址存着一个我们“看不懂”的值:
先给出结论3:属于B和C的作用域中都分别还存在着一个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。
- 查看B中的虚基表指针指向的值:
我们发现在B的虚基表中有一个地址存着一个值,这个值就是B域的虚基指针的地址到A域的_a的地址的偏移量!
0x007FFCEC+20=0x007FFD00
无独有偶,我们再来观察C域中的虚基指针指向的内容:
于是我们就可以通过C域虚基指针找到指向的虚基表,再从里面获得与A域中的_a的偏移量12,用虚基指针的地址加上这个偏移量我们就能找到A域中的_a。
0x007FFCF4+12=0x007FFD00
总结:
- 虚拟继承中的虚拟基类成员只会实例化一份,在菱形继承中解决了数据冗余的问题,同时也解决了二义性。
- 虚拟继承虽然解决了菱形继承问题,但它也引入了额外的复杂性和性能开销。每次通过派生类访问虚拟基类的成员时,都需要进行额外的间接寻址。这可能会影响性能,尤其是在性能敏感的应用中。因此,在设计类的继承结构时,应仔细考虑是否真的需要虚拟继承。
关于继承和组合
组合,又称对象组合,是一种设计策略,一个类中包含另一个类的实例作为字段,利用这个字段提供的功能。组合常被描述为“有一个(
has a
)”关系,假设B组合了A,每个B对象中都有一个A对象。
给出一个组合的例子:
// Tire和Car构成has-a的关系 class Tire{ protected: string _brand = "Michelin"; // 品牌 size_t _size = 17; // 尺寸 }; class Car{ protected: string _colour = "白色"; // 颜色 string _num = "陕ABIT00"; // 车牌号 Tire _t; // 轮胎 };
在Car类中有一个Tire类的实例,我们称为Car类组合了Tire类。有了Tire类的实例,在Car类中依旧可以复用Tire的代码。
组合的优势
- 增强封装性:组合允许一个类隐藏内部实现的细节,只通过接口与其它对象交互。这种封装性有助于减少依赖关系,使得代码修改时对其他部分的影响最小化。
- 减低耦合性:组合提供了更高的灵活性,使得组合的对象可以独立于使用它的类变化,想用哪个对象就用哪个,互不干扰。
- 更好的代码复用:使用组合,可以将功能分解成独立的小部件,每个部件都可以被独立更新和复用,而无需继承所有的实现。这种方式避免了通过继承引入不必要的接口或功能,使得每个部分保持轻量和专注。
- 避免了复杂的多次继承:过深的继承层次会使代码难以理解和维护。每增加一层继承,理解类的行为就更加困难,因为需要考虑更多的基类行为。
代码复用最常见的几种手段有:
- 继承:通过继承,子类可以重用父类的方法和属性。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。
- 组合:对象组合是类继承之外的另一种复用选择。它允许一个类包含另一个类的实例作为其成员变量。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。
- 模板:泛型编程的基础。允许程序员编写与数据类型无关的代码。模板可以用于函数和类,使得相同的逻辑可以应用于不同的数据类型
继承的合理使用场景
虽然我们建议多使用组合,但是在有些场景中使用继承会显得更加合适:
- 当两个类的关系很明显是
is a
关系,这个时候用继承更容易理解代码。但是假如可以是has a
也可以是is a
时,还是优先考虑组合。 - 实现多态行为:继承是多态的关键机制之一,要向实现多态,必须是使用继承。
- 接口继承:在C++中,可以通过继承抽象基类(通常是纯虚类)来定义接口。这种方式可以强制派生类遵循特定的接口规范,是一种有效的设计策略。
总结
在选择是使用继承还是组合时,我们应该根据实际需求的上下文来决定。如果类之间的关系是很明显的
is a
且又要实现多态的时候,用继承比较合适。但是大多情况下,我们应该尽量使用组合,因为组合更灵活也更容易维护。