【C++基础】第一百零五课:[用于大型程序的工具]多重继承与虚继承

多重继承,虚继承,虚基类

Posted by x-jeff on August 19, 2024

【C++基础】系列博客为参考《C++ Primer中文版(第5版)》C++11标准)一书,自己所做的读书笔记。
本文为原创文章,未经本人允许,禁止转载。转载请注明出处。

1.多重继承与虚继承

多重继承(multiple inheritance)是指从多个直接基类(参见:定义派生类)中产生派生类的能力。多重继承的派生类继承了所有父类的属性。

为了探讨有关多重继承的问题,我们将以动物园中动物的层次关系作为教学实例。动物园中的动物存在于不同的抽象级别上。有个体的动物,如Ling-Ling、Mowgli和Balou等,它们以名字进行区分;每个动物属于一个物种,例如Ling-Ling是一只大熊猫;物种又是科的成员,大熊猫是熊科的成员;每个科是动物界的成员,在这个例子中动物界是指一个动物园中所有动物的总和。

我们将定义一个抽象类ZooAnimal,用它来保存动物园中动物共有的信息并提供公共接口。类Bear将存放Bear科特有的信息,以此类推。

除了类ZooAnimal之外,我们的应用程序还包含其他一些辅助类,这些类负责封装不同的抽象,如濒临灭绝的动物。以类Panda的实现为例,Panda是由Bear和Endangered共同派生而来的。

2.多重继承

在派生类的派生列表中可以包含多个基类:

1
2
class Bear : public ZooAnimal { /*...*/ };
class Panda : public Bear, public Endangered { /*...*/ };

每个基类包含一个可选的访问说明符(参见:访问控制与继承)。一如往常,如果访问说明符被忽略掉了,则关键字class对应的默认访问说明符是private,关键字struct对应的是public。

和只有一个基类的继承一样,多重继承的派生列表也只能包含已经被定义过的类,而且这些类不能是final的。对于派生类能够继承的基类个数,C++没有进行特殊规定;但是在某个给定的派生列表中,同一个基类只能出现一次。

2.1.多重继承的派生类从每个基类中继承状态

在多重继承关系中,派生类的对象包含有每个基类的子对象。如图18.2所示,在Panda对象中含有一个Bear部分(其中又含有一个ZooAnimal部分)、一个Endangered部分以及在Panda中声明的非静态数据成员。

2.2.派生类构造函数初始化所有基类

构造一个派生类的对象将同时构造并初始化它的所有基类子对象。与从一个基类进行的派生一样(参见:定义派生类),多重继承的派生类的构造函数初始值也只能初始化它的直接基类:

1
2
3
4
//显式地初始化所有基类
Panda::Panda(std::string name, bool onExhibit) : Bear(name, onExhibit, "Panda"), Endangered(Endangered::critical) { }
//隐式地使用Bear的默认构造函数初始化Bear子对象
Panda::Panda() : Endangered(Endangered::critical) { }

派生类的构造函数初始值列表将实参分别传递给每个直接基类。其中基类的构造顺序与派生列表中基类的出现顺序保持一致,而与派生类构造函数初始值列表中基类的顺序无关。一个Panda对象按照如下次序进行初始化:

  • ZooAnimal是整个继承体系的最终基类,Bear是Panda的直接基类,ZooAnimal是Bear的基类,所以首先初始化ZooAnimal。
  • 接下来初始化Panda的第一个直接基类Bear。
  • 然后初始化Panda的第二个直接基类Endangered。
  • 最后初始化Panda。

2.3.继承的构造函数与多重继承

在C++11新标准中,允许派生类从它的一个或几个基类中继承构造函数(参见:继承的构造函数)。但是如果从多个基类中继承了相同的构造函数(即形参列表完全相同),则程序将产生错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Base1 {
    Base1() = default;
    Base1(const std::string&);
    Base1(std::shared_ptr<int>);
};
struct Base2 {
    Base2() = default;
    Base2(const std::string&);
    Base2(int);
};
//错误:D1试图从两个基类中都继承D1::D1(const string&)
struct D1 : public Base1, public Base2 {
    using Base1::Base1; //从Base1继承构造函数
    using Base2::Base2; //从Base2继承构造函数
};

