第05章 C++语言专题(二.02)多继承与虚继承

声明:仅为个人学习总结,还请批判性查看,如有不同观点,欢迎交流。

摘要

内容主要包括:多重继承与虚继承的概念,其派生类对象的构造与拷贝控制、类型转换、作用域。


多重继承(multiple inheritance),是指从多个直接基类派生一个类,派生类继承了所有父类的属性。尽管概念上很简单,但是多个基类相互交织产生的细节可能会带来错综复杂的设计问题和实现问题。

1 程序示例

我们将以动物园中动物的层次关系作为示例,来探讨有关多重继承的问题。

  1. 动物园中的动物(ZooAnimal)可以分为多个科,例如熊科(Bear);
  2. 每个科可以分为多个物种,例如熊科中的大熊猫物种(Panda);
  3. 每个物种包括多个动物,每个动物有自己的名字,例如大熊猫 Ling-ling、Ying-Yang。

示例程序需要:

  • 定义一个抽象类 ZooAnimal,保存动物园中动物共有的信息,并提供公共接口;
  • 定义一个 Bear 类,存放 Bear 科特有的信息;
  • 定义一些辅助类,负责封装不同的抽象,例如濒临灭绝(Endangered)的动物。
  • 定义一个 Panda 类,由 BearEndangered 共同派生获得。

2 多重继承

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

  • 基类数量,没有特殊限制;
  • 在一个派生列表中,同一个基类只能出现一次。
class Bear : public ZooAnimal { /* ... */ };
class Panda : public Bear, public Endangered { /* ... */ };
«abstract»
ZooAnimal
Endangered
Bear
Panda

2.1 派生类从每个基类中继承状态

单继承派生类对象的组成一样,在多重继承关系中,派生类的对象包含每个基类的子对象。

例如,在 Panda 对象中,包含:

  1. 一个 Bear 子部分,其中又包含:
    • 一个 ZooAnimal 子部分(即 ZooAnimal 非静态数据成员);
    • Bear 非静态数据成员;
  2. 一个 Endangered 子部分(即 Endangered 非静态数据成员);
  3. Panda 非静态数据成员。

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

单继承派生类构造函数一样,在多重继承中,构造一个派生类的对象,将同时构造并初始化它的所有基类子对象。

// 显式地初始化所有直接基类
Panda::Panda(string name, bool onExhibit)
      : Bear(name, onExhibit, "Panda"),
        Endangered(Endangered::critical) {}

// 隐式地使用 Bear 的默认构造函数初始化 Bear 子对象
Panda::Panda()
      : Endangered(Endangered::critical) {}

派生类的构造函数初始值列表将实参分别传递给每个直接基类。

  • 基类的构造顺序,与它们在派生列表中出现的顺序一致,与它们在构造函数初始值列表中出现的顺序无关。
  • 类似成员的初始化顺序,与它们在类定义中出现的顺序一致,与它们在构造函数初始值列表中出现的顺序无关。复习回顾:C++(一.03)类 => 3.3 构造函数初始值列表

一个 Panda 对象按照如下次序进行初始化:

  1. 在派生列表中,Panda 的第一个直接基类是 BearBear 的直接基类是 ZooAnimal,所以首先初始化 ZooAnimal
  2. 接下来,初始化 Panda 的第一个直接基类 Bear
  3. 然后,初始化 Panda 的第二个直接基类 Endangered
  4. 最后,初始化 Panda

2.3 继承的构造函数与多重继承(C++11)

在 C++11 标准中,派生类可以从它的一个或多个基类继承构造函数。复习回顾:C++(二.01)单继承 => 6.4 继承的构造函数

如果一个类从它的多个基类继承了相同的构造函数(形参列表完全相同),那么这个类必须为该构造函数定义它自己的版本,否则程序会产生错误。

struct Base1 {
    Base1() = default;
    Base1(const string &);
    Base1(shared_ptr<int>);
};
struct Base2 {
    Base2() = default;
    Base2(const string &);
    Base2(int);
};

// 错误:D1 试图从两个基类中都继承 D1::D1 (const string&)
struct D1 : public Base1, public Base2 {
    using Base1::Base1; // inherit constructors from Base1
    using Base2::Base2; // inherit constructors from Base2
};

struct D2 : public Base1, public Base2 {
    using Base1::Base1; //  inherit constructors from Base1
    using Base2::Base2; //  inherit constructors from Base2

