多重继承/菱形继承(virtual)(虚基类)

1、一个我从来没见过的场景

A作为基类
B、C均public继承自A
D同时继承自B、C

class A
{
public:
	A() : number(-1) { cout << "A()" << endl; }
	A(int n) : number(n) { cout << "A(" << n << ")" << endl; }
	int get_num() { return 10 * number; }
	int number;
};

class B : public A
{
public:
	B(int n) : A(n) { cout << "B(" << n << ")" << endl; }
};

class C : public A
{
public:
	C(int n) : A(n) { cout << "C(" << n << ")" << endl; }
};

class D : public B, public C
{
public:
	D() : B(1), C(2) { cout << "D()" << endl; }
};

上述构造class的方式没有问题,你可以实例化一个D类型的对象。

不过,一个很明显的问题是:B、C中均包含了继承自A的成员,而D又同时包含了B和C,那么相当于D中有了2份A。这样4者的继承关系类似于一个菱形,所以称为菱形继承问题。

严格来说,菱形继承本身不算是问题,因为上述的代码是可以用的,只是在使用D的实例时,需要明确区分是使用父类B的成员还是使用父类C的成员。

int main()
{
	cout << "----" << endl;
	D d;
	cout << "----" << endl;
	cout << d.B::number << endl;
	cout << d.C::number << endl;
	cout << d.B::get_num() << endl;
	cout << d.C::get_num() << endl;
	cout << "----" << endl;
	// cout << d.number << endl; /* error: request for member 'number' is ambiguous */
	// cout << d.get_num() << endl; /* error: request for member 'get_num' is ambiguous */
}

上述代码的输出为

----
A(1)
B(1)
A(2)
C(2)
D()
----
1
2
10
20
----

4个测试用例不难理解,正如前面所说的,D中包含了2个A,如果使用d.numberd.get_num()的形式来引用d的成员,编译器无法确定是使用B中继承来的那个还是C中继承来的那个。使用作用域解析符::(大概是叫这么个名字,我记不清了)来明确你要使用哪个。

至此,「使用哪个」的问题已经解决了,不过上述问题并不是只有菱形继承才有的。通常来说,只要B、C中出现了相同特征标的函数/成员变量(即允许函数的返回类型不同),并均继承给了D,那么在使用D的实例对象时,如果涉及到了重名的那个成员,就必须要明确使用B中的那个还是C中的那个。

另外,如果代码并不介意具体使用哪个父类的同名成员,从而不想具体地指出要用哪个,那么可以把这个工作推给D的设计者:

class D : public B, public C
{
public:
	D() : B(1), C(2) { cout << "D()" << endl; }
	int get_num() { return B::get_num(); }
};

当然,上述方法无法应用在int number;成员上。

2、如果只允许有1个A怎么办?

上一节中,对于菱形继承带来的重名问题,提出了最基本也是最简单的解决方案。但是如果由于某种需求,我们不需要在D中包含2个A,那怎么办?

以一个实际的例子来理解:假设A代表有生命的物体,A::number为其年龄,B代表雌性动物,C代表雄性动物,D代表人类。显然,代表人类的数据结构中不需要有2份「年龄」,也不需要有2份「获取10倍年龄的」接口。哪怕所提供的2份数据/接口是完全相同的,这样做也没有多大实际意义。

借助「虚基类」可以实现上述需求(参加下面代码中的注释)

class A
{
public:
	A() : number(-1) { cout << "A()" << endl; }
	A(int n) : number(n) { cout << "A(" << n << ")" << endl; }
	int get_num() { return 10 * number; }
	int number;
};

class B : virtual public A /* 将A切换为虚基类 */
{
public:
	B(int n) : A(n) { cout << "B(" << n << ")" << endl; }
};

class C : public virtual A /* 将A切换为虚基类 */
{
public:
	C(int n) : A(n) { cout << "C(" << n << ")" << endl; }
};

class D : public B, public C
{
public:
	D() : B(1), C(2) { cout << "D()" << endl; }
};

只用virtual关键字来指示派生时,基类就成为了虚基类。
上述代码中virtualpublic关键字的先后顺序没有影响,B、C中的写法都是正确的。

