第15章 面向对象程序设计
OOP:概述
- 面向对象程序设计(object-oriented programming)的核心思想是数据抽象、继承和动态绑定。
- 数据抽象:通过使用数据抽象,可以将类的接口和实现分离。
- 继承(inheritance):
- 通过继承联系在一起的类构成一种层次关系。
- 通常在层次关系的根部有一个基类(base class)。
- 其他类直接或者简介从基类继承而来,这些继承得到的类成为派生类(derived class)。
- 基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自特有的成员。
- 对于某些函数,基类希望它的派生类个自定义适合自己的版本,此时基类就将这些函数声明成虚函数(virtual function)。
- 派生类必须通过使用类派生列表(class derivation list)明确指出它是从哪个基类继承而来。形式:一个冒号,后面紧跟以逗号分隔的基类列表,每个基类前都可以有访问说明符。
class Bulk_quote : public Quote{};
- 派生类必须在其内部对所有重新定义的虚函数进行声明。可以在函数之前加上
virtual
关键字,也可以不加。C++11新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,即在函数的形参列表之后加一个override
关键字。
- 动态绑定(dynamic binding,又称运行时绑定):
- 使用同一段代码可以分别处理基类和派生类的对象。
- 函数的运行版本由实参决定,即在运行时选择函数的版本。
定义基类和派生类
定义基类
- 基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。
- 基类通过在其成员函数的声明语句前加上关键字
virtual
使得该函数执行动态绑定。 - 任何构造函数之外的非静态函数都可以是虚函数,如果基类把一个函数声明成虚函数,则该函数在派生类中隐式的也是虚函数。
- 如果成员函数没有被声明为虚函数,则解析过程发生在编译时而非运行时。
- 访问控制:
protected
: 基类和和其派生类还有友元可以访问。private
: 只有基类本身和友元可以访问。
练习 15.2
Q: protected
访问说明符与 private
有何区别?
A: protected
: 基类和和其派生类还有友元可以访问。
private
: 只有基类本身和友元可以访问。
定义派生类
-
派生类必须通过类派生列表(class derivation list)明确指出它是从哪个基类继承而来。形式:冒号,后面紧跟以逗号分隔的基类列表,每个基类前面可以有一下三种访问说明符的一个:
public
、protected
、private
。 -
C++11新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,即在函数的形参列表之后加一个
override
关键字。 -
派生类构造函数:派生类必须使用基类的构造函数去初始化它的基类部分。
-
尽管在派生类对象中含有从基类继承而来的成员,但是派生类并不能直接放初始化这些成员,必须使用基类的构造函数来初始化它的基类部分。
每个类控制它的成员初始化过程。
每个类负责定义各自的接口,因此派生类对象不能直接初始化基类的成员(尽管从语法上就可以在派生类构造函数体内给它的公有或受保护的基类成员赋值,但是最好不要这样,而应当遵循其接口——通过调用基类的构造函数来初始化从基类继承的成员)。首先初始化基类的部分(可在初始化列表中显式调用对应基类的初始化列表,否则使用默认初始化),然后按照声明的顺序依次初始化派生类的成员。
-
静态成员:如果基类定义了一个基类成员,则在整个继承体系中只存在该成员的唯一定义。
-
派生类的声明:声明中不包含它的派生列表。
-
派生类的作用域嵌套在基类的作用域内。
-
如果想将某个类用作基类,则该类必须已经定义而非仅仅声明。
-
最终的派生类将包含它的直接基类和每个间接基类的子对象,即使是
private
(会继承,只不过访问不到)。 -
C++11新标准提供了一种防止继承的方法,在类名后面跟一个关键字
final
。1
2
3// 以下两种方式均可:
class NoDrived find {};
class Last final : Base {};
练习15.4
Q: 下面哪条声明语句是不正确的?请解释原因。
1 | class Base { ... }; |
A:
- (a) 不正确。类不能派生自身。
- (b) 不正确。这是定义而非声明。
- © 不正确。派生类的声明中不包含它的派生列表。
类型转换与继承
理解基类和派生类之间的类型转换是理解C++语言面向对象编程的关键所在。
- 可以将基类的指针或引用绑定到派生类对象上,智能指针也支持。
- 不存在从基类向派生类的隐式类型转换。
- 派生类向基类的自动类型转换只对指针或引用类型有效,对象之间不存在类型转换。
- 和其他任何成员一样,派生类向基类的类型转换也可能会由于访问受限而变得不可行。
练习15.8
Q: 给出静态类型和动态类型的定义。
A: 表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型。
动态类型则是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知。
练习15.9
Q: 在什么情况下表达式的静态类型可能与动态类型不同?
A: 基类的指针或引用的静态类型可能与其动态类型不一致。
虚函数
-
通常情况下如果不适用某个函数,则无须为该函数提供定义,但必须为每一个虚函数都提供定义,而不管它是否被使用到了,这是因为连编译器也无法确定到底会使用哪个虚函数。
-
使用虚函数可以执行动态绑定。
-
OOP的核心思想是多态性(polymorphism)。
-
当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型才有可能与静态类型不同。
-
当使用基类的引用或指针调用一个基类中定义的一个函数时
- 如果该函数是虚函数,则直到运行时才会决定到底执行哪个版本,判断的依据是绑定的真实类型。
- 对非虚函数的调用在编译时进行绑定。
- 类似的,通过对象进行的函数(虚函数或非虚函数)调用也在编译时绑定。
对象的类型是确定不变的,无论如何都不能让对象的动态类型与静态类型不一致。
-
派生类必须在其内部对所有重新定义的虚函数进行声明。可以在函数之前加上
virtual
关键字,也可以不加,因为一旦某个函数被声明为虚函数,则在所有派生类中它都是虚函数。 -
一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须和它覆盖掉基类函数完全一致。
同样,派生类中虚函数的返回类型也必须和基类函数匹配。不过该规则也有一个例外,当类的虚函数返回类型是类本身的指针或引用时,该规则无效。 -
如果我们想覆盖某个虚函数,但不小心把形参列表弄错了,这个时候就不会覆盖基类中的虚函数。加上
override
可以明确程序员的意图,让编译器帮忙确认参数列表是否出错。 -
如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。
实参值使用本次调用的静态类型。 -
通常,只有成员函数(或友元)中的代码才需要使用作用域运算符(
::
)来回避虚函数的机制。
练习15.14
Q: 给定上一题中的类以及下面这些对象,说明在运行时调用哪个函数:
其中 name
为普通函数,而 print
为虚函数。
1 | base bobj; base *bp1 = &bobj; base &br1 = bobj; |
A:
- (a) 编译时。
- (b) 编译时。
- © 编译时。
- (d) 编译时。
- (e) 运行时。
base::print()
- (f) 运行时。
derived::print()
抽象基类
- 纯虚函数(pure virtual):清晰地告诉用户当前的函数是没有实际意义的。纯虚函数无需定义,只用在函数体的位置前书写
=0
就可以将一个虚函数说明为纯虚函数。 - 含有纯虚函数的类是抽象基类(abstract base class)。不能创建抽象基类的对象。
访问控制与继承
- 受保护的成员:
-
protected
说明符可以看做是public
和private
中的产物。 -
类似于私有成员,受保护的成员对类的用户来说是不可访问的。
-
类似于公有成员,受保护的成员对于派生类的成员和友元来说是可访问的。
-
派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class Base {
protected:
int prot_mem;
};
class Sneaky : public Base {
friend void clobber(Sneaky&); // 能访问Sneaky::prot_mem
friend void clobber(Base&); // 不能访问Base::prot_mem
int j;
};
// 正确:clobber能访问Derived对象中的private和protected成员
void clobber(Sneaky) &s) { s.j = s.prot_mem = 0; }
// 错误:clobber不能访问Base的protected成员
void clobber(Base &b) { b.prot_mem = 0; }
-
公有、私有和受保护继承
-
派生访问说明符
-
对于派生类的成员(及友元)能否访问其直接基类的成员没什么影响。
对基类成员的访问权限只与基类中的访问说明符有关。 -
派生访问说明符的目的是:
-
控制派生类用户对于基类成员的访问权限。比如
struct Priv_Drev: private Base{}
意味着在派生类Priv_Drev
中,从Base
继承而来的部分都是private
的,那么Pub_derv d2; d2.pub_mem();
会报错。 -
控制继承自派生类的新类的访问权限。
1
2
3
4
5
6struct Derived_from_Private : public Priv_Derv {
// 错误:Base::prot_mem在Priv_Derv中是private的
int use_base() {
return prot_mem;
}
}
-
-
派生类向基类转换的可行性
假设D继承自B:
- 只有当D公有地继承B时,用户代码才能使用派生类向基类的转换;
- 不论D以什么方式继承B,D的成员函数和友元都能使用派生类向基类的转换;
- 如果D继承B的方式是公有或者受保护的,则D的派生类的成员和友元才能使用D向B的类型转换。
关键概念:类的设计与受保护的成员
考虑类的继承,一个类有三种不同的用户:
- 普通用户:只能访问类的公有(接口)成员。
- 类的实现者:负责写类的成员和友元代码,既能访问接口,又能访问实现。
- 派生类:基类把希望派生类能访问的部分声明成受保护的。
接口成员应声明成公有的;
实现部分应分成两组:一组供派生类师访问,另一组只能由基类及基类的友元使用。
友元与继承
-
就像友元关系不能传递一样,友元关系也不能继承。
每个类负责控制自己的成员的访问权限。
Pal
是Base
的友元,所以Pal
能够访问Base
对象的成员,这种可访问性包括了Base
对象内嵌在派生类对象中的情况。
改变个别成员的可访问性
-
改变个别成员的可访问性:使用
using
。1
2
3
4
5
6class Derived : private Base {
public:
using Base::size;
protected:
using Base::n;
}- 改变后,
Derived
的用户可以使用size
成员,而Derived
的派生类可以使用n
; using
声明中名字的访问权限由该using
声明语句之前的访问说明符来决定。- 派生类只能为它可以访问的名字提供
using
声明。
- 改变后,
-
struct
和class
唯一的区别是默认成员访问说明符及默认派生访问说明符。 -
默认情况下,使用
class
关键字定义的派生类是私有继承的;使用struct
关键字定义的派生类是公有继承的。
练习15.18
Q: 假设给定了第543页和第544页的类,同时已知每个对象的类型如注释所示,判断下面的哪些赋值语句是合法的。解释那些不合法的语句为什么不被允许:
1 | Base *p = &d1; //d1 的类型是 Pub_Derv |
A:
1 | Base *p = &d1; 合法 |
只有在派生类是使用public
的方式继承基类时,用户代码才可以使用派生类到基类(derived-to-base
)的转换。
练习15.19
Q: 假设543页和544页的每个类都有如下形式的成员函数:
1 | void memfcn(Base &b) { b = *this; } |
对于每个类,分别判断上面的函数是否合法。
解:
合法:
- Pub_Derv
- Priv_Derv
- Prot_Derv
- Derived_from_Public
- Derived_from_Protected
不合法: - Derived_from_Private
原因如下:
- 无论派生类D以什么方式继承基类B,派生类D的成员函数和友元都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和函数来说永远是可访问的。
- 如果派生类D继承基类B的方式是公有的或者受保护的,则D的派生类的成员和友元可以使用派生类向基类的类型转换;反之,如果派生类继承基类的方式是私有的,则不能使用。
继承中的类作用域
- 派生类的作用域嵌套在其基类的作用域之内。
- 一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。
名字冲突与继承
-
派生类的成员将隐藏同名的基类成员。
和其他作用域一样,派生类也能重用定义在其直接基类或间接基类中的名字,此时内层作用域中名字将隐藏定义在外层作用域中的名字。区分"隐藏"和”覆盖“:
- 隐藏是内层名字把外层的隐藏了,但通过作用域运算符仍然能访问到。
- 二者可能只是名字一样,但参数和返回类型很可能不同。
- 隐藏 和 重用 对应
- 覆盖是派生类重新定义名字和形参列表都相同的函数,以实现对基类虚函数的覆盖。
- 形参列表必须相同——否则无法通过基类的引用或指针调用派生类的虚函数。
- 覆盖 = 重写(override)
- 隐藏是内层名字把外层的隐藏了,但通过作用域运算符仍然能访问到。
-
除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。
关键概念:名字查找和继承
假定调用
p->mem()
或obj.mem()
,则依次执行以下4个步骤:
- 首先确定
p
或obj
的静态类型。- 在
p
或obj
的静态类型对应类中查找mem
。如果找不到,就依次在直接基类中不断直至到达继承链的顶端。如果仍然找不到,则编译器将报错。- 一旦找到了
mem
,就进行常规的类型检查以判断本次调用是否合法。- 若调用合法,则编译器根据调用的时是否是虚函数而产生不同的代码:
- 如果
mem
是虚函数,且通过引用或指针进行调用,则编译器产生的代码将在运行时确定到底运行该虚函数的哪个版本,依据是对象的动态类型。- 如果
mem
不是虚函数或通过对象继续调用,则编译器将产生一个常规函数调用。
一如往常,名字查找先于类型查找
- 声明在内层作用域的函数并不会重载声明在外层作用域的函数。
即使派生类成员和基类成员的形参列表不一致,基类成员也仍然会被隐藏掉。
要调用基类成员:d.Base::memfun();
- 一旦名字找到,编译器就不再继续查找。
虚函数与作用域
- 基类和派生类中的虚函数必须拥有相同的形参列表,否则就无法通过基类的引用或指针调用派生类的虚函数。
- 如果派生类中函数与基类中虚函数形参列表不同,则这是并非覆盖,而是派生类中此函数隐藏基类函数,二者在派生类中并存。
1 | class Base { |
通过基类调用隐藏的虚函数
1 | Base bobj; D1 d1obj; D2 d2obj; |
若是针对非虚函数如fcn(int)
的调用,则使用静态绑定。
覆盖重载的函数
- 如果派生类希望所有的重载版本对它来说都是可见的,那么它就需要覆盖所有版本,或者一个都不覆盖。
- 有时一个类仅需要覆盖重载集合中的一些而非全部函数,一种好的解决方案就是为重载的成员提供一条
using
声明语句。 - 一条基类的
using
声明语句就可以把该函数的所有重载实例添加到派生类的作用域中。
构造函数与拷贝控制
虚析构函数
- 基类通常应该定义一个虚析构函数,这样我们就能动态分配继承体系中的对象了。
- 和其他虚函数一样,析构函数的虚属性也会被继承。
只要基类的析构函数是虚函数,无论派生类使用合成的析构函数还是自定义析构函数,都是虚函数。 - 如果基类的析构函数不是虚函数,则
delete
一个指向派生类对象的基类指针将产生未定义的行为。
虚析构函数将阻止合成移动操作
- 如果一个类定义了析构函数,即使它通过
=default
使用了合成的版本,编译器也不会为这个类合成移动操作。
(当一个类显式定义了析构函数时,编译器就认为该类可能有一些特殊的资源管理需求,因此不会假定默认的移动操作是安全的,而是要求程序员自己显式地定义移动操作以确保资源管理的正确性。)
合成拷贝控制与继承
- 基类或派生类的合成拷贝控制成员的行为和其他合成的构造函数、赋值运算符或析构函数类似:他们对类本身的成员依次进行初始化、赋值或销毁的操作。
例如,合成的Bulk_quote
拷贝构造函数使用合成的Disc_quote
拷贝构造函数,而后者又使用合成的Quote
拷贝构造函数,三者各自控制拷贝自己的成员。
派生类中删除的拷贝控制和基类的关系、
- 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的或不可访问的,则派生类中对应的成员是被删除的。
- 如果在基类中有一个不可访问的或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将删除的。
- 编译器不会合成一个删除掉的操作。如果基类中移动或析构操作是删除的或不可访问的,则派生类的移动构造函数同样是被删除的。
移动操作和继承
-
大多数基类都会定义一个虚析构函数。
因此默认情况下,基类通常不含有合成的移动操作,而且在它的派生类中也没有合成的移动操作。 -
一旦
Quote
定义了自己的移动操作,就必须同时显示地定义拷贝操作——否则拷贝操作默认被定义为删除的。P477:如果类定义了一个移动构造函数和/或一个移移动赋值运算符,则该类的合成拷贝函数和拷贝赋值运算符会被定义为删除的。
(这是因为在 C++ 中,如果一个类显式定义了移动构造函数或移动赋值运算符,编译器就会认为该类可能在内部进行了资源管理或者需要特殊的移动语义。因此,为防止误用,编译器会默认删除合成的拷贝构造函数和拷贝赋值运算符。)
派生类的拷贝控制成员
-
当派生类定义了拷贝或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。
需要在派生类的构造函数初始值列表中显式使用基类的拷贝(或移动)构造函数。1
2
3
4
5
6
7
8
9
10
11class D : public Base {
public:
D(const D& d): Base(d) /* D成员初始值*/ {}
D(D&& d): Base(std::move(d)) /* D成员初始值*/ noexcept {}
D& operator=(const D &rhs) {
Base::operator=(rhs);
// ... (处理自赋值、释放已有资源、为派生类成员赋值)
return *this;
}
~D() { /*清除派生类成员的操作*/ } // Base::~Base会被自动调用
}Base(d)
一般会匹配Base
的拷贝构造函数,D
类型的对象d
会被绑定到该构造函数的Base&
形参上。 -
析构函数只负责销毁派生类自己分配的部分。
派生类析构函数先执行,然后执行基类的析构函数(自动调用)。
继承的构造函数
- C++11新标准中,派生类可以重用其直接基类定义的构造函数。
- 通常情况下,
using
声明语句只是令某个名字在当前作用域内可见。而当作用于构造函数时,using
声明语句会令编译器产生代码。
如using Disc_quote::Disc_quote;
,注明了要继承Disc_quote
的构造函数。如果派生类有自己的数据成员,则这些成员将被默认初始化。 - 和普通的
using
声明不一样,构造函数的using
声明不会改变该构造函数的访问级别。
容器与继承
- 派生类对象直接赋值给基类对象,其中的派生类部分会被切掉。
- 在容器中放置(智能)指针而非对象。
- 对于C++面向对象的编程来说,一个悖论是我们无法直接使用对象进行面向对象编程。相反,我们必须使用指针和引用。因为指针会增加程序的复杂性,所以经常定义一些辅助的类来处理这些复杂的情况。
文本查询程序再探
- 使系统支持:单词查询、逻辑非查询、逻辑或查询、逻辑与查询。
面向对象的解决方案
- 将几种不同的查询建模成相互独立的类,这些类共享一个公共基类:
WordQuery
NotQuery
OrQuery
AndQuery
- 这些类包含两个操作:
eval
:接受一个TextQuery
对象并返回一个QueryResult
。rep
:返回基础查询的string
表示形式。
- 继承和组合:
- 当我们令一个类公有地继承另一个类时,派生类应当反映与基类的“是一种(Is A)”的关系。
- 类型之间另一种常见的关系是“有一个(Has A)”的关系。
- 对于面向对象编程的新手来说,想要理解一个程序,最困难的部分往往是理解程序的设计思路。一旦掌握了设计思路,接下来的实现也就水到渠成了。
Query程序设计:
操作 | 解释 |
---|---|
Query 程序接口类和操作 |
|
TextQuery |
该类读入给定的文件并构建一个查找图。包含一个query 操作,它接受一个string 实参,返回一个QueryResult 对象;该QueryResult 对象表示string 出现的行。 |
QueryResult |
该类保存一个query 操作的结果。 |
Query |
是一个接口类,指向Query_base 派生类的对象。 |
Query q(s) |
将Query 对象q 绑定到一个存放着string s 的新WordQuery 对象上。 |
q1 & q2 |
返回一个Query 对象,该Query 绑定到一个存放q1 和q2 的新AndQuery 对象上。 |
`q1 | q2` |
~q |
返回一个Query 对象,该Query 绑定到一个存放q 的新NotQuery 对象上。 |
Query 程序实现类 |
|
Query_base |
查询类的抽象基类 |
WordQuery |
Query_base 的派生类,用于查找一个给定的单词 |
NotQuery |
Query_base 的派生类,用于查找一个给定的单词 |
BinaryQuery |
Query_base 的派生类,查询结果是Query 运算对象没有出现的行的集合 |
OrQuery |
Query_base 的派生类,返回它的两个运算对象分别出现的行的并集 |
AndQuery |
Query_base 的派生类,返回它的两个运算对象分别出现的行的交集 |