Chapter 13 类继承
- is-a关系的继承
- 如何以公有方式从一个类派生出另一个类
- 保护访问
- 构造函数成员初始化列表
- 向上和向下强制转换
- 虚成员函数
- 早期(静态)联编与晚期(动态)联编
- 抽象基类
- 纯虚函数
- 何时即如何使用公有继承
为了提高代码的重用性,C++提供了类继承来拓展和修改类。通过继承可以完成的一些工作如下:
- 可以在已有的类的基础上添加功能
- 可以给类添加数据
- 可以修改类的行为
继承机制只需要提供新特性,甚至不需要访问源代码就可以派生出类,可以在不公开的情况下将自己的类分享给别人。
13.1 一个简单的基类
从一个类派生另一个类,原始类称为基类,继承类称为派生类。
13.1.1 派生一个类
1.类声明
声明从类里派生而来,其语法如下:
class Class_Name : public other Class_name{}
派生类继承的东西:
- 派生类对象存储了基类的数据成员
- 派生类可以使用基类的方法
派生类需要完成什么:
- 派生类需要自己的构造函数
- 根据需要添加额外的数据成员和成员函数
13.1.2 构造函数:访问权限的考虑
派生类不能直接访问基类的私有成员,必须通过基类方法进行访问。意味着派生类必须使用基类的构造函数。C++通常采用初始化类表的方法来完成这种工作。
派生类构造函数的要点:
- 创建基类对象
- 派生类构造函数应通过成员初始化列表将基类的信息传递给基类构造函数
- 派生类构造函数应通过派生类新增的数据成员
释放对象时的顺序与创建对象的顺序相反,首先执行派生类的析构函数,然后自动调用基类的析构函数。
13.1.3 使用派生类
要使用派生类,程序必须能够访问基类声明。可以把每个类放在独立的头文件中,也可以类声明放在一起,相关的类放在一起更合适。
13.1.4 派生类和基类之间的特殊关系
派生类与基类有一些特殊关系,之中之一是派生类对象可以使用基类的方法,条件是方法不是私有的。
另外两个重要关系是:基类指针可以在不进行显式类型转换的情况下指向派生类对象;基类引用可以在可以在不进行显式转换的情况下引用派生类对象;基类指针或引用只能调用基类方法
通常,C++要求引用和指针类型与赋给的类型匹配,但这一规则对继承来说是例外。这种意外是单向的,不可以将基类对象和地址赋给派生类引用和指针。
基类引用定义的函数或指针参数可用于基类对象或派生类对象;对于形参为指向为基类的指针的函数,它可以使用基类对象的地址或派生类对象的地址作为实参:
引用兼容性属性也让您能够将基类对象初始化为派生类对象,调用隐式复制构造函数。
同样可以将派生对象赋给基类对象,调用隐式赋值运算符。
13.2 继承:is-a关系
派生类和基类之间特殊关系是基于C++继承的底层模型。
C++有三种继承方式:公有继承、保护继承、私有继承。
公有继承是最常用的方式,它建立一种is-a关系,即派生类对象也是一个基类对象,可以对基类对象执行的任何操作,也可以对派生类对象执行。这种关系称为is-a-kind-of。
公有继承不建立has-a关系。
公有继承不建立is-like-a关系,该关系不采用明喻。
公有继承不建立is-implemented-as-a(作为......来实现)关系。
公有继承不建立use-a关系。
13.3 多态公有继承
方法的行为应取决于调用该方法的对象,这种较复杂的行为称为多态——具有多种形态。
有两种重要的机制可以实现多态公有继承:
- 在派生类重新定义基类的方法
- 使用虚方法
13.3.1 开发Brass类和BrassPlus类
- 继承类在基类的基础上添加了私有数据成员和公有成员函数。
- 基类和继承类都声明的两个相同的方法函数,但是不同对象的相同方法的行为不同
- 使用virtual关键字的方法称为虚方法
- 基类有一个虚析构函数,该析构函数不执行任何操作
第二点指出方法在派生类行为的不同,程序根据对象来确定使用哪个版本。
方法通过引用或者指针而不是对象调用:
- 没有virtual,程序根据引用类型或指针类型选择方法;
- 使用virtual,程序根据引用或指针指向的对象的而类型来选择方法
方法在基类被声明为虚的后,它在派生类将自动称为虚方法。
声明虚析构函数的目的是确保释放派生对象时,按照正确的顺序调用析构函数。
在派生类方法中,标准技术是使用作用域解析运算符来调用基类方法。
基类必须有一个虚析构函数。
13.4 静态联编和动态联编
将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编。
编译过程中进行联编称为静态联编,又称早期联编。
而虚函数使得使用哪一个函数在编译时不能确定,编译器必须生成在程序运行时选择正确虚方法的代码,这成为动态联编。
13.4.1 指针和引用类型的兼容性
隐式向上强制转换使基类指针或引用可以指向基类对象或者派生对象,因此需要动态联编,C++使用虚函数来满足这种需求。
13.4.2 虚成员函数和动态联编
编译对非虚方法使用静态联编;编译对虚方法使用动态联编。
1.为什么有两种类型的联编以及为什么默认为静态联编
效率和概念模型:
效率:为使程序能够在运行阶段进行决策,必须采用一些方法来跟踪基类指针或引用指向的对象类型,这增加了额外的处理开销,对于不会用作基类的类型,不需要动态联编,效率跟高。静态联编的效率高,因此C++的默认选择时静态联编。
概念模型:在设计类时,可能包含一些不在派生类重新定义的成员函数,这些函数不应该重新定义,因此不需要设置成虚函数。这样有两个好处,效率更高而却指出无需重新定义该函数。
2.虚函数的工作原理
编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针,这种数组称为虚函数表。
虚函数表中存储了为类对象进行声明的虚函数的地址,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表;派生类对象包含一个指向独立地址表的指针,如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址,否则保存函数原始版本的地址。
调用虚函数时,程序将查看存储在对象中的vtbl地址,然后转向相应的函数地址表。
使用虚函数时,在内存和执行速度方面有一定的成本:
- 每个对象都将增大,增大量为存储地址的空间
- 对于每个类,编译器都创建一个虚函数的地址表(数组)
- 对于每个函数调用,都需要执行一项额外的操作,到表中查地址
13.4.3 有关虚函数的注意事项
虚函数的一些要点:
- 在基类方法的声明中使用关键字virtual可使该方法在基类以及所有的派生类中是虚的
- 如果使用对象的引用或者指针调用虚方法,程序将使用为对象类型定义的方法,而不使用引用或指针类型定义的方法。
- 如果定义的类被用作基类,则应将那些要在派生类中重新定义的类方法声明为虚的。
1.构造函数
构造函数不能是虚函数。创建派生类对象时,将调用派生类的构造函数,派生类的构造函数将使用基类的一个构造函数。这种顺序不同于继承机制,派生类不继承基类的构造函数。
2.析构函数
析构函数应当是虚函数,除非类不用做基类。
通常应该给基类提供一个虚析构函数,即使它并不需要析构函数
3.友元
友元不能是虚函数,只有成员函数才可以是虚函数。可以让友元函数使用虚函数。
- 没有重新定义
如果派生类没有重新定义函数,将使用该函数的基类版本。如果派生类位于派生链中,则将使用最新的虚函数版本。
5.重新定义将隐藏方法
派生类重新定义不会生成函数的两个重载版本,而是将基类的版本隐藏。
有两条经验原则:
- 如果重新定义继承的方法,应确保于原来的圆形完全相同,但如果返回类型是基类引用或者指针,则可以修改为指向派生类的引用或指针,该特性称为返回类型协变,该例外只适用于返回值,而不适用于参数。
- 如果基类声明被重载了,则应在派生类重新定义所有的基类版本,如果只重新定义一个版本,则另外两个版本将隐藏
13.5 访问控制:protected
protected和private的区别只有在基类派生的类中才会表现出来。
派生类的成员可以直接访问基类的保护成员,但不能直接访问基类的私有成员。对于外部世界来说,保护乘员的行为于私有成员类似,对于派生类来说,保护成员的行为与公有成员类似。
最好对类数据成员采用私有访问控制,不要使用保护访问控制,对成员函数来说,保护访问控制很有效
13.6 抽象基类
抽象基类(abstract base class, ABC),当两个类型相似但又不一些不同时,可以抽象出两个类型的共性,将这些特性放到一个ABC中。C++通过使用纯虚函数提供未实现的函数。
当一个类包含纯虚函数时,则不能创建该类的对象,包含纯虚函数的类只能用作基类。
=0指出类是一个抽象基类,在类中可以不定义该函数。ABC是抽象类,至少使用一个纯虚函数的接口,派生类根据具体特征,使用常规虚函数来实现这种接口
13.6.1 应用ABC概念
13.6.2 ABC理念
ABC方法更具系统性和规范性,在设计ABC之前:
首先应开发一个模型——指出编程问题所需的类以及它们之间相互关系。
可以将ABC看作一种必须实施的接口。ABC要求具体派生类覆盖其纯虚函数——迫使派生类遵循ABC设置的接口规则。
13.7 继承和动态内存分配
13.7.1 第一种情况:派生类不使用new
假设基类使用了动态内存分配:
基类需要包含使用new时需要的特殊方法:析构函数、复制构造函数和重载赋值运算符。
从该基类中派生出不使用new的派生类,不需要定义显式析构函数、复制构造函数和赋值运算符。其默认复制构造函数使用显式基类复制构造函数来复制派生类的基类部分,对赋值运算符也是如此,因此不需要这三种特殊方法。
13.7.2 第二种情况:派生类使用new
这种情况下,必须为派生类定义显式析构函数、复制构造函数和赋值运算符。
析构函数:
派生类析构函数自动调用基类的析构函数,其主要任务是释放派生类构造函数中指针管理的内存,依赖基类析构函数释放基类构造函数中指针管理的内存。
复制构造函数:
派生类的复制构造函数只能访问自己数据成员,不能访问基类的数据成员,因此它需要调用基类的复制构造函数来处理共享的基类数据。
成员初始化列表将派生类对象的引用传递给基类复制构造函数,因为基类引用可以指向派生类型
赋值运算符:
派生类的显式运算符必须负责所有继承基类对象的赋值,可以通过显式调用基类赋值运算符来完成该工作。
ClassBaseName::oprator=(继承类引用);该方法时函数表示法,没有使用运算符表示法,是为了阻止错误的递归调用。
析构函数,自动完成;构造函数,通过初始化成员列表调用基类的复制构造函数完成,如果不这样做,将会调用基类的默认构造函数;赋值运算符,通过作用域解析运算符显式调用基类的赋值运算符来完成。
(const baseDMA &) hs;可以将派生类强制转换为基类。
13.8 类设计回顾
13.8.1 编译器生成的成员函数
1.默认构造函数
默认构造函数要么没有参数,要么所有的参数都有默认值。没有定义任何构造函数,编译器将定义默认构造函数,使你可以创建对象。
构造函数的目的之一使确保对象总能被正确的初始化。如果类包含指针成员,必须初始化这些成员。
2.复制构造函数
使用复制构造函数的情况:
- 将新对象初始化为一个同类对象;
- 按值将对象传递给函数
- 函数按值返回对象
- 编译器生成临时对象
3.赋值运算符
默认的赋值运算符用于处理同类对象之间的赋值。与初始化不同。
编译器不会生成将一种类型赋给另一种类型的赋值运算符。如果希望如此,有两种方法:
- 显式定义该赋值运算符
- 使用转换函数
显式定义运算速度块,需要的代码多,使用转换函数可能导致编译器混乱。
13.8.2 其他的类方法
1.构造函数
构造函数创建新的对象,其他类方法是被现有的对象调用。
2.析构函数
一定要定义显式析构函数来释放类构造函数使用new分配的所有内存,并完成类对象所需的任何特殊的清理工作。对于基类,即使它不需要析构函数,也应提供一个虚析构函数。
3.转换
使用一个参数就可以调用构造函数定义从参数类型到类类型的转换。
在带一个参数的构造函数原型中使用explicit将禁止进行隐式转换,仍然允许显式转换。
要将类对象转换为其他类型,应当定义转换函数。函数应返回所需的转换值,示例如下:
Star::Star double() {...}
应理智的使用这样的函数,仅当它们有帮助时才使用,错误使用这些函数容易二义性错误。
explicit可以用于转换函数,将禁止隐式转换。
4.按值传递对象于传递引用
通常编写使用对象作为参数的函数时,应按引用传递对象,为了提高效率,函数不修改对象,应该将函数参数声明为const引用。
另一个原因是基类引用参数的函数可以接受派生类。
5.返回对象和引用
如果可以不返回对象,则应该返回引用。原因:返回引用可以节省时间和内存。
函数不能返回在函数中创建的临时对象的引用,当函数结束时,临时对象将消失,这种情况只能返回对象。
6.使用const
使用const可以确保方法不修改参数;
使用const来确保方法不修改调用它的对象;
返回引用使用const确保指针返回的值不能用于修改对象的数据。
13.8.3 公有继承的考虑因素
1.is-a关系
遵循is-a关系。如果派生类不是一种特殊的基类,则不要使用公有派生。
某些情况下,最好的方法是创建包含纯虚函数的抽象数据类,并从它派生出其他的类。
表示is-a关系的方式是无需进行显式类型转换,基类指针可以指向派生类对象,基类引用可以引用派生类对象。
2.什么不能被继承
构造函数不能继承,创建派生类对象必须调用派生类的构造函数。派生类构造函数使用初始化成员列表语法调用基类构造函数,如果没有使用初始化成员列表语法显式调用基类的构造函数,将调用基类的默认构造函数。
在继承链中,每个类都可以使用成员初始化列表语法将信息传递给相邻的基类。
析构函数也是不能继承的,在释放对象时,程序将首先调用派生类的析构函数,然后调用基类的析构函数。对于基类,其析构函数应设置为虚的。
赋值运算符不能继承,派生类继承的方法的特征标与基类完全相同,但赋值运算符的特征标随类而异。
3.赋值运算符
赋值运算符可以将派生类对象赋给基类对象,反之未必可以。
4.私有成员和保护成员
私用数据成员,保护方法用于函数更好。
5.虚方法
设计基类时,必须确定是否将类方法声明为虚的。如果希望派生类能够重新定义方法,则应该将方法定义为虚的,这样可以使用动态联编,按值传递参数的函数不能使用动态联编。
6.析构函数
7.友元函数
友元函数并非类成员,因此不能继承。如果希望派生类的友元函数可以使用基类的友元函数,可以通过强制类型转换来实现。
8.有关使用基类方法的说明
以公有方式派生的类的对象可以通过多种方式来使用基类的方法:
- 派生类对象自动使用继承而来的基类方法,如果派生类没有重新定义该方法
- 派生类的构造函数自动调用基类的构造函数
- 派生类的对象自动调用基类的默认构造函数,如果没有在成员初始化列表中指定其他构造函数。
- 派生类构造函数显式的调用初始化列表中指定的基类构造函数。
- 派生类可以使用作用域解析运算符来调用公有的和受保护的基类方法
- 派生类的友元函数可以通过可以通过强制类型转换,将派生类引用或指针转换为基类引用或指针,然后使用该引用或指针来调用基类友元函数。
13.8.4 类函数小结
C++类函数有很多不同的变体,其中有些可以继承,有些不可以。
成员函数属性
函数 | 能否继承 | 成员还是友元 | 默认能否生成 | 能否为虚函数 | 是否可以有返回类型 |
---|---|---|---|---|---|
构造函数 | 否 | 成员 | 能 | 否 | 否 |
析构函数 | 否 | 成员 | 能 | 能 | 否 |
= | 否 | 成员 | 能 | 能 | 能 |
& | 能 | 任意 | 能 | 能 | 能 |
转换函数 | 能 | 成员 | 否 | 能 | 否 |
() | 能 | 成员 | 否 | 能 | 能 |
[] | 能 | 成员 | 否 | 能 | 能 |
-> | 能 | 成员 | 否 | 能 | 能 |
op= | 能 | 任意 | 否 | 能 | 能 |
new | 能 | 静态成员 | 否 | 否 | void* |
delete | 能 | 静态成员 | 否 | 否 | void |
其他运算符 | 能 | 任意 | 否 | 能 | 能 |
其他成员 | 能 | 成员 | 否 | 能 | 能 |
友元 | 否 | 友元 | 否 | 否 | 能 |
13.9 复习题
1.派生类从基类那里继承了什么?
公有的方法和保护的方法,私有成员被继承,但不能直接访问。
2.派生类不能从基类那里继承什么?
不能继承私有数据成员,构造函数、析构函数、赋值运算符、友元函数。
3.假设baseDMA::operator=()函数的返回类型为void,而不是baseDMA &,这将有什么后果?如果返回类型为baseDMA,而不是baseDMA&,又将有什么后果?
改为void,使用=对象将会称为空对象。改为void,仍可以使用单个复制,但不能使用连续赋值改为baseDMA,将会增加内存消耗和运行的时间。
4.创建和删除派生类对象时,构造函数和析构函数的调用顺序是怎样的?
创建派生类对象,基类构造函数,派生类构造函数;删除派生类对象,派生类析构函数,基类析构函数。
5.如果派生类没有添加任何数据成员,他是否需要构造函数?
需要,因为派生类不会继承基类的构造函数。
6.如果基类和派生类定义了同名的方法,当派生类对象调用该方法时,被调用的将是哪个方法?
派生类对象调用的时派生类定义的方法。
7.在什么情况下,派生类应定义赋值运算符?
在派生类使用new分配动态内存的情况下,应定义赋值运算符。
8.可以将派生类对象的地址赋给基类指针吗?可以将基类对象的地址赋给派生类指针吗?
可以将派生类的地址赋给基类指针,不能将基类对象的地址赋给派生类指针。只有通过显式类型转换,才可以将基类对象的地址赋给派生类指针(向下转换),使用这样的指针不一定安全。
9.可以将派生类对象赋给基类对象吗?可以将基类对象赋给派生类对象吗?
可以将派生类对象赋给基类对象,如果派生类定义了转换函数,可以,没有定义转换运算符不可以。
10.假设定义了一个函数,它将基类对象的引用作为参数。为什么该函数也可以将派生类对象作为参数?
派生类对象可以通过强制向上转换为基类对象,该函数使用转换后的对象。C++允许基类引用指向从该基类派生而来的任何类型。
11.假设定义了一个函数,它将基类对象作为参数(即函数按值传递基类对象)。为什么该函数也可以将派生类对象作为参数?
该函数将调用复制构造函数,使用基类引用,派生类对象将向上强制转换为基类对象,然后该函数调用生成的该副本对象。
12.为什么通常按引用传递对象比按值传递对象的效率高?
按引用传递对象直接使用该对象进行操做,而按值传递对象,将生成该对象的一个副本,将会调用类的复制构造函数创建一个副本对象,花费时间和空间。
13.假设Corporation是基类,PublicCorporation是派生类。再假设这两个类都定义了head()函数,ph是指向Corporation类型的指针,且被赋给了一个PublicCorporation对象的地址。如果基类将head()定义为:
a. 常规非虚方法;
b. 虚方法;
则ph->head()将被如何解释?
a调用基类的head()函数;b调用派生类的head()函数。
14.下述代码有什么问题?
class Kitchen
{
private:
double kit_sq_ft;
public:
kitchen() {kit_sq_ft = 0.0; }
virtual double area() const { return kit_sq_ft * kit_sq_ft; }
};
class House : public kitchen
{
private:
double all_sq_ft;
public:
House() {all_sq_ft += kit_sq_ft;}
double area(const char * s) const { cout << s; return all_sq_ft;}
};
首先没有定义接口修改kit_sq_ft的值,派生类构造函数不能直接使用基类的私有成员,area()函数将会把基类的area()函数隐藏。这种情况不符合is-a模型,因此公有继承不适用。