第10章 实现继承

    面向对象编程基于 4 个重要方面:封装、抽象、继承和多态。继承是一种强大的属性重用方式,是通向多态的跳板。

    在本章中,您将学习:

    • 编程意义的继承;

    • C++继承语法;

    • 公有继承、私有继承和保护继承;

    • 多继承;

    • 隐藏基类方法和切除(slicing)导致的问题。

    10.1 继承基础

    在Tom Smith从祖先那里继承的东西中,最重要的是姓,因此他姓Smith。另外,他还从父母那里继承了某些价值观以及木雕手艺,因为Smith家族数代人都从事木雕行业。这些属性一起标识了Tom作为Smith家族后代的身份。

    在编程领域,您经常会遇到具有类似属性,但细节或行为存在细微差异的组件。在这些情形下,一种解决之道是将每个组件声明为一个类,并在每个类中实现所有的属性,这将重复实现相同的属性。另一种解决方案是使用继承,从同一个基类派生出类似的类,在基类中实现所有通用的功能,并在派生类中覆盖基本功能,以实现让每个类都独一无二的行为。第二种方法通常更佳。面向对象编程支持继承,如图10.1所示。

    第10章 实现继承 - 图1

    图10.1 类之间的继承关系

    10.1.1 继承和派生

    图10.1说明了基类与派生类之间的关系。现在要明白基类和派生类是什么可能不容易;派生类继承了基类,从这种意义上说,它是一个基类,就像Tom是Smith家族的成员一样。

    第10章 实现继承 - 图2派生类和基类之间的这种is-a关系仅适用于公有继承。本章首先介绍公有继承,让您明白继承的概念以及最常见的继承方式,然后再介绍私有和保护继承。

    为方便理解这种概念,先来看基类Bird(鸟)。Crow(乌鸦)、Parrot(鹦鹉)和Kiwi(鹬鸵)都是从Bird派生而来的。Bird类将定义鸟的最基本属性,如有羽毛和翅膀、孵蛋、能飞。派生类Crow、Parrot和Kiwi继承这些属性,并进行定制(例如,Kiwi不包含Fly()的实现)。表10.1说明了其他几个继承的例子。

    表10.1 日常生活中的公有继承示例

    第10章 实现继承 - 图3

    这些示例表明,戴上面向对象编程的眼镜后,就可在周围的很多物体中看到继承的例子。鱼是金枪鱼的基类,因为与鲤鱼一样,金枪鱼也是一种鱼,具备鱼的所有特征,如冷血。然而,金枪鱼在外观、游泳方式上不同于鲤鱼,另外,它是一种海水鱼。因此,金枪鱼和鲤鱼都从基类鱼那里继承了一些共同的特征,同时新增了一些特征,让它们彼此不同。图10.2说明了这一点。

    第10章 实现继承 - 图4

    图10.2 Tuna、Carp和Fish之间的继承关系

    鸭嘴兽(Platypus)能游,但是一种特殊动物,具备哺乳动物的特征,如通过哺乳喂养后代,同时具备鸟类的特征(如产卵)和爬行动物的特征(有毒腺)。因此,可将Platypus类视为继承了两个基类——Mammal和Bird。这种继承被称为多继承,将在本章后面讨论。

    10.1.2 C++派生语法

    如何从Fish类派生出Carp类呢?C++派生语法如下:

    第10章 实现继承 - 图5

    其中access-specifier可以是public(这是最常见的,表示派生类是一个基类)、private或protected (表示派生类有一个基类)。

    下面的继承层次结构表明,Carp类是从Fish类派生而来的:

    第10章 实现继承 - 图6

    程序清单10.1从Fish类派生出了Carp和Tuna类,这些代码能够通过编译。

    有关术语的说明

    阅读介绍继承的文献时,您将遇到“从…继承而来”和“从…派生而来”等术语,它们的含义相同。

    同样,基类也被称为超类;从基类派生而来的类称为派生类,也叫子类。

    程序清单10.1 鱼类世界呈现的一种简单的继承层次结构

    第10章 实现继承 - 图7

    输出:

    第10章 实现继承 - 图8

    分析:

    在main()中,第37和38行分别创建了一个Carp对象和一个Tuna对象:MyLunch和MyDinner。第43和46行调用方法Swim()让这些对象游动。接下来,看看Tuna和Carp的类定义,如第17~24行和第26~33行所示。正如您看到的,这些类非常紧凑,好像都没有定义main()中调用的Swim()方法。显然,Swim()来自第3~15行定义的基类Fish。由于Fish类声明了公有方法Swim(),从Fish类(通过公有继承,如第17和26行所示)派生而来的Tuna和Carp类将自动暴露该基类的公有方法Swim()。注意到Carp和Tuna类的构造函数初始化了基类的FreshWaterFish标记,该标记决定了Fish::Swim()显示的输出。

    10.1.3 访问限定符protected

    在程序清单10.1中,Fish类包含公有属性FreshWaterFish。派生类Tuna和Carp通过设置它来定制Fish的行为——在海水还是淡水中游动。然而,程序清单10.1存在一个严重的缺陷:如果您愿意,可在main()中修改这个被声明为公有的标记,这为在Fish类外部使用类似于下面的代码操纵该标记打开了方便之门:

    第10章 实现继承 - 图9

    这种缺陷显然应该避免。您需要让基类的某些属性能在派生类中访问,但不能在继承层次结构外部访问。这意味着您希望Fish类的布尔标记FreshWaterFish可在派生类Tuna和Carp中访问,但不能在实例化Tuna和Carp的main()中访问。为此,可使用关键字protected。

    第10章 实现继承 - 图10与public和private一样,protected也是一个访问限定符。将属性声明为protected时,相当于允许派生类和友元类访问它,但禁止在继承层次结构外部(包括main())访问它。

    要让派生类能够访问基类的某个属性,可使用访问限定符protected,如程序清单10.2所示。

    程序清单10.2 一种更好的Fish类设计,它使用关键字protected将成员属性只暴露给派生类

    第10章 实现继承 - 图11

    输出:

    第10章 实现继承 - 图12

    分析:

    虽然程序清单10.2的输出与程序清单10.1相同,但对Fish类做了大量重要的修改,如第3~19行所示。第一项也是最显而易见的修改是,布尔成员Fish::FreshWaterFish现在是一个protected属性,因此不能在main()中访问,如第51行所示(如果取消对这行的注释,将导致编译错误)。但在派生类Tuna和Carp中,仍可访问这个protected属性,如第23和32行所示。这个小程序表明,通过使用关键字protected,可对需要继承的基类属性进行保护,禁止在继承层次结构外部访问它。

    这是面向对象编程的一个非常重要的方面,它与数据抽象和继承一起确保派生类可安全地继承基类的属性,同时禁止在继承层次结构外部对其进行修改。

    10.1.4 基类初始化——向基类传递参数

    如果基类包含重载的构造函数,需要在实例化时给它提供实参,该怎么办呢?创建派生对象时将如何实例化这样的基类?方法是使用初始化列表,并通过派生类的构造函数调用合适的基类构造函数,如下面的代码所示:

    第10章 实现继承 - 图13

    对 Fish 类来说,这种机制很有用。通过给 Fish 的构造函数提供一个布尔输入参数,以初始化Fish::FreshWaterFish,可强制每个派生类都指出它是淡水鱼还是海水鱼,如程序清单10.3所示。

    程序清单10.3 包含初始化列表的派生类构造函数

    第10章 实现继承 - 图14

    输出:

    第10章 实现继承 - 图15

    分析:

    现在,Fish 有一个构造函数,它接受一个默认参数,用于初始化 Fish::FreshWaterFish。因此,要创建Fish对象,必须提供一个用于初始化该保护成员的参数。这样,Fish类便避免了保护成员包含随机值,尤其是派生类忘记设置它时。派生类Tuna和Carp被迫定义一个这样的构造函数,即使用合适的参数(true或false,表示是否是淡水鱼)来实例化基类Fish,如第24和30行所示。

    第10章 实现继承 - 图16在程序清单10.3中,派生类没有直接访问布尔成员变量Fish::FreshWaterFish,虽然这是一个protected成员,这是因为这个变量是通过Fish的构造函数设置的。

    为最大限度地提高安全性,对于派生类不需要访问的基类属性,别忘了将其声明为私有的。

    10.1.5 在派生类中覆盖基类的方法

    如果派生类实现了从基类继承的函数,且返回值和特征标相同,就相当于覆盖了基类的这个方法,如下面的代码所示:

    第10章 实现继承 - 图17

    因此,如果使用Derived类的实例调用方法DoSomething(),调用的将不是Base类中的这个方法。

    如果Tuna和Carp类实现了自己的Swim()方法,则在程序清单10.3的main()中,下述代码将调用Tuna::Swim()的实现,这相当于覆盖了基类Fish的方法Swim():

    第10章 实现继承 - 图18

    程序清单10.4演示了这一点。

    程序清单10.4 派生类Tuna和Carp覆盖了基类Fish的方法Swim()

    第10章 实现继承 - 图19

    输出:

    第10章 实现继承 - 图20

    分析:

    输出表明,第51行的myLunch.Swim()调用的是第37~40行定义的Carp::Swim()。同样,第54行的myDinner.Swim()调用的是第26~29行定义的Tuna::Swim()。换句话说,基类Fish中Swim()的实现(第12~18行)被派生类Tuna和Carp类中的Swim()覆盖了。要调用Fish::Swim(),要么让派生类在其成员函数中显式地使用它,要么在main()中使用作用域解析运算符显式地调用它,这将在本章后面演示。

    10.1.6 调用基类中被覆盖的方法

    在程序清单10.4中,派生类Tuna通过实现Swim()覆盖了Fish类的Swim()函数,其结果如下:

    第10章 实现继承 - 图21

    在程序清单10.4中,如果要在main()中调用Fish::Swim(),需要使用作用域解析运算符(::),如下所示:

    第10章 实现继承 - 图22

    稍后的程序清单10.5演示了如何通过派生类的实例调用基类的成员。

    10.1.7 在派生类中调用基类的方法

    通常,Fish::Swim()包含适用于所有鱼类(包括金枪鱼和鲤鱼)的通用实现。如果要在Tuna::Swim()和Carp::Swim()的实现中重用Fish::Swim()的通用实现,可使用作用域解析运算符(::),如下面的代码所示:

    第10章 实现继承 - 图23

    程序清单10.5演示了这一点。

    程序清单10.5 在基类方法和main()中,使用作用域解析运算符(::)来调用基类方法

    第10章 实现继承 - 图24

    输出:

    第10章 实现继承 - 图25

    分析:

    第 37~41 行的 Carp::Swim()使用作用域解析运算符(::)调用了基类方法 Fish::Swim(),而第 55行演示了如何在main中,使用作用域解析运算符(::)通过Tuna实例调用基类方法Fish::Swim()。

    10.1.8 在派生类中隐藏基类的方法

    覆盖的一种极端情形是,Tuna::Swim()可能隐藏 Fish::Swim()的所有重载版本,使得调用这些重载版本会导致编译错误(因此称为被隐藏),程序清单10.6演示了这一点。

    程序清单10.6 Tuna::Swim()隐藏了重载方法Fish::Swim(bool)

    第10章 实现继承 - 图26

    输出:

    第10章 实现继承 - 图27

    分析:

    这个版本的Fish类与前面的Fish类有点不同。除采用尽可能简单的版本诠释当前问题外,这个 Fish 版本还包含两个重载的 Swim()方法:一个不接受任何参数,如第 6~9 行所示,另一个接受一个bool参数,如第11~17行所示。鉴于Tuna以公有方式继承了Fish,您可能认为通过Tuna实例可调用这两个版本的Fish::Swim()方法。然而,由于Tuna实现了自己的Tuna::Swim(),如第23~26行所示,这对编译器隐藏了Fish::Swim(bool)。如果取消对第35行的注释,将出现编译错误。

    要通过Tuna实例调用Fish::Swim(bool),可采用如下解决方案。

    • 解决方案1:在main()中使用作用域解析运算符(::)。

    第10章 实现继承 - 图28

    • 解决方案2:在Tuna类中,使用关键字using解除对Fish::Swim()的隐藏。

    第10章 实现继承 - 图29

    • 解决方案3:在Tuna类中,覆盖Fish::Swim()的所有重载版本(如果需要,可通过Tuna::Fish(…)调用方法Fish::Swim())。

    第10章 实现继承 - 图30

    10.1.9 构造顺序

    如果Tuna类是从Fish类派生而来的,创建Tuna对象时,先调用Tuna的构造函数还是Fish的构造函数?另外,实例化对象时,成员属性(如 Fish::FreshWaterFish)是调用构造函数之前还是之后实例化?基类对象在派生类对象之前被实例化,因此,首先构造Tuna对象的Fish部分,这样实例化Tuna部分时,成员属性(具体地说是Fish的保护和公有属性)已准备就绪,可以使用了。实例化Fish部分和 Tuna 部分时,先实例化成员属性(如 Fish::FreshWaterFish),再调用构造函数,确保成员属性准备就绪,可供构造函数使用。这也适用于Tuna::Tuna()。

    10.1.10 析构顺序

    Tuna实例不再在作用域内时,析构顺序与构造顺序相反。程序清单10.7所示的简单示例演示了构造顺序和析构顺序。

    程序清单10.7 基类、派生类及其成员的构造顺序和析构顺序

    第10章 实现继承 - 图31

    输出:

    第10章 实现继承 - 图32

    分析:

    第67~70行的main()很短,但输出量很大。实例化一个Tuna对象就生成了这些输出,这是由于构造函数和析构函数包含cout语句。为帮助理解成员变量是如何被实例化和销毁的,定义了两个毫无用途的类——FishDummyMember 和 TunaDummyMember,并在其构造函数和析构函数中包含了 cout语句。Fish和Tuna类分别将这些类的对象作为成员,如第20和53行所示。输出表明,实例化Tuna对象时,将从继承层次结构顶部开始,因此首先实例化Tuna对象的Fish部分。为此,首先实例化Fish的成员属性,即Fish::dummy。构造好成员属性(如dummy)后,将调用Fish的构造函数。构造好基类部分后,将实例化Tuna部分——首先实例化成员Tuna::dummy,再执行构造函数Tuna::Tuna()的代码。输出表明,析构顺序正好相反。

    10.2 私有继承

    前面介绍的都是公有继承,私有继承的不同之处在于,指定派生类的基类时使用关键字private:

    第10章 实现继承 - 图33

    私有继承意味着在派生类的实例中,基类的所有公有成员和方法都是私有的——不能从外部访问。换句话说,即便是 Base 类的公有成员和方法,也只能被 Derived 类使用,而无法通过 Derived实例来使用它们。

    这与前面的示例截然不同。在程序清单10.1中,可在main()中通过Tuna实例调用Fish::Swim(),因为 Fish::Swim()是个公有方法,且 Tuna类是以公有方式从 Fish类派生而来的。如果将第 17行的public改为private,该程序清单将无法通过编译。

    因此,从继承层次结构外部看,私有继承并非 is-a 关系。私有继承使得只有子类才能使用基类的属性和方法,因此也被称为has-a关系。在现实世界中,存在一些私有继承的例子,如表10.2所示。

    表10.2 现实世界的私有继承示例

    第10章 实现继承 - 图34

    下面来看看汽车与发动机之间的私有继承关系,如程序清单10.8所示。

    程序清单10.8 Car类以私有方式继承Motor类

    第10章 实现继承 - 图35

    输出:

    第10章 实现继承 - 图36

    分析:

    Motor 类是在第3~18行定义的,它非常简单,包含3 个公有的成员函数,它们分别开启点火开关、喷油和点火。Car类使用关键字private继承了Motor类,如第20行所示。公有函数Car::Move()调用了基类Motor的成员函数。如果在main()中插入下述代码:

    第10章 实现继承 - 图37

    将无法通过编译,编译错误类似于下面这样:error C2247: Motor::PumpFuel not accessible because ‘Car’ uses ‘private’ to inherit from ‘Motor’。

    第10章 实现继承 - 图38如果有一个SuperCar类,它继承了Car类,则不管SuperCar和Car之间的继承关系是什么样的,SuperCar都不能访问基类Motor的公有成员和方法。这是因为Car和Motor之间是私有继承关系,这意味着除Car外,其他所有实体都不能访问Motor的公有成员。换句话说,编译器在确定派生类能否访问基类的公有或保护成员时,考虑的是继承层次结构中最严格的访问限定符。

    10.3 保护继承

    保护继承不同于公有继承之处在于,声明派生类继承基类时使用关键字protected:

    第10章 实现继承 - 图39

    保护继承与私有继承的类似之处如下:

    • 它也表示has-a关系;

    • 它也让派生类能够访问基类的所有公有和保护成员;

    • 在继承层次结构外面,也不能通过派生类实例访问基类的公有成员。

    随着继承层次结构的加深,保护继承将与私有继承有些不同:

    第10章 实现继承 - 图40

    在保护继承层次结构中,子类的子类(即Derived2)能够访问Base类的公有成员,如程序清单10.9所示。如果Derived和Base之间的继承关系是私有的,就不能这样做。

    程序清单10.9 SuperCar类以保护方式继承了Car类,而Car类以保护方式继承了Motor类

    第10章 实现继承 - 图41

    输出:

    第10章 实现继承 - 图42

    分析:

    Car类以保护方式继承了Motor类,如第20行所示,而SuperCar类以保护方式继承了Car类,如第31 行所示。正如您看到的,SuperCar::Move()的实现使用了基类 Motor 中定义的公有方法。能否经由中间基类Car访问终极基类Motor的公有成员呢?这取决于Car和Motor之间的继承关系。如果继承关系是私有的,而不是保护的,SuperCar将不能访问Motor类的公有成员,因为编译器根据最严格的访问限定符来确定访问权。请注意,SuperCar和Car之间的继承关系不会影响它对Motor类公有成员的访问权,因此即便将第31行的protected改为public或private,也改变不了这个程序能否通过编译的命运。

    第10章 实现继承 - 图43仅当必要时才使用私有或保护继承。

    对于大多数使用私有继承的情形(如Car和Motor之间的私有继承),更好的选择是,将基类对象作为派生类的一个成员属性。通过继承Motor类,相当于对Car类进行了限制,使其只能有一台发动机,同时,相比于将Motor对象作为私有成员,没有任何好处可言。汽车在不断发展,例如,混合动力车除电力发动机外,还有一台汽油发动机。在这种情况下,让Car类继承Motor类将成为兼容性瓶颈。

    第10章 实现继承 - 图44将Motor对象作为Car类的私有成员被称为组合(composition)或聚合(aggergation),这样的Car类类似于下面这样:

    第10章 实现继承 - 图45

    这是一种不错的设计,让您能够轻松地在Car类中添加Motor成员,而无需改变继承层次机构,也不用修改客户看到的设计。

    10.4 切除问题

    如果程序员像下面这样做,结果将如何呢?

    第10章 实现继承 - 图46

    如果程序员像下面这样做,结果又将如何呢?

    第10章 实现继承 - 图47

    它们都将Derived对象复制给Base对象,一个是通过显式复制,另一个是通过传递参数。在这些情形下,编译器将只复制objectDerived的Base部分,即不是整个对象,而是Base能容纳的部分,这通常不是程序员的本意。这种无意间裁减数据,导致Derived变成Base的行为称为切除(slicing)。

    第10章 实现继承 - 图48要避免切除问题,不要按值传递参数,而应以指向基类的指针或const引用的方式传递。

    10.5 多继承

    本章前面说过,在有些情况下,采用多继承是合适的,如对鸭嘴兽来说就很合适,这是因为鸭嘴兽具备哺乳动物、鸟类和爬行动物的特征。为应对这样的情形,C++允许继承多个基类:

    第10章 实现继承 - 图49

    图10.3是Platypus(鸭嘴兽)的类图,这与Tuna和Carp的类图(见图10.2)完全不同。

    第10章 实现继承 - 图50

    图10.3 Platypus类与Mammal、Reptile和Bird类之间的关系

    因此,表示Platypus类的C++代码如下:

    第10章 实现继承 - 图51

    程序清单10.10的Platypus类演示了多继承。

    程序清单10.10 使用多继承模拟具备哺乳动物、鸟类和爬行动物特征的鸭嘴兽

    第10章 实现继承 - 图52

    输出:

    第10章 实现继承 - 图53

    分析:

    Platypus类的定义非常简单,如第30~37行所示。除继承Mammal、Reptile和Bird等3个类外,它几乎什么都没做。在main()中的第41~44行,可通过派生类Platypus的对象调用这3个基类的方法,该对象被恰当地命名为realFreak。除调用从Mammal、Reptile和Bird类继承而来的方法外,第45行还调用了Platypus::Swim()。这个程序演示了多继承的语法,还表明派生类暴露了从众多基类继承而来的公有属性(这里是公有成员方法)。

    第10章 实现继承 - 图54鸭嘴兽会游泳,但不属于鱼类。因此,在程序清单 10.10 中,没有仅为方便使用现有的Fish::Swim()函数,而让Platypus也继承Fish。做设计决策时,别忘了公有继承应表示is-a关系,因此不应为实现重用目标而不分青红皂白地使用公有继承。可采取其他方式实现这种目标。

    第10章 实现继承 - 图55

    10.6 总结

    在本章中,您学习了C++继承的基本知识。您了解到,公有继承在派生类和基类之间建立is-a关系,而私有和保护继承建立has-a关系。您得知,通过使用访问限定符protected,可将基类的属性暴露给派生类,同时对继承层次结构外部隐藏它们。您了解到,保护继承与私有继承的不同之处在于,派生类的子类可访问基类的公有和保护成员,而使用私有继承时不能。您学习了覆盖和隐藏方法的基本知识,还知道可使用关键字using避免隐藏方法。

    现在,您可以回答一些问题,然后选择面向对象编程的下一个主要支柱——多态。

    10.7 问与答

    问:我需要模拟哺乳动物(Mammal)以及一些具体的哺乳动物,如人(Human)、狮子(Lion)和鲸鱼(Whale)。我该使用继承层次结构吗?如果该使用,应使用哪种继承关系?

    答:鉴于人、狮子和鲸鱼都是哺乳动物,这是is-a关系,因此应使用公有继承,并将Mammal作为基类,而Human、Lion和Whale类从它派生而来。

    问:术语派生类和子类有何不同?

    答:是一回事,它们都表示从基类派生而来的类。

    问:以公有方式继承基类的派生类能访问基类的私有成员吗?

    答:不能。编译器总是执行最严格的访问限定符。无论继承关系如何,类的私有成员都不能在类外访问,一个例外是类的友元函数和友元类。

    10.8 作业

    作业包括测验和练习,前者帮助读者加深对所学知识的理解,后者提供了使用新学知识的机会。请尽量先完成测验和练习题,然后再对照附录D的答案。在继续学习下一章前,请务必弄懂这些答案。

    10.8.1 测验

    1.我希望基类的某些成员可在派生类中访问,但不能在继承层次结构外访问,该使用哪种访问限定符?

    2.如果一个函数接受一个基类对象作为参数,而我将一个派生类对象作为实参按值传递给它,结果将如何?

    3.该使用私有继承还是组合?

    4.在继承层次结构中,关键字using有何用途?

    5.Derived 类以私有方式继承了 Base 类,而 SubDerived 类以公有方式继承了 Derived 类。请问SubDerived类能访问Base类的公有成员吗?

    10.8.2 练习

    1.创建程序清单10.10所示的Platypus对象时,将以什么样的顺序调用构造函数?

    2.使用代码说明Polygon、Triangle和Shape类之间的关系。

    3.D2类继承了D1类,而D1类继承了Base类。要禁止D2访问Base的公有方法,应使用哪种访问限定符?在什么地方使用?

    4.下面的代码表示哪种继承关系?

    第10章 实现继承 - 图56

    5.查错:下述代码有何问题?

    第10章 实现继承 - 图57