《C++Primer 第五版》——第十五章 面向对象程序设计
面向对象程序设计是基于三个基本概念的:数据抽象(data abstraction)、继承(inheritance)和动态绑定(dynamic binding)。
继承和动态绑定对程序的编写有两方面影响:
- 一是我们可以更容易地定义与其他类相似但不完全相同的新类;
- 二是在使用这些彼此相似的类编写程序时,我们可以在一定程度上忽略掉它们的区别。
15.1 OOP:概述
面向对象程序设计(OOP,object-oriented programming) 的核心思想是数据抽象、继承和动态绑定。
- 通过数据抽象:可以将类的接口与实现分离;
- 通过继承:可以定义相似的类型并对其相似关系建模;
- 使用动态绑定:可以在一定程度上忽略相似类型的区别,将其看作同一类型并使用这些对象。
继承
通过继承(inheritance) 联系在一起的类构成一种层次关系。通常在层次关系的根部有一个基类(base class,又称父类),其他类则直接或间接地从基类继承而来,这些继承得到的类称为派生类(derived class,又称子类)。基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自的成员。
在C++中,基类将类型相关的函数与派生类不作改变直接继承的函数区分对待 (换句话说,参见15.2.1节中的成员函数与继承):
- 对于前者,基类希望它的派生类各自自定义适合类型本身的版本,此时基类就将这些函数声明为虚函数(virtual function);
- 对于后者,则派生类不作任何改变直接继承。
派生类必须通过类派生列表(class derivation list) 显式指出它是从 哪个或哪些 直接基类继承而来的。同时类派生列表的声明形式如下:
class 派生类名: 访问说明符 直接基类名1, 直接基类名2 ..{
//派生类自定义的成员
}
- 其中访问说明符是可选项,可以不写。此时,如果是class,则默认继承模式是private继承;如果是struct,则默认继承模式是public继承。
- 一个派生类可以继承自多个直接基类,其中它们之间以逗号分隔开。
派生类必须在其内部对所有需要重新定义的虚函数进行声明。派生类可以在这样的函数之前加上 virtual 关键字,但这不是必须的,原因在后面。
C++11新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,具体措施是在该函数的形参列表之后增加一个 override 关键字。
动态绑定
通过使用动态绑定(dynamic binding),我们可以用同一段代码分别处理基类和派生类的对象。
比如下面例子,其中q为基类类型,bq为派生类类型
// 基类为 Quote void print_total(std::ostream &os, const Quote &item, >std::size_t n) { double price = item.net_price(n); std::cout << "ISBN:" << item.isbn() << " 原价:" << >item.price << " 折后价:" << price << std::endl; } print_total(std::cout, q, 20); // 调用基类的虚函数 print_total(std::cout, bq, 20); // 调用派生类的虚函数
因为发生动态绑定的函数的运行版本由实参决定,即在运行时选择函数的版本,所以动态绑定有时又被称为运行时绑定(run-time binding)。
Note:
在C++中,当使用基类的引用或指针调用一个虚函数时将发生动态绑定。
15.2 定义基类和派生类
继承关系中根节点的类(最终的基类)通常会定义一个虚析构函数。即使该函数不执行任何实际操作也是如此。(有关知识在15.7.1节中会介绍)
成员函数与继承
派生类可以继承其基类的大部分成员,然而遇到与类型相关的操作时,派生类必须对其重新定义(虚函数不一定全都必须重新定义)。换句话说,派生类需要对这些操作提供自己的新定义以 覆盖(override) 从基类继承来的旧定义。
在15.1节中提到过,在C++语言中,基类必须将它的两种成员函数区分开来:
- 一种是基类希望其派生类进行覆盖的函数;
- 另一种是基类希望派生类直接继承而不要改变的函数。
对于前者,基类通常将其定义为虚函数。当我们使用指针或引用调用虚函数时,该调用将被动态绑定。根据引用或指针所绑定的对象类型不同,该调用可能执行基类的版本,也可能执行某个派生类的版本。
基类通过在其成员函数的声明语句之前加上关键字 virtual 使得该函数执行动态绑定。
- 任何构造函数之外的非静态函数都可以是虚函数;
- 关键字 virtual 只能出现在类内部的声明语句之前而不能用于类外部的函数定义;
- 如果基类把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数。
成员函数如果没有被声明为虚函数,则其解析过程发生在编译时而不是运行时。
访问控制与继承
15.2.1 定义基类
派生类可以继承定义在基类中的成员,但是派生类的成员函数不一定有权访问从基类继承而来的成员。和其他使用基类的代码一样,派生类能访问它的公有成员,而不能访问其私有成员。
不过某些时候基类中还有这样一种成员,基类希望它的派生类有权访问该成员,同时禁止其他用户访问。我们用 受保护(protected) 的访问运算符protected
说明这样的成员。
15.2.2 定义派生类
派生类必须通过使用 类派生列表(class derivation list) 明确指出它是从哪个(哪些)直接基类继承而来的。类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有以下(可选)三种访问说明符中的一个:public
、protected
、private
。如果不写,对于 class 默认继承模式是 private 继承,对于 struct 是 public 继承。
派生类必须将其继承而来的成员函数中需要覆盖的那些重新声明。
访问说明符的作用是控制派生类从基类继承而来的成员是否对派生类的用户可见。(将在15.5节中详细介绍派生类列表中用到的访问说明符的具体作用)
如果一个派生类是公有继承的,则基类的所有公有成员也是派生类接口的组成部分。此外,我们还能将公有派生类型的对象绑定到基类的引用或指针上(换句话说能隐式转换)。
大多数类都只继承自一个类,这种形式的继承被称作“单继承”。关于派生列表中含有多于一个基类的情况将在18.3节中介绍。
基类的 private 成员会被添加到派生类的数据结构里面,但是派生类的函数没有权限直接访问基类私有成员,必须通过基类的 protect 或者 public 成员来访问。这导致,即使只是想使用类外的同名对象或函数,也会被基类的同名成员干扰。
class A {
private:
using foo = void;
};
int foo();
class B : public A {
int x = foo(); // 错误:试图使用基类的私有成员,而不是调用 int foo()
};
派生类中的虚函数
派生类经常(但不总是)覆盖它继承的虚函数。 如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于基类的其它普通成员,派生类会直接继承其在基类中的版本。
派生类可以在它覆盖的函数前使用 virtual 关键字,但不是非得这样做。
C++11新标准允许派生类显式地指明它使用某个成员函数的覆盖了它继承的虚函数。具体做法是在形参列表后面、或者在 const 成员函数的 const 关键字后面、或者在引用成员函数的引用限定符后面 添加关键字 override 。
派生类对象及派生类向基类的类型转换
一个派生类对象包含多个组成部分: 一个派生类自己定义的(非静态,静态是共用的)成员的子对象,以及一个与该派生类继承的基类对应的子对象,如果继承自多个基类,那么这样的子对象也有多个。
C++标准并没有明确规定派生类的对象的数据成员在内存中如何分布:
在一个对象中,继承自基类的部分和派生类自定义的部分在内存中不一定是连续存储的。
只是在概念上这两个部分是一起的。
因为在派生类对象中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到该派生类对象中的基类部分上。比如:
Quote item; // 基类对象
BulkQuote bulk; // 派生类对象
Quote *p = &item; // p指向Quote对象
p = &bulk; // p指向bulk的Quote部分
Quote &r = bulk; // r绑定到bulk的Quote对象
这种转换通常称为派生类到基类的(derived-to-base)类型转换。和其他类型转换一样,编译器会隐式地执行派生类到基类的转换。这种隐式特性意味着我们可以把派生类对象或者派生类对象的引用用在需要基类引用的地方;同样的,我们也可以把派生类对象的指针用在需要基类指针的地方。
派生类构造函数
尽管派生类对象中含有从基类继承而来的成员,但是派生类并不能直接初始化这些成员。和其他创建了基类对象的代码一样,派生类也必须使用基类的构造函数来初始化它的基类部分。
Note:每个类控制它自己的成员的初始化过程,基类初始化基类部分,派生类初始化派生类部分。
派生类对象的基类部分与派生类自己的数据成员都是在构造函数的初始化阶段执行初始化操作的(参见7.5.1节)。类似我们初始化成员的过程,派生类构造函数同样是通过构造函数初始化列表来将实参传递给基类的构造函数的。
初始化顺序:首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。
除非我们在初始化列表中显式指出基类的构造函数,否则派生类对象的基类部分会像数据成员一样隐式地执行默认初始化。 如果想使用其他的基类构造函数,我们需要以类名加圆括号内的实参列表的形式为构造函数提供初始值。这些实参会帮助编译器决定匹配哪个构造函数来执行初始化。。
比如:
BulkQuote(const std::string &_bookno, double _price, std::size_t qty, double disc) :Quote(_bookno, _price), min_qty(qty), discount(disc) { } // 下面两个都是合法的基类的默认初始化形式,一个显式调用一个隐式调用 BulkQuote(std::size_t qty, double disc) :Quote(), min_qty(qty), discount(disc) { } BulkQuote(std::size_t qty, double disc) : min_qty(qty), discount(disc) { }
当派生类BulkQuote的基类部分初始化完成后才会初始化其它数据成员,然后执行函数体。
派生类使用基类的成员
- 派生类可以访问基类的公有(public)成员和受保护(protected)成员。但是派生类的用户不可以访问基类的受保护成员。
- 派生类的作用域嵌套在基类的作用域中,前者相当于内层作用域,后者相当于外层作用域。
关键概念:遵循基类的接口
必须明确一点:每个类负责定义各自的接口。要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。
因此,派生类对象不应该直接初始化基类的成员。尽管从语法上来说我们可以在派生类构造函数体内给它的公有或受保护的基类成员赋值,但是最好不要这么做。和使用基类的其他场合一样,派生类应该遵循基类的接口,并且通过调用基类的构造函数来初始化那些从基类中继承而来的成员。
继承与静态成员
如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。不论从基类中派生出来多少个派生类,对于每个静态成员来说都只存在唯一实例。
静态成员遵循通用的访问控制规则:
如果基类中的静态成员是private的,则派生类无权访问它。假设某静态成员是可访问的,则我们既能通过基类使用它也能通过派生类使用它。
派生类的声明
派生类的声明与其他类差别不大,声明中包含类名,但是派生类的声明不包含它的派生列表。
比如:
class BulkQuote : public Quote; // 错误:派生列表不能出现在这里
class BulkQuote; // 正确
一个声明语句的目的是令程序知晓某个名字的存在以及该名字表示一个什么样的实体。如一个类、一个函数或一个变量等。派生列表以及与定义有关的其他细节必须与类的主体一起出现。
被用作基类的类
如果我们想将某个类用作基类,则该类必须已经定义而非仅声明。 这一规定的原因是:
派生类中包含并且可以使用它从基类继承而来的成员,为了使用这些成员,派生类当然要知道它们是什么。
一个类是基类,同时它也可以是一个派生类:
class Base{ /*...*/ };
class D1:public Base { /*...*/ };
class D2:public D1 { /*...*/ };
直接基类(direct base) 出现在派生列表中,而 间接基类(indirect base) 由派生类通过其直接基类继承而来。
每个类都会继承直接基类的所有成员。对于一个最终的派生类来说,它会继承其直接基类的成员;该直接基类的成员又含有其基类的成员;依次列推直至继承链的顶端、因此,最终的派生类将包含它的直接基类的子对象以及每个间接基类的子对象。
防止继承的发生
有时我们会定义这样一种类,我们不希望其他类继承它,或者不想考虑它是否适合作为一个基类。为了实现这一目的,C++11标准提供了一种防止继承发生的方法,即在需要阻止继承的类名后跟一个关键字 final 。
15.2.3 类型转换与继承
通常情况下,如果我们想把引用或指针绑定到一个对象上,则引用或指针的类型应与对象的类型一致,或者对象的类型含有一个可接收的 const 类型转换规则(参见4.11.2节)。
存在继承关系的类是一个重要的例外:我们可以将指向基类的指针或引用绑定到派生类对象上。
可以将基类的指针或引用绑定到派生类对象上有一层极为重要的含义:
当使用基类的引用(或指针)时,实际上我们并不清楚该引用(或指针)所绑定的对象的真实类型。该对象可能是指针,也可能是派生类的对象。
Note:和内置指针一样,智能指针类也支持派生类向基类的类型转换,这意味着我们可以将一个派生类对象的指针存储在一个基类的智能指针内。
静态类型与动态类型
当我们使用存在继承关系的类型时,必须将一个变量或其他表达式的 静态类型(static type) 与该表达式表示对象的 动态类型(dynamic type) 区分开来。
- 静态类型在编译时是已知的,它是变量声明时的类型或表达式生成的类型;
- 动态类型则是变量或表达式表示的内存中的对象的类型,动态类型直到运行时才可知。
如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致。
不存在从基类向派生类的隐式转换……
之所以存在派生类向基类的类型转换时因为每个派生类对象都包含一个基类部分,而基类的引用或指针可以绑定到该基类部分上。一个基类的对象既可以以独立的形式存在,也可以作为派生类对象的一部分存在。如果基类对象不是派生类对象的一部分,则它只含有基类定义的成员,而不含有派生类定义的成员。
因为一个基类的对象可能是派生类对象的一部分,也可能不是,所以 不存在从基类向派生类的自动类型转换 。
还有一种特殊的情况,即使一个基类指针或引用绑定在一个派生类对象上,我们也不能执行从基类向派生类的转换:
Bulk_qupte bulk;
Qupte *itemP = &bulk; // 正确:动态类型是Bulk_quote
Bulk_quote *bulkP = itemP; // 错误:不能将基类转换成派生类
一般情况下,编译器在编译时无法确定某个特定的转换在运行时是否安全,这是因为编译器只能通过检查指针或引用的静态类型来推断该转换是否合法。
- 如果在基类中含有一个或多个虚函数,我们可以使用
dynamic_cast
请求一个类型转换,该转换的安全检查在运行时执行。 - 同样,如果已知某个基类向派生类的转换是安全的,则可以使用
static_cast
来强制覆盖编译器的检查工作。
……在继承对象之间不存在类型转换
派生类向基类的自动转换只对指针或引用类型有效,在派生类类型和基类类型之间不存在这样的转换。很多时候,我们确实希望将派生类对象转换成它的基类类型,但是这种转换的实际发生过程往往与我们期望的有所差别。
请注意,当我们初始化或赋值一个类类型的对象时,实际上是在调用某个函数。当执行初始化时,我们调用构造函数;而当执行赋值操作时,我们调用赋值运算符。这些成员函数通常都包含一个参数,该参数的类型是类类型的 const 引用。
因为这些成员接受引用作为参数,所以派生类向基类的引用的转换允许我们给基类的拷贝/移动操作传递一个派生类的对象。 因为这些操作一般都不是虚函数,所以当我们给基类的构造函数传递一个派生类对象时, 实际运行的构造函数是基类中定义的那个,显然该构造函数只能处理基类自己的成员。类似的,如果我们将一个派生类对象赋值给一个基类对象,则实际运行的赋值运算符也是基类中定义的那个,该运算符同样只能处理基类自己的成员。
Note:当我们用一个派生类对象为一个基类对象初始化或赋值时,只有派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分会被忽略掉(切掉 sliced down)。
关键概念:具有继承关系的类型之间的转换规则
- 从派生类向基类的类型转换只对指针或者引用类型有效。
- 基类向派生类不存在隐式类型转换。
- 和任何其他成员一样,派生类向基类的类型转换可能也会由于访问受限而变得不可行。
尽管自动类型转换只对指针或引用有效,但是继承体系中的大多数仍然定义了拷贝控制成员,因此,我们通常能够将一个派生类对象拷贝、移动或赋值给一个基类对象,不过需要注意的是,这种操作只处理派生类对象的基类部分。
15.3 虚函数
如前所述,在C++中,当使用基类的引用或指针调用一个虚成员函数时会执行动态绑定。因为我们直到运行时才能知道到底调用了哪个版本的虚函数,所以全部虚函数都应该有定义。通常情况下,如果我们不使用某个函数,则无须为该函数提供定义。但是我们应该为每一个虚函数都提供定义,而不管它是否被用到了,这是因为连编译器也无法确定到底会使用哪个虚函数。
若该类有实例化对象,则虚函数必须定义,而不是简单的声明。
- 若该类有实例化对象,在类中仅仅声明虚函数而没有定义实现,编译是不可以通过的,将会出现连接错误;
- 若该类没有实例化对象,则只声明不定义是可以通过编译的。
对虚函数的调用可能在运行时才被解析
动态绑定(动态联编):
是指在程序执行的时候才将函数实现和函数调用关联。当某个虚函数通过指针或引用调用时,编译器产生的代码直到运行时才能确定应该调用哪个版本的函数。被调用的函数时与绑定到指针或引用上的对象的动态类型相匹配的那一个。
必须要搞清楚的一点是,动态绑定只有当我们通过指针或引用调用虚函数时才会发生。当我们通过一个具有普通类型(非应用非指针)的表达式调用虚函数时,在编译时就会将调用的版本确定下来。
关键概念:C++的多态性
OOP的核心思想是多态性(polymorphic),其含义是“多种形式”。我们把具有继承关系的多个类型称为多态类型,因为我们能使用这些类型的“多种形式”而无须在意它们的差异。引用或指针的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本所在。
当我们使用基类的引用或指针调用基类中定义的一个函数时,我们并不知道该函数真正作用的对象是什么类型,因为它可能是一个基类的对象也可能是一个派生类的对象。如果该函数是虚函数,则直到运行时才会决定到底执行哪个版本,判断的依据是引用或指针所绑定的对象的真实类型。
另一方面,对非虚函数的调用是在编译时进行绑定。类似的,通过对象进行的函数(虚函数或非虚函数)调用也在编译时绑定。对象的类型是确定不变的,我们无论如何都不可能令对象的动态类型与静态类型不一致。因此,通过对象进行的函数调用将在编译时绑定到该对象所属类中的函数版本上。
Note:当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型才有可能与静态类型不同。
派生类中的虚函数
当我们在派生类中覆盖了某个虚函数时,可以再一次使用 virtual 关键字指出该函数的性质。然而这么做并非必须——因为一旦某个函数被声明成虚函数,则在所有派生类中它都被隐式声明成虚函数。
如果派生类想要覆盖了某个继承而来的虚函数,则派生类中的同名函数:
- 它的形参类型必须与被它覆盖的基类函数完全一致。
- 同样,派生类中虚函数的返回类型也必须与基类函数匹配。该规则存在一个例外,当基类的虚函数返回类型是基类本身的指针或引用时,上述规则无效。也就是说,如果D由B派生得到,则基类的虚函数可以返回B而派生类的对象函数可以返回D,只不过这样的返回类型要求从D到B的类型转换是可访问的。
final 和 override 说明符对于覆盖虚函数的作用
派生类如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,这仍然是合法的行为。
编译器将认为新定义的这个函数与基类中的同名函数时相互独立的(不是重载,不在同一作用域中)。这时,派生类的函数并没有覆盖掉基类中的版本。就实际的编程习惯而言,这种声明往往意味着发生了错误,因为我们可能原本希望派生类能覆盖掉基类中的虚函数,但是一不小心把形参列表弄错了。
一般要想调试并发现这样的错误显然非常困难:
而在C++11新标准中,可以使用 override 关键字来说明派生类中的虚函数。
这么做的好处是:在使得程序员的意图更加清晰的同时让编译器可以为我们发现一些错误,后者在编程实践中显得更加重要。如果我们使用 override 标记了某个函数,但该函数没有覆盖已存在的虚函数,此时编译器将报错。只有虚函数才能被覆盖,所以非虚函数的覆盖将会报错。
如果已经把函数定义成 final 了,则之后任何尝试覆盖该函数的操作都将会引发错误。 final 和 override 说明符出现在形参列表(包括任何 const 或引用修饰符 & 或 && )以及尾置返回类型之后。
虚函数与默认实参
和其他函数一样,虚函数也可以拥有默认实参。如果某次虚函数调用使用了默认实参,则该默认实参由本次调用虚函数的指针或引用的静态类型决定。
换句话说,如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。此外,传入派生类函数的将是基类函数定义的默认实参。如果派生类函数依赖不同的实参,则程序结果将与我们的预期不符。
Note:如果虚函数使用默认实参,则基类和派生类中定义的默认参数最好一致。
回避虚函数的机制
在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符::
可以实现这一目的,且函数调用将在编译时完成解析。。
例如下面的代码:
//强行调用基类中定义的函数版本而不管baseP的动态类型到底是什么 double undiscounted = baseP->Quote::net_price(42);
该代码强行调用Quote的net_price函数,而不管baseP实际指向的对象类型到底是什么。该调用将在编译时完成解析。
Note:通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。
什么时候我们需要回避虚函数的默认机制呢? 通常是当一个派生类的虚函数调用它覆盖的基类的虚函数版本时。在此情况下,基类的版本通常完成继承层次中所有类型都要做的共同任务,而派生类中定义的版本需要执行一些与派生类本身密切相关的操作。
WARNING:如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归。
15.4 抽象基类
纯虚函数
纯虚(pure virtual)函数 清晰明了地告诉用户当前这个函数时没有实际意义的,和普通的虚函数不一样,一个纯虚函数一般不需要定义。
我们通过在函数体的位置(即在声明语句的分号之前)书写=0
就可以将一个虚函数说明纯虚函数。其中,=0
只能出现在类内部的虚函数声明语句处。
纯虚函数的声明形式如下:
virtual 返回值类型 函数名(参数表)= 0;
值得注意的是,我们也 可以为纯虚函数提供定义,不过函数体必须定义在类的外部。也就是说,我们不能在类的内部为一个=0
的函数提供函数体。
含有纯虚函数的类是抽象基类
含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类(abstract base class)。
抽象基类负责定义接口,而后续的其他类可以覆盖该接口。我们 不能(直接)创建一个抽象基类的对象。
派生类构造函数只初始化它的直接基类
关键概念:重构
重构(refactoring) 负责重新设计类的体系以便将操作和/或数据从一个类移动到另一个类中。对于面向对象的应用程序来说,重构是一种很普遍的现象。
值得注意的是,即使我们改变了整体继承体系,那些使用了派生类或基类的代码也无须进行任何改动。不过一旦类被重构(或以其他方式被改变),就意味着我们必须重新编译含有这些类的代码了。
15.5 访问控制与继承
每个类分别控制自己的成员初始化过程,与之类似,每个类还分别控制着其成员对于派生类及其用户来说是否可访问(accessible)。
受保护的成员
如前所述,一个类使用 protected 关键字来声明那些它希望与派生类分享但是不想被其他公共访问使用的成员。 proetcted 说明符可以看做是 public 和 private 中和后的产物:
- 和私有成员类似,本类的受保护的成员对于本类的用户(比如本类对象)来说是不可访问的。
- 和公有成员类似,本类的受保护的成员对于派生类的成员和友元来说是可访问的。此外, protected 还有另外一条重要的性质。
- 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。
比如:
class Base{
protected:
int prot_mem;
};
class Sneaky:public Base{
friend void clobber(Sneaky&); // 能访问Sneaky::prot_men
friend void clobber(Base&); // 不能访问 Base::prot_men
int j; // j默认是private
};
// 正确:clobber能访问Sneaky对象的private和protected成员
void clobber(Sneaky &s) {s.j = s.prot_men = 0;}
// 错误:clobber不能访问Base的protected成员
void clobber(Base &b) {b.prot_men = 0;}
公有、私有和受保护继承
某个类对其从基类继承而来的的成员的访问权限受到两个因素影响:
- 在基类中该成员的访问说明符;
- 在派生类的派生列表中的访问说明符。
类派生列表的访问说明符,对于派生类的成员(及友元)能否访问其直接基类的成员没有什么影响。派生类的成员和友元对直接基类成员的访问权限只与直接基类中的访问说明符有关。
类派生列表的访问说明符的目的是: 控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限。
公有、私有和受保护继承对派生类的用户对于基类成员的访问权限影响 | |
---|---|
继承方式 | 派生类对基类成员的访问权限 |
public 继承 | 遵循原有的访问说明符 |
protected 继承 | 基类所有的public成员在派生类中变为protected成员,其它成员不变 |
private 继承 | 基类所有的成员在派生类中变为private成员 |
派生类向基类转换的可访问性
派生类向基类的转换是否可访问由使用该转换的代码决定,同时派生类的派生访问说明符也会有影响。假定B是D的直接基类:
- 只有当D公有地继承B时,用户才能使用派生类向基类的转换;如果D继承B的方式是受保护的或者私有的,则用户代码不能使用该转换。
- 不论D以什么方式继承B,D的成员函数和友元都能使用派生类向基类的转换;派生类向其基类的类型转换对于派生类的成员和友元来说永远是可访问的。
- 如果D继承B的方式是公有地或者受保护的,则D的派生类的成员和友元可以使用D向B的类型转换;反之,如果D继承B的方式是私有的,则不能使用。
TIP:对于代码中的某个给定节点来说,如果基类的公有成员是可访问的,则派生类向基类的类型转换也是可访问的;反之则不行,因为如果代码不能访问基类的公有成员,那该派生类一定是非公有继承。
关键概念:类的设计与受保护的成员
不考虑继承的话,我们可以认为一个类有两种不同的用户:普通用户和类的实现者。其中,普通用户编写的代码使用类的对象,这部分代码只能访问类的公有(接口)成员;实现者则负责编写类的成员和友元的代码,成员和友元既能访问类的公有部分,也能访问类的私有(实现)部分。
如果进一步考虑考虑继承的话就会出现第三种用户,即派生类。基类把它希望派生类能够使用的部分声明成受保护的。普通用户不能访问受保护的成员,则派生类及其友元仍旧不能访问私有成员。
和其他类一样,基类应该将其接口成员声明为公有地;同时将属于其实现的部分分成两组:一组可供派生类访问,另一组只能由基类及基类的友元访问。对于前者应该声明为受保护的,这样派生类就能在实现自己的功能时使用基类的这些操作和数据;对于后者应该声明为私有的。
友元和继承
就像友元关系不能传递一样,友元关系同样也不能继承。
基类的友元在访问派生类成员时不具有特殊性,类似的,派生类的友元也不能随意访问基类的成员:(注意下面例子的 f3 )
class Base{ // 添加friend声明,其他成员与之前的版本一致 friend class Pal; // Pal在访问Base的派生类时不具有特殊性 }; class Pal{ public: int f(Base b) {return b.prot_mem;} // 正确:Pal是Base的友元 // Sneaky是Base的派生类 int f2(Sneaky s) {return s.j;} // 错误:Pal不是Sneaky的友元 // 对基类的访问权限由基类本身控制,即使对于派生类的基类部分也是如此 int f3(Sneaky s) {return s.prot_mem;} // 正确:Pal是Base的友元 };
如前所述,每个类负责控制自己的成员的访问权限,因此尽管看起来有点儿奇怪,但是f3确实是正确的。Pal是Base的友元,所以Pal能够访问Base对象的成员,这种可访问性包括了Base对象内嵌在其派生类对象中的情况。
当一个类将另一个类声明为友元时,这种友元关系只对做出声明的类有效,即使对于派生类的基类部分也是如此。对于原来的那个类来说,其友元的基类或者派生类不具有特殊的访问能力。
如果是基类A的友元类B访问了A的派生类C的基类成员,这是合法的行为,编译器不会报错。因为派生类实际上是包含了一个基类的成员。
改变基类中可访问的个别成员的访问权限
有时我们需要 改变派生类继承的某个名字(前提是,派生类能访问)的访问级别,通过使用 using 声明 可以达到这一目的:
class Base { public: std::size_t size() const {return n;} protected: std::size_t n; }; class Derived : private Base { // 注意 private 继承 public: //保持对象迟尺寸相关的成员的访问级别 using Base::size; protected: using Base::n; };
因为Derived 使用了私有继承,所以继承而来的成员size和n(在默认情况下)是Derived 的私有成员。然而,我们使用using声明语句改变了这些成员的可访问性。改变之后,Derived 的用户将可以使用size成员,而Derived 的派生类将能使用n。
- 通过在类的内部使用 using 声明语句,我们可以将该类的直接或间接基类中的任何可访问成员标记出来。
- using 声明语句中名字的访问权限由该 using 声明语句之前的访问说明符来决定。
也就是说,如果一条using声明语句出现在类的private部分,则该名字只能被类的成员和友元访问;如果using声明语句位于public部分,则类的所有用户都能访问它;如果using声明语句位于protected部分,则该名字对于成员、友元和派生类是可访问的。
Note:派生类只能为那些它可以访问的名字提供 using 声明。
默认的继承访问级别
使用 struct 和 class 关键字定义的类具有不同的默认访问说明符。类似的,派生类的默认的访问继承模式也由定义派生类所用的关键字来决定。默认情况下,使用 class 关键字定义的派生类是私有继承的;而使用 struct 关键字定义的派生类是公有继承的:
class Base {/* ... */};
struct D1 : Base {/* ... */}; //默认public继承
class D2 : Base {/* ... */ }; //默认private继承
事实上,使用 struct 关键字和 class 关键字唯一的差别就是默认成员访问说明符及默认派生继承模式;除此之外,再无其他不同之处。
Note:一个私有派生的类最好显式地将 private 声明出来,而不要仅仅依赖于默认的设置。显式声明的好处是可以令私有继承关系清晰明了,不至于产生误会。
15.6 继承中的类作用域
每个类定义自己的作用域,在这个作用域内我们定义类的成员。当存在继承关系时,派生类的作用域嵌套在其基类的作用域之内。如果一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找该名字的定义。
派生类的作用域位于基类作用域之内这一事实可能有点出人意料,毕竟派生类和基类的定义是相互分离开来的。不过也恰恰因为类作用域有这种继承嵌套的关系,所以派生类才能像使用自己的成员一样使用基类的成员。
在编译时进行名字查找
一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。即使静态类型与动态类型可能不一致(当使用基类的引用或指针时会发生这种情况),但是我们能使用哪些成员仍然是由静态类型决定的。
比如:
class Disc_quote :public Quote {
public:
pair <size_t, double> discount_policy() const
{ return { quantity,discount }; }
//其他成员
};
Bulk_quote bulk;
Bulk_quote *bulkP = &bulk; //静态类型与动态类型一致
Quote *itemP = &bulk; //动态类型与静态类型不一致
bulkP->discount_policy(); //正确:类型为Bulk_Quote
itemP->discount_policy(); //错误:Quote无这个成员
名字冲突与继承
和其他作用域一样,派生类也能重用定义在其直接基类或间接基类中的名字,此时定义在内存作用域(即派生类)的名字将隐藏定义在外城作用域(即基类)的名字。这里是隐藏而不是重载,因为都不在同一个作用域。
Note:派生类的成员将隐藏同名的基类成员。
通过作用域运算符来使用隐藏的成员
我们可以通过作用域运算符::
来使用一个被派生类自定义的同名成员隐藏的基类成员,作用域运算符将覆盖掉原有的查找规则,并指示编译器从另一个类的作用域开始查找该名字:
struct Derived : Base { int get_base_mem() {return Base::mem;} //... };
作用域运算符将覆盖掉原有的查找规则,并指示编译器从Base类的作用域开始查找mem。
若 ::
左边为空,则查找过程仅会考虑全局命名空间作用域中作出(或通过 using 声明引入到全局命名空间中)的声明。这使得即使被局部声明隐藏的名字也能够被访问。
建议:除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。
关键概念:名字查找与继承
理解函数调用的解析过程对于理解C++的继承至关重要,假定我们调用 p->men() (或者obj.mem()),则依次执行以下4个步骤:
- 首先确定p(或obj)的静态类型。因为我们调用的是一个成员,所以该类型必然是类类型。
- 在p(或obj)的静态类型对应的类的作用域中查找mem。如果找不到,则依次在直接基类中不断查找直至到达继承链的顶端。如果找遍了该类及基类仍然找不到,则编译器将报错。
- 一旦找到了mem,就进行常规的类型检查以确认对于当前找到的mem本次调用是否合法。
- 假定调用合法,则编译器将根据调用的是否是虚函数而产生不同的代码:
——如果mem是虚函数且我们是通过引用或指针进行的调用,则编译器产生的代码将在运行时确定到底运行该虚函数的哪个版本,依据是对象的动态类型。
——反之,如果mem不是虚函数或者我们是通过对象(而非引用或指针)进行的调用,则编译器将在编译时产生一个常规函数调用。
一如往常,名字查找先于类型检查
如前所述,声明在内层作用域的函数并不会重载声明在外层作用域的函数。因此,定义派生类中的函数也不会重载其基类中的成员。
和其他作用域一样,如果派生类(即内层作用域)的成员与基类(即外层作用域)的某个成员同名,则派生类在其作用域内隐藏该基类成员(除非它们是同名的形参列表相同的虚函数,这会覆盖而不是隐藏)。
即使派生类成员和基类成员的形参列表不一致,基类成员也仍然会被隐藏掉。
struct Base {
int memfcn();
};
struct Drrived :Base {
int memfcn(int); //隐藏基类的memfcn
};
Derived d; Base b;
b.memfcn(); //调用Base::memfcn
d.memfcn(); //调用Derived::memfcn
d.memfcn(); //错误,参数列表为空的memfcn被隐藏了
d.Base::memfcn(); //正确:调用Base::memfcn
虚函数与作用域
为什么基类和派生类的同名虚函数必须有相同的形参列表?
假如基类与派生类的虚函数接受的实参不同,则我们就无法通过基类的引用或指针调用派生类的虚函数了。因为编译器将派生类的与基类同名的形参列表不同的函数视为新函数,它隐藏了基类的同名虚函数,而不是覆盖基类的同名虚函数。只有同名的形参列表也相同的派生类成员函数才能覆盖基类的基类虚函数。
class Base { public: virtual int fcn(); }; class D1 : public Base { public: //隐藏基类的fcn,这个fcn不是虚函数 //D1继承了Base::fcn()的定义 int fcn(int); //形参列表与Base中的fcn不一致 virtual void f2(); //是一个新的虚函数,在Base中不存在 }; class D2:public D1{ >public: int fcn(int); //是一个非虚函数,隐藏了D1::fcn(int) int fcn(); //覆盖了Base的虚函数fcn void f2(); //覆盖了D1的虚函数f2 };
D1的fcn 函数并没有覆盖 Base的 虚函数 fcn,原因是它们的形参列表不同。实际上,D1的fcn将隐藏Base的fcn。此时拥有了两个名为fcn的函数:一个是D1从Base继承而来的虚函数fcn;另一个是D1自己定义的接受一个int参数的非虚函数fcn。
通过基类调用隐藏的虚函数
给定上面定义的这些类后,我们来看几种使用其函数的方法:
Base bobj;D1 d1obj;D2 d2obj; Base *bp1 = &bobj,*bp2 = &d1obj, *bp3 = &d2obj; bp1->fcn(); //虚调用,将在运行时调用Base::fcn() bp2->fcn(); //虚调用,将在运行时调用Base::fcn() bp3->fcn(); //虚调用,将在运行时调用D2::fcn() D1 *d1p = &dlobj; D2 *d2p = &d2obj; bp2->f2(); //错误:Base没有名为f2的成员 d1p->f2(); //虚调用,将在运行时调用D1::f2() d2p->f2(); //虚调用,将在运行时调用D2::f2()
前三条调用语句是通过基类的指针进行的,因为fcn是虚函数,所以编译器产生的代码将在运行时确定使用虚函数的哪个版本,判断的依据是该指针所绑定对象的真实类型。在bp2的例子中,实际绑定的对象时D1类型,而D1并没有覆盖那个不接受实参的fcn,所以通过bp2进行的调用将在运行时解析为Base定义的版本。
接下来的三条调用语句是通过不同类型的指针进行的,每个指针分别指向继承体系中的一个类型。因为Base类中没有f2(),所以第一条语句是非法的,即使当前的指针碰巧指向了一个派生类对象也无济于事。
因为名字查找是从静态类型的作用域开始查找的,所以bp2->fcn();
是从静态类型Base开始查找,而不是动态类型D1。
覆盖重载的函数
和其他函数一样,成员函数无论是否是虚函数都能被重载。
派生类可以覆盖基类的重载函数的0个或多个实例。如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖(直接继承)。
有时一个类仅需要覆盖重载集合中的一些而非全部函数,此时,如果我们不得不覆盖基类中的每一个版本的话,显然操作将及其烦琐。
一种好的解决方案是 为重载的成员提供一条 using 声明语句 ,这样我们就无须覆盖基类中的每一个重载版本了。 using 声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的 using 声明语句就可以把该函数的所有重载实例添加到派生类作用域中。
- 类内 using 声明的一般规则(即 using 声明前的访问说明符决定了 using 指定的基类成员的访问权限)同样适用于重载函数的名字;
- 在派生类内 using 声明的基类函数的每个实例,它们在派生类中都必须是可访问的。对派生类中没有重新定义的重载版本的访问实际上是对 using 声明点的访问。
当使用 using 将基类的函数添加到派生类的作用域中时,派生类只需要定义其同名的函数就可以重载(形参列表不一样)或者隐藏(形参列表一样)特定的基类重载函数,而无须为继承而来的其他函数重新定义。
#include <iostream>
using namespace std;
class A
{
public:
void printfSth(){ cout<<"A::printfsth()"<<endl; }
void printfSth(string str){ cout<<"A::printfsth(string str):"<<str<<endl; }
void printfSth(int i){ cout<<"A::printfSth(int i):"<<i<<endl; }
};
class C:public A
{
public:
using A::printfSth;
void printfSth(double d){ cout<<"C::printfSth(double i):"<<i<<endl; } // 重载
// void printfSth(int s){ cout<<"C::printfSth(int s):"<<s<<endl; }
// 将会隐藏基类的void printfSth(int i)函数
};
int main()
{
C* c=new C();
c->printfSth(); // 输出:A::printfsth()
c->printfSth(1); // 输出:A::printfsth(string str):1
c->printfSth(1.1); // 输出:A::printfSth(int i):1.1
delete c;
}
15.7 构造函数与拷贝控制
和其他类一样,位于继承体系中的类也需要控制当其对象执行一系列操作时发生什么样的行为,这些操作包括创建、拷贝、移动、赋值和销毁。如果一个类(基类或派生类)没有定义拷贝控制操作,则编译器将为它合成一个版本。当然,这个合成的版本也可以定义成被删除的函数。
15.7.1 虚析构函数
继承关系对基类控制最直接的影响是基类通常应该定义一个虚析构函数(当然不是必须要虚析构函数),这样我们就能动态分配继承体系中的对象了。
如前所述,当我们delete一个动态分配的对象的指针时将执行析构函数。此时如果该指针指向继承体系中的某个类型,则有可能出现指针的静态类型与被删除对象的动态类型不符的情况。
和其他函数一样,我们通过在基类中析构函数定义成虚函数以确保执行正确的析构函数版本。
例如,如果我们delete一个Quote*类型的指针,则该指针有可能实际指向一个Bulk_quote类型的对象。如果这样的话,编译器就必须清楚它应该执行的是Bulk_quote的析构函数:
class Quote { public: // 如果我们删除的是一个指向派生类对象的基类指针,则需要虚析构函数 virtual ~Quote() = default; // 动态绑定析构函数 };
和其他虚函数一样,析构函数的虚属性也会被继承(虽然不同类中的析构函数不同名)。
- 因此,只要基类的析构函数是虚函数,无论它的派生类使用合成的析构函数还是定义自己的析构函数,都将是虚析构函数。
- 而且,只要基类的析构函数是虚函数,就能确保当我们delete基类指针时将运行正确地析构函数版本:
Quote *itemP = new Quote; // 静态类型与动态类型一致
delete itemP; // 调用Quote的析构函数
itemP = new Bulk_quote; // 静态类型与动态类型不一致
delete itemP; // 调用Bulk_quote的析构函数
WARNING:如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为。
之前我们曾介绍过一条经验准则,即如果一个类需要析构函数,那么它也同样需要拷贝和赋值操作(参见13.14节)。基类的析构函数并不遵循上述准则,它是一个重要的例外。
一个基类总是需要析构函数,而且它能将析构函数设定为虚函数。基类的析构函数被声明为虚函数且一般会令其函数体内容为空,我们显然无法由此推断该基类还需要赋值运算符或拷贝构造函数。如果你的这个基类持有了一些资源,虚析构函数也不应当为空,需要做一些清理工作。
虚析构函数将阻止合成移动操作
基类需要一个虚析构函数(参见13.6.2节)这一事实还会对基类和派生类的定义产生另外一个间接地影响:
如果一个类定义了析构函数(是否是虚函数无所谓)(参见13.1.6节),无论是以函数体的形式,还是以=default
或=delete
的形式定义,编译器都将不会为这个类合成移动操作。
15.7.2 合成拷贝控制与继承
基类或派生类的合成拷贝控制成员的行为与其他合成的构造函数、赋值运算符或析构函数类似:它们对类本身的成员依次进行初始化、赋值或销毁的操作。此外,这些合成的成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁的操作。
值得注意的是,当派生类使要用基类的成员时,无论基类成员是合成的版本还是自定义的版本都没有太大影响。唯一的要求是相应的成员应该可访问(参见15.5节)并且不是一个被删除的函数。
一如既往,编译器合成的析构函数体是空的,其隐式地析构部分负责销毁类的成员。
对于派生类的析构函数来说,它除了销毁派生类自己的成员外,还负责销毁派生类的直接基类;该直接基类又销毁它自己的直接基类,一次类推直至继承链的顶端。
假设某一基类只定义了析构函数,则它不能拥有合成的移动操作,所以当我们移动这个基类的对象时实际上使用的是合成的拷贝操作。所以 基类没有移动操作则意味着它的派生类也没有 ,原因是因为基类缺少移动操作会阻止编译器生成派生类的合成移动操作。
派生类中删除的拷贝控制操作与基类的关系
就像其他任何类一样,基类或派生类也能出于同样的原因将其合成的默认构造函数或者任何一个拷贝控制成员定义成被删除的函数。此外,某些定义基类的方式也可能导致有的派生类成员成为被删除的函数:
- 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或者不可访问的,则派生类中对应的成员将是被删除的。原因是编译器不能使用基类成员来执行派生对象基类部分的构造、赋值或销毁操作。
- 如果在基类中有一个不可访问或被删除的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的。因为编译器无法销毁派生类对象的基类部分。
- 和过去一样,编译器将不会合成一个删除掉的移动操作。当我们使用
=default
请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的,那么派生类中该函数将是被删除的,原因是派生类对象的基类部分不可移动。同样,如果基类的析构函数是删除的或不可访问的,则派生类的移动构造函数也将是被删除的。
举例:
class B { public: B(); B(const B&) = delete; // 其他成员,不含有移动构造函数 }; class D:public B { // 没有声明任何构造函数 }; D d; // 正确:D的合成默认构造函数使用B的默认构造函数 D d2(d); // 错误:D的合成拷贝函数是被删除的 D d3(std::move(d)); // 错误:隐式地使用D的被删除的拷贝构造函数
基类B含有一个可访问的默认构造函数和一个显示删除的拷贝构造函数。因为我们定义了拷贝构造函数,所以编译器将不会为B合成一个移动构造函数。因此,我们既不能移动也不能拷贝B的对象。如果B的派生类希望它自己的对象能被移动和拷贝,则派生类需要自定义相应版本的构造函数。当然,在这一过程中派生类还必须考虑如何移动或拷贝其基类部分的成员。
在实际编程过程中,如果在基类中没有默认、拷贝或移动构造函数,则一般情况下派生类也不会定义相应的操作。
移动操作与继承
如前所述,大多数基类都会定义一个虚析构函数。因此在默认情况下,基类通常不含有合成的移动操作,而且在它的派生类中也没有合成的移动操作。
这是因为基类缺少移动操作会阻止派生类拥有自己的合成移动操作,所以当我们确实需要执行移动操作时应该首先在基类中进行定义。
我们的Quote可以使用合成的版本,不过前提是Quote必须显示地定义这些成员。一旦Quote定义了自己的移动操作,那么它必须同时显式地定义拷贝操作。
class Quote { public: Quote() = default; // 对成员依次进行默认初始化 Quote(const Quote&) = default; // 对成员依次拷贝 Quote(Quote &&) = default; // 对成员依次拷贝 Quote& operator = (const Quote&) = default; // 拷贝赋值 Quote& operator = (Quote&&) = default; // 移动赋值 virtual ~Quote() = default; // 其他成员与之前版本一致 };
通过上面的定义,我们就能对Quote的对象逐成员地分别进行拷贝、移动、赋值和销毁操作了。而且除非Quote的派生类中含有不能移动的成员,否则它将自动获得合成的移动操作。
15.7.3 派生类的拷贝控制成员
- 派生类构造函数在其初始化阶段中不但要初始化派生类自己的成员,还负责初始化派生类对象的基类部分。
- 因此,派生类的 拷贝和移动构造函数 在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员。
- 类似的,派生类赋值运算符也必须为其基类部分的成员赋值。
和构造函数及赋值运算符不同的是,派生类的析构函数只负责销毁派生类自己分配的资源(隐式——编译器会自动调用基类的析构函数,才会导致基类部分被自动销毁)。如前所述,对象的成员时被隐式销毁的(析构函数的隐式的析构部分执行销毁);类似的,派生类对象的基类部分也是自动销毁的。
WARNING:当派生类定义了拷贝或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。
定义派生类的拷贝或移动构造函数
当为派生类定义拷贝或移动构造函数时,我们通常使用对应的基类构造函数初始化对象的基类部分。而且是在初始化列表中显式地调用对应的构造函数:
class Base {/*...*/}; class D : public Base { public: // 默认情况下,基类的默认构造函数初始化对象的基类部分 // 要想使用拷贝或移动构造函数,我们必须在构造函数初始化列表中 // 显式地调用该构造函数 D(const D& d): Base(d) // 拷贝基类成员 /*D的成员的初始值*/ {/* ... */} D(D&& d): Base(std::move(d)) // 移动基类成员 /*D的成员的初始值*/ {/* ... */} };
初始值Base(d)将一个D对象传递给基类构造函数。尽管从道理上来说,Base可以包含一个参数为D的构造函数,但是在实际编程过程中通常不会这么做。相反,Base(d)一般会匹配Base的拷贝构造函数。D类型的对象d将被绑定到该构造函数的Base&形参上。Base的拷贝构造函数负责将d的基类部分拷贝给要创建的对象。
假如没有在派生类的构造函数的初始化列表中调用基类的构造函数,且没有提供给基类初始值的话,基类部分将被隐式地默认初始化,而非拷贝或移动初始化。
也可以在派生类的初始化列表中显式调用基类的默认构造函数,从而实现基类部分的默认初始化。
比如:
// D的这个拷贝构造函数很可能是不正确的定义 // 基类部分被默认初始化,而非拷贝 D(const D& d) /*成员初始值,但是没有提供记录类初始值*/ {/* ... */} D(const D&& d):Base() // {/* ... */}
在上面的例子中,D的基类成员被赋予了默认值,而D的自定义成员的值则是从别的对象中拷贝过来的。
派生类赋值运算符
与拷贝和移动构造函数一样,派生类的赋值运算符也必须显式地为基类部分赋值。
基类的拷贝和移动赋值运算符不会被自动调用。
// Base::operator=(const Base&)不会被自动调用
D &D::operator=(const D& rhs){
Base::operator=(rhs); // 为基类部分赋值
// 按照原来的方式为派生类的成员赋值
return *this;
}
派生类析构函数
在析构函数执行完成后,对象的成员会被析构函数中的编译器定义的析构部分隐式地销毁(参见13.1.3节)。
类似的,对象的基类部分也是隐式销毁的。因此,和构造函数以及赋值运算符不同的是,派生类析构函数只负责销毁派生类自己分配的资源,编译器会在派生类的析构函数执行完后调用其直接基类的析构函数,依此类推。
class D:public Base{
public:
// 直接基类的析构函数被隐式调用执行
~D(){ };
};
派生类的对象销毁的顺序正好与其创建的顺序相反:
- 派生类析构函数首先执行;
- 然后是基类的析构函数;
- 以此类推,沿着继承体系的反方向直至最后的基类。
在构造函数和析构函数中调用虚函数
像之前提到的一样,派生类对象的基类部分将首先被构建。当执行基类的构造函数时,该对象的派生类部分是未被初始化的状态。类似的销毁派生类对象的次序正好相反,因此当执行基类的析构函数时,派生类的部分已经被销毁掉了。
由此可知,当执行基类的构造函数和析构函数时,该对象处于未完成的状态。
为了能够正确地处理这种未完成状态,编译器认为对象的类型在构造或析构的过程中好像发生了改变一样。
- 也就是说,当我们在构建一个对象时,需要把对象的类和当前调用的构造函数所属的类看作是同一个(调用基类的构造函数构建基类部分,此时对象类型是基类);
- 对虚函数的调用绑定正好符合这种把对象的类和构造函数的类看作是同一个的要求;
- 对于析构函数也是这样的道理。
- 上述的绑定不但对直接调用虚函数有效,对间接调用也是有效的,这里的间接调用是指通过构造函数(或析构函数)调用另一个函数(比如在构造函数中调用另一个函数,而这个函数调用了一个虚函数)。
我自己的理解是:
当构建派生类对象时,先调用直接基类的构造函数。此时因为派生类对象部分未被初始化,所以编译器认为党建构建的对象的类型是直接基类,此时调用的是基类的虚函数而不是派生类的虚函数。析构函数同理。
为了理解上述行为,不妨考虑当基类构造函数调用虚函数的派生类版本会发生什么情况。这个虚函数可能会访问派生类的成员。逼近如果它不需要访问派生类成员的话,则派生类直接使用基类的虚函数版本就可以了。然而,当执行基类构造函数时,他要用到的派生类成员尚未初始化,如果我们允许这样的访问,则程序很有可能会崩溃。
所以编译器才认为对象的类型在构造或析构的过程中仿佛发生了改变一样。
Note:如果在构造函数或析构函数中调用了某个虚函数,则我们应该执行与该构造函数或析构函数所属类型对应的虚函数版本。(可以理解为:在该期间不会出现动态绑定的情况)
15.7.4 “继承”的构造函数(实际上不能继承)
虽然实际上派生类不能继承基类的构造函数、析构函数和友元关系。
基类的私有成员会被添加到派生类的数据结构里面,但是派生类的函数没有权限直接访问基类私有成员,必须通过基类的protect或者public函数来访问
但是在C++11标准中,派生类能够使用 using 声明“继承”其直接基类定义的构造函数。这些构造函数并不是通常意义上的继承,它只是一种代码复用,为方便起见,我们在这里暂称为“继承”。
一个派生类只能初始化它的直接基类,出于同样的原因,一个类也只能“继承”其直接基类的构造函数。
还有一点,派生类不能“继承”其直接基类的默认、拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们。
派生类"继承"直接基类的构造函数的方式是:提供一条注明了(直接)基类名的 using 声明语句。
举个例子,我们可以重新定义Bulk_quote类,令其继承Disc_quote类的构造函数:
class Bulk_quote:public Disc_quote { public: using Disc_quote::Disc_quote; // 继承Disc_quote的构造函数 double net_price(std::size_t) const; };
通常情况下, using 声明语句只是令某个名字在当前作用域内可见。而当作用域构造函数时, using 声明语句将令编译器产生代码。对于基类的每个构造函数,编译器都生成一个与之对应的派生类构造函数。
换句话说,对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数。
这些编译器生成的构造函数形如:
drived(parms):base(args) {}
其中,derived是派生类的名字,base是基类的名字,parms是构造函数的形参列表,args将派生类构造函数的形参传递给基类的构造函数。
在我们的Bulk_quote类中,继承的构造函数等价于:
Bulk_quote(const std::string& book, double price, std::size_t qty,double disc): Disc_quote(book,price,qty,disc) {}
如果派生类含有自己的数据成员,则这些成员将被默认初始化。
“继承”的构造函数的特点
- 和普通成员的 using 声明不一样,指定构造函数的 using 声明不会改变该构造函数的访问级别。
- 而且,一个 using 声明语句不能指定 explicit 或 constexpr 。如果基类的构造函数是 explicit 或者 constexpr ,则“继承”的构造函数也拥有相同的属性。
一个基类构造函数含有默认实参时,这些实参并不会被继承。相反,派生类将获得多个继承的构造函数,其中每个构造函数分别省略一个含有默认实参的形参。
例如,如果基类有一个接受两个形参的构造函数,其中第二个形参含有默认实参,则派生类将获得两个构造函数:一个构造函数接受两个形参(没有默认实参),另一个构造函数只接受一个形参,它对应于基类中最左侧的没有默认值的那个形参。
如果直接基类含有多个构造函数,则除了两个例外情况,大多数情况派生类会“继承”所有这些构造函数:
- 第一个例外是:派生类可以“继承”一部分构造函数,而为其他构造函数定义自己的版本。如果派生类定义的构造函数与从直接基类中“继承”来的构造函数具有相同的参数列表,则该构造函数将不会被继承。定义在派生类中的构造函数将替换“继承”而来的构造函数。
- 第二个例外是:直接基类的默认、拷贝和移动构造函数不会被“继承”。这些构造函数按照正常规则被合成。“继承”的构造函数不会被作为用户定义的构造函数来使用,因此,如果一个类只含有“继承”的构造函数,则它也将拥有一个合成的默认构造函数。
class base {
public:
base() {}
base(string s, int a) {}
};
class son : public base {
public:
// 继承直接基类所有的构造函数,对于基类的每个构造函数,
// 编译器都在派生类中生成一个形参完全相同的构造函数
// using不能指定explicit和constexpr
using base::base;
private:
// 使用继承来的构造函数时,如果派生类有自己的数据成员,
// 则这些成员将被默认初始化
string str;
int num;
};
// 等价于:
class son : public base {
public:
son():base() {}
son(string s, int a): base(s, a) {}
};
15.8 容器与继承
当我们使用容器存放继承体系中的对象时,通常必须采取间接存储的方式。因为不允许在容器中保存不同类型的元素,所以我们不能把具有继承关系的多种类型的对象直接存放在容器当中。
不应该使用标准库容器直接保存类型为基类的对象,这么做虽然能把其派生类的对象存放在容器中,但是这些对象只剩下了对应的基类部分,而不再是派生类对象了。
当派生类对象被赋值给基类对象时,其中的派生类部分将被“切掉”,因此容器和存在继承关系的类型无法兼容。
在容器中放置(智能)指针而非对象
当我们希望在容器中存放具有继承关系的对象时,我们实际上存放的通常是基类的指针(更好的选择是智能指针)。
正如我们可以将一个派生类的引用或指针转换成基类的指针或引用一样,我们也能把一个派生类的智能指针转换成基类的智能指针。比如存在基类base和派生类que,shared_ptr<que>
可以转换成shared_ptr<base>
。
15.8. 编写辅助类
对于C++面向对象的编程来说,一个悖论是我们无法直接使用对象进行面向对象编程。相反,我们必须使用指针和引用。因为指针会增加程序的复杂性,所以经常定义一些辅助的类来处理这种复杂情况。
通过将指针和引用进行隐藏,我们可以实现面向对象编程。
15.9 文本查询程序再探
15.9.1 面向对象的解决方案
关键概念:继承与组合
当我们令一个类公有地继承另一个类时,派生类应当反映与基类的 “是一种(Is A)” 关系。在设计良好的类体系中,公有派生类的对象应该可以用在任何需要基类的地方。
类型之间另一种常见的关系是 “有一个(Has A)” 的关系,具有这种关系的类暗含成员的意思。
抽象基类
有时候多种类之间并不存在彼此的继承关系,从概念上来讲它们互为兄弟,因为这些类都共享同一接口,所以我们需要定义一个抽象基类(参见15.4节)来表示该接口。