如果一个类从它的多个基类中继承了相同的构造函数,则这个类必须为该构造函数定义它自己的版本:

1
2
3
4
5
6
7
struct D2 : public Base1, public Base2 {
    using Base1::Base1; //从Base1继承构造函数
    using Base2::Base2; //从Base2继承构造函数
    //D2必须自定义一个接受string的构造函数
    D2(const string &s): Base1(s), Base2(s) { }
    D2() = default; //一旦D2定义了它自己的构造函数,则必须出现
};

2.4.析构函数与多重继承

和往常一样,派生类的析构函数只负责清除派生类本身分配的资源,派生类的成员及基类都是自动销毁的。合成的析构函数体为空。

析构函数的调用顺序正好与构造函数相反,在我们的例子中,析构函数的调用顺序是~Panda~Endangered~Bear~ZooAnimal

2.5.多重继承的派生类的拷贝与移动操作

与只有一个基类的继承一样,多重继承的派生类如果定义了自己的拷贝/赋值构造函数和赋值运算符,则必须在完整的对象上执行拷贝、移动或赋值操作(个人注解:所谓“完整的对象”指的是派生类对象的所有部分,包括其继承的基类部分。因此,手动编写这些函数时,需要显式处理基类部分,通常通过调用基类的拷贝/移动构造函数或赋值运算符)。只有当派生类使用的是合成版本的拷贝、移动或赋值成员时,才会自动对其基类部分执行这些操作。在合成的拷贝控制成员中,每个基类分别使用自己的对应成员隐式地完成构造、赋值或销毁等工作。

例如,假设Panda使用了合成版本的成员ling_ling的初始化过程:

1
2
Panda ying_yang("ying_yang");
Panda ling_ling = ying_yang; //使用拷贝构造函数

将调用Bear的拷贝构造函数,后者又在执行自己的拷贝任务之前先调用ZooAnimal的拷贝构造函数。一旦ling_ling的Bear部分构造完成,接着就会调用Endangered的拷贝构造函数来创建对象相应的部分。最后,执行Panda的拷贝构造函数。合成的移动构造函数的工作机理与之类似。

合成的拷贝赋值运算符的行为与拷贝构造函数很相似。它首先赋值Bear部分(并且通过Bear赋值ZooAnimal部分),然后赋值Endangered部分,最后是Panda部分。移动赋值运算符的工作机理与之类似。

3.类型转换与多个基类

在只有一个基类的情况下,派生类的指针或引用能自动转换成一个可访问基类的指针或引用(参见:定义派生类访问控制与继承)。多个基类的情况与之类似。我们可以令某个可访问基类的指针或引用直接指向一个派生类对象。例如,一个ZooAnimal、Bear或Endangered类型的指针或引用可以绑定到Panda对象上:

1
2
3
4
5
6
7
8
//接受Panda的基类引用的一系列操作
void print(const Bear&);
void highlight(const Endangered&);
ostream& operator<<(ostream&, const ZooAnimal&);
Panda ying_yang("ying_yang");
print(ying_yang); //把一个Panda对象传递给一个Bear的引用
highlight(ying_yang); //把一个Panda对象传递给一个Endangered的引用
cout << ying_yang << endl; //把一个Panda对象传递给一个ZooAnimal的引用

编译器不会在派生类向基类的几种转换中进行比较和选择,因为在它看来转换到任意一种基类都一样好。例如,如果存在如下所示的print重载形式:

1
2
void print(const Bear&);
void print(const Endangered&);

则通过Panda对象对不带前缀限定符的print函数进行调用将产生编译错误:

1
2
Panda ying_yang("ying_yang");
print(ying_yang); //二义性错误

3.1.基于指针类型或引用类型的查找