    // D2 必须自定义一个接受 string 的构造函数
    D2(const string &s) : Base1(s), Base2(s) {}
    D2() = default; // needed once D2 defines its own constructor
};

2.4 析构函数与多重继承

和单继承派生类析构函数一样,复习回顾:C++(二.01)单继承 => 6.3.3 派生类析构函数
在多重继承中:

  • 派生类的析构函数只负责清除派生类本身分配的资源;
  • 派生类的成员和基类都被隐式销毁;
  • 合成的析构函数体为空;
  • 析构函数的调用顺序与构造函数正好相反。
    • 示例代码的调用顺序为:~Panda~Endangered~Bear~ZooAnimal

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

和单继承派生类的拷贝与移动操作一样,复习回顾:C++(二.01)单继承 => 6.3 派生类的拷贝控制成员
在多重继承中:

  • 派生类如果定义了自己的拷贝/移动构造函数和赋值运算符,那么就必须拷贝、移动或赋值包括基类部分在内的完整对象。
  • 只有当派生类使用的是合成版本的拷贝、移动或赋值成员时,才会自动对其基类部分执行相应操作。

例如,假设 Panda 使用了合成版本的成员,

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

那么 ling_ling 的初始化过程为:

  1. 调用 Bear 的拷贝构造函数,Bear 在执行自己的拷贝任务之前,又先调用 ZooAnimal 的拷贝构造函数;
  2. 调用 Endangered 的拷贝构造函数;
  3. 执行 Panda 的拷贝构造函数。

合成的移动构造函数与拷贝构造函数类似。

合成的拷贝赋值运算符的行为与拷贝构造函数类似:

  1. 首先赋值 Bear 部分(通过 Bear 赋值 ZooAnimal 部分);
  2. 然后赋值 Endangered 部分;
  3. 最后赋值 Panda 部分。

合成的移动赋值运算符与拷贝赋值运算符类似。

3 类型转换与多个基类

在只有一个基类的情况下,派生类的指针或引用能够自动转换成一个可访问基类的指针或引用。

在多个基类的情况下,也是如此,可以让某个可访问基类的指针或引用直接指向一个派生类对象。

// 接受 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 重载形式
void print(const Bear&);
void print(const Endangered&);

// 通过 Panda 对象调用 print 函数将产生编译错误
Panda ying_yang("ying_yang");
print(ying_yang);             // 二义性错误

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

和单继承一样,在多重继承中,对象、指针和引用的静态类型决定了可以使用哪些成员。复习回顾:C++(二.01)单继承 => 5.1 在编译时进行名字查找

例如,类 ZooAnimal/Endangered 中定义的虚函数如下:

函数含有自定义版本的类
printZooAnimal::ZooAnimal
Bear::Bear
Endangered::Endangered
Panda::Panda
highlightEndangered::Endangered
Panda::Panda
toesBear::Bear
Panda::Panda
cuddlePanda::Panda
析构函数ZooAnimal::ZooAnimal
Endangered::Endangered
// 通过 Bear 的指针或引用访问一个 Panda 对象时,
// 只能访问 Bear 和 ZooAnimal 的成员,
// Panda 接口中 Panda 特有的部分,以及属于 Endangered 的部分都是不可见的。
Bear *pb = new Panda("ying_yang");
pb->print();     // ok: Panda::print()
pb->cuddle();    // error: not part of the Bear interface
pb->highlight(); // error: not part of the Bear interface
delete pb;       // ok: Panda::~Panda()

// 通过 Endangered 的指针或引用访问一个 Panda 对象时,
// 只能访问 Endangered 的成员,
// Panda 接口中 Panda 特有的部分,以及属于 Bear、ZooAnimal 的部分都是不可见的。
Endangered *pe = new Panda("ying_yang");
pe->print();     // ok: Panda::print()
pe->toes();      // error: not part of the Endangered interface
pe->cuddle();    // error: not part of the Endangered interface
pe->highlight(); // ok: Panda::highlight()
delete pe;       // ok: Panda::~Panda()

4 多重继承下的类作用域

在只有一个基类的情况下,派生类的作用域嵌套在直接基类和间接基类的作用域中。名字查找过程沿着继承体系自底向上进行,直到找到所需的名字。派生类的名字会隐藏基类的同名成员。复习回顾:C++(二.01)单继承 => 5 继承中的类作用域

在多重继承的情况下,相同的查找过程会在所有直接基类中同时进行。如果一个名字在多个基类中被找到,那么对该名字的使用具有二义性。但是,对于一个派生类来说,从它的几个基类中分别继承名字相同的成员是完全合法的,只不过在使用这个名字时,必须明确指出它的版本。

