[C++]C++Primer Chapter 15
面向对象程序设计
面向对象程序设计基于三个基本概念:数据抽象、继承和动态绑定。
继承和动态绑定对程序的编写有两方面的影响:一是我们可以更容易地定义与其他类相似但不完全相同的新类;二是在使用这些彼此相似的类编写程序时,我们可以在一定程度上忽略掉它们的区别。
15.1 OOP:概述
面向对象程序设计(object-oriented programming)的核心思想是数据抽象、继承和动态绑定。通过使用数据抽象,我们可以将类的接口与实现分离(见第7章);使用继承,可以定义相似的类型并对其相似关系建模;使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。
继承
通过继承(inheritance)联系在一起的类构成一种层次关系。通常在层次关系的根部有一个基类(base class),其他类则直接或间接地从基类继承而来,这些继承得到的类称为派生类(derived class)。基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自特有的成员。
在C++语言中,基类将类型相关的函数与派生类不做改变直接继承的函数区分对待。对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数(virtual function)。
派生类必须通过使用类派生列表(class derivation list)明确指出它是从哪个(哪些)基类继承而来的。类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有访问说明符
派生类必须在其内部对所有重新定义的虚函数进行声明。C++11新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,具体措施是在该函数的形参列表之后增加一个override关键字。
动态绑定
在C++语言中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。
通过使用动态绑定(dynamic binding),我们能用同一段代码分别处理Quote和Bulk_quote的对象。
我们既能使用基类Quote的对象调用该函数,也能使用派生类Bulk_quote的对象调用它;又因为print_total是使用引用类型调用net_price函数的,所以出于15.2.1节(第528页)将要解释的原因,实际传入print_total的对象类型将决定到底执行net_price的哪个版本.
在上述过程中函数的运行版本由实参决定,即在运行时选择函数的版本,所以动态绑定有时又被称为运行时绑定(run-time binding)。
15.2 定义基类和派生类
15.2.1 定义基类
基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。
成员函数与继承
派生类可以继承其基类的成员,然而当遇到如net_price这样与类型相关的操作时,派生类必须对其重新定义。换句话说,派生类需要对这些操作提供自己的新定义以覆盖(override)从基类继承而来的旧定义。
在C++语言中,基类必须将它的两种成员函数区分开来:
- 一种是基类希望其派生类进行覆盖的函数;
- 另一种是基类希望派生类直接继承而不要改变的函数。
对于前者,基类通常将其定义为虚函数(virtual)。当我们使用指针或引用调用虚函数时,该调用将被动态绑定。
任何构造函数之外的非静态函数(参见7.6节,第268页)都可以是虚函数。关键字virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义。如果基类把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数。
成员函数如果没被声明为虚函数,则其解析过程发生在编译时而非运行时。
访问控制与继承
派生类可以继承定义在基类中的成员,但是派生类的成员函数不一定有权访问从基类继承而来的成员。和其他使用基类的代码一样,派生类能访问公有成员,而不能访问私有成员。不过在某些时候基类中还有这样一种成员,基类希望它的派生类有权访问该成员,同时禁止其他用户访问。我们用受保护的(protected)访问运算符说明这样的成员。
15.2.2 定义派生类
派生类必须通过使用类派生列表(class derivation list)明确指出它是从哪个(哪些)基类继承而来的。类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有以下三种访问说明符中的一个:public、protected或者private。
派生类必须将其继承而来的成员函数中需要覆盖的那些重新声明。
派生类中的虚函数
派生类经常(但不总是)覆盖它继承的虚函数。如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本。
派生类可以在它覆盖的函数前使用virtual关键字,但不是非得这么做。我们将在15.3节(第538页)介绍其原因,C++11新标准允许派生类显式地注明它使用某个成员函数覆盖了它继承的虚函数。具体做法是
- 在形参列表后面
- 或者在const成员函数(参见7.1.2节,第231页)的const关键字后面
- 或者在引用成员函数(参见13.6.3节,第483页)的引用限定符后面
添加一个关键字override。
派生类对象及派生类向基类的类型转换
在派生类对象中含有与其基类对应的组成部分,这一事实是继承的关键所在。
一个派生类对象包含多个组成部分:一个含有派生类自己定义的(非静态)成员的子对象,以及一个与该派生类继承的基类对应的子对象,如果有多个基类,那么这样的子对象也有多个。
C++标准并没有明确规定派生类的对象在内存中如何分布,但是我们可以认为Bulk_quote的对象包含如图15.1所示的两部分。
因为在派生类对象中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到派生类对象中的基类部分上。这种转换通常称为派生类到基类的(derived-to-base)类型转换。
派生类构造函数
每个类控制它自己的成员初始化过程。
首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。
尽管在派生类对象中含有从基类继承而来的成员,但是派生类并不能直接初始化这些成员。和其他创建了基类对象的代码一样,派生类也必须使用基类的构造函数来初始化它的基类部分。
派生类使用基类的成员
派生类可以访问基类的公有成员和受保护成员
关键概念:遵循基类的接口
必须明确一点:每个类负责定义各自的接口。要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。
因此,派生类对象不能直接初始化基类的成员。尽管从语法上来说我们可以在派生类构造函数体内给它的公有或受保护的基类成员赋值,但是最好不要这么做。和使用基类的其他场合一样,派生类应该遵循基类的接口,并且通过调用基类的构造函数来初始化那些从基类中继承而来的成员。
继承与静态成员
如果基类定义了一个静态成员(参见7.6节,第268页),则在整个继承体系中只存在该成员的唯一定义。不论从基类中派生出来多少个派生类,对于每个静态成员来说都只存在唯一的实例。
静态成员遵循通用的访问控制规则,如果基类中的成员是private的,则派生类无权访问它。假设某静态成员是可访问的,则我们既能通过基类使用它也能通过派生类使用它
派生类的声明
派生类的声明与其他类差别不大(参见7.3.3节,第250页),声明中包含类名但是不包含它的派生列表。
被用作基类的类
如果我们想将某个类用作基类,则该类必须已经定义而非仅仅声明。
防止继承的发生
有时我们会定义这样一种类,我们不希望其他类继承它,或者不想考虑它是否适合作为一个基类。为了实现这一目的,C++11新标准提供了一种防止继承发生的方法,即在类名后跟一个关键字final
15.2.3 类型转换与继承
可以将基类的指针或引用绑定到派生类对象上有一层极为重要的含义:当使用基类的引用(或指针)时,实际上我们并不清楚该引用(或指针)所绑定对象的真实类型。该对象可能是基类的对象,也可能是派生类的对象。
和内置指针一样,智能指针类(参见12.1节,第400页)也支持派生类向基类的类型转换,这意味着我们可以将一个派生类对象的指针存储在一个基类的智能指针内
静态类型与动态类型
表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型;动态类型则是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知。
例如,当print_total调用net_price时,我们知道item的静态类型是Quote&,它的动态类型则依赖于item绑定的实参,动态类型直到在运行时调用该函数时才会知道。如果我们传递一个Bulk_quote对象给print_total,则item的静态类型将与它的动态类型不一致。
如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致。
不存在从基类向派生类的隐式类型转换
编译器在编译时无法确定某个特定的转换在运行时是否安全,这是因为编译器只能通过检查指针或引用的静态类型来推断该转换是否合法。如果在基类中含有一个或多个虚函数,我们可以使用dynamic_cast(参见19.2.1节,第730页)请求一个类型转换,该转换的安全检查将在运行时执行。同样,如果我们已知某个基类向派生类的转换是安全的,则我们可以使用static_cast(参见4.11.3节,第144页)来强制覆盖掉编译器的检查工作。
在对象之间不存在类型转换
当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分将被忽略掉。
派生类向基类的自动类型转换只对指针或引用类型有效,在派生类类型和基类类型之间不存在这样的转换。
15.3 虚函数
当我们使用基类的引用或指针调用一个虚成员函数时会执行动态绑定。
通常情况下,如果我们不使用某个函数,则无须为该函数提供定义(参见6.1.2节,第186页)。但是我们必须为每一个虚函数都提供定义,而不管它是否被用到了,这是因为连编译器也无法确定到底会使用哪个虚函数。
对虚函数的调用可能在运行时才被解析
动态绑定只有当我们通过指针或引用调用虚函数时才会发生。当我们通过一个具有普通类型(非引用非指针)的表达式调用虚函数时,在编译时就会将调用的版本确定下来。
关键概念:C++的多态性
OOP的核心思想是多态性(polymorphism)。把具有继承关系的多个类型称为多态类型,因为我们能使用这些类型的“多种形式”而无须在意它们的差异。引用或指针的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本所在。
派生类中的虚函数
当我们在派生类中覆盖了某个虚函数时,可以再一次使用virtual关键字指出该函数的性质。然而这么做并非必须,因为一旦某个函数被声明成虚函数,则在所有派生类中它都是虚函数。
一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与被它覆盖的基类函数完全一致。
同样,派生类中虚函数的返回类型也必须与基类函数匹配。该规则存在一个例外,当类的虚函数返回类型是类本身的指针或引用时,上述规则无效。也就是说,如果D由B派生得到,则基类的虚函数可以返回B*而派生类的对应函数可以返回D*,只不过这样的返回类型要求从D到B的类型转换是可访问的。
final和override说明符
派生类如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,这仍然是合法的行为。编译器将认为新定义的这个函数与基类中原有的函数是相互独立的。这时,派生类的函数并没有覆盖掉基类中的版本。
在C++11新标准中我们可以使用override关键字来说明派生类中的虚函数。这么做的好处是在使得程序员的意图更加清晰的同时让编译器可以为我们发现一些错误,后者在编程实践中显得更加重要。如果我们使用override标记了某个函数,但该函数并没有覆盖已存在的虚函数,此时编译器将报错。
还能把某个函数指定为final,如果我们已经把函数定义成final了,则之后任何尝试覆盖该函数的操作都将引发错误。
final和override说明符出现在形参列表(包括任何const或引用修饰符)以及尾置返回类型(参见6.3.3节,第206页)之后。
虚函数与默认实参
如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。
虚函数也可以拥有默认实参(参见6.5.1节,第211页)。如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。换句话说,如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。
回避虚函数的机制
在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的,例如下面的代码
通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。
什么时候我们需要回避虚函数的默认机制呢?通常是当一个派生类的虚函数调用它覆盖的基类的虚函数版本时。在此情况下,基类的版本通常完成继承层次中所有类型都要做的共同任务,而派生类中定义的版本需要执行一些与派生类本身密切相关的操作。
如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归。
15.4 抽象基类
纯虚函数
可以将net_price定义成纯虚(pure virtual)函数从而令程序实现我们的设计意图,这样做可以清晰明了地告诉用户当前这个net_price函数是没有实际意义的。和普通的虚函数不一样,一个纯虚函数无须定义。我们通过在函数体的位置(即在声明语句的分号之前)书写=0就可以将一个虚函数说明为纯虚函数。其中,=0只能出现在类内部的虚函数声明语句处
尽管我们不能直接定义这个类的对象,但是Disc_quote的派生类构造函数将会使用Disc_quote的构造函数来构建各个派生类对象的Disc_quote部分。
值得注意的是,我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。也就是说,我们不能在类的内部为一个=0的函数提供函数体。
含有纯虚函数的类是抽象基类
含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类(abstract base class)。抽象基类负责定义接口,而后续的其他类可以覆盖该接口。我们不能(直接)创建一个抽象基类的对象。因为Disc_quote将net_price定义成了纯虚函数,所以我们不能定义Disc_quote的对象。我们可以定义Disc_quote的派生类的对象,前提是这些类覆盖了net_price函数。
Disc_quote的派生类必须给出自己的net_price定义,否则它们仍将是抽象基类。
派生类构造函数只初始化它的直接基类
这个版本的Bulk_quote的直接基类是Disc_quote,间接基类是Quote。每个Bulk_quote对象包含三个子对象:一个(空的)Bulk_quote部分、一个Disc_quote子对象和一个Quote子对象。该构造函数将它的实参传递给Disc_quote的构造函数,随后Disc_quote的构造函数继续调用Quote的构造函数。Quote的构造函数首先初始化bulk的bookNo和price成员,当Quote的构造函数结束后,开始运行Disc_quote的构造函数并初始化quantity和discount成员,最后运行Bulk_quote的构造函数,该函数无须执行实际的初始化或其他工作。
关键概念:重构
在Quote的继承体系中增加Disc_quote类是重构(refactoring)的一个典型示例。重构负责重新设计类的体系以便将操作和/或数据从一个类移动到另一个类中。对于面向对象的应用程序来说,重构是一种很普遍的现象。
值得注意的是,即使我们改变了整个继承体系,那些使用了Bulk_quote或Quote的代码也无须进行任何改动。不过一旦类被重构(或以其他方式被改变),就意味着我们必须重新编译含有这些类的代码了。
受保护的成员
一个类使用protected关键字来声明那些它希望与派生类分享但是不想被其他公共访问使用的成员。
- 和私有成员类似,受保护的成员对于类的用户来说是不可访问的。
- 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的。
- 此外,protected还有另外一条重要的性质。派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。
为了理解最后一条规则,请考虑如下的例子
如果派生类(及其友元)能访问基类对象的受保护成员,则上面的第二个clobber(接受一个Base&)将是合法的。该函数不是Base的友元,但是它仍然能够改变一个Base对象的内容。如果按照这样的思路,则我们只要定义一个形如Sneaky 的新类就能非常简单地规避掉protected提供的访问保护了。
要想阻止以上的用法,我们就要做出如下规定,即派生类的成员和友元只能访问派生类对象中的基类部分的受保护成员;对于普通的基类对象中的成员不具有特殊的访问权限。
公有、私有和受保护继承
某个类对其继承而来的成员的访问权限受到两个因素影响:一是在基类中该成员的访问说明符,二是在派生类的派生列表中的访问说明符。
派生访问说明符对于派生类的成员(及友元)能否访问其直接基类的成员没什么影响。对基类成员的访问权限只与基类中的访问说明符有关。
派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限。
派生类向基类转换的可访问性
派生类向基类的转换(参见15.2.2节,第530页)是否可访问由使用该转换的代码决定,同时派生类的派生访问说明符也会有影响。假定D继承自B:
- 只有当D公有地继承B时,用户代码才能使用派生类向基类的转换;如果D继承B的方式是受保护的或者私有的,则用户代码不能使用该转换。
- 不论D以什么方式继承B,D的成员函数和友元都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的。
- 如果D继承B的方式是公有的或者受保护的,则D的派生类的成员和友元可以使用D向B的类型转换;反之,如果D继承B的方式是私有的,则不能使用。
关键概念:类的设计与受保护的成员
不考虑继承的话,我们可以认为一个类有两种不同的用户:普通用户和类的实现者。其中,普通用户编写的代码使用类的对象,这部分代码只能访问类的公有(接口)成员;实现者则负责编写类的成员和友元的代码,成员和友元既能访问类的公有部分,也能访问类的私有(实现)部分。
如果进一步考虑继承的话就会出现第三种用户,即派生类。基类把它希望派生类能够使用的部分声明成受保护的。普通用户不能访问受保护的成员,而派生类及其友元仍旧不能访问私有成员。
和其他类一样,基类应该将其接口成员声明为公有的;同时将属于其实现的部分分成两组:一组可供派生类访问,另一组只能由基类及基类的友元访问。对于前者应该声明为受保护的,这样派生类就能在实现自己的功能时使用基类的这些操作和数据;对于后者应该声明为私有的。
友元与继承
基类的友元在访问派生类成员时不具有特殊性,类似的,派生类的友元也不能随意访问基类的成员
Pal是Base的友元,所以Pal能够访问Base对象的成员,这种可访问性包括了Base对象内嵌在其派生类对象中的情况。
改变个别成员的可访问性
有时我们需要改变派生类继承的某个名字的访问级别,通过使用using声明(参见3.1节,第74页)可以达到这一目的
因为Derived使用了私有继承,所以继承而来的成员size和n(在默认情况下)是Derived的私有成员。然而,我们使用using声明语句改变了这些成员的可访问性。改变之后,Derived的用户将可以使用size成员,而Derived的派生类将能使用n。
通过在类的内部使用using声明语句,我们可以将该类的直接或间接基类中的任何可访问成员(例如,非私有成员)标记出来。
派生类只能为那些它可以访问的名字提供using声明。
默认的继承保护级别
默认情况下,使用class关键字定义的派生类是私有继承的;而使用struct关键字定义的派生类是公有继承的。
15.6 继承中的类作用域
每个类定义自己的作用域(参见7.4节,第253页),在这个作用域内我们定义类的成员。当存在继承关系时,派生类的作用域嵌套(参见2.2.4节,第43页)在其基类的作用域之内。如果一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找该名字的定义。
在编译时进行名字查找
一个对象、引用或指针的静态类型(参见15.2.3节,第532页)决定了该对象的哪些成员是可见的。即使静态类型与动态类型可能不一致(当使用基类的引用或指针时会发生这种情况),但是我们能使用哪些成员仍然是由静态类型决定的。
名字冲突与继承
和其他作用域一样,派生类也能重用定义在其直接基类或间接基类中的名字,此时定义在内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字(参见2.2.4节,第43页)
通过作用域运算符来使用隐藏的成员
可以通过作用域运算符来使用一个被隐藏的基类成员
除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。
关键概念:名字查找与继承
理解函数调用的解析过程对于理解C++的继承至关重要,假定我们调用p->mem()(或者obj.mem()),则依次执行以下4个步骤:
- 首先确定p(或obj)的静态类型。因为我们调用的是一个成员,所以该类型必然是类类型。
- 在p(或obj)的静态类型对应的类中查找mem。如果找不到,则依次在直接基类中不断查找直至到达继承链的顶端。如果找遍了该类及其基类仍然找不到,则编译器将报错。
- 一旦找到了mem,就进行常规的类型检查(参见6.1节,第183页)以确认对于当前找到的mem,本次调用是否合法。
- 假设调用合法,则编译器将根据调用的是否是虚函数而产生不同的代码:
- 如果mem是虚函数且我们是通过引用或指针进行的调用,则编译器产生的代码将在运行时确定到底运行该虚函数的哪个版本,依据是对象的动态类型。
- 反之,如果mem不是虚函数或者我们是通过对象(而非引用或指针)进行的调用,则编译器将产生一个常规函数调用。
一如往常,名字查找先于类型检查
声明在内层作用域的函数并不会重载声明在外层作用域的函数(参见6.4.1节,第210页)。因此,定义派生类中的函数也不会重载其基类中的成员。
虚函数与作用域
我们现在可以理解为什么基类与派生类中的虚函数必须有相同的形参列表了(参见15.3节,第537页)。假如基类与派生类的虚函数接受的实参不同,则我们就无法通过基类的引用或指针调用派生类的虚函数了。
覆盖重载的函数
成员函数无论是否是虚函数都能被重载。派生类可以覆盖重载函数的0个或多个实例。如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。
有时一个类仅需覆盖重载集合中的一些而非全部函数,此时,如果我们不得不覆盖基类中的每一个版本的话,显然操作将极其烦琐。
一种好的解决方案是为重载的成员提供一条using声明语句(参见15.5节,第546页),这样我们就无须覆盖基类中的每一个重载版本了。using声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的using声明语句就可以把该函数的所有重载实例添加到派生类作用域中。此时,派生类只需要定义其特有的函数就可以了,而无须为继承而来的其他函数重新定义。
类内using声明的一般规则同样适用于重载函数的名字(参见15.5节,第546页);基类函数的每个实例在派生类中都必须是可访问的。对派生类没有重新定义的重载版本的访问实际上是对using声明点的访问。
15.7 构造函数与拷贝控制
15.7.1 虚析构函数
继承关系对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数(参见15.2.1节,第528页),这样我们就能动态分配继承体系中的对象了。
因为指针或者引用的静态类型可能与实际指向对象的动态类型不一致,所以析构函数应该被声明为虚函数,这样在析构的时候可以动态绑定,执行动态类型的析构函数,以确保析构正确。
虚析构函数将阻止合成移动操作
基类需要一个虚析构函数这一事实还会对基类和派生类的定义产生另外一个间接的影响:如果一个类定义了析构函数,即使它通过=default的形式使用了合成的版本,编译器也不会为这个类合成移动操作(参见13.6.2节,第475页)。
15.7.2 合成拷贝控制与继承
基类或派生类的合成拷贝控制成员的行为与其他合成的构造函数、赋值运算符或析构函数类似:它们对类本身的成员依次进行初始化、赋值或销毁的操作。此外,这些合成的成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁的操作。
派生类中删除的拷贝控制与基类的关系
就像其他任何类的情况一样,基类或派生类也能出于同样的原因将其合成的默认构造函数或者任何一个拷贝控制成员定义成被删除的函数(参见13.1.6节,第450页和13.6.2节,第475页)。
此外,某些定义基类的方式也可能导致有的派生类成员成为被删除的函数:
- 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或者不可访问(参见15.5节,第543页),则派生类中对应的成员将是被删除的,原因是编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或销毁操作。
- 如果在基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类对象的基类部分。
- 和过去一样,编译器将不会合成一个删除掉的移动操作。当我们使用=default请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的,那么派生类中该函数将是被删除的,原因是派生类对象的基类部分不可移动。同样,如果基类的析构函数是删除的或不可访问的,则派生类的移动构造函数也将是被删除的。
移动操作与继承
大多数基类都会定义一个虚析构函数。因此在默认情况下,基类通常不含有合成的移动操作,而且在它的派生类中也没有合成的移动操作。
因为基类缺少移动操作会阻止派生类拥有自己的合成移动操作,所以当我们确实需要执行移动操作时应该首先在基类中进行定义。我们的Quote可以使用合成的版本,不过前提是Quote必须显式地定义这些成员。一旦Quote定义了自己的移动操作,那么它必须同时显式地定义拷贝操作。
15.7.3 派生类的拷贝控制成员
派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员。类似的,派生类赋值运算符也必须为其基类部分的成员赋值。析构函数只负责销毁派生类自己分配的资源。
定义派生类的拷贝或移动构造函数
当为派生类定义拷贝或移动构造函数时(参见13.1.1节,第440页和13.6.2节,第473页),我们通常使用对应的基类构造函数初始化对象的基类部分
在默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显式地使用基类的拷贝(或移动)构造函数。
派生类赋值运算符
与拷贝和移动构造函数一样,派生类的赋值运算符(参见13.1.2节,第443页和13.6.2节,第474页)也必须显式地为其基类部分赋值
派生类析构函数
对象的基类部分是隐式销毁的。因此,和构造函数及赋值运算符不同的是,派生类析构函数只负责销毁由派生类自己分配的资源。
对象销毁的顺序正好与其创建的顺序相反:派生类析构函数首先执行,然后是基类的析构函数,以此类推,沿着继承体系的反方向直至最后。
在构造函数和析构函数中调用虚函数
如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型相对应的虚函数版本。
15.7.4 继承的构造函数
在C++11新标准中,派生类能够重用其直接基类定义的构造函数。尽管如我们所知,这些构造函数并非以常规的方式继承而来,但是为了方便,我们不妨姑且称其为“继承”的。一个类只初始化它的直接基类,出于同样的原因,一个类也只继承其直接基类的构造函数。
类不能继承默认、拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们。
派生类继承基类构造函数的方式是提供一条注明了(直接)基类名的using声明语句。
通常情况下,using声明语句只是令某个名字在当前作用域内可见。而当作用于构造函数时,using声明语句将令编译器产生代码。对于基类的每个构造函数,编译器都生成一个与之对应的派生类构造函数。换句话说,对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数。
如果派生类含有自己的数据成员,则这些成员将被默认初始化(参见7.1.4节,第238页)。
继承的构造函数的特点
和普通成员的using声明不一样,一个构造函数的using声明不会改变该构造函数的访问级别。例如,不管using声明出现在哪儿,基类的私有构造函数在派生类中还是一个私有构造函数;受保护的构造函数和公有构造函数也是同样的规则。
而且,一个using声明语句不能指定explicit或constexpr。如果基类的构造函数是explicit(参见7.5.4节,第265页)或者constexpr(参见7.5.6节,第267页),则继承的构造函数也拥有相同的属性。
当一个基类构造函数含有默认实参(参见6.5.1节,第211页)时,这些实参并不会被继承。相反,派生类将获得多个继承的构造函数,其中每个构造函数分别省略掉一个含有默认实参的形参。
如果基类含有几个构造函数,则除了两个例外情况,大多数时候派生类会继承所有这些构造函数。
- 第一个例外是派生类可以继承一部分构造函数,而为其他构造函数定义自己的版本。如果派生类定义的构造函数与基类的构造函数具有相同的参数列表,则该构造函数将不会被继承。定义在派生类中的构造函数将替换继承而来的构造函数。
- 第二个例外是默认、拷贝和移动构造函数不会被继承。这些构造函数按照正常规则被合成。继承的构造函数不会被作为用户定义的构造函数来使用,因此,如果一个类只含有继承的构造函数,则它也将拥有一个合成的默认构造函数。
15.8 容器与继承
当我们使用容器存放继承体系中的对象时,通常必须采取间接存储的方式。因为不允许在容器中保存不同类型的元素,所以我们不能把具有继承关系的多种类型的对象直接存放在容器当中。
在容器中放置(智能)指针而非对象
当我们希望在容器中存放具有继承关系的对象时,我们实际上存放的通常是基类的指针(更好的选择是智能指针(参见12.1节,第400页))。