与只有一个基类的继承一样,对象、指针和引用的静态类型决定了我们能够使用哪些成员(参见:继承中的类作用域)。如果我们使用一个ZooAnimal指针,则只有定义在ZooAnimal中的操作是可以使用的,Panda接口中的Bear、Panda和Endangered特有的部分都不可见。类似的,一个Bear类型的指针或引用只能访问Bear及ZooAnimal的成员,一个Endangered的指针或引用只能访问Endangered的成员。

举个例子,已知我们的类已经定义了表18.1列出的虚函数,考虑下面的这些函数调用:

1
2
3
4
5
Bear *pb = new Panda("ying_yang");
pb->print(); //正确:Panda::print()
pb->cuddle(); //错误:不属于Bear的接口
pb->highlight(); //错误:不属于Bear的接口
delete pb; //正确:Panda::~Panda()

个人注解:因为Bear类中并没有定义cuddle()和highlight(),所以即使Panda中有这些函数,通过Bear类型的指针调用这些函数是不允许的。这表明静态类型决定了能调用的成员函数范围。

当我们通过Endangered的指针或引用访问一个Panda对象时,Panda接口中Panda特有的部分以及属于Bear的部分都是不可见的:

1
2
3
4
5
6
Endangered *pe = new Panda("ying_yang");
pe->print(); //正确:Panda::print()
pe->toes(); //错误:不属于Endangered的接口
pe->cuddle(); //错误:不属于Endangered的接口
pe->highlight(); //正确:Panda::highlight()
delete pe; //正确:Panda::~Panda()

4.多重继承下的类作用域

在只有一个基类的情况下,派生类的作用域嵌套在直接基类和间接基类的作用域中(参见:继承中的类作用域)。查找过程沿着继承体系自底向上进行,直到找到所需的名字。派生类的名字将隐藏基类的同名成员。

在多重继承的情况下,相同的查找过程在所有直接基类中同时进行。如果名字在多个基类中都被找到,则对该名字的使用将具有二义性。

对于一个派生类来说,从它的几个基类中分别继承名字相同的成员是完全合法的,只不过在使用这个名字时必须明确指出它的版本。

例如,如果ZooAnimal和Endangered都定义了名为max_weight的成员,并且Panda没有定义该成员,则下面的调用是错误的:

1
double d = ying_yang.max_weight();

Panda在派生的过程中拥有了两个名为max_weight的成员,这是完全合法的。派生仅仅是产生了潜在的二义性,只要Panda对象不调用max_weight函数就能避免二义性错误。另外,如果每次调用max_weight时都指出所调用的版本(ZooAnimal::max_weight或者Endangered::max_weight),也不会发生二义性。只有当要调用哪个函数含糊不清时程序才会出错。

在上面的例子中,派生类继承的两个max_weight会产生二义性,这一点显而易见。一种更复杂的情况是,有时即使派生类继承的两个函数形参列表不同也可能发生错误。此外,即使max_weight在一个类中是私有的,而在另一类中是公有的或受保护的同样也可能发生错误。最后一种情况,假如max_weight定义在Bear中而非ZooAnimal中,上面的程序仍然是错误的。

和往常一样,先查找名字后进行类型检查(参见:重载与作用域)。当编译器在两个作用域中同时发现了max_weight时,将直接报告一个调用二义性的错误。

要想避免潜在的二义性,最好的办法是在派生类中为该函数定义一个新版本。例如,我们可以为Panda定义一个max_weight函数从而解决二义性问题:

1
2
3
4
double Panda::max_weight() const
{
    return std::max(ZooAnimal::max_weight(), Endangered::max_weight());
}

5.虚继承

尽管在派生列表中同一个基类只能出现一次,但实际上派生类可以多次继承同一个类。派生类可以通过它的两个直接基类分别继承同一个间接基类,也可以直接继承某个基类,然后通过另一个基类再一次间接继承该类。

举个例子,IO标准库的istream和ostream分别继承了一个共同的名为base_ios的抽象基类。该抽象基类负责保存流的缓冲内容并管理流的条件状态。iostream是另外一个类,它从istream和ostream直接继承而来,可以同时读写流的内容。因为istream和ostream都继承自base_ios,所以iostream继承了base_ios两次,一次是通过istream,另一次是通过ostream。

