本章内容包括:
- is-a 关系的继承;
- 如何以公有方式从一个类派生出另一个类;
- 保护访问;
- 构造函数成员初始化列表;
- 向上和向下强制转换;
- 虚成员函数;
- 早期(静态)联编与晚期(动态)联编;
- 抽象基类;
- 纯虚函数;
- 何时以及如何使用公有继承。
面向对象编程的主要目的之一是提供可重用的代码。开发新项目, 尤其是当项目十分庞大时,重用经过测试的代码比重新编写代码要好得多。另外,必须考虑的细节越少, 便越能专注于程序的整体策略。
继承是一种非常好的概念,其基本实现非常简单。
从一个类派生出另一个类时,原始类称为基类,继承类称为派生类。
与其从零开始,不如从 TableTennisClass类派生出一个类。首先将RatedPlayer类声明为从 TableTennisClass类派生而来:
class RetedPlayer: public TableTennisPlayer {}
冒号指出RatedPlayer类的基类是TableTennisplayer类。上述特殊的 声明头表明TableTennisPlayer是一个公有基类,这被称为公有派生。
派生类对象包含基类对象。使用公有派生,基类的公有成员将成为派生类 的公有成员;基类的私有部分也将成为派生类的一部分,但只能通过基 类的公有和保护方法访问。
上述代码完成了哪些工作呢?Ratedplayer对象将具有以下特征:
- 派生类对象存储了基类的数据成员(派生类继承了基类的实现);
- 派生类对象可以使用基类的方法(派生类继承了基类的接口)。
需要在继承特性中添加什么呢?
- 派生类需要自己的构造函数。
- 派生类可以根据需要添加额外的数据成员和成员函数。
构造函数必须给新成员(如果有的话)和继承的成员提供数据。
在 第一个RatedPlayer构造函数中,每个成员对应一个形参;而第二个 Ratedplayer构造函数使用一个TableTennisPlayer参数,该参数包括 firstname、lastname和hasTable。
// simple derived class
class RatedPlayer : public TableTennisPlayer
{
private:
unsigned int rating;
public:
RatedPlayer (unsigned int r = 0, const string & fn = "none",
const string & ln = "none", bool ht = false);
RatedPlayer(unsigned int r, const TableTennisPlayer & tp);
unsigned int Rating() const { return rating; }
void ResetRating (unsigned int r) {rating = r;}
};
派生类不能直接访问基类的私有成员,而必须通过基类方法进行访问。具体地说,派生类构造函数需要使用基类构造函数。
创建派生类对象时,程序首先创建基类对象。
从概念上说,这意味 着基类对象应当在程序进入派生类构造函数之前被创建。C++使用成员 初始化列表语法来完成这种工作。例如,下面是第一个RatedPlayer构造 函数的代码:
// RatedPlayer methods
RatedPlayer::RatedPlayer(unsigned int r, const string & fn,
const string & ln, bool ht) : TableTennisPlayer(fn, ln, ht)
{
rating = r;
}
其中: TableTennisPlayer(fn,ln,ht)
是成员初始化列表。它是可执行的 代码,调用TableTennisPlayer
构造函数。
RatedPlayer::RatedPlayer(unsigned int r, const string & fn,
const string & ln, bool ht) {
rating = r;
}
如果省略成员初始化列表,情况将如何呢?首先必须创建基类对象,如果不调用基类构造函数,程序将使用默 认的基类构造函数,因此上述代码与下面等效:
RatedPlayer::RatedPlayer(unsigned int r, const string & fn,
const string & ln, bool ht) : TableTennisPlayer() {
rating = r;
}
除非要使用默认构造函数,否则应显式调用正确的基类构造函数。
如果愿意,也可以对派生类成员使用成员初始化列表语法。在这种 情况下,应在列表中使用成员名,而不是类名。所以,第二个构造函数 可以按照下述方式编写:
RatedPlayer::RatedPlayer(unsigned int r, const string & fn,
const string & ln, bool ht) : TableTennisPlayer(fn, ln, ht), rating(r) {
rating = r;
}
有关派生类构造函数的要点如下:
- 首先创建基类对象;
- 派生类构造函数应通过成员初始化列表将基类信息传递给基类构造函数;
- 派生类构造函数应初始化派生类新增的数据成员。
派生类对象过期时,程序将首先调用派生类析构函数,然后再调用基类析构函数。
要使用派生类,程序必须要能够访问基类声明。既可以将两种类的声明置于同一个头文件中。也可以将每个类放在独立的头文件 中,如果两个类是相关的,把它们的类声明放在一起更合适。
第一,派生类对象可以使用基类的方法,条件是方法不是私有的; 第二,基类指针可以在不进行显式类型转换的情况下指向派生类对象(基类指针可以直接指向派生类,神奇); 第三,基类引用可以在不进行显式类型转换的情况下引用派生类对象(基类引用可以直接应用派生类,神奇)。
不过,基类指针或引用只能用于调用基类方法,因此,不能使用rt
或pt
来调用派生类的ResetRanking
方法。
通常,C++要求引用和指针类型与赋给的类型匹配,但这一规则对 继承来说是例外。
然而,这种例外只是单向的,不可以将基类对象和地 址赋给派生类引用和指针。
上述规则是有道理的。允许基类引用隐式地引用派生类对象,等于是可以使用基类引用为派生类对象调用基类的方法。因为派生类 继承了基类的方法,所以这样做不会出现问题。
而如果可以将基类对象赋 给派生类引用,将发生什么情况呢?派生类引用能够为基对象调用派生 类方法,这样做将出现问题。例如,将RatedPlayer::Rating( )方法用于 TableTennisPlayer对象是没有意义的,因为TableTennisPlayer对象没有 rating成员。
基类引用和指针可以指向派生类对象,将出现一些很有趣的结果。其中之一是基类中引用定义的函数或指针参数可用于派生类对象。
派生类和基类之间的特殊关系是基于C++继承的底层模型的。实际 上,C++有3种继承方式:公有继承、保护继承和私有继承。公有继承 是最常用的方式,它建立一种is-a关系,即派生类对象也是一个基类对象,任何可以对基类对象执行的操作,也可以对派生类对象执行。
因为派生类可以添加特性,所以,将这种关系称为 is-a-kind-of(是一种)关系可能 更准确,但是通常使用术语 is-a。
派生类对象使用基类的方法,而未做 任何修改。然而,可能会遇到这样的情况,即希望同一个方法在派生类 和基类中的行为是不同的,这称为多态公有继承。有两种重要的机制可用于实现多态公有继承:
- 在派生类中重新定义基类的方法;
- 使用虚方法(关键字
virtual
),然后在各自类中对该函数编写相关定义即可。
虚函数的这种行为非常方便。方法在基类中被声明为虚的后,它在派生类中将自动成为虚方法。
第四点是,基类声明了一个虚析构函数。这样做是为了确保释放派 生对象时,按正确的顺序调用析构函数。
为何需要虚析构函数?
在程序清单13.10中,使用delete释放由new分配的对象的代码说明 了为何基类应包含一个虚析构函数。
如果析构函数不是虚的,则将只调用对应于指针类型的析构函数。对于 程序清单13.10,这意味着只有Brass的析构函数被调用,即使指针指向 的是一个BrassPlus对象。如果析构函数是虚的,将调用相应对象类型的 析构函数。因此,如果指针指向的是BrassPlus对象,将调用BrassPlus的 析构函数,然后自动调用基类的析构函数。因此,使用虚析构函数可以 确保正确的析构函数序列被调用。
对于程序清单13.10,这种正确的行为并不是很重要,因为析构函数没有执行任何操作。然而,如果 BrassPlus包含一个执行某些操作的析构函数,则Brass必须有一个虚析构函数,即使该析构函数不执行任何操作。
程序调用函数时,将使用哪个可执行代码块呢?编译器负责回答这 个问题。将源代码中的函数调用解释为执行特定的函数代码块被称为函 数名联编(binding)。在编译过程中进行联 编被称为静态联编(static binding),又称为早期联编(early binding)。然而,虚函数使这项工作变得更困难。正如在程序清单 13.10所示的那样,使用哪一个函数是不能在编译时确定的,因为编译 器不知道用户将选择哪种类型的对象。所以,编译器必须生成能够在程 序运行时选择正确的虚方法的代码,这被称为动态联编(dynamic binding),又称为晚期联编(late binding)。
将派生类引用或指针转换为基类引用或指针被称为向上强制转换 (upcasting),这使公有继承不需要进行显式类型转换。该规则是is-a 关系的一部分。
相反的过程——将基类指针或引用转换为派生类指针或引用——称为向下强制转换(downcasting),如果不使用显式类型转换,则向下强制转换是不允许的。原因是is-a关系通常是不可逆的。派生类可以新增 数据成员,因此使用这些数据成员的类成员函数不能应用于基类。
对于使用基类引用或指针作为参数的函数调用,将进行向上转换。
编译器对虚方法使用动态联编。在大多数情况下,动态联编很好,因为它让程序能够选择为特定类 型设计的方法。
1.为什么有两种类型的联编以及为什么默认为静态联编
如果动态联编让您能够重新定义类方法,而静态联编在这方面很 差,为何不摒弃静态联编呢?原因有两个——效率和概念模型。
首先来看效率。为使程序能够在运行阶段进行决策,必须采取一些 方法来跟踪基类指针或引用指向的对象类型,这增加了额外的处理开销 (稍后将介绍一种动态联编方法)。
接下来看概念模型。在设计类时,可能包含一些不在派生类重新定 义的成员函数。例如,Brass::Balance( )函数返回账户结余,不应该重新 定义。不将该函数设置为虚函数,有两方面的好处:首先效率更高;其次,指出不要重新定义该函数。这表明,仅将那些预期将被重新定义的 方法声明为虚的。
2.虚函数的工作原理
编译器处理虚函数的方法是:给每个对象添加一个隐藏成 员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组称为虚 函数表(virtual function table,vtbl)。虚函数表中存储了为类对象进行 声明的虚函数的地址。例如,基类对象包含一个指针,该指针指向基类 中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指 针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地 址;如果派生类没有重新定义虚函数,该vtbl将保存函数原始版本的地 址。如果派生类定义了新的虚函数,则该函数的地址也将被添加到vtbl 中(参见图13.5)。注意,无论类中包含的虚函数是1个还是10个,都 只需要在对象中添加1个地址成员,只是表的大小不同而已。
调用虚函数时,程序将查看存储在对象中的vtbl地址,然后转向相 应的函数地址表。如果使用类声明中定义的第一个虚函数,则程序将使 用数组中的第一个函数地址,并执行具有该地址的函数。如果使用类声 明中的第三个虚函数,程序将使用地址为数组中第三个元素的函数。
总之,使用虚函数时,在内存和执行速度方面有一定的成本,包括:
- 每个对象都将增大,增大量为存储地址的空间;
- 对于每个类,编译器都创建一个虚函数地址表(数组);
- 对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址;
- 在基类方法的声明中使用关键字virtual可使该方法在基类以及所有 的派生类(包括从派生类派生出来的类)中是虚的;
- 如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法。这称为 动态联编。这种行为非常重要,因为这样基类指针或引 用可以指向派生类对象;
- 如果定义的类将被用作基类,则应将那些要在派生类中重新定义的 类方法声明为虚的;
- 构造函数不能是虚函数;
- 析构函数应当是虚函数,除非类不用做基类。这意味着,即使基类不需要显式析构函数提供服务,也不应依赖于默认构造函数,而应提供虚析构函数,即使它不执行任何操作;
给类定义一个虚析构函数并非错误,即使这个类不用做基类;这只是一个效率方面的问题。
- 友元不能是虚函数,因为友元不是类成员,而只有成员才能是虚函 数。
- 如果派生类没有重新定义函数,将使用该函数的基类版本。如果派 生类位于派生链中,则将使用最新的虚函数版本,例外的情况是基类版本是隐藏的。
关键字protected
与 private
相似,在类外只能用公有类成员来访问protected
部分中的类成员。private
和protected
之间的区别只有在基类派生的类中才会表现出来。派生类的成员可以直接访问基类的保护成员,但不能直接访问基类的私有成员。因此,对于外部世界来说, 保护成员的行为与私有成员相似;但对于派生类来说,保护成员的行为与公有成员相似。
C++通过使用纯虚函数(pure virtual function) 提供未实现的函数。纯虚函数声明的结尾处为=0。
virtual double Area() const = 0; // a pure virtual function
当类声明中包含纯虚函数时,则不能创建该类的对象。这里的理念 是,包含纯虚函数的类只用作基类。要成为真正的ABC,必须至少包含 一个纯虚函数。原型中的=0使虚函数成为纯虚函数。
在原型中使用=0指出类是一个抽象基类,在类中可以不定义该函数。
【ABC理念】如果要设计类继承层次,则只能将那些不会被用作基类的类设计 为具体的类。这种方法的设计更清晰,复杂程度更低。
可以将ABC看作是一种必须实施的接口。ABC要求具体派生类覆盖 其纯虚函数——迫使派生类遵循ABC设置的接口规则。这种模型在基于 组件的编程模式中很常见,在这种情况下,使用ABC使得组件设计人员 能够制定“接口约定”,这样确保了从ABC派生的所有组件都至少支持 ABC指定的功能。
继承是怎样与动态内存分配(使用new和delete)进行互动的呢?例如,如果基类使用动态内存分配,并重新定义赋值和复制构造函数,这将怎样影响派生类的实现呢?
假设基类使用了动态内存分配,基类中包含了构造函数使用new
时需要的特殊方法:析构函数、复 制构造函数和重载赋值运算符。如例子中的 baseDMA
。
现在,从 baseDMA
派生出 lackDMA
类,而后者不使用 new
,也未包含其他一些不常用的、需要特殊处理的设计特性。
class lacksDMA: public baseDMA {
private:
char color[40];
public:
...
}
是否需要为lackDMA类定义显式析构函数、复制构造函数和赋值运算符呢?不需要。
首先,来看是否需要析构函数。如果没有定义析构函数,编译器将 定义一个不执行任何操作的默认构造函数。实际上,派生类的默认构造 函数总是要进行一些操作:执行自身的代码后调用基类析构函数。因为 我们假设lackDMA成员不需执行任何特殊操作,所以默认析构函数是合 适的。
接着来看复制构造函数。第12章介绍过,默认复制构造函数执行成 员复制,这对于动态内存分配来说是不合适的,但对于新的lacksDMA 成员来说是合适的。因此只需考虑继承的baseDMA对象。要知道,成员 复制将根据数据类型采用相应的复制方式,因此,将long复制到long中 是通过使用常规赋值完成的;但复制类成员或继承的类组件时,则是使 用该类的复制构造函数完成的。所以,lacksDMA类的默认复制构造函 数使用显式baseDMA复制构造函数来复制lacksDMA对象的baseDMA部 分。因此,默认复制构造函数对于新的lacksDMA成员来说是合适的, 同时对于继承的baseDMA对象来说也是合适的。
对于赋值来说,也是如此。类的默认赋值运算符将自动使用基类的 赋值运算符来对基类组件进行赋值。因此,默认赋值运算符也是合适的。
在这种情况下,必须为派生类定义显式析构函数、复制构造函数和 赋值运算符。
总之,当基类和派生类都采用动态内存分配时,派生类的析构函 数、复制构造函数、赋值运算符都必须使用相应的基类方法来处理基类 元素。这种要求是通过三种不同的方式来满足的。对于析构函数,这是 自动完成的;对于构造函数,这是通过在初始化成员列表中调用基类的 复制构造函数来完成的;如果不这样做,将自动调用基类的默认构造函 数。对于赋值运算符,这是通过使用作用域解析运算符显式地调用基类 的赋值运算符来完成的。
编译器会自动生成一些公有成员函数——特殊成员 函数。这表明这些特殊成员函数很重要:
- 默认构造函数,要么没有参数,要么所有的参数都有默认值。如果没 有定义任何构造函数,编译器将定义默认构造函数,让我们能够创建对象;
- 复制构造函数,接受其所属类的对象作为参数。如果程序没有使用(显式或隐式)复制构造函数,编译器将提供原型,但不提供函数定义;否则,程序将定义一个执行成员初始化的复制构造函数;
- 赋值运算符,默认的赋值运算符用于处理同类对象之间的赋值。不要将赋值与初 始化混淆了。如果语句创建新的对象,则使用初始化;如果语句修改已有对象的值,则是赋值;
定义类时,还需要注意其他几点:
- 构造函数。构造函数不同于其他类方法,因为它创建新的对象,而其他类方法 只是被现有的对象调用。这是构造函数不被继承的原因之一。继承意味着派生类对象可以使用基类的方法,然而,构造函数在完成其工作之前,对象并不存在;
- 析构函数。一定要定义显式析构函数来释放类构造函数使用new分配的所有内 存,并完成类对象所需的任何特殊的清理工作。对于基类,即使它不需 要析构函数,也应提供一个虚析构函数;
- 转换。使用一个参数就可以调用的构造函数定义了从参数类型到类类型的转换。将可转换的类型传递给以类为参数的函数时,将调用转换构造函数。在带一个参数的构造函数原型中使用
explicit
将禁止进行隐式转换, 但仍允许显式转换:
要将类对象转换为其他类型,应定义转换函数(参见第11章)。
- 按值传递对象与传递引用。编写使用对象作为参数的函数时,应按引用而不是按值来传递对象。这样做的原因之一是为了提高效率。按值传递对象涉及到生成 临时拷贝,即调用复制构造函数,然后调用析构函数。调用这些函数需 要时间,复制大型对象比传递引用花费的时间要多得多。如果函数不修 改对象,应将参数声明为const引用。按引用传递对象的另外一个原因是,在继承使用虚函数时,被定义 为接受基类引用参数的函数可以接受派生类;
- 返回对象和返回引用。有些成员函数直接返回对 象,而另一些则返回引用。有时方法必须返回对象,但如果可以不返回对象,则应返回引用。如果函数返回在函数中创建的临时对象,则不要使用引用,而是返回该对象。如果函数返回的是通过引用或指针传递给它的对象,则应按引用返回对象;
- 使用const。 使用const来确保方法不修改调用它的对象。如果函数将参数声明为指向const的引用或指针,则不能将该 参数传递给另一个函数,除非后者也确保了参数不会被修改(即,确保const对const)。
- is-a 关系。表示is-a关系的方式之一是,无需进行显式类型转换,基类指针就可以指向派生类对象,基类引用可以引用派生类对象。另外, 反过来是行不通的,即不能在不进行显式类型转换的情况下,将派生类指针或引用指向基类对象;
- 什么不能被继承:构造函数不能继承;析构函数不能继承;赋值运算符不能继承;
- 赋值运算符;
- 私有成员与保护成员;
- 虚方法。如果希望派生类 能够重新定义方法,则应在基类中将方法定义为虚的;如果不希望重新定义方法,则不必将其声明为虚的,这样虽然无法禁止他人重新定义方法,但表达了这样的意思:您不 希望它被重新定义;
- 析构函数;
- 友元函数。由于友元函数并非类成员,因此不能继承。然而,您可能希望派生 类的友元函数能够使用基类的友元函数。为此,可以通过强制类型转换将派生类引用或指针转换为基类引用或指针,然后使用转换后的指针 或引用来调用基类的友元函数:
- 关于使用基类方法的说明:
- 派生类对象自动使用继承而来的基类方法,如果派生类没有重新定 义该方法;
- 派生类的构造函数自动调用基类的构造函数;
- 派生类的构造函数自动调用基类的默认构造函数,如果没有在成员初始化列表中指定其他构造函数;
- 派生类构造函数显式地调用成员初始化列表中指定的基类构造函数;
- 派生类方法可以使用作用域解析运算符来调用公有的和受保护的基类方法;
- 派生类的友元函数可以通过强制类型转换,将派生类引用或指针转 换为基类引用或指针,然后使用该引用或指针来调用基类的友元函数。
C++类函数有很多不同的变体,其中有些可以继承,有些不可以。 有些运算符函数既可以是成员函数,也可以是友元,而有些运算符函数 只能是成员函数。
继承通过使用已有的类(基类)定义新的类(派生类),使得能够 根据需要修改编程代码。基类的析构函数通常应当是虚的。
如果要将类用作基类,则可以将成员声明为保护的,而不是私有 的,这样,派生类将可以直接访问这些成员。
可以考虑定义一个ABC:只定义接口,而不涉及实现。例如,可以 定义抽象类Shape,然后使用它派生出具体的形状类,如Circle和 Square。ABC必须至少包含一个纯虚方法,可以在声明中的分号前面加 上=0来声明纯虚方法。