struct Endangered {
    double max_weight() const;
    //...
};
struct ZooAnimal {
    double max_weight() const;
    //...
};
struct Bear : public ZooAnimal {
    //...
};
struct Panda : public Bear, public Endangered {
    Panda(const string &name);
    //...
};

int main()
{
    Panda ying_yang("ying_yang");

    // 如果通过 Panda 对象、指针或引用使用了某个名字,
    // 程序会并行地在 Endangered 和 Bear/ZooAnimal 这两颗子树中查找。
    // 如果在多颗子树中找到该名字,那么该名字的使用具有二义性。
    double d = ying_yang.max_weight(); // error: ambiguous access of 'max_weight'

    // 每次使用这个名字时,明确指出它的版本,可以避免二义性
    double d2 = ying_yang.ZooAnimal::max_weight();
    double d3 = ying_yang.Endangered::max_weight();
}

更复杂的二义性情况还包括:对于派生类继承的两个同名函数,

  • 形参列表并不相同(先查找名字,后检查类型);
  • 在一个类中是私有的,在另一个类中是公有的或受保护的;
  • 名字出现在子树的不同层级(例如 max_weight 定义在 Bear 中)。

为了避免潜在的二义性, 更好的办法是,在派生类中为该函数定义一个自己的版本。

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

5 虚继承

尽管在派生列表中同一个基类只能出现一次,但实际上派生类可以间接地多次继承同一个基类。例如,在 IO 标准库中,输入输出流 iostream 继承了抽象基类 basic_ios 两次,一次通过输入流 istream,一次通过输出流 ostream

«abstract»
basic_ios
istream
ostream
iostream

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

但是,在一些情况下,我们并不希望在派生类对象中包含某个基类的多个子对象。例如,基类 basic_ios 对象负责保存流的缓冲内容,管理流的条件状态。而派生类 iostream 对象需要在同一个缓冲区中进行读写操作,需要条件状态同时反映输入和输出操作的情况,这时,就需要 istreamostream 共享同一个 basic_ios 子对象,也就是将 basic_ios 声明为虚基类。

虚基类(virtual base class),为派生类提供共享子对象的基类。
虚继承(virtual inheritance),派生类在派生列表中使用关键字 virtual 来声明虚基类,承诺愿意共享它的虚基类。在虚继承中,无论虚基类在继承体系中出现多少次,派生类对象只包含该虚基类的一个共享子对象。

5.1 使用虚基类

在过去,科学界对于大熊猫属于浣熊科(Raccoon)还是熊科(Bear)争论不休。为了反映这种争论,我们对 Panda 类进行修改,使其同时继承 BearRaccoon。此时,为了避免赋予 Panda 两份 ZooAnimal 的子对象,将 BearRaccoon 继承 ZooAnimal 的方式定义为虚继承。

«abstract»
ZooAnimal
Endangered
Bear
Raccoon
Panda

Note:虚继承只影响从“指定了虚基类的派生类”中进一步派生出的类(如 Panda),不影响派生类本身(如 BearRaccoon)。

// 通过在派生列表中添加关键字 virtual 来指定虚基类
// 关键字 public 和 virtual 的顺序随意
class Raccoon : public virtual ZooAnimal { /* ... */ };
class Bear : virtual public ZooAnimal { /* ... */ };

// 对于指定了虚基类的派生类(Bear/Raccoon),它的派生类(Panda)仍然按照常规方式定义
// Panda 通过 Bear 和 Raccoon 继承了 ZooAnimal,但在 Panda 中只有一个 ZooAnimal 基类部分
class Panda : public Bear, public Raccoon, public Endangered { /* ... */ };

5.2 向基类的常规类型转换

不管基类是不是虚基类,派生类对象都能被可访问基类的指针或引用操作。

// 下面这些从 Panda 向基类的类型转换都是合法的
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.3 虚基类成员的可见性

通过派生类对象访问虚基类的成员时,

  • 因为每个虚基类只有唯一一个共享的子对象,所以该基类的成员可以被直接访问,并不会产生二义性。
  • 如果虚基类的成员只被一条派生路径覆盖,仍然可以直接访问这个被覆盖的成员;
  • 如果虚基类的成员被多条派生路径覆盖,通常情况下,派生类必须为该成员定义一个自己的版本

参考如下类图,假设在类 D 的作用域中,类 B 的成员 x 通过 D 的两个基类 D1D2 都可见。

