Lesson32 继承
我们花费了数节 Lesson 来讲类的 “封装”,或者叫作 “组合”。借助基本数据类型和简单类,我们可以构造出一个复杂类。不过,还有另外一种构造复杂类的方法 —— 继承 (inheritance)。前者表示类的 “has a” 关系,而后者表示类的 “is a” 关系。
在继承方法中,新的类对象的属性与行为直接来源于其他对象,前者是后者的扩展与特殊化。
C++ 从其基础语言 C 那里继承了众多特性,而 C 又从其之前的编程语言那里继承了众多特性。
考虑苹果和香蕉。尽管苹果和香蕉是不同的水果,但它们共同点是都是水果。因为苹果和香蕉是水果,对水果适用的任何东西也对苹果和香蕉适用。例如,所有水果都有名字、颜色和大小。因此,苹果和香蕉也有名字、颜色和大小。我们可以这样说,苹果和香蕉继承了水果的所有属性,因为它们是水果。
不同层次的继承类构成了层次结构 (hierarchy),例如经典的生物分类方法:
(来源:分类阶元 - 维基百科)
这种结构从一般(顶部)到具体(底部)依次展开,其中每个层级中的成员都继承其上层的属性和行为。
32.1 继承的基本语法
接下来我们讨论一下 C++ 中继承的基础实现。
C++ 的继承发生在类之间。在继承关系中,被继承的类称为父类 (parent class)、基类 (base class) 或超类 (superclass),而继承的类称为子类 (child class /subclass) 或派生类 (derived class)。
合格的父类必须先于子类声明并定义(这不难理解吧?)
基类分为直接基类 (direct base class) 和间接基类 (indirect base class)。直接基类需要在派生类声明中直接指定,而间接基类没有明说它是基类,需要通过继承链和类间关系推测。对于给定的类,所有不是直接基类的基类(及其对象)都是间接基类。
一个子类会从父类处继承所有行为和非静态属性。父类的成员变量和成员函数也成为子类的成员。
由于子类是一个完整的类,因此它可以拥有自己独有的成员。
这里我们构建了一个表示基本信息的简单类:
1 |
|
此 Person
类旨在表示通用的个人信息,所以我们只定义了任何类型的人都会有的属性和行为。为了降低难度,在这个例子中,我们将所有变量和函数设置为 public
。
现在我们想要编写一个程序来跟踪一些棒球运动员的信息。有些信息是独属于棒球运动员的 —— 例如,我们可能想要存储一个球员的打击率和他们击出的本垒打数量。下面是一个不完整的类示例:
1 | class BaseballPlayer |
此时我们还想在这个类中追踪 Person
类代表的通用信息,要怎么做?
-
直接将姓名和年龄添加到
BaseballPlayer
类中作为成员。这可能是最差的选择,因为我们正在重复我们已经在Person
类中存在的代码。 -
Person
类作为BaseballPlayer
的成员,即 “组合”。但显然BaseballPlayer
并不是 “has a”Person
,类间关系错误。 -
BaseballPlayer
继承Person
的属性。BaseballPlayer
“is a”Person
类,所以,继承在这里是一个好的选择。
最简单的继承声明方式:在 class BaseballPlayer
声明之后紧跟冒号、访问类型(常用 public
)以及我们希望继承的类的名称。
1 | // BaseballPlayer publicly inheriting Person |
当 BaseballPlayer
继承自 Person
时,BaseballPlayer
获得了 Person
的成员函数和变量。此外,BaseballPlayer
定义了两个自己的成员:m_battingAverage
和 m_homeRuns
,因为这些属性是特属于 BaseballPlayer
的。
因此,BaseballPlayer
对象将具有 4 个成员变量:类自己的 m_battingAverage
和 m_homeRuns
,以及从 Person
继承的 m_name
和 m_age
。
派生类自身也可以再继承,形成继承链 (Inheritance chains)。通过构建继承链,我们可以创建一组非常通用的可重用类(在顶部),并且随着继承层次的增加,它们变得越来越具体。后面层次的派生类将继承前面所有层次的类的成员。
从基类继承意味着我们不需要在派生类中重新定义基类中的信息。我们通过继承自动接收基类的成员函数和成员变量,然后添加我们想要的额外函数或成员变量。这不仅节省了工作量,而且意味着如果我们更新或修改基类(例如添加新函数或修复错误),所有派生类将自动继承这些更改。
32.2 继承方式
继承有三种方式:公有 (public) 继承、保护 (protected) 继承和私有 (private) 继承。为此,只需在从类中继承时指定您想要的访问类型即可。默认为私有继承。
那么这些方式之间有什么区别?简而言之,当成员被继承时,继承成员的访问修饰符可能会根据所使用的继承类型而改变(仅在派生类中)。换句话说,在基类中是 public
或 protected
的成员,在派生类中可能会改变访问修饰符。
在继续学习前,请记住以下规则:
- 一个类可以始终访问其自身的(非继承的)成员。
- 类外访问类成员基于访问它所访问的类的访问说明符。
- 一个派生类根据从父类继承的访问修饰符访问继承的成员。这取决于访问修饰符和使用的继承类型。
32.2.1 公有继承
公有继承是最常用的继承类型。实际上,你很少会看到或使用其他类型的继承。
公有继承也是最容易理解的。当你以 public
方式继承基类时,继承的 public
成员保持为 public
,继承的 protected
成员保持为 protected
。继承的 private
成员,由于在基类中是私有的,因此不可访问。
1 | class Base |
Pub
可以访问 m_public
和 m_protected
,但无法访问 m_private
。由于公有继承不改变成员的访问类型,因此类外可以通过 Pub
类对象访问 m_public
。
除非有特定原因,否则请使用公有继承。
32.2.2 保护继承
保护继承是继承中最不常见的方法。它几乎从不使用,除非在非常特定的情况下。使用保护继承时,public
和 protected
的成员变为 protected
,而 private
成员保持不可访问。
32.2.3 私有继承
使用私有继承时,基类中的所有成员都被继承为 private
。这意味着 private
成员不可访问,protected
成员和 public
成员变为 private
。
请注意,这不会影响派生类访问从其父类继承的成员的方式。它只会影响试图通过派生类访问这些成员的代码。换句话说,不可访问不代表这些成员不存在。
在 VS 的 Developer Command Prompt 中,可以使用 cl <filename>.cpp /d1reportSingleClassLayout[className]
命令查看 className
类内存布局
1 | class Base |
Pri
可以访问 m_public
和 m_protected
,但无法访问 m_private
。由于采用私有继承,Pri
的 m_public
和 m_protected
现在被视为是 private
成员,类外不可通过 Pri
类对象访问 m_public
。
私有继承在派生类与基类没有明显关系但内部使用基类实现时可能有用。在这种情况下,我们可能不希望基类的接口被派生类的对象暴露。但在实践中,私有继承依然很少使用。
这里是一个所有访问修饰符和继承类型组合的表格:
基类的访问标识符 | 公有继承的访问标识符 | 私有继承的访问标识符 | 保护继承的访问标识符 |
---|---|---|---|
public |
public |
private |
protected |
protected |
protected |
private |
protected |
private |
Inaccessible | Inaccessible | Inaccessible |
在这种规则中,称派生类的对象对基类访问为水平访问,称派生类的派生类对基类的访问为垂直访问。
公有继承时,水平访问和垂直访问对基类中的公有成员不受限制;
私有继承时,水平访问和垂直访问对基类中的公有成员也不能访问;
保护继承时,对于垂直访问同于公有继承,对于水平访问同于私有继承。
32.3 派生类的构造顺序
C++ 分阶段构造派生类,从最基的类(位于继承树的顶部)开始,到最子的类(位于继承树的底部)结束。构造每个类时,将调用该类中的相应构造函数来初始化该类的该部分。
换句话说,派生类并不是作为一个完整类而构造的,而是一个类包含另一个类而构造的。
例如,看这个继承链:
1 | class A |
C++ 将首先构造 A
类,即 “最基的类”,然后按顺序遍历继承树并构造继承类。
以下程序可以输出该链的构造顺序:
1 |
|
输出为:
1 | Constructing A: |
对于间接基类对象,将插在直接基类和派生类之间构造:
1 | class BaseContainer { |
输出为:
1 | BaseContainer ctor |
32.4 派生类的构造函数
如果派生类的构造函数不改动基类成员,那么正常地写派生类的构造函数即可,只是调用流程相对复杂:
1 | class Base |
当执行 Derived derived{ 1.3 }
时,程序进行了以下流程:
- 为
derived
分配内存 (Base
+Derived
) - 调用
Derived
的构造函数,但不作任何修改 - 然后调用
Base
的构造函数以构造Base
对象(隐式调用),然后将控制权交给Derived
的构造函数 Derived
的构造函数初始化成员变量Derived
的构造函数执行函数体- 控制权交给调用方
教材里的 “调用” 实际上还包括函数体执行,因此根据后者的说法,程序首先调用直接基类构造函数。然后,按照派生类声明中出现的顺序初始化间接基类成员。最后调用派生类构造函数。
但 LearnC++ 的 “调用” 不包括函数体的执行。在 cppreference 中,提到 “基类子对象(直接和间接基类)的构造函数由派生类的构造函数调用”,那么从这个角度思考,派生类的构造函数又是首先被调用的。
而 VS2022 文档则同时使用以上两种 “调用” 含义。
(语言的艺术……)
与非继承类的区别是:在 Derived
构造函数可以执行任何实质性行为之前,首先调用 Base
构造函数。Base
构造函数设置类对象的 Base
部分,将控制权返回给 Derived
构造函数,让 Derived
构造函数完成其工作。
32.4.1 初始化基类成员
但如果我们想要在继承类的构造函数里初始化基类成员,要怎么办?
既然要初始化,那么构造函数就需要知道初始化哪个成员变量。所以,有些新手会写出这种构造函数:
1 | class Derived: public Base |
这有什么问题?
C++ 会阻止构造函数初始化继承来的成员变量。换句话说,成员变量的值只能在与变量属于同一类的构造函数的成员初始化列表中初始化。
为什么 C++ 要这么做
答案与 const
和引用变量有关。
考虑一下如果 m_id
设置为 const
会发生什么。由于 const
变量必须在创建时初始化,因此基类构造函数必须在创建变量时设置其值。
但是,当基类构造函数完成后,即执行派生类构造函数的成员初始化。然后,每个派生类都有机会初始化该变量,从而可能更改其值。
通过将变量的初始化限制为这些变量所属的类的构造函数,C++ 确保所有变量只初始化一次。
如果在构造函数体内设置基类成员呢?
1 | class Derived: public Base |
对 const
和引用依然不起作用,重复赋值的效率也较低,并且 Base
类在构建过程中也无法访问 id
。
目前为止,我们都是在 Derived
的构造函数上做工作,但既然 Base
的成员只能由 Base
的构造函数来初始化,为什么我们不能借用 Base
的构造函数呢?
C++ 能够显式选择要调用的 Base
构造函数。为此,只需在派生类的成员初始化列表中添加对 Base
构造函数的调用:
1 | class Derived: public Base |
现在调用 Derived derived{ 1.3, 5 }
时,基类构造函数 Base(int)
将 m_id
初始化为 5,派生类构造函数将 m_cost
初始化为 1.3
程序执行过程
- 分配
derived
的内存 - 调用
Derived(double, int)
构造函数,其中cost = 1.3
,id = 5
。 - 编译器会查看我们是否请求了特定的
Base
类构造函数。确实有!因此调用id = 5
的Base(int)
- 基类构造函数成员初始化列表将
m_id
设置为 5 - 执行基类构造函数体
- 基类构造函数返回
- 派生类构造函数成员初始化列表将
m_cost
设置为 1.3 - 执行派生类构造函数体
- 派生类构造函数返回
简单来讲,Derived
构造函数调用特定的 Base
构造函数来初始化对象的 Base
部分。由于 m_id
位于对象的 Base
部分中,因此 Base
构造函数是唯一可以初始化该值的构造函数。
Base
构造函数始终首先执行。
32.4.2 析构函数
销毁派生类时,将相反顺序调用每个析构函数。
因此派生类的析构过程为:
- 先销毁间接基类(对象)
- 然后销毁直接基类
- 最后销毁派生类
32.5 同名成员的处理
当子类与父类出现同名成员时,子类的成员会遮蔽父类的同名成员。
如果需要控制是哪个类的成员,按一般方法办:同类成员直接访问,不同类成员访问加作用域解析运算符 (::
)
1 |
|
输出结果:
1 | 20 |
32.6 继承成员函数调用与重载
在派生类对象上调用成员函数时,编译器首先会查看派生类中是否存在具有该名称的任何函数。如果存在,则考虑所有重载的具有该名称的函数,并使用函数重载解析过程来确定最佳匹配。如果不存在,编译器将沿着继承链向上查找,依次检查每个父类,方式相同。
1 |
|
输出
1 | Base::identify() |
在派生类中重新定义一个函数时,派生类的函数不会继承基类中的对应访问修饰符。它使用在派生类中定义的访问修饰符。因此,在基类中定义为私有的函数可以被重新定义为派生类中的公有函数。
有时我们想跳过派生类,调用基类中的重载函数。但根据编译器规则,如果不加限制,它只会在派生类中寻找函数。
此时应使用 using
声明,让基类函数在派生类中被编译器 “看见”:
1 |
|
通过在 Derived
内声明 using Base::print;
,我们告诉编译器所有名为 print
的 Base
类成员函数都应该在 Derived
中可见,这将使它们有资格进行重载解析。
32.7 多继承
截至目前,我们介绍的都是单继承 (Single inheritance)—— 每个继承类只有一个父类。但 C++ 也提供了多重继承 (Multiple inheritance),允许派生类从多个父类处继承成员。
例如,Teacher
既属于 Person
,又属于 Employee
,那么:
1 | // Teacher publicly inherits Person and Employee |
多重继承的语法为:class <derived_class>: <identifier> <base_class_1>, <identifier> <base_class_2>, ...
32.7.1 混合类
混合类 (mixin) 是一个小的类,可以从它继承以向类添加属性和行为。“混合” 这个名字表明这个类是为了混合到其他类中,而不是独立实例化的。
1 | class Button : public Box, public Label, public Tooltip {}; // Button using three mixins |
32.7.2 多继承的劣势
-
当多个基类包含具有相同名称的函数时,可能会出现歧义。这可以通过作用域解析来暂时解决
-
菱形继承(钻石继承)问题
两个派生类继承同一个基类,又有某个类同时继承这两个派生类。
例如,考虑以下类:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class PoweredDevice
{
};
class Scanner: public PoweredDevice
{
};
class Printer: public PoweredDevice
{
};
class Copier: public Scanner, public Printer
{
};此时如何处理
Copier
?它继承一次还是两次PoweredDevice
?如果Scanner
和Printer
有冲突,如何解决?这个问题也可以通过作用域解析来解决,但在下个 Lesson 中,我们还提供了其他更好的解决方法。
实际上,大多数可以用多重继承解决的问题也可以用单继承来解决。许多面向对象的语言(例如 Smalltalk、PHP)甚至不支持多重继承。不允许这些语言中多重继承的人认为,它会使语言变得过于复杂,最终造成的问题比解决的问题还要多。
除非替代方案更复杂,否则请避免多重继承。
作为有趣的插曲,我们已经在不知情的情况下使用了使用多重继承编写的类:iostream
库对象 std::cin
和 std::cout
都是通过多重继承实现的。