第15章 面向对象程序设计
面向对象的程序设计(OOP)
15.1 OOP:概述
面向对象程序设计的核心思想是数据抽象、继承和动态绑定。
- 通过使用数据抽象,我们可以将类的接口和实现分离;
- 使用继承,可以定义相似的类型并对其相似关系建模
- 使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象
继承
通过继承联系在一起的类构成一种层次关系。通常在层次关系的根部有一个基类,其他类则直接或间接从基类继承而来,这些继承得到的类称为派生类。
在cpp 中,基类将类型相关的函数与派生类不做改变直接继承的函数区分对待。对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数 virtual function
1
2
3
4
5
class Quote{
public:
string isbn() const;
virtual double net_price(size_t n) const;
};
派生类 必须通过使用类派生列表明确指出它是从哪个基类继承而来的。 类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有访问说明符:
1
2
3
4
class Bulk_quote:public Quote{
public:
double net_price(std::size_t) const override;
};
因为Bulk_quote在它的派生列表中使用了public关键字,我们完全可以把Bulk_quote的对象当成Quote的对象来使用
派生类必须在其内部对所有重新定义的虚函数进行声明。派生类可以在这样的函数之前加上 virtual 关键字。
cpp11 新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,具体措施是在该函数的形参列表后面增加一个override关键字
动态绑定
函数的运行版本由实参决定,即在运行时选择函数的版本,所以动态绑定有时候又被称为运行时绑定。
在cpp 语言中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。
15.2 定义基类和派生类
15.2.1 定义基类
基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。
成员函数与继承
派生类需要对这些操作提供自己的新定义以覆盖(override)从基类继承而来的旧定义。 在cpp中,基类必须将它的两种成员函数区分开来:
- 一种是基类希望其派生类进行覆盖的函数
- 另一种是 基类希望派生类直接继承而不要改变的函数。
对于1,基类通常定义为虚函数。任何构造函数之外的非静态函数都可以是虚函数。关键字virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义。
访问控制与继承
在基类中 有这样一种成员,基类希望它的派生类有权访问该成员,同时禁止其它用户访问。我们用受保护的访问运算符说明这样的成员protected
15.2.2 定义派生类
派生类必须通过使用类派生列表明确指出它是从哪个(基类)继承而来的。 其中每个基类前面可以由以下三种访问说明符中的一个:public、proctecd或者private。
访问说明符的作用是控制派生类从基类继承而来的成员是否对派生类的用户可见。
派生类中的虚函数
派生类经常覆盖它继承的虚函数 在后面添加override来显式地注明它使用某个成员函数覆盖它继承的虚函数。
派生类对象以及派生类向基类的类型转换
1
2
3
4
5
Quote item;//基类对象
Bulk_quote bulk;//派生类对象
Quote *p = &item;//p指向Quote对象
p = &bulk;//p指向bulk 的Quote部分
Quote &r = bulk;//r绑定刀bulk的Quote部分
这种转换称为派生类到基类的类型转换。
派生类构造函数
派生类也必须使用基类的构造函数来初始化它的基类部分。
每个类控制它自己的成员初始化过程
派生类构造函数同样是通过构造函数初始化列表来将实参传递给基类构造函数的
1
2
3
4
Bulk_quote(const std::string& book,double p,
size_t qty,double disc):
Quote(book,p),min_qty(qty),discount(disc)
{ }
派生类使用基类的成员
派生类可以访问基类的公有成员和受保护成员:
继承与静态成员
如果基类定义了一个静态成员,则在整个继承体系中都只存在该成员的唯一定义。
派生类的声明
声明中包含类名,但是不包含它的派生列表
1
class Bulk_quote;
被用作基类的类
如果某个类要被用作基类,则该类必须已经定义而非仅仅声明
防止继承的发生
cpp11新标准提供了一种防止继承发生的方法,即在类名后面跟一个关键字final;
1
class NoDerived final{ };//NoDerived不能作为基类
15.2.3 类型转换与继承
通常情况,我们想把引用或指针绑定到一个对象上,则引用或指针的类型应该与对象的类型一致,或对象的类型含有一个可以接受的const类型转换规则。 存在继承关系的类是一个重要例外: 我们可以将基类的指针或引用绑定到派生类对象上。—其有一条重要含义,当使用基类的引用或指针时,我们并不清楚该引用或指针所绑定对象的真实类型。
静态类型与动态类型
当我们使用存在继承关系的类型时,必须将一个变量或其他表达式的静态类型与该表达式表示对象的动态类型区分开。
表达式的静态类型 在编译时总是已知的,它是变量声明时的类型或表达式生成的类型; 动态类型则是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知。
基类的指针或引用的静态类型可能与其动态类型不一致
不存在从基类向派生类的隐式类型转换
1
2
3
4
5
6
7
Quote base;
Bulk_quote* bulkp = &base;
Bulk_quote& bulkRef = base;
//都错误
Bulk_quote bulk;
Quote *itemP = &bulk;//正确:动态类型是 Bulk_quote
Bulk_quote *bulkP = itemP;//错误,不能将基类转换为派生类
在对象之间不存在类型转换
派生类向基类的自动类型转换只对指针或引用类型有效。
15.3 虚函数
因为编译器无法确定哪个虚函数会被用到,所以所有的虚函数都必须定义
对虚函数的调用可能在运行时才被解析
动态绑定只有我们通过指针或引用调用虚函数才会发生
关键概念:cpp的多态性
OPP的核心思想是 多态性 我们把具有继承关系的多个类型称为多态类型
派生类中的虚函数
基类中的虚函数在派生类中隐含地也是一个虚函数,当派生类覆盖了某个虚函数时,该函数在基类中的形参必须与派生类中的形参严格匹配
final和override说明符
如果我们使用override关键字标记了某个虚函数,但该函数并没有覆盖已经存在的虚函数,则编译器将报错
我们可以把某个函数指定为final,则之后任何尝试覆盖该函数的操作都会引发错误。
虚函数与默认实参
如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致
回避虚函数的机制
某些情况下,我们希望对虚函数的调用不进行动态绑定,而是强迫其执行虚函数的某个特定版本,使用作用域运算符可以实现这一目的
1
2
double undiscounted = baseP->Quote::net_price(12);
//强行调用基类中定义的函数版本而不管baseP的动态类型
通常情况下,只有成员函数(或友元)中的代码才需要作用域运算符来回避虚函数的机制
15.4 抽象基类
纯虚函数
将一个函数定义为纯虚函数(pure virtual) 从而告诉用户这个函数是没有实际意义的。 在函数体的位置(即在声明语句的分号之前)书写=0就可以将一个虚函数说明为纯虚函数。
1
2
double net_price(std::size_t) const =0;
//函数体定义必须在类的外部;
含有纯虚函数的类是抽象基类
抽象基类负责定义接口,而后续的其他类可以覆盖该接口 不能直接创建一个抽象基类的对象,但是可以定义其派生类的对象,前提是这些类覆盖了net_price(纯虚函数)。
派生类构造函数只初始化它的直接基类
关键概念 :重构 重构负责重新设计类的体系以便将操作和/或数据从一个类移动到另一个类中。对于面向对象的应用程序来说,重构是一种很普遍的现象。
15.5 访问控制与继承
每个类还控制着其成员对于派生类来说是否可以访问
受保护的成员
一个类使用protected来声明它希望与派生类分享但是不想被其他公共访问的成员。 可以看作public与private中和的产物:
- 类似私有成员,受保护的成员对类的用户是不可访问的
- 类似公有成员,受保护的成员对于派生类和友元来说可访问
- 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class 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 ;//j默认是private成员
};
//正确,clobber能访问Sneaky对象的private和protected成员
void clobber(Sneaky &s)
{
s.j = s.prot_mem = 0;
}
//错误,clobber不能访问Base的protected成员
void clobber(Base &s)
{
b.prot_mem = 0;
}
公有、私有和受保护继承
某个类对其继承而来的成员访问权限收到两个因素影响:
- 基类中该成员的访问说明符
- 在派生类的派生列表中的访问说明符
1
2
3
4
5
6
7
8
9
10
class Base{
public:
void pub_mem();
protected:
void prot_mem();
private:
void priv_mem();
};
struct Pub_Derv: public Base{};
struct Pri_Derv: private Base{};
派生访问说明符 对于派生类的成员能否访问其基类的成员没什么影响 若是public:和private: 派生类都可以访问protected 同时都不能访问private 派生访问说明符的目的是控制派生类用户 对于基类成员的访问权限
1
2
3
4
Pub_Derv d1;//继承自Base的成员是 public的
Pri_Derv d2;//继承自Base的成员是private的
d1.pub_mem();//正确
d2.pub_mem();//错误
派生访问说明符还可以控制继承自派生类的新类的访问权限 Pir_Derv 继承自Base 的所有成员都是私有的 如果定义了一个Prot_Derv,它采用受保护继承,则Base的所有公有成员在新定义的类中都是受保护的.
派生类向基类转换的可访问性
对于代码中的某个给定节点来说,如果基类的公有成员是可以访问的,则派生类向基类的类型转换也是可以访问的。
友元与继承
友元关系不能传递,同样也不能继承 对基类的访问权限由基类本身控制,对于派生类的基类部分也是如此 基类的友元类可以访问派生类中的基类成员
改变个别成员的可访问性
有时需要改变派生类继承的某个名字的访问级别,通过使用using声明 派生类只能为那些它可以访问的名字提供using声明
1
2
3
4
class Derived: private Base{
public:
using Base::size;//size默认是private的,但是using出现在public中,则类的用户都可以访问。
}
默认的继承保护级别
默认情况下,使用class关键字定义的派生类是私有继承的,使用struct关键字定义的派生类是公有继承
15.6 继承中的类作用域
当存在继承关系时,派生类的作用域嵌套在其基类的作用域之内
在编译时进行查找
名字冲突与继承
派生类也能重用定义在其直接基类或间接基类中的名字 此时定义在派生类的名字将隐藏定义在基类的名字 派生类的成员将隐藏同名的基类成员
通过作用域运算符来使用隐藏的成员
Base::mem;指示编译器从Base类的作用域开始查找mem。
一如往常 名字查找先于类型检查
虚函数与作用域
调用非虚函数时,不会发生动态绑定,实际调用的函数版本由指针的静态类型决定。
覆盖重载的函数
有时候一个类仅需覆盖重载集合中的一些而非全部函数,一种好的解决方案是为重载成员提供一条using声明语句。
15.7 构造函数与拷贝控制
15.7.1 虚析构函数
基类通常应该定义一个虚析构函数,这样就能动态分配继承体系中的对象 通过在基类中将析构函数定义成虚函数以确保执行正确的析构函数版本:
1
2
3
4
class Quote{
public:
virtual ~Quote() = default;//动态绑定析构函数
}
此时无论Quote的派生类使用合成的析构函数还是定义自己的析构函数,都将是虚析构函数 之前提到:如果一个类需要析构函数,则它同样需要拷贝和赋值操作,但是基类的析构函数不遵循上述准则
15.7.2 合成拷贝控制与继承
Quote因为定义了析构函数而不能拥有合成的移动操作,因此实际移动Quote对象时使用合成的拷贝操作,其派生类也没有移动操作
派生类中删除的拷贝控制与基类的关系
某些定义基类的方式也有可能导致有的派生类成员成为被删除的函数:
- 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或者不可访问(参见15.5节,第543页),则派生类中对应的成员将是被删除的,原因是编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或销毁操作。
- 如果在基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类对象的基类部分。
- 和过去一样,编译器将不会合成一个删除掉的移动操作。当我们使用=default请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的,那么派生类中该函数将是被删除的,原因是派生类对象的基类部分不可移动。同样,如果基类的析构函数是删除的或不可访问的,则派生类的移动构造函数也将是被删除的。
移动操作与集成
因为基类缺少移动操作会阻止派生类拥有自己的合成移动操作,所以当我们确实需要执行移动操作时应该首先在基类中定义。
15.7.3 派生类的拷贝控制成员
当派生类定义了拷贝或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象
定义派生类的拷贝或移动构造函数
通常使用对应的基类构造函数初始化对象的基类部分
在默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果我们想拷贝或移动基类部分,则必须在派生类的构造函数初始值列表中显式地使用基类的拷贝或移动构造函数。
派生类赋值运算符
派生类的赋值运算符也必须显式地为其基类部分赋值
派生类析构函数
与构造函数及赋值运算符不同的是,派生类析构函数只负责销毁由派生类自己分配的资源 对象销毁的顺序正好与其创建的顺序相反:派生类析构函数首先执行,然后是基类的析构函数,一次类推
在构造函数和析构函数中调用虚函数
15.7.4 继承的构造函数
派生类继承基类构造函数的方式是提供了一条注明了(直接)基类名的using声明语句。 对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数。
1
2
3
4
5
6
7
8
9
10
class Bulk_quote: public: Disc_quote{
public:
using Disc_quote::Disc_quote;//继承DIsc_quote的构造函数;
...
};
编译器生成的构造函数形如:
derived(parms): base(args){ }
上面using 继承的构造函数等价于:
Bulk_quote(const string& book, double price,size_t qyt,double price):
Disc_quote(book,price,qty,disc){ }
如果派生类有自己的数据成员,则这些成员将被默认初始化
继承的构造函数特点
不管using 声明出现在哪,基类的私有构造函数在派生类还是一个私有构造函数,其他同理 且,一个using声明语句不能指定explicit或constexpr。 如果基类的构造函数时explicit或者constexpr,则继承的构造函数也拥有同样的属性。 当一个基类构造函数含有默认实参,这些实参不会被继承 相反,派生类将获得多个继承的构造函数。
15.8 容器与继承
当使用容器存放继承体系中的对象,必须采取间接存储的方式。因为不允许在容器中保存不同类型的元素,所以不能把具有继承关系的多种类型对象直接存放在容器之中。 一个基类对象的vector中,如果添加一个派生类,则其派生类部分将被忽略掉
在容器中存放(智能)指针而非对象
如果希望在容器中存放具有继承关系的对象时,实际上存放基类的指针,更好是智能指针。
15.8.1 编写Basket类
15.9 文本查询程序
面向对象的结局方案
抽象基类
抽象基类命名为Query_base
将层次关系隐藏于接口类中
文本查询程序 有机会练习一下