上述变更后,对B、C的使用本身没有影响,但是D中的A变为了「只有1个」,这样便解决了「2个A」带来的问题,参见如下测试代码

int main()
{
	cout << "----" << endl;
	D d;
	cout << "----" << endl;
	cout << d.number << endl;
	cout << d.get_num() << endl;
	cout << "----" << endl;
}

输出为

----
A()
B(1)
C(2)
D()
----
-1
-10
----

尽管编译通过了,但是运行结果相对之前的版本,却变化了很多。

3、虚基类对构造规则的破坏

暂且先不关注上一节中的输出该如何解释。仔细思考一下我们可以发现,虚基类尽管保证了D中只有1个A,但是,在声明一个D的实例时,改如何来构造其中的A?

在通常的(非虚基类)继承关系中,A的构造完全依赖于B、C的构造,你可以在B/C的构造函数初始化列表中指明调用A的哪个构造函数,如果没有指明,编译器会自动调用A的默认构造函数。而D的构造函数只需关心如何构造它的直接父类(B、C)即可,并不关心其间接父类(A)的构造。

在虚基类的继承关系中,D中的A既不属于B,也不属于C,故本着公平公正的原则,不应该指派B或者C来完成A的构造,于是「构造A的工作则交由D来完成」。也就是说这种情况下,D需要同时负担A、B、C三者的构造,而B、C的构造函数中对A的构造工作会被屏蔽。

因此,基于虚基类的继承关系中,破坏了C++原本的构造规则。

4、回到原来的问题

结合前2节的介绍,对于第2节中测试代码的诡异输出就很好理解了:

代码中,D的构造函数初始化列表为B(1), C(2),又因为D要负责虚基类A的构造,所以编译器在这里自动调用了A的默认构造函数,相当于将初始化列表改为了A(), B(1), C(2)

同时,编译器又屏蔽掉了B、C的初始化列表中调用的A(n)

因此,便导致了测试代码的输出相当对前一版本(基于非虚基类的继承关系),有了很大的变化。

5、优先规则

根据前面的测试代码我们可以了解到一个事实:如果一个class从几个class那里继承来了多个同名的成员,则在使用该成员时,如果不用类名进行限定,将会导致二义性,即编译器不知道该调用哪个。比如如下代码

#include <iostream>
using namespace std;

class A
{
public:
	char get_char() { return 'A'; }
};

class B : public A
{
public:
	char get_char() { return 'B'; }
};

class C : public A
{
};

class D : public B, public C
{
};

int main()
{
	D d;
	cout << d.get_char() << endl;	/* 二义性,编译报错 */
	cout << d.B::get_char() << endl; /* 输出:B */
	cout << d.C::get_char() << endl; /* 输出:A */
}

以上,D中从B和C都继承到了一个get_char函数,所以d.get_char()会有二义性,引起编译报错。

如果将B、C改为虚继承自A,则不会引起二义性,代码如下

#include <iostream>
using namespace std;

class A
{
public:
	char get_char() { return 'A'; }
};

class B : virtual public A
{
public:
	char get_char() { return 'B'; }
};

class C : virtual public A
{
};

class D : public B, public C
{
};

int main()
{
	D d;
	cout << d.get_char() << endl;	/* 输出:B */
	cout << d.B::get_char() << endl; /* 输出:B */
	cout << d.C::get_char() << endl; /* 输出:A */
}

变动之后的main函数内容没有变,但是可以正常编译并运行了。
根据输出可知,d.get_char()调用的是B中的实现。也就是说编译器认为此时的类设计没有二义性了,这是因为编译器通过以下优先规则,推断出了相应的调用目标:

派生类中的名称优先于直接或间接祖先类中的相同名称。

用这个规则解释一下上面的代码:
B是A的派生类,所以B中定义的get_char优先于A中的同名函数
C是A的派生类,C没有重新定义get_char,所以C中的get_char是继承自A的
因此,B中的get_char优先于C中的get_char,故编译器在遇到d.get_char()代码时,会自动去调用B中的get_char

具体为什么会有这种优先规则,其实我也不知道,估计和虚基类的底层实现方式有关,这篇文章涉及到的虚基类的种种特性,至今我还没有在实际的工程项目中用到过,在这里只是作为一个笔记,之后如果有更为深入的体会,再补充。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值