在默认情况下,派生类中含有继承链上每个类对应的子部分。如果某个类在派生过程中出现了多次,则派生类中将包含该类的多个子对象。

这种默认的情况对某些形如iostream的类显然是行不通的。一个iostream对象肯定希望在同一个缓冲区中进行读写操作,也会要求条件状态能同时反映输入和输出操作的情况。假如在iostream对象中真的包含了base_ios的两份拷贝,则上述的共享行为就无法实现了。

在C++语言中我们通过虚继承(virtual inheritance)的机制解决上述问题。虚继承的目的是令某个类做出声明,承诺愿意共享它的基类。其中,共享的基类子对象称为虚基类(virtual base class)。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含唯一一个共享的虚基类子对象。

5.1.另一个Panda类

在过去,科学界对于大熊猫属于Raccoon科还是Bear科争论不休。为了如实地反映这种争论,我们可以对Panda类进行修改,令其同时继承Bear和Raccoon。此时,为了避免赋予Panda两份ZooAnimal的子对象,我们将Bear和Raccoon继承ZooAnimal的方式定义为虚继承。图18.3描述了新的继承体系。

观察这个新的继承体系,我们将发现虚继承的一个不太直观的特征:必须在虚派生的真实需求出现前就已经完成虚派生的操作。例如在我们的类中,当我们定义Panda时才出现了对虚派生的需求,但是如果Bear和Raccoon不是从ZooAnimal虚派生得到的,那么Panda的设计者就显得不太幸运了。

在实际的编程过程中,位于中间层次的基类将其继承声明为虚继承一般不会带来什么问题。

虚派生只影响从指定了虚基类的派生类中进一步派生出的类,它不会影响派生类本身。

5.2.使用虚基类

我们指定虚基类的方式是在派生列表中添加关键字virtual:

1
2
3
//关键字public和virtual的顺序随意
class Raccoon : public virtual ZooAnimal { /*...*/ };
class Bear : virtual public ZooAnimal { /*...*/ };

什么样的类能够作为虚基类并没有特殊规定。如果某个类指定了虚基类,则该类的派生仍按常规方式进行:

1
class Panda : public Bear, public Raccoon, public Endangered { };

Panda通过Raccoon和Bear继承了ZooAnimal,因为Raccoon和Bear继承ZooAnimal的方式都是虚继承,所以在Panda中只有一个ZooAnimal基类部分。

5.3.支持向基类的常规类型转换

不论基类是不是虚基类,派生类对象都能被可访问基类的指针或引用操作。例如,下面这些从Panda向基类的类型转换都是合法的:

1
2
3
4
5
6
7
void dance(const Bear&);
void rummage(const Raccoon&);
ostream& operator<<(ostream&, const ZooAnimal&);
Panda ying_yang;
dance(ying_yang); //正确:把一个Panda对象当成Bear传递
rummage(ying_yang); //正确:把一个Panda对象当成Raccoon传递
cout << ying_yang; //正确:把一个Panda对象当成ZooAnimal传递

5.4.虚基类成员的可见性

因为在每个共享的虚基类中只有唯一一个共享的子对象,所以该基类的成员可以被直接访问,并且不会产生二义性。此外,如果虚基类的成员只被一条派生路径覆盖,则我们仍然可以直接访问这个被覆盖的成员。但是如果成员被多余一个基类覆盖,则一般情况下派生类必须为该成员自定义一个新的版本。

例如,假定类B定义了一个名为x的成员,D1和D2都是从B虚继承得到的,D继承了D1和D2,则在D的作用域中,x通过D的两个基类都是可见的。如果我们通过D的对象使用x,有三种可能性:

  • 如果在D1和D2中都没有x的定义,则x将被解析为B的成员,此时不存在二义性,一个D的对象只含有x的一个实例。
  • 如果x是B的成员,同时是D1和D2中某一个的成员,则同样没有二义性,派生类的x比共享虚基类B的x优先级更高。
  • 如果在D1和D2中都有x的定义,则直接访问x将产生二义性问题。

