面向对象程序设计
[TOC]
面向对象程序设计的核心思想
- 数据抽象
- 继承
- 动态绑定
使用数据抽象将类的接口和实现分离。
继承
通过继承联系在一起的类构成一种层次关系。层次关系的根部有一个基类,其他类则直接或间接地从基类继承而来,这些继承而来的类成为派生类。
基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自特有的成员。
基类将类型相关的函数与派生类不做任何改变直接继承的函数区分对待。对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明为。
动态绑定
有时我们希望使用同一段代码同时处理具有继承关系的类的对象,这时就需要用到动态绑定。例如如下一段代码:
1 | double print_total(Ostream &os, const Quote &item, size_t n) |
在上述代码中,需要注意的地方是,传入的形参item的类型是基类Quote的引用,且调用的函数为虚函数。只有这样,才会在运行时进行动态绑定,如果形参的类型为类的具体对象或者调用的函数不是虚函数,就不会发生动态绑定,这时,所调用的函数是确定的。
在C++语言中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。
定义基类
定义基类如下:
1 | class Quote { |
基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作
成员函数与继承
派生类可以继承其基类的成员,但对于虚函数这种与类型相关的操作,派生类必须定义自己的操作以覆盖基类的旧定义。
基类将其两种成员函数进行区分:
- 基类希望其派生类进行覆盖的函数,需要将该类函数定义为虚函数,当使用指针或引用调用虚函数时,将进行动态绑定。任何构造函数之外的非静态函数(static)都可以被定义为虚函数。关键字virtual只能出现在类内部的声明语句之前,而不能用于类外部的函数定义。虚函数在派生类中隐式地也是虚函数。
- 成员函数没被声明为虚函数时,则其解析过程发生在编译而非运行时。这类普通的成员函数不存在动态绑定问题,行为是固定的。
访问控制和继承
派生类有权访问基类的公有成员,而不能访问私有成员。除了公有成员和私有成员之外,基类还有一种受保护成员,这一类成员只允许基类的派生类访问,而不允许其他用户访问。
定义派生类
派生类必须将其继承而来的成员函数中需要覆盖的那些函数重新声明。
1 | class Bulk_quote : Quote { |
在上述代码中,对基类的虚函数进行了覆盖,但有时不需要对虚函数进行覆盖,这时,派生类会直接继承其在基类中的版本。
在派生类中,可以使用override关键字显示的表示它使用某个成员函数覆盖了它继承的虚函数。
派生类对象及派生类向基类的类型转换
可以将一个派生类的对象划分为两个部分:
- 一个含有派生类自己定义的(非静态)成员的子对象。
- 派生类从基类继承而来的子对象。
因为派生类中含有基类所对应的子对象,所以可以将基类的指针或者引用绑定到派生类对象中的基类部分。这种转换称为派生类到基类的类型转换。
这一转换过程是由编译器隐式进行的,意味着我们可以将派生类对象或者派生类对象的引用(指针)用在需要基类引用(指针)的部分。
特别注意,只能是指针或引用。
可以将基类的指针或引用绑定到派生类对象上有一层极为重要的含义:当使用基类的指针(或引用)时,实际上我们并不清楚该引用(或指针)所绑定的对象的真实类型,可能是基类的对象,也可能是派生类的对象。
智能指针也支持派生类向基类的类型转换,即可以将一个派生类对象的指针存储在一个基类的智能指针中。
静态类型与动态类型
在使用存在继承关系的类型时,必须将一个变量或其他表达式的静态类型和动态类型区分开来。
- 静态类型:在编译时是已知的,它是变量声明时的类型或表达式生成的类型。
- 动态类型:变量或表达式所表示的内存中实际存储的对象的类型,在运行时才可知。
如果表达式既不是引用也不是指针,则它的动态类型和静态类型永远一致。
类型转换注意点
不存在从基类向派生类的隐式类型转换。因为一个基类的对象可能是派生类对象的一部分,也可能不是。
1
2
3Quote base;
Bulk_quote* bulkP = &base; //错误:不能将基类转换为派生类
Bulk_quote& bulkRef = base; //错误:不能将基类转换为派生类在对象之间不存在类型转换。派生类向基类的自动类型转换只对指针或引用类型有效,在派生类类型和基类类型之间不存在这样的转换。在初始化或赋值一个类类型的对象时,实际是在调用某个函数。初始化调用构造函数;赋值调用赋值运算符。这些函数接受引用作为参数,因而我们可以向基类的拷贝/赋值操作传递一个派生类的对象。构造函数和赋值运算符不是虚函数,因而调用的函数是确定的。将派生类对象赋值给基类对象时会调用基类的赋值运算符,因而只会对基类中存在的数据成员进行赋值,而不会赋值派生类中的成员,这与我们所期望的将派生类对象赋值给基类对象的操作是不同的。也就是说,派生类对象不属于基类的部分会被舍弃。
派生类构造函数
对于从基类中继承而来的成员,派生类也必须使用基类的构造函数对这些部分进行初始化。类似于初始化成员的过程,派生类构造函数同样时通过构造函数初始化列表来将实参传递给基类构造函数的。
1 | // 在派生类的构造函数中,需要调用基类的构造函数对基类部分进行初始化 |
如果我们没有显式指定初始化基类部分时所使用的构造函数,基类部分会执行默认初始化。可以通过基类名加圆括号内的实参列表的形式为构造函数提供初始值。
首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。
继承和静态成员
如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。不论从基类中派生出多少个派生类,对于每个静态成员来说都只存在唯一的实例。
被用作基类的类
当我们想将某个类用作基类时,则该类需要已经被声明且被定义而不仅仅是被声明(类不能派生它本身)。
虚函数
在动态绑定时,直到运行时才能知道到底调用了那个版本的虚函数,所以所有虚函数都必须有定义。
动态绑定只有在我们通过指针或者引用调用虚函数时才会发生。
当我们通过一个具有普通类型(非引用或指针)的表达式调用虚函数时,在编译时就会将所调用的函数版本确定下来。
C++的多态性
面向对象编程的核心思想是多态性。我们把具有继承关系的多个类型成为多态类型,因为我们能使用这些类型的“多种形式”而无须在意它们的差异。引用或指针的静态类型和动态类型不同这一事实正是C++语言支持多态性的根本所在。
当我们使用基类的引用或指针调用基类中定义的一个函数时,我们并不知道该函数真正作用的对象是什么类型,可能是基类对象也可能是派生类对象。如果该函数是虚函数,则直到运行时才决定到底执行哪个版本。
而对非虚函数的调用在编译时进行绑定。类似的,通过对象进行的函数(无论虚函数或非虚函数)调用也在编译时绑定。因为对象的静态类型和动态类型是完全一致的,因而所调用的函数版本必定是确定的。
派生类中的虚函数
一个函数一旦被声明为虚函数,则其在所有派生类中都是虚函数。当派生类覆盖了某个虚函数时,该函数在基类中的形参必须与派生类中的形参严格匹配,如果函数名相同,但形参或返回值不匹配,则会产生新的函数。这一错误会给编程造成较大的麻烦,我们可以在派生类中使用override关键字来要求强制覆盖基类中的虚函数,此时如果参数不匹配,则编译器会报错。
我们也可以使用final关键字来禁止以后的派生类对给函数进行覆盖。
虚函数与默认实参
虚函数同样可以定义默认实参,但需要注意的是,如果虚函数的调用使用了默认实参,则实参值由调用该虚函数的静态类型所确定。也就是说,如果使用基类的引用或指针调用虚函数,则虚函数的默认实参由基类中定义的默认实参决定,即使在实际运行时使用的是派生类中的虚函数版本。因而,当基类中虚函数的默认实参与派生类中的虚函数的默认实参不一致时,很容易发生我们所不期望的结果。
如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。
回避虚函数的机制
有时,我们希望调用特定版本的虚函数,则可以使用作用域运算符:
1 | double undiscounted = baseP->Quote::net_price(42); |
什么时候需要回避虚函数的默认机制?通常是当一个派生类的虚函数调用它覆盖的基类的虚函数版本时。
如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归。
抽象基类
有时,不希望用户创建某个类的对象,该类只是一种通用的概念,不存在某种具体的含义。此时,我们可以将该类中的虚函数定义为纯虚函数,纯虚函数是没有实际意义的。和普通的虚函数不同,纯虚函数无须定义,在函数体的位置(声明语句的分号之前)书写=0
即可将一个虚函数说明为纯虚函数,=0
只能出现在类内部的虚函数声明语句处。
1 | class Disc_quote : public Quote() { |
同样需要定义该类的默认构造函数和带有四个参数的构造函数,尽管用户无法创建该类的对象,但是在创建该类的派生类的对象时,派生类需要调用该类的构造函数创建派生类对象中的Disc_quote部分。
也可以为纯虚函数提供定义,但函数体必须定义在类的外部。
含有纯虚函数的类是抽象基类
含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类。抽象基类负责定义借口,后续的派生类可以选择覆盖该接口。我们无法从创建抽象基类的对象,抽象基类的派生类必须给出自己的纯虚函数的定义,负责派生类同样无法创建对象。
派生类的构造函数只能初始化其直接基类
在派生类的构造函数中,只能初始化其直接基类的子对象,不能初始化其间接基类的子对象。
访问控制与继承
每个类除了控制自己的成员的初始化过程之外,还需要控制其成员对于派生类是否可访问。
受保护的成员
一个类使用protected关键字来说明那些希望与派生类分享但不希望被其他公共访问的成员。
- 和私有成员类似,受保护的成员对于类的用户来说不可访问。
- 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的。
- 但要注意,派生类的成员和友元只能通过派生类的对象对受保护的成员进行访问,派生类本身是无法访问受保护成员的。
1 | class Base{ |
如果派生类及其友元可以直接访问基类对象的受保护成员,那么只要定义一个Sneaky类便可规避protected提供的访问保护。这样一来,虽然clobber并非Base的友元,但同样可以访问Base的受保护成员。
为了阻止上述现象的发生,必须作出如下假设:
派生类的成员和友元只能访问派生类对象中的基类部分的受保护成员;对于普通的基类对象中的成员不具有特殊的访问权限。
公有、私有和受保护继承
某个类对其继承而来的成员的访问权限受两个因素影响:
- 在基类中该成员的访问说明符;
- 在派生类的派生列表中的访问说明符。
派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限,不影响派生类自身的成员和友元对基类成员的访问权限。
不考虑继承的情况下,可以认为一个类有两个不同的用户,一个是普通用户,一个是类的实现者。普通用户编写的代码使用类的对象,这部分代码只能访问类的公有成员;类的实现者则负责编写类的成员和友元,成员和友元既能访问类的公有部分,也可以访问类的私有部分。
在普通用户和类的实现者之间存在第三种用户,即派生类,基类将希望派生类使用而不希望普通用户使用的成员声明为受保护的。普通用户不能访问受保护部分,派生类的成员和友元不能访问私有部分。
改变个别成员的可访问性
有时,我们需要改变派生类继承的某个名字的访问级别,通过使用using
声明可以达到这一目的:
1 | class Base{ |
派生类只能为那些它本身可以访问的成员(公有和受保护成员)提供using声明。
默认的继承保护级别
默认情况下,class
关键字定义的派生类是私有继承,struct
关键字定义的派生类是公有继承。
这两个关键字之间的唯一的差别就是,默认成员访问说明符和默认派生访问说明符。
继承中的类作用域
每个类定义自己的作用域,在该作用域内我们定义类的成员。
当存在继承关系时,派生类的作用域嵌套在其基类的作用域之内。
如果一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找该名字的定义。
在编译时进行名字查找
一个对象、引用或指针的静态类型决定该对象的那些类型可见,静态类型在编译时就已经确定。对于派生类中的对象,使用基类的指针指向它,那么无法使用基类指针访问派生类中新增的成员。
1 | class Disc_quote : public Quote { |
名字冲突与继承
定义在内层作用域(派生类)中的名字将隐藏定义在外城作用域(基类)的名字。
但我们可以使用作用域运算符使用被内层作用域隐藏的成员。
除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。
名字查找与继承
p->mem()
或obj.mem()
:
- 编译器首先确定p或者obj的静态类型
- 在该静态类型对应的类中查找
mem
(依据名字进行查找,而不管形参列表),如果找不到,则依次在直接基类中不断查找直至到达继承链的顶端,如果查找完整个继承链仍旧未找到,编译器报错。 - 找到
mem
,则进行常规类型检查,以确定调用是否合法。 - 合法,则根据调用的是否时虚函数而进行不同的操作:
- 如果是虚函数且使用引用或指针进行调用,则编译器产生的代码将在运行时确定虚函数的版本,依据对象的动态类型。
- 否则,编译器产生常规函数调用。
名字查找先于类型检查
内层作用域的函数不会重载声明在外层作用域的函数,而是直接隐藏。因而定义派生类中的函数也不会重载其基类中的成员,如果派生类中的而成员与基类的成员重名,则即使两者的形参列表不同,派生类也将在其作用域内直接隐藏基类的成员。
所以,基类与派生类中的虚函数必须具有相同的形参列表,否则无法通过基类的引用或指针调用派生类的虚函数。
覆盖重载的函数
对于类来说,成员函数无论是否是虚函数都可以被重载。派生类可以覆盖重载函数的0个或多个实例。如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个都不覆盖。
正如上一小节所说,派生类一旦声明了一个和基类重载函数同名的函数,派生类将会覆盖基类的所有重载函数。有时候,我们只希望覆盖基类的重载函数集合中的而一个函数,继承剩余的重载函数,如果给每一个重载函数的版本都定义一个覆盖版本的化将会非常麻烦。
可以为重载的成员提供一条using
声明语句,这样就无须覆盖基类的每一个重载版本。using
只需要成员函数的名称即可,而不需形参列表,一条基类成员函数的using
声明语句就可以把该函数的所有重载实例添加到派生类作用域中,派生类只需定义自己特定格式的函数即可。
构造函数与拷贝控制
位于继承体系中的类也需要控制当其对象执行一系列操作时发生什么样的行为,包括创建、拷贝、移动、赋值和销毁。
虚析构函数
基类通常应该定义一个虚析构函数,以对继承体系中的对象进行动态分配。
delete一个动态分配的对象的指针时将执行析构函数。如果该动态指针指向继承体系中的某个类型,则有可能出现指针的静态类型与被删除对象的动态类型不符的情况。这时,如果使用静态类型的析构函数删除动态类型的对象,将会出现未定义的情况。为了避免这一情况,在基类中将析构函数定义为虚函数以确保执行正确的析构函数版本。
1 | class Quote{ |
虚析构函数的虚属性也会被继承。只要基类的析构函数是虚函数,就能确保当我们delete基类指针时将运行正确的析构函数版本。
注意,基类有析构函数时,并不一定同样需要拷贝和赋值操作。
虚析构函数将阻止编译器合成移动操作。
合成拷贝控制与继承
基类或派生类的合成拷贝控制成员的行为与其他合成的构造函数、赋值运算符或析构函数类似:他们对类本身的成员依次进行初始化、赋值或销毁操作。
除此之外,这些合成的成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁的操作。
派生类的拷贝控制成员
派生类的构造函数在其初始化阶段不但要初始化派生类自己的数据成员,还要初始化其基类的成员。因此,派生类的拷贝和移动构造函数在拷贝和移动自由成员的同时,也要拷贝和移动基类部分的成员。同样,派生类的赋值运算符也必须为其基类部分的成员赋值。
不同的是,派生类的析构函数只负责销毁派生类自己的成员,派生类的基类部分是隐式自动销毁的。
当派生类定义了拷贝和移动操作时,该操作负责拷贝和移动包括基类部分成员在内的整个对象。
默认情况下,基类默认构造函数初始化派生类对象的基类部分,如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显式地调用基类的拷贝(或移动)构造函数。