前言
这一章主要讲解了面向对象程序设计,重点是继承和动态绑定,继承使得我们可以编写一些新的类,这些新类既能共享其类的行为,又能根据需要覆盖或添加行为。动态绑定使得我们可以忽略类型之间的差异,其机理是在运行时根据对象的动态类型来选择运行函数的哪个版本。继承和动态绑定的结合使得我们能够编写具有待定类型行为但又独立于类型的程序。动态绑定只作用于虚函数,并且需要通过指针或引用调用。关于这章更多的知识建议大家自行查看书籍,这里主要介绍一些细节。
最后,如果有理解不对的地方,希望大家不吝赐教,谢谢!
十二、面向对象程序设计
面向对象程序设计基于三个基本概念:数据抽象、继承和动态绑定。
数据抽象:通俗讲就是编写自己的类
继承和动态绑定对程序的编写有两方面的影响:一是我们可以更容易地定义与其他类相似但不完全相同的新类;二是在使用这些彼此相似的类编写程序时,我们可以在一定程度上忽略它们的区别。
OOP:概述
通过使用数据抽象,我们可以将类的接口与实现分离;使用继承,可以定义相似的类型并对其相似关系建模;使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。
继承
通过继承联系在一起的类构成一种层次关系,通常在层次关系的根部有一个基类,其他类则直接或间接地从基类继承而来,这些继承得到地类称为派生类。基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自特有的成员。
基类将类型相关的函数与派生类不做改变直接继承的函数区别对待,对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数,在函数前面加上virtual关键字。
派生类列表形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有访问说明符。
派生类必须在其内部对所有重新定义的虚函数进行声明,派生类可以在这样的函数之前加上virtual关键字,但是并不是非得这么做,允许显式地注明它将使用哪个成员函数改写基类的虚函数,具体措施是在该函数的形参列表之后增加一个override关键字。
动态绑定
函数的运行版本由实参决定,即在运行时选择函数版本,所以动态绑定有时又称为运行时绑定。
在C++语言中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。
定义基类和派生类
定义基类
基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。
成员函数与继承
派生类可以继承其类的成员,当遇到虚函数时,派生类需要对这些操作提供自己的新定义以覆盖从基类继承而来的旧定义。
基类通过在其成员函数的声明语句之前加上关键字virtual使得该函数执行动态绑定。任何构造函数之外的非静态函数都可以是虚函数。关键字virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义。
访问控制与继承
派生类能访问基类的公有成员,而不能访问私有成员。不过在某些时候基类中还有这样一种成员,基类希望它的派生类有权访问该成员,同时禁止其他用户访问,我们用受保护的访问运算符说明这样的成员。
定义派生类
派生类必须通过使用类派生列表明确指出它是从哪个(哪些)基类继承而来的。类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有以下三种访问说明符中的一个:public、protected或者private。
派生类必须将其继承而来的成员函数中需要覆盖的那些重新声明。
派生类中的虚函数
派生类经常(但不总是)覆盖它继承的虚函数。如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本。
派生类对象及派生类向基类的类型转换
一个派生类对象包含多个组成部分:一个含有派生类自己定义的(非静态)成员的子对象,以及一个与该派生类继承的基类对应的子对象,如果有多个基类,那么这样的子对象也有多个。因为在派生类对象中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到派生类对象中的基类部分。
Quote item; //基类对象 Bulk_quote bulk; //派生类对象 Quote *p=&item; //p指向Quote对象 p=&bulk; //p指向bulk的Quote部分 Quote &r=bulk; //r绑定到了bulk的Quote部分
这种转换通常称为派生类到基类的类型转换。
在派生类对象中含有与基类对应的组成部分,这一事实是继承的关键所在。
派生类构造函数
尽管在派生类对象中含有从基类继承而来的成员,但是派生类并不能直接初始化这些成员。和其他创建了基类对象的代码一样,派生类也必须使用基类的构造函数来初始化它的基类部分。
每个类控制它自己的成员初始化过程。
首先初始化基类部分,以类名加圆括号内的实参列表的形式为构造函数提供初始值,然后按照声明的顺序依次初始化派生类的成员。
派生类使用基类的成员
派生类可以访问基类的公有成员和受保护成员。派生类的作用域嵌套在基类的作用域之内,因此,对于派生类的一个成员来说,它使用派生类成员的方式与使用基类成员的方式没什么不同。
关键概念:遵循基类的接口
每个类负责定义各自的接口,要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。因此,派生类对象不能直接初始化基类的成员,需采用基类的构造函数接口。
继承与静态成员
如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。不论从基类中派生出来多少个派生类,对于每个静态成员来说都只存在唯一的实例。
派生类的声明
声明中包含类名但是不包含它的派生列表。
被用作基类的类
如果我们想将某个类用作基类,则该类必须已经定义而非仅仅声明。所以一个类不能派生它本身。一个类是基类,同时它也可以是一个派生类。每个类都会继承直接基类的所有成员,对于一个最终的派生类来说,它会继承其直接基类的成员;该直接基类的成员又含有其基类的成员;依次类推直至继承链的顶端。因此,最终的派生类将包含它的直接基类的子对象以及每个间接基类的子对象。
防止继承的发生
有时我们会定义这样一种类,我们不希望其他类继承它,或者不想考虑它是否适合作为一个基类,为了实现这以目的,在类名后跟一个关键字final。
类型转换与继承
通常情况下,如果我们想把引用或指针绑定到一个对象上,则引用或指针的类型应与对象的类型一致,或者对象的类型含有一个可接受的const类型转换规则。存在继承关系的类是一个重要的例外:我们可以将基类的指针或引用绑定到派生类对象上。当使用基类的引用(或指针)时,实际上我们并不清楚该引用(或指针)所绑定的真实类型,该对象可能是基类的对象,也可能是派生类的对象。
和内置指针一样,智能指针类也支持派生类向基类的类型转换,这意味着我们可以将一个派生类对象的指针存储在一个基类的智能指针内。
静态类型与动态类型
当我们使用存在继承关系的类型时,必须将一个变量或其他表达式的静态类型与该表达式表示对象的动态类型区分开来。表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型;动态类型则是变量或表达式表示的内存中的对象的类型,动态类型直到运行时才可知。
在对象之间不存在类型转换。当我们用一个派生类对象为一个基类对象初始化赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分将被忽略。
关键概念:存在继承关系的类型之间的转换规则:
- 从派生类向基类的类型转换只对指针或引用类型有效
- 基类向派生类不存在隐式类型转换
- 派生类向基类的类型转换也可能由于访问受限而变得不可信
虚函数
通常情况下,如果我们不使用某个函数,则无需为该函数提供定义,但是我们必须为每个虚函数提供定义,而不管它是否被用到,这是因为连编译器也无法确定到底会使用哪个虚函数。
对虚函数的调用可能在运行时才能被解析
当某个虚函数通过指针或引用被调用时,编译器产生的代码直到运行时才能确定应该调用哪个版本的函数,被调用的函数是与绑定到指针或引用上的对象的动态类型相匹配的那一个。
必须要搞清楚一点是,动态绑定只有当我们通过指针或引用调用虚函数时才会发生。当我们通过一个具体普通类型(非引用非指针)的表达式调用虚函数时,在编译时就会将调用的版本确定下来。
关键概念:C++的多态性
OPP的核心思想是多态性,我们把具有继承关系的多个类型称为多态性,因为我们能使用这些类型的“多种形式”而无须在意它们的差异,引用或指针的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本所在。
当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型才有可能与静态类型不同。
派生类中的虚函数
一旦某个函数被声明成虚函数,则在所有派生类中它都是虚函数。一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与它覆盖的基类函数完全一致。派生类中虚函数的返回类型也必须与基类函数匹配,但可以存在派生类类型转换成基类类型,则可以返回派生类类型。
虚函数与默认实参
和其他函数一样,虚函数也可以拥有默认实参,如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。
如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。
回避虚函数的机制
在某些情况下,我们希望对虚函数不要进行动态绑定,而是强迫其执行虚函数的某个特定版本,使用作用域运算符可以实现这一目的。
通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。
如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归。
抽象基类
纯虚函数
和普通的虚函数不一样,一个纯虚函数无须定义,我们通过在函数体的位置书写=0就可以将一个虚函数说明为纯虚函数。其中,=0只能出现在类内部的虚函数声明语句处。
值得注意的是,我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部,也就是说,我们不能在类的内部为一个=0的函数提供函数体。
含有纯虚函数的类是抽象基类
抽象基类负责定义接口,而后续的其他类可以覆盖该接口,我们不能(直接)创建一个抽象基类的对象。我们可以定义派生类的对象,前提是这些类覆盖了继承来的纯虚函数。
派生类构造函数只初始化它的直接基类
关键概念:重构
重构负责重新设计类的体系以便于将操作和/据哦数据从一个类移动到另一个类中。不过一旦类被重构(或以其他方式被改变),意味着我们必须重新编译含有这些类的代码了。
访问控制与继承
每个类分别控制自己的成员初始化过程,与之类似,每个类还分别控制着其成员对于派生类来说是否可访问。
受保护的成员
一个类使用protected关键字来说明那些它希望与派生类分享但是不想被其他公共访问使用的成员。
- 和私有成员类似,受保护的成员对于类的用户来说是不可访问的
- 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的
- 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中受保护成员没有任何访问特权
公有、私有和受保护继承
某个类对于继承而来的成员的访问权限收到两个因素影响:一是在基类中该成员的访问说明符,二是在派生类的派生列表中的访问说明符。
派生访问说明符对于派生类的成员(及友元)能否访问其直接基类的成员没什么影响。对于基类成员的访问权限只与与基类中的访问说明符有关。派生访问说明符的目的是控制派生类用户对于基类成员的访问权限。
派生类向基类转换的可访问性
假定D继承自B:
- 只有当D公有地继承B时,用户代码才能使用派生类向基类的转换;如果D继承B的方式是受保护的或者私有的,则用户代码不能使用该转换。
- 不论D以什么方式继承B,D的成员函数和友元都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的。
- 如果B继承B的方式是公有的或者受保护的,则D的派生类的成员和友元可以使用D向B的类型转换;反之,如果D继承B的方式是私有的,则不能使用。
友元与继承
就像友元关系不能传递一样,友元关系同样也不能继承。每个类负责控制各自成员的访问权限。
改变个别成员的可访问性
如果using声明语句出现在类的private部分,则该名字只能被类的成员和友元访问;如果using声明语句位于public部分,则类的所有用户都能访问它如果using声明语句位于protected部分,则该名字对于成员、友元和派生类是可访问的。
派生类只能为那些它可以访问的名字提供using声明。
继承中的类作用域
派生类的作用域位于基类作用域。
在编译时进行名字查找
一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。
派生类的成员将隐藏同名的基类成员。
除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。
一如既往,名字查找先于类型检查
声明在内存作用域的函数并不会重载声明在外层作用域的函数。因此,定义派生类中的函数也不会重载其基类中的成员,如果同名,则派生类将在其作用域内隐藏该基类成员,即使派生类成员和基类成员的形参列表不一致,基类成员也仍然会被隐藏。
覆盖重载的函数
using声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的using声明语句就可以把该函数的所有重载实例添加到派生类作用域中。
构造函数与拷贝构造
如果一个类(基类或派生类)没有定义拷贝控制操作,则编译器将会为它合成一个版本。当然,这个合成的版本也可以定义成被删除的函数。
虚析构函数
继承关系对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数,这样我们就能动态分配继承体系中的对象。如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为。该析构函数为了成为虚函数而令内容为空,我们显然无法由此推断该基类还需要赋值运算或拷贝构造函数。
虚析构函数将阻止合成移动操作
如果一个类定义了析构函数,即使它通过=default的形式使用了合成的版本,编译器也不会为这个类合成移动操作。
合成拷贝控制与继承
无论基类成员是合成的版本还是自定义的版本都没有太大影响,唯一的要求是相应的成员应该可访问并且不是一个被删除的函数。
移动操作与继承
基类通常不含有合成的移动操作,而且在它的派生类中也没有合成的移动操作。一旦基类定义了自己的移动操作,那么它必须同时显式地定义拷贝操作。
派生类地拷贝控制成员
派生类构造函数在其初始化阶段中不但要初始化派生类自己的成员,还负责初始化派生类对象的基类部分。因此,派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员。类似的,派生类赋值运算符也必须为其基类部分的成员赋值。
而析构函数只负责销毁派生类自己分配的资源,对象的成员是被隐式销毁的,类似的,派生类的基类部分也是自动销毁的。
当派生类定义了拷贝或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。
在默认情况下,基类默认构造函数初始化派生类对象的基类部分,如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显式地使用基类的拷贝(或移动)构造函数。
如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型相对应的虚函数版本。
继承的构造函数
这些构造函数并非以常规的方式继承而来的,一个类只初始化它的直接基类,出于同样原因,一个类也只继承其直接基类的构造函数。类不能继承默认、拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,则编译器将会为派生类合成它们。派生类继承基类构造函数的方式是提供一条注明了基类名的using声明语句。对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数。这些编译器生成的构造函数形如:
derived(parms) : base(args) { }
继承的构造函数的特定
和普通成员的using声明不一样,一个构造函数的using声明不会改变该构造函数的访问级别。例如,不管using声明在哪儿,基类的私有构造函数在派生类中还是一个私有构造函数;受保护的构造函数和公有构造函数也是同样的规则。而且一个using声明语句不能指定explicit或constexpr。
容器与继承
当我们使用容器存放继承体系中的对象时,通常必须采用间接存储方式,因为不允许在容器中保存不同类型的元素,所以我们不能把具有继承关系的多种类型的对象直接存放在容器中。
当派生类对象被赋值给基类对象时,其中的派生类部分将被“切掉”,因此容器和存在继承关系的类型无法兼容。
在容器中放置(智能)指针而非对象
当我们希望在容器中存放具有继承关系的对象时,我们实际上存放的通常是基类的指针(更好的选择是智能指针)。和往常一样,这些指针所指对象的动态类型可能是基类类型,也可能是派生类类型。
vector<shared_ptr<Quote>> basket;