与非虚的多重继承体系一样,解决这种二义性问题最好的方法是在派生类中为成员自定义新的实例。

6.构造函数与虚继承

在虚派生中,虚基类是由最低层的派生类初始化的。以我们的程序为例,当创建Panda对象时,由Panda的构造函数独自控制ZooAnimal的初始化过程。

为了理解这一规则,我们不妨假设当以普通规则处理初始化任务时会发生什么情况。在此例中,虚基类将会在多条继承路径上被重复初始化。以ZooAnimal为例,如果应用普通规则,则Raccoon和Bear都会试图初始化Panda对象的ZooAnimal部分。

当然,继承体系中的每个类都可能在某个时刻成为“最低层的派生类”。只要我们能创建虚基类的派生类对象,该派生类的构造函数就必须初始化它的虚基类。例如在我们的继承体系中,当创建一个Bear(或Raccoon)的对象时,它已经位于派生的最低层,因此Bear(或Raccoon)的构造函数将直接初始化其ZooAnimal基类部分:

1
2
Bear::Bear(std::string name, bool onExhibit) : ZooAnimal(name, onExhibit, "Bear") { }
Raccoon::Raccoon(std::string name, bool onExhibit) : ZooAnimal(name, onExhibit, "Raccoon") { }

而当创建一个Panda对象时,Panda位于派生的最低层并由它负责初始化共享的ZooAnimal基类部分。即使ZooAnimal不是Panda的直接基类,Panda的构造函数也可以初始化ZooAnimal:

1
Panda::Panda(std::string name, bool onExhibit) : ZooAnimal(name, onExhibit, "Panda"), Bear(name, onExhibit), Raccoon(name, onExhibit), Endangered(Endangered::critical), sleeping_flag(false) { }

6.1.虚继承的对象的构造方式

含有虚基类的对象的构造顺序与一般的顺序稍有区别:首先使用提供给最低层派生类构造函数的初始值初始化该对象的虚基类子部分,接下来按照直接基类在派生列表中出现的次序依次对其进行初始化。

例如,当我们创建一个Panda对象时:

  • 首先使用Panda的构造函数初始值列表中提供的初始值构造虚基类ZooAnimal部分。
  • 接下来构造Bear部分。
  • 然后构造Raccoon部分。
  • 然后构造第三个直接基类Endangered。
  • 最后构造Panda部分。

如果Panda没有显式地初始化ZooAnimal基类,则ZooAnimal的默认构造函数将被调用。如果ZooAnimal没有默认构造函数,则代码将发生错误。

虚基类总是先于非虚基类构造,与它们在继承体系中的次序和位置无关。

6.2.构造函数与析构函数的次序

一个类可以有多个虚基类。此时,这些虚的子对象按照它们在派生列表中出现的顺序从左向右依次构造。例如,在下面这个稍显杂乱的TeddyBear派生关系中有两个虚基类:ToyAnimal是直接虚基类,ZooAnimal是Bear的虚基类:

1
2
3
4
class Character { /*...*/ };
class BookCharacter : public Character { /*...*/ };
class ToyAnimal { /*...*/ };
class TeddyBear : public BookCharacter, public Bear, public virtual ToyAnimal { /*...*/ };

编译器按照直接基类的声明顺序对其依次进行检查,以确定其中是否含有虚基类。如果有,则先构造虚基类,然后按照声明的顺序逐一构造其他非虚基类。因此,要想创建一个TeddyBear对象,需要按照如下次序调用这些构造函数:

1
2
3
4
5
6
ZooAnimal(); //Bear的虚基类
ToyAnimal(); //直接虚基类
Character(); //第一个非虚基类的间接基类
BookCharacter(); //第一个直接非虚基类
Bear(); //第二个直接非虚基类
TeddyBear(); //最低层的派生类

合成的拷贝和移动构造函数按照完全相同的顺序执行,合成的赋值运算符中的成员也按照该顺序赋值。和往常一样,对象的销毁顺序与构造顺序正好相反,首先销毁TeddyBear部分,最后销毁ZooAnimal部分。