title: 虚继承
date: 2020-09-14 20:26:37
tags: c++
categories: c++
虚继承是由多继承和多重继承引发的一些问题。先说明虚继承和虚函数是两个不同的概念。下面看一个例子:
1、菱形继承
两个派生类继承同一个基类,又有某个类同时继承者两个派生类,这种继承被称为菱形继承或者钻石继承。
典型菱形继承示例
菱形继承问题:
-
假设动物类中有个属性m_age。羊继承了动物的数据,驼同样继承了动物的数据,当草羊驼使用数据时,就会产生二义性。
-
羊驼继承自动物的数据继承了两份,其实我们应该清楚,这份数据我们只需要一份就可以。
案例
#include <iostream>
using namespace std;
//基类 动物类
class Animal
{
public:
int m_age;
};
// 羊类
class Sheep:public Animal{};
//驼类
class Tuo:public Animal{};
class SheepTuo :public Sheep, public Tuo{};
int main(int argc, char const *argv[])
{
SheepTuo st;
//st.m_age = 10;
/*编译器报错,产生二义性。因为m_age属性既有继承自Tuo类,也有继承自Sheep类。
如果直接使用st.m_age编译器不知道到底访问谁。但是可以通过下面方式区分访问*/
st.Sheep::m_age = 10;
st.Tuo::m_age = 20;
/*虽然加上作用域可以访问到该属性,但是不是我们本质想要的结果。显然m_age属性
在SheepTuo类中存在两份。而实际我们只需要一份该属性就行。菱形继承导致了该数据有两份。
这样做显示浪费了我们的资源。后面可以使用虚继承解决该问题*/
return 0;
}
在上面案例中,就是一个菱形继承问题。基类animal类中有个属性m_age,我们在SheepTuo类中想通过对象st以st.m_age = 10方式访问m_age时编译器报错,提示二义性。因为在SheepTuo类中继承了来自Sheep类中的m_age属性,也继承了Tuo类中m_age的属性。如果直接访问m_age属性编译器并不知道你要访问哪个,就会报错。但是我们可以在访问前面加上作用域限定符,告诉编译器我们访问那个。好了现在通过以上方式是能访问到m_age属性了,但是对象st的年龄到底是多少岁呢?可以看到在SheepTuo类中存在两份年龄属性。实际上我们只需要一份该数据即可。显然菱形继承承导致了该数据有两份。浪费了我们的资源。后面可以使用虚继承解决该问题
借助visual studio 2017提供的工具查看一下SheepTuo类的对象模型
通过对象模型看到,在SheepTuo
类中确实存在两份m_age属性。一份是Sheep继承自Animal的,另一份是Tuo继承自Animal的。造成了内存资源的浪费。
2、虚继承
为了解决多继承时的命名冲突和冗余数据问题,C++提出了虚继承,使得在派生类中只保留一份间接基类的成员。虚继承只需在继承方式前加virtual关键词即可。此时公共的父类Animal称为虚基类
还是上面这个案例,使用虚继承看看。
#include <iostream>
using namespace std;
//基类 动物类
class Animal
{
public:
int m_age;
};
// 羊类
class Sheep:virtual public Animal{};
//驼类
class Tuo:virtual public Animal{};
//羊驼类
class SheepTuo :public Sheep, public Tuo{};
int main(int argc, char const *argv[])
{
/*使用虚继承后st对象中m_age属性就只有一个了,继承自其它类的m_age共享一份数据*/
SheepTuo st;
st.m_age = 10; //因为虚继承,m_age就只有一份了共享了,因此可以使用该方式访问了
cout<<"Sheep::m_age ="<<st.Sheep::m_age<<endl;
//由于共享数据,st.m_age改变后使用Sheep::m_age方式访问它,就是改变后的值
st.Sheep::m_age = 20;
cout<<"Tuo::m_age ="<<st.Tuo::m_age<<endl;
//由于共享数据,Sheep::m_age改变后使用Tuo::m_age方式访问它,就是改变后的值
st.Tuo::m_age = 20;
cout<<"st.m_age ="<<st.m_age<<endl;
return 0;
}
上面的使用虚继承解决了菱形继承带来的问题,在本例中派生类SheepTuo
中就只保留了一份成员变量m_age,直接访问也不会有歧义。
虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为虚基类(Virtual Base Class),本例中的 Aniaml
就是一个虚基类。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含一份虚基类的成员。
3、虚继承对象模型(底层实现)
上面看了在菱形继承时,不使用虚继承下对象的内存模型。下面看看使用了虚继承后的对象模型。
借助visual studio 2017提供的工具查看一下SheepTuo类的对象模型
cl /d1 reportSingleClassLayout查看的类名 所属文件名
左边为未使用虚继承时的对象模型,右边为虚继承后的对象模型。
使用虚继承后值只保留了虚基类Animal中m_age属性,Sheep类和Tuo类中多了一个虚基类指针(vbptr),它们指向各自的虚基类表(vbtable)。在vatable中存在一个偏移量,虚基类指针通过该类的地址加上偏移量即可访问到m_age属性。
4、虚继承下的构造函数
#include <iostream>
using namespace std;
//虚基类A
class A
{
public:
A(int a):m_a(a){};
int m_a;
};
//直接派生类B
class B: virtual public A
{
public:
B(int a, int b):A(a),m_b(b){};
void display()
{
cout<<"m_a="<<m_a<<", m_b="<<m_b<<endl;
}
int m_b;
};
//直接派生类C
class C: virtual public A
{
public:
C(int a, int c):A(a),m_c(c){};
void display()
{
cout<<"m_a="<<m_a<<", m_b="<<m_c<<endl;
}
int m_c;
};
//间接派生类D
class D: public B, public C
{
public:
D(int a, int b, int c, int d): A(a), B(90, b), C(100, c), m_d(d){};
void display()
{
cout<<"m_a="<<m_a<<", m_b="<<m_b<<", m_c="<<m_c<<", m_d="<<m_d<<endl;
}
int m_d;
};
int main(){
B b(10, 20);
b.display();
C c(30, 40);
c.display();
D d(50, 60, 70, 80);
d.display();
return 0;
}
/*
m_a=10, m_b=20
m_a=30, m_b=40
m_a=50, m_b=60, m_c=70, m_d=80*/
在虚继承中,虚基类是由最终的派生类初始化的,换句话说,最终派生类的构造函数必须要调用虚基类的构造函数。对最终的派生类来说,虚基类是间接基类,而不是直接基类。这跟普通继承不同,在普通继承中,派生类构造函数中只能调用直接基类的构造函数,不能调用间接基类的。
采用了虚继承时,虚基类 A 在最终派生类 D 中只保留了一份成员变量 m_a,如果由 B 和 C 初始化 m_a,那么 B 和 C 在调用 A 的构造函数时很有可能给出不同的实参,这个时候编译器就会犯迷糊,不知道使用哪个实参初始化 m_a。
为了避免出现这种矛盾的情况,C++干脆规定必须由最终的派生类 D 来初始化虚基类 A,直接派生类 B 和 C 对 A 的构造函数的调用是无效的。在第 42 行代码中,调用 B 的构造函数时试图将 m_a 初始化为 90,调用 C 的构造函数时试图将 m_a 初始化为 100,但是输出结果有力地证明了这些都是无效的,m_a 最终被初始化为 50,这正是在 D 中直接调用 A 的构造函数的结果。
另外需要关注的是构造函数的执行顺序。虚继承时构造函数的执行顺序与普通继承时不同:在最终派生类的构造函数调用列表中,不管各个构造函数出现的顺序如何,编译器总是先调用虚基类的构造函数,再按照出现的顺序调用其他的构造函数;而对于普通继承,就是按照构造函数出现的顺序依次调用的。
修改本例中第 42 行代码,改变构造函数出现的顺序:
D::D(int a, int b, int c, int d): B(90, b), C(100, c), A(a), m_d(d){ }
虽然我们将 A() 放在了最后,但是编译器仍然会先调用 A(),然后再调用 B()、C(),因为 A() 是虚基类的构造函数,比其他构造函数优先级高。如果没有使用虚继承的话,那么编译器将按照出现的顺序依次调用 B()、C()、A()。