B
+void x()
D1
D2
D

通过 D 的对象使用 x,有三种可能:

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

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

6 构造函数与虚继承

在虚继承中,虚基类由最终派生类的构造函数进行初始化
当然,继承体系中的每个类在某个时刻都可能成为“最终端的派生类”。只要能够创建虚基类的派生类对象,那么该派生类的构造函数就必须初始化它的虚基类。

// 在创建一个 Bear(或 Raccoon)的对象时,它就是最终端的派生类,
// 因此,Bear(或 Raccoon)的构造函数需要直接初始化它的 ZooAnimal 基类部分
Bear::Bear(string name, bool onExhibit): ZooAnimal(name, onExhibit, "Bear") { }
Raccoon::Raccoon(string name, bool onExhibit): ZooAnimal(name, onExhibit, "Raccoon") { }

// 在创建一个 Panda 对象时,Panda 是最终端的派生类,由它负责初始化共享的 ZooAnimal 基类部分;
// 即使 ZooAnimal 不是 Panda 的直接基类,Panda 的构造函数也可以初始化 ZooAnimal
Panda::Panda(string name, bool onExhibit)
      : ZooAnimal(name, onExhibit, "Panda"),
        Bear(name, onExhibit),
        Raccoon(name, onExhibit),
        Endangered(Endangered::critical),
        sleeping_flag(false) {}

6.1 虚继承对象的构造方式

含有虚基类的对象的构造顺序,与通常的顺序稍有区别

  1. 首先,使用“提供给最终端派生类构造函数的”初始值,初始化该对象的虚基类子部分;
  2. 然后,按照直接基类在派生列表中出现的次序依次对其进行初始化。
class Raccoon : public virtual ZooAnimal { /* ... */ };
class Bear : virtual public ZooAnimal { /* ... */ };
class Panda : public Bear, public Raccoon, public Endangered { /* ... */ };

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

例如,在创建一个 Panda 对象时,

  1. 首先使用 Panda 构造函数初始值列表中提供的初始值,构造虚基类 ZooAmimal 部分;
    • 如果 Panda 构造函数没有显式地初始化 ZooAnimal 基类,那么会使用 ZooAnimal 的默认构造函数;
    • 如果 ZooAnimal 没有默认构造函数,那么程序会产生错误。
  2. 接下来,构造第一个直接基类 Bear 部分;
  3. 然后,构造第二个直接基类 Raccoon 部分;
  4. 再然后,构造第三个直接基类 Endangered 部分;
  5. 最后,构造 Panda 部分。

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

6.2 多个虚基类构造/析构次序

一个类可以有多个虚基类。在这种情况下,这些虚基类子对象按照它们在派生列表中出现的顺序从左向右依次构造。

  1. 首先,按照直接基类的声明顺序,依次检查其中是否含有虚基类,如果含有虚基类,则优先构造虚基类;
  2. 然后,按照直接基类的声明顺序,逐一构造其他非虚基类。
// 在 TeddyBear 的派生关系中,有两个虚基类:
// ToyAnimal 是直接虚基类,ZooAnimal 是 Bear 的虚基类:
class Bear : virtual public ZooAnimal { /* ... */ };
class Character { /* ... */ };
class BookCharacter : public Character { /* ... */ };
class ToyAnimal { /* ... */ };
class TeddyBear : public BookCharacter, public Bear, public virtual ToyAnimal { /* ... */ };

// 创建 TeddyBear 对象时,按照以下次序调用构造函数:
ZooAnimal();        // Bear 的虚基类
ToyAnimal();        // 直接虚基类
Character();        // 第一个直接非虚基类的基类(间接非虚基类)
BookCharacter();    // 第一个直接非虚基类
Bear();             // 第二个直接非虚基类
TeddyBear();        // 最终端的派生类

// 销毁 TeddyBear 对象时,顺序正好相反,首先销毁 TeddyBear 部分,最后销毁 ZooAnimal 部分

与上述构造顺序比较:

  • 合成的拷贝和移动构造函数,按照完全相同的顺序执行;
  • 合成的赋值运算符中的成员,也按照该顺序进行赋值;
  • 对象的销毁顺序与构造顺序正好相反。

参考

  1. [美] Stanley B.Lippman著.C++ Primer 中文版(第5版).电子工业出版社.2013.
  2. [美] Stephen Prata著.C++ Primer Plus(第6版)中文版.人民邮电出版社.2012.

宁静以致远,感谢 Vico 老师。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值