- 内容提要
- 前言
- 第 1 章 预备知识
- 第 2 章 开始学习 C++
- 第 3 章 处理数据
- 第 4 章 复合类型
- 第 5 章 循环和关系表达式
- 第 6 章 分支语句和逻辑运算符
- 第 7 章 函数——C++的编程模块
- 第 8 章 函数探幽
- 第 9 章 内存模型和名称空间
- 第 10 章 对象和类
- 第 11 章 使用类
- 第 12 章 类和动态内存分配
- 第 13 章 类继承
- 第 14 章 C++中的代码重用
- 第 15 章 友元、异常和其他
- 第 16 章 string 类和标准模板库
- 第 17 章 输入、输出和文件
- 第 18 章 探讨 C++新标准
- 附录 A 计数系统
- 附录 B C++保留字
- 附录 C ASCII 字符集
- 附录 D 运算符优先级
- 附录 E 其他运算符
- 附录 F 模板类 string
- 附录 G 标准模板库方法和函数
- 附录 H 精选读物和网上资源
- 附录 I 转换为 ISO 标准 C++
- 附录 J 复习题答案
14.3 多重继承
MI 描述的是有多个直接基类的类。与单继承一样,公有 MI 表示的也是 is-a 关系。例如,可以从 Waiter 类和 Singer 类派生出 SingingWaiter 类:
请注意,必须使用关键字 public 来限定每一个基类。这是因为,除非特别指出,否则编译器将认为是私有派生:
正如本章前面讨论的,私有 MI 和保护 MI 可以表示 has-a 关系。Student 类的 studenti.h 实现就是一个这样的示例。下面将重点介绍公有 MI。
MI 可能会给程序员带来很多新问题。其中两个主要的问题是:从两个不同的基类继承同名方法;从两个或更多相关基类那里继承同一个类的多个实例。为解决这些问题,需要使用一些新规则和不同的语法。因此,与使用单继承相比,使用 MI 更困难,也更容易出现问题。由于这个原因,很多 C++用户强烈反对使用 MI,一些人甚至希望删除 MI;而喜欢 MI 的人则认为,对一些特殊的工程来说,MI 很有用,甚至是必不可少的;也有一些人建议谨慎、适度地使用 MI。
下面来看一个例子,并介绍有哪些问题以及如何解决它们。要使用 MI,需要几个类。我们将定义一个抽象基类 Worker,并使用它派生出 Waiter 类和 Singer 类。然后,便可以使用 MI 从 Waiter 类和 Singer 类派生出 SingingWaiter 类(参见图 14.3)。这里使用两个独立的派生来使基类(Worker)被继承,这将导致 MI 的大多数麻烦。首先声明 Worker、Waiter 和 Singer 类,如程序清单 14.7 所示。
图 14.3 祖先相同的 MI
程序清单 14.7 Worker0.h
程序清单 14.7 的类声明中包含一些表示声音类型的内部常量。一个枚举用符号常量 alto、contralto 等表示声音类型,静态数组 pv 存储了指向相应 C-风格字符串的指针,程序清单 14.8 初始化了该数组,并提供了方法的定义。
程序清单 14.8 worker0.cpp
程序清单 14.9 是一个简短的程序,它使用一个多态指针数组对这些类进行了测试。
程序清单 14.9 worktest.cpp
下面是程序清单 14.7~程序清单 14.9 组成的程序的输出:
这种设计看起来是可行的:使用 Waiter 指针来调用 Waiter::Show( ) 和 Waiter::Set( );使用 Singer 指针来调用 Singer::Show( ) 和 Singer::Set( )。然后,如果添加一个从 Singer 和 Waiter 类派生出的 SingingWaiter 类后,将带来一些问题。具体地说,将出现以下问题。
- 有多少 Worker?
- 哪个方法?
14.3.1 有多少 Worker
假设首先从 Singer 和 Waiter 公有派生出 SingingWaiter:
因为 Singer 和 Waiter 都继承了一个 Worker 组件,因此 SingingWaiter 将包含两个 Worker 组件(参见图 14.4)。
正如预期的,这将引起问题。例如,通常可以将派生类对象的地址赋给基类指针,但现在将出现二义性:
通常,这种赋值将把基类指针设置为派生对象中的基类对象的地址。但 ed 中包含两个 Worker 对象,有两个地址可供选择,所以应使用类型转换来指定对象:
图 14.4 继承两个基类对象
这将使得使用基类指针来引用不同的对象(多态性)复杂化。
包含两个 Worker 对象拷贝还会导致其他的问题。然而,真正的问题是:为什么需要 Worker 对象的两个拷贝?唱歌的侍者和其他 Worker 对象一样,也应只包含一个姓名和一个 ID。C++引入多重继承的同时,引入了一种新技术——虚基类(virtual base class),使 MI 成为可能。
1.虚基类
虚基类使得从多个类(它们的基类相同)派生出的对象只继承一个基类对象。例如,通过在类声明中使用关键字 virtual,可以使 Worker 被用作 Singer 和 Waiter 的虚基类(virtual 和 public 的次序无关紧要):
然后,可以将 SingingWaiter 类定义为:
现在,SingingWaiter 对象将只包含 Worker 对象的一个副本。从本质上说,继承的 Singer 和 Waiter 对象共享一个 Worker 对象,而不是各自引入自己的 Worker 对象副本(请参见图 14.5)。因为 SingingWaiter 现在只包含了一个 Worker 子对象,所以可以使用多态。
您可能会有这样的疑问:
- 为什么使用术语“虚”?
- 为什么不抛弃将基类声明为虚的这种方式,而使虚行为成为多 MI 的准则呢?
- 是否存在麻烦呢?
首先,为什么使用术语虚?毕竟,在虚函数和虚基类之间并不存在明显的联系。C++用户强烈反对引入新的关键字,因为这将给他们带来很大的压力。例如,如果新关键字与重要程序中的重要函数或变量的名称相同,这将非常麻烦。因此,C++对这种新特性也使用关键字 virtual——有点像关键字重载。
图 14.5 虚基类继承
其次,为什么不抛弃将基类声明为虚的这种方式,而使虚行为成为 MI 的准则呢?第一,在一些情况下,可能需要基类的多个拷贝;第二,将基类作为虚的要求程序完成额外的计算,为不需要的工具付出代价是不应当的;第三,这样做有其缺点,将在下一段介绍。
最后,是否存在麻烦?是的。为使虚基类能够工作,需要对 C++规则进行调整,必须以不同的方式编写一些代码。另外,使用虚基类还可能需要修改已有的代码。例如,将 SingingWaiter 类添加到 Worker 集成层次中时,需要在 Singer 和 Waiter 类中添加关键字 virtual。
2.新的构造函数规则
使用虚基类时,需要对类构造函数采用一种新的方法。对于非虚基类,唯一可以出现在初始化列表中的构造函数是即时基类构造函数。但这些构造函数可能需要将信息传递给其基类。例如,可能有下面一组构造函数:
C 类的构造函数只能调用 B 类的构造函数,而 B 类的构造函数只能调用 A 类的构造函数。这里,C 类的构造函数使用值 q,并将值 m 和 n 传递给 B 类的构造函数;而 B 类的构造函数使用值 m,并将值 n 传递给 A 类的构造函数。
如果 Worker 是虚基类,则这种信息自动传递将不起作用。例如,对于下面的 MI 构造函数:
存在的问题是,自动传递信息时,将通过 2 条不同的途径(Waiter 和 Singer)将 wk 传递给 Worker 对象。为避免这种冲突,C++在基类是虚的时,禁止信息通过中间类自动传递给基类。因此,上述构造函数将初始化成员 panache 和 voice,但 wk 参数中的信息将不会传递给子对象 Waiter。然而,编译器必须在构造派生对象之前构造基类对象组件;在上述情况下,编译器将使用 Worker 的默认构造函数。
如果不希望默认构造函数来构造虚基类对象,则需要显式地调用所需的基类构造函数。因此,构造函数应该是这样:
上述代码将显式地调用构造函数 worker(const Worker &)。请注意,这种用法是合法的,对于虚基类,必须这样做;但对于非虚基类,则是非法的。
警告:
如果类有间接虚基类,则除非只需使用该虚基类的默认构造函数,否则必须显式地调用该虚基类的某个构造函数。
14.3.2 哪个方法
除了修改类构造函数规则外,MI 通常还要求调整其他代码。假设要在 SingingWaiter 类中扩展 Show( ) 方法。因为 SingingWaiter 对象没有新的数据成员,所以可能会认为它只需使用继承的方法即可。这引出了第一个问题。假设没有在 SingingWaiter 类中重新定义 Show( ) 方法,并试图使用 SingingWaiter 对象调用继承的 Show( ) 方法:
对于单继承,如果没有重新定义 Show( ),则将使用最近祖先中的定义。而在多重继承中,每个直接祖先都有一个 Show( ) 函数,这使得上述调用是二义性的。
警告:
多重继承可能导致函数调用的二义性。例如,BadDude 类可能从 Gunslinger 类和 PokerPlayer 类那里继承两个完全不同的 Draw( ) 方法。
可以使用作用域解析运算符来澄清编程者的意图:
然而,更好的方法是在 SingingWaiter 中重新定义 Show( ),并指出要使用哪个 Show( )。例如,如果希望 SingingWaiter 对象使用 Singer 版本的 Show( ),则可以这样做:
对于单继承来说,让派生方法调用基类的方法是可以的。例如,假设 HeadWaiter 类是从 Waiter 类派生而来的,则可以使用下面的定义序列,其中每个派生类使用其基类显示信息,并添加自己的信息:
然而,这种递增的方式对 SingingWaiter 示例无效。下面的方法将无效,因为它忽略了 Waiter 组件:
可以通过同时调用 Waiter 版本的 Show( ) 来补救:
然而,这将显示姓名和 ID 两次,因为 Singer::Show( ) 和 Waiter::Show( ) 都调用了 Worker::Show( )。
如果解决呢?一种办法是使用模块化方式,而不是递增方式,即提供一个只显示 Worker 组件的方法和一个只显示 Waiter 组件或 Singer 组件(而不是 Waiter 和 Worker 组件)的方法。然后,在 SingingWaiter::Show( ) 方法中将组件组合起来。例如,可以这样做:
与此相似,其他 Show( ) 方法可以组合适当的 Data( ) 组件。
采用这种方式,对象仍可使用 Show( ) 方法。而 Data( ) 方法只在类内部可用,作为协助公有接口的辅助方法。然而,使 Data( ) 方法成为私有的将阻止 Waiter 中的代码使用 Worker::Data( ),这正是保护访问类的用武之地。如果 Data( ) 方法是保护的,则只能在继承层次结构中的类中使用它,在其他地方则不能使用。
另一种办法是将所有的数据组件都设置为保护的,而不是私有的,不过使用保护方法(而不是保护数据)将可以更严格地控制对数据的访问。
Set( ) 方法取得数据,以设置对象值,该方法也有类似的问题。例如,SingingWaiter::Set( ) 应请求 Worker 信息一次,而不是两次。对此,可以使用前面的解决方法。可以提供一个受保护的 Get( ) 方法,该方法只请求一个类的信息,然后将使用 Get( ) 方法作为构造块的 Set( ) 方法集合起来。
总之,在祖先相同时,使用 MI 必须引入虚基类,并修改构造函数初始化列表的规则。另外,如果在编写这些类时没有考虑到 MI,则还可能需要重新编写它们。程序清单 14.10 列出了修改后的类声明,程序清单 14.11 列出实现。
程序清单 14.10 workermi.h
程序清单 14.11 workermi.cpp
当然,好奇心要求我们测试这些类,程序清单 14.12 提供了测试代码。注意,该程序使用了多态属性,将各种类的地址赋给基类指针。另外,该程序还在下面的检测中使用了 C-风格字符串库函数 strchr( ):
该函数返回参数 choice 指定的字符在字符串“wstq”中第一次出现的地址,如果没有这样的字符,则返回 NULL 指针。使用这种检测比使用 if 语句将 choice 指定的字符同每个字符进行比较简单。
请将程序清单 14.12 与 workermi.cpp 一起编译。
程序清单 14.12 workmi.cpp
下面是程序清单 14.10~程序清单 14.12 组成的程序的运行情况:
下面介绍其他一些有关 MI 的问题。
1.混合使用虚基类和非虚基类
再来看一下通过多种途径继承一个基类的派生类的情况。如果基类是虚基类,派生类将包含基类的一个子对象;如果基类不是虚基类,派生类将包含多个子对象。当虚基类和非虚基类混合时,情况将如何呢?例如,假设类 B 被用作类 C 和 D 的虚基类,同时被用作类 X 和 Y 的非虚基类,而类 M 是从 C、D、X 和 Y 派生而来的。在这种情况下,类 M 从虚派生祖先(即类 C 和 D)那里共继承了一个 B 类子对象,并从每一个非虚派生祖先(即类 X 和 Y)分别继承了一个 B 类子对象。因此,它包含三个 B 类子对象。当类通过多条虚途径和非虚途径继承某个特定的基类时,该类将包含一个表示所有的虚途径的基类子对象和分别表示各条非虚途径的多个基类子对象。
2.虚基类和支配
使用虚基类将改变 C++解析二义性的方式。使用非虚基类时,规则很简单。如果类从不同的类那里继承了两个或更多的同名成员(数据或方法),则使用该成员名时,如果没有用类名进行限定,将导致二义性。但如果使用的是虚基类,则这样做不一定会导致二义性。在这种情况下,如果某个名称优先于(dominates)其他所有名称,则使用它时,即便不使用限定符,也不会导致二义性。
那么,一个成员名如何优先于另一个成员名呢?派生类中的名称优先于直接或间接祖先类中的相同名称。例如,在下面的定义中:
类 C 中的 q( ) 定义优先于类 B 中的 q( ) 定义,因为类 C 是从类 B 派生而来的。因此,F 中的方法可以使用 q( ) 来表示 C::q( )。另一方面,任何一个 omg( ) 定义都不优先于其他 omg( ) 定义,因为 C 和 E 都不是对方的基类。所以,在 F 中使用非限定的 omg( ) 将导致二义性。
虚二义性规则与访问规则无关,也就是说,即使 E::omg( ) 是私有的,不能在 F 类中直接访问,但使用 omg( ) 仍将导致二义性。同样,即使 C::q( ) 是私有的,它也将优先于 D::q( )。在这种情况下,可以在类 F 中调用 B::q( ),但如果不限定 q( ),则将意味着要调用不可访问的 C::q( )。
14.3.3 MI 小结
首先复习一下不使用虚基类的 MI。这种形式的 MI 不会引入新的规则。然而,如果一个类从两个不同的类那里继承了两个同名的成员,则需要在派生类中使用类限定符来区分它们。即在从 GunSlinger 和 PokerPlayer 派生而来的 BadDude 类中,将分别使用 Gunslinger::draw( ) 和 PokerPlayer::draw( ) 来区分从这两个类那里继承的 draw( ) 方法。否则,编译器将指出二义性。
如果一个类通过多种途径继承了一个非虚基类,则该类从每种途径分别继承非虚基类的一个实例。在某些情况下,这可能正是所希望的,但通常情况下,多个基类实例都是问题。
接下来看一看使用虚基类的 MI。当派生类使用关键字 virtual 来指示派生时,基类就成为虚基类:
主要变化(同时也是使用虚基类的原因)是,从虚基类的一个或多个实例派生而来的类将只继承了一个基类对象。为实现这种特性,必须满足其他要求:
- 有间接虚基类的派生类包含直接调用间接基类构造函数的构造函数,这对于间接非虚基类来说是非法的;
- 通过优先规则解决名称二义性。
正如您看到的,MI 会增加编程的复杂程度。然而,这种复杂性主要是由于派生类通过多条途径继承同一个基类引起的。避免这种情况后,唯一需要注意的是,在必要时对继承的名称进行限定。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论