第14章 C++中的代码重用
本章内容包括:
● has-a关系。
● 包含成员对象的类。
● 模板类valarray。
● 私有和保护继承。
● 多重继承。
● 虚基类。
● 创建类模板。
● 使用类模板。
● 模板的具体化。
C++的一个主要目标是促进代码重用。公有继承是实现这种目标的机制之一,但并不是惟一的机制。本章将介绍其他方法,其中之一是使用这样的类成员:本身是另一个类的对象。这种方法称为包含(containment)、组合(composition)或层次化(layering)。另一种方法是使用私有或保护继承。通常,包含、私有继承和保护继承用于实现has-a关系,即新的类将包含另一个类的对象。例如,HomeTheater类可能包含一个DvdPlayer对象。多重继承使得能够使用两个或更多的基类派生出新的类,将基类的功能组合在一起。
第10章介绍了函数模板,本章将介绍类模板——另一种重用代码的方法。类模板使我们能够使用通用术语定义类,然后使用模板来创建针对特定类型定义的特殊类。例如,可以定义一个通用的堆栈模板,然后使用该模板创建一个用于表示int值堆栈的类和一个用于表示double值堆栈的类,甚至可以创建一个这样的类,即用于表示堆栈的堆栈。
14.1 包含对象成员的类
首先介绍包含对象成员的类。有一些类(如string类和第16章将介绍的标准C++类模板)为表示类中的组件提供了方便的途径。下面来看一个具体的例子。
学生是什么?入学者?参加研究的人?残酷现实社会的避难者?有姓名和一系列考试分数的人?显然,最后一个定义完全没有表示出人的特征,但非常适合于简单的计算机表示。因此,让我们根据该定义来开发Student类。
将学生简化成姓名和一组考试分数后,可以使用一个包含两个成员的类来表示它:一个成员用于表示姓名,另一个成员用于表示分数。对于姓名,可以使用字符数组来表示,但这将限制姓名的长度。当然,也可以使用char指针和动态内存分配,然而正如第12章指出的,这将要求提供大量的支持代码。一种更好的方法是,使用一个由他人开发好的类的对象来表示。例如,可以使用一个String类(参见第12章)或标准C++的string类的对象来表示姓名。较简单的选择是使用string类,因为C++库提供了这个类的所有实现代码。要使用String类,您必须在项目中包含实现文件string1.cpp。
对于考试分数,存在类似的选择。可以使用一个定长数组,这限制了数组的长度;可以使用动态内存分配并提供大量的支持代码;也可以设计一个使用动态内存分配的类来表示该数组;还可以在标准C++库中查找一个能够表示这种数据的类。
当然,如果C++库提供了合适的类,实现起来将更简单。C++库确实提供了一个这样的类,它就是valarrayo
14.1.1 valarray类简介
valarray类是由头文件valarray支持的。顾名思义,这个类用于处理数值(或具有类似特性的类),它支持诸如将数组中所有元素的值相加以及在数组中找出最大和最小的值等操作。valarray被定义为一个模板类,以便能够处理不同的数据类型。本章后面将介绍如何定义模板类,但就现在而言,读者只需知道如何使用模板类即可。
模板特性意味着声明对象时,必须指定具体的数据类型。因此,使用valarray类来声明一个对象时,需要在标识符valarray后面加上一对尖括号,并在其中包含所需的数据类型:
这是读者需要学习的惟一新句法,它非常简单。
类特性意味着要使用valarray对象,需要了解这个类的构造函数和其他类方法。下面是几个使用其构造函数的例子:
从中可知,可以创建长度为零的空数组、指定长度的空数组、所有元素都被初始化为指定值的数组以及用常规数组中的值进行初始化的数组。
下面是这个类的一些方法:
● operator[]:让您能够访问数组中的元素。
● size:返回数组中包含的元素数。
● sum:返回所有元素的总和。
● max:返回最大的元素。
● min:返回最小的元素。
还有很多其他的方法,其中的一些将在第16章介绍;但就这个例子而言,上述方法足够了。
14.1.2 Student类的设计
至此,已经确定了Student类的设计计划:使用一个string对象来表示姓名,使用一个valarray<double>来表示考试分数。那么如何设计呢?读者可能想以公有的方式从这两个类派生出Student类,这将是多重公有继承,C++允许这样做,但在这里并不合适,因为学生与这些类之间的关系不是is-a模型。学生不是姓名,也不是一组考试成绩。这里的关系是has-a,学生有姓名,也有一组考试分数。通常,用于建立has-a关系的C++技术是组合(包含),即创建一个包含其他类对象的类。例如,可以将Student类声明为如下所示:
同样,上述类将数据成员声明为私有的。这意味着Student类的成员函数可以使用string和valarray<double>类的公有接口来访问和修改name和scores对象,但在类的外面不能这样做,而只能通过Student类的公有接口访问name和scores(请参见图14)。对于这种情况,通常被描述为Student类获得了其成员对象的实现,但没有继承接口。例如,Student对象使用string的实现,而不是char * name或char name [26]实现来保存姓名。但Student对象并不是天生就有使用函数string operator+=()的能力。
图14.1 对象中的对象:包含
接口和实现
使用公有继承时,类可以继承接口,可能还有实现(基类的纯虚函数提供接口,但不提供实现)。获得接口是is-a关系的组成部分。而使用组合,类可以获得实现,但不能获得接口。不继承接口是has-a关系的组成部分。
对于has-a关系来说,类对象不能自动获得被包含对象的接口是一件好事。例如,string类将+操作符重载为将两个字符串连接起来;但从概念上说,将两个Student对象串接起来是没有意义的。这也是这里不使用公有继承的原因之一。另一方面,被包含的类的接口部分对新类来说可能是有意义的。例如,可能希望使用string接口中的operato<()方法将Student对象按姓名进行排序,为此可以定义Student::operator<()成员函数,它在内部使用string::operator<()函数。下面介绍一些细节。
14.1.3 Student类范例
现在需要提供Student类的定义,当然它应包含构造函数以及一些用作Student类接口的方法。程序清单14.1是Student类的定义,其中所有构造函数都被定义为内联的;它还提供了一些用于输入和输出的友元函数。
程序清单14.1 studentc.h
为简化表示,Student类的定义中包含下述typedef:
typedef std::valarray<double> ArrayDb;
这样,在以后的代码中便可以使用表示ArrayDb,而不是std::valarray<double>,因此类方法和友元函数可以使用ArrayDb类型。将该typedef放在类定义的私有部分意味着可以在Student类的实现中使用它,但在Student类外面不能使用。
注意,其中的一个构造函数使用了关键字explicit:
explicit Student(int n): name("Nully"), scores(n) {}
本书前面说过,可以用一个参数调用的构造函数将用作从参数类型到类类型的隐式转换函数。在上述构造函数中,第一个参数表示数组的元素个数,而不是数组中的值,因此将一个构造函数用作int到Student的转换函数是没有意义的,所以使用explicit关闭隐式转换。如果省略该关键字,则可以编写如下所示的代码:
在这里,马虎的程序员键入了doh而不是doh[0]。如果构造函数省略了explicit,则将使用构造函数调用Student(5)将5转换为一个临时Student对象,并使用“Nully”来设置成员name的值。因此赋值操作将使用临时对象替换原来的doh值。使用了explicit后,编译器将认为上述赋值操作符是错误的。
C++和约束
C++包含让程序员能够限制程序结构的特性——使用explicit防止单参数构造函数的隐式转换,使用const限制方法修改数据,等等。这样做的根本原因是:在编译阶段出现错误优于在运行阶段出现错误。
1.初始化被包含的对象
构造函数全都使用读者熟悉的成员初始化列表句法来初始化name和score成员对象。在前面的一些例子中,构造函数用这种句法来初始化内置类型的成员:
Queue::Queue (int qs): qsize (qs) {…} // initialize qsize to qs
上述代码在成员初始化列表中使用的是数据成员的名称(qsize)。另外,前面介绍的范例中的构造函数还使用成员初始化列表初始化派生对象的基类部分:
hasDMA::hasDMA (const hasDMA & hs): baseDMA (hs) {…}
对于继承的对象,构造函数在成员初始化列表中使用类名来调用特定的基类构造函数。对于成员对象,构造函数则使用成员名。例如,请看程序清单14.1的最后一个构造函数:
因为该构造函数初始化的是成员对象,而不是继承的对象,所以在初始化列表中使用的是成员名,而不是类名。初始化列表中的每一项都调用与之匹配的构造函数,即name (str)调用String (const char)构造函数,scores (pd, n)调用ArrayDb (const double , int)构造函数。
如果不使用初始化列表句法,情况将如何呢?C++要求在构建对象的其他部分之前,先构建继承对象的所有成员对象。因此,如果省略初始化列表,C++将使用成员对象类的默认构造函数。
初始化顺序
当初始化列表包含多个项目时,这些项目被初始化的顺序为它们被声明的顺序,而不是它们在初始化
列表中的顺序。例如,假设Student构造函数如下:
则name成员仍将首先被初始化,因为在类定义中它首先被声明。对于这个例子来说,初始化顺序并不重要,但如果代码使用一个成员的值作为另一个成员的初始化表达式的一部分时,初始化顺序就非常重要了。
2.使用被包含对象的接口
被包含对象的接口不是公有的,但可以在类方法中使用它。例如,下面的代码说明了如何定义一个返回学生平均分数的函数:
上述代码定义了可由Student对象调用的方法,该方法内部使用了valarray的方法size()和sum()。这是因为scores是一个valarray对象,所以它可以调用valarray类的成员函数。简而言之,Student对象调用Student的方法,而后者使用被包含的valarray对象来调用valarray类的方法。
同样,可以定义一个使用string版本的<<操作符的友元函数:
因为stu.name是一个string对象,所以它将调用operatot<< (ostream &, const string &)函数,该函数位于string类中。注意,operator<<(ostream & os, const Student & stu)必须是Student类的友元函数,这样才能访问name成员。另一种方法是,在该函数中使用公有方法Name(),而不是私有数据成员name。
同样,该函数也可以使用valarray的<<实现来进行输出,不幸的是没有这样的实现;因此,Student类定义了一个私有辅助方法来处理这种任务:
通过使用这样的辅助方法,可以将零乱的细节放在一个地方,使得友元函数的编码更为整洁:
辅助函数也可用作其他用户级输出函数的构建块——如果您选择提供这样的函数的话。
程序清单14.2是Student类的类方法文件,其中包含了让您能够使用[]操作符来访问Student对象中各项成绩的方法。
程序清单14.2 student.cpp
除私有辅助方法外,程序清单14.2并没有新增多少代码。使用包含让您能够充分利用已有的代码。
3.使用新的Student类
下面编写一个小程序来测试这个新的Student类。出于简化的目的,该程序将使用一个只包含3个Student对象的数组,其中每个对象保存5个考试成绩。另外还将使用一个不复杂的输入循环,该循环不验证输入,也不让用户中途退出。程序清单14.3列出了该测试程序,请务必将该程序与Student.cpp一起进行编译。
程序清单14.3 use_stuc.cpp
// use_stuc.cpp — using a composite class
注意:如果您的系统不能正确地支持string类的友元函数getline(),将不能正确地运行该程序;在这种情况下,可以对该程序进行修改,使之使用程序清单14.2中的operator>>()。由于该友元函数只读取一个单词,因此需要对提示语进行修改,要求用户输入学生的姓。
下面是程序清单14.1~14.3组成的程序的运行情况:
14.2 私有继承
C++还有另一种实现has-a关系的途径——私有继承。使用私有继承,基类的公有成员和保护成员都将成为派生类的私有成员。这意味着基类方法将不会成为派生对象公有接口的一部分,但可以在派生类的成员函数中使用它们。
下面更深入地探讨接口问题。使用公有继承,基类的公有方法将成为派生类的公有方法。简而言之,派生类将继承基类的接口;这是is-a关系的一部分。使用私有继承,基类的公有方法将成为派生类的私有方法。简而言之,派生类不继承基类的接口。正如从被包含对象中看到的,这种不完全继承是has-a关系的一部分。
使用私有继承,类将继承实现。例如,如果从String类派生出Student类,后者将有一个String类组件,可用于保存字符串。另外,Student方法可以使用String方法来访问String组件。
包含将对象作为一个命名的成员对象添加到类中,而私有继承将对象作为一个未被命名的继承对象添加到类中。我们将使用术语子对象(subobject)来表示通过继承或包含添加的对象。
因此私有继承提供的特性与包含相同:获得实现,但不获得接口。所以,私有继承也可以用来实现has-a关系。接下来介绍如何使用私有继承来重新设计Student类。
Student类范例(新版本)
要执行私有继承,请使用关键字private而不是public来定义类(实际上,private是默认值,因此省略访问限定符也将导致私有继承)。Student类应从两个类派生而来,因此声明将列出这两个类:
使用多个基类的继承被称为多重继承(multiple inheritance,MI)。通常,MI尤其是公有MI将导致一些问题,必须使用额外的句法规则来解决它们,这将在本章后面介绍。但在这个范例中,MI不会导致问题。
新的Student类不需要私有数据,因为两个基类已经提供了所需的所有数据成员。包含版本提供了两个被显式命名的对象成员,而私有继承提供了两个无名称的子对象成员。这是这两种方法的第一个主要区别。
1.初始化基类组件
隐式地继承组件而不是成员对象将影响代码的编写,因为再也不能使用name和scores来描述对象了,而必须使用用于公有继承的技术。例如,对于构造函数,包含将使用这样的构造函数:
对于继承类,新版本的构造函数将使用成员初始化列表句法,它使用类名而不是成员名来标识构造函数:
在这里,ArrayDb是std::valarray<double>的别名。成员初始化列表使用std::string (str),而不是name (str)。这是包含和私有继承之间的第二个主要区别。
程序清单14.4列出了新的类定义。惟一不同的地方是,省略了显式对象名称,并在内联构造函数中使用了类名,而不是成员名。
程序清单14.4 studenti.h
2.访问基类的方法
使用私有继承时,只能在派生类的方法中使用基类的方法。但有时候可能希望基类工具是公有的。例如,在类声明中提出可以使用average()函数。和包含一样,要实现这样的目的,可以在公有Student::average()函数中使用私有Student::Average()函数(参见图14.2)。包含使用对象来调用方法:
图14.2 对象中的对象:私有继承
而私有继承使得能够使用类名和作用域解析操作符来调用基类的方法:
总之,使用包含时将使用对象名来调用方法,而使用私有继承时将使用类名和作用域解析操作符来调用方法。
3.访问基类对象
使用作用域解析操作符可以访问基类的方法,但如果要使用基类对象本身,该如何做呢?例如,Student类的包含版本实现了Name()方法,它返回string对象成员name;但使用私有继承时,该string对象没有名称。那么,Student类的代码如何访问内部的string对象呢?
答案是使用强制类型转换。由于Student类是从string类派生而来的,因此可以通过强制类型转换,将Student对象转换为string对象;结果为继承而来的string对象。本书前面介绍过,指针this指向用来调用方法的对象,因此*this为用来调用方法的对象,在这个例子中,为类型为Student的对象。为避免调用构造函数创建新的对象,可使用强制类型转换来创建一个引用:
上述方法返回一个引用,该引用指向用于调用该方法的Student对象中的继承而来的string对象。
4.访问基类的友元函数
用类名显式地限定函数名不适合于友元函数,这是因为友元不属于类。不过,可以通过显式地转换为基类来调用正确的函数。例如,对于下面的友元函数定义:
如果plato是一个Student对象,则下面的语句:
cout << plato;
将调用上述函数,stu将是指向plato的引用,而os将是指向cout的引用。下面的代码:
os << "Scores for" << (const String &) stu << ": \n";
显式地将stu转换为string对象引用,这与operator<< (ostream &, const String &)函数匹配。
引用stu不会自动转换为string引用。根本原因在于,在私有继承中,在不进行显式类型转换的情况下,不能将指向派生类的引用或指针赋给基类引用或指针。
不过,即使这个例子使用的是公有继承,也必须使用显式类型转换。原因之一是,如果不使用类型转换,下述代码将与友元函数原型匹配,从而导致递归调用:
os << stu;
另一个原因是,由于这个类使用的是多重继承,编译器将无法确定应转换成哪个基类,如果两个基类都提供了函数operator<<()。程序清单14.5列出了除内联函数之外的所有Student类方法。
程序清单14.5 student.cpp
同样,由于这个范例也重用了string和valarray类的代码,因此除私有辅助方法外,它包含的新代码很少。
5.使用修改后的Student类
接下来也需要测试这个新类。注意到两个版本的Student类的公有接口完全相同,因此可以使用同一个程序测试它们。惟一不同的是,应包含studenti.h而不是studentc.h,应使用studenti.cpp而不是studentc.cpp来链接程序。程序清单14.6列出列该程序,请将其与studenti.cpp一起编译。
程序清单14.6 use_stui.cpp
注意:如果您的系统不能正确地支持string类的友元函数getline(),将不能正确地运行该程序;在这种情况下,可以对该程序进行修改,使之使用程序清单14.5中的operator>>()。由于该友元函数只读取一个单词,因此需要对提示语进行修改,要求用户输入学生的姓。
下面是该程序的运行情况:
输入与前一个测试程序相同,输出也相同。
6.使用包含还是私有继承
由于既可以使用包含,也可以使用私有继承来建立has-a关系,那么应使用种方式呢?大多数C++程序员倾向于使用包含。首先,它易于理解。类声明中包含表示被包含类的显式命名对象,代码可以通过名称引用这些对象,而使用继承将使关系更抽象。其次,继承会引起许多问题,尤其从多个基类继承时,可能必须处理许多问题,例如包含同名方法的独立的基类,或共享祖先的独立基类。总之,使用包含不太可能遇到这样的麻烦。另外,包含能够包括多个同类的子对象。如果某个类需要3个string对象,可以使用包含声明3个独立的string成员。而继承则只能使用一个这样的对象(当对象都没有名称时,将难以区分)。
不过,私有继承所提供的特性确实比包含多。例如,假设类包含保护成员(可以是数据成员,也可以是成员函数),则这样的成员在派生类中是可用的,但在继承层次结构外是不可用的。如果使用组合将这样的类包含在另一个类中,则后者将不是派生类,而是位于继承层次结构之外,因此不能访问保护成员。但通过继承得到的将是派生类,因此它能够访问保护成员。
另一种需要使用私有继承的情况是需要重新定义虚函数。派生类可以重新定义虚函数,但包含类不能。使用私有继承,重新定义的函数将只能在类中使用,而不是公有的。
提示:通常,应使用包含来建立has-a关系;如果新类需要访问原有类的保护成员,或需要重新定义虚函数,则应使用私有继承。
7.保护继承
保护继承是私有继承的变体。保护继承在列出基类时使用关键字protected:
使用保护继承时,基类的公有成员和保护成员都将成为派生类的保护成员。和私有继承一样,基类的接口在派生类中也是可用的,但在继承层次结构之外是不可用的。当从派生类派生出另一个类时,私有继承和保护继承之间的主要区别便呈现出来了。使用私有继承时,第三代类将不能使用基类的接口,这是因为基类的公有方法在派生类中将变成私有方法;使用保护继承时,基类的公有方法在第二代中将变成受保护的,因此第三代派生类可以使用它们。
表14.1总结了公有、私有和保护继承。隐式向上转换(implicit upcasting)意味着无须进行显式类型转换,就可以将基类指针或引用指向派生类对象。
表14.1 不同种类的继承
8.使用using重新定义访问权限
使用保护派生或私有派生时,基类的公有成员将成为保护成员或私有成员。假设要让基类的方法在派生类外面可用,方法之一是定义一个使用该基类方法的派生类方法。例如,假设希望Student类能够使用valarray类的sum()方法,可以在Student类的声明中声明一个sum()方法,然后像下面这样定义该方法:
这样Student对象便能够调用Student::sum(),后者进而将valarray<double>::sum()方法应用于被包含的valarray对象(如果ArrayDb typedef在作用域中,也可以使用ArrayDb而不是std::valarray<double>)。
另一种方法是,将函数调用包装在另一个函数调用中,即使用一个using声明(就像名称空间那样)来指出派生类可以使用特定的基类成员,即使采用的是私有派生。例如,假设希望通过Student类能够使用valarray的方法min()和max(),可以在studenti.h的公有部分加入如下using声明:
上述using声明使得valarray<double>::min()和valarray<double>::max()可用,就像它们是Student的公有方法一样:
cout << "high score:" << ada[i].max() << endl;
注意,using声明只使用成员名——没有圆括号、函数特征标和返回类型。例如,为使Student类可以使用valarray的operator方法,只需在Student类声明的公有部分包含下面的using声明:
using student::valarray<double>::operator[];
这将使两个版本(const和非const)都可用。这样,便可以删除Student::operator的原型和定义。using声明只适用于继承,而不适用于包含。
有一种老式方式可用于在私有派生类中重新声明基类方法,即将方法名放在派生类的公有部分,如下所示:
这看起来像不包含关键字using的using声明。这种方法已被摒弃,即将停止使用。因此,如果编译器支持using声明,应使用它来使派生类可以使用私有基类中的方法。
14.3 多重继承
MI描述的是有多个直接基类的类。与单继承一样,公有MI表示的也是is-a关系。例如,可以从Waiter类和Singer类派生出SingingWaiter类:
class SingingWaiter: public Waiter, public Singer {…};
请注意,必须使用关键字public来限定每一个基类。这是因为,除非特别指出,否则编译器将认为是私有派生:
class SingingWaiter: public Waiter, Singer {…}; // Singer is a private base
正如本章前面讨论的,私有MI和保护MI可以表示has-a关系。Student类的studenti.h实现就是一个这样的范例。下面将重点介绍公有MI。
MI可能会给程序员带来许多新问题。其中两个主要的问题是:从两个不同的基类继承同名方法;从两个或更多相关基类那里继承同一个类的多个实例。为解决这些问题,需要使用一些新规则和不同的句法。因此,与使用单继承相比,使用MI更困难,也更容易出现问题。由于这个原因,许多C++用户强烈反对使用MI,一些人甚至希望删除MI。而喜欢MI的人则认为,对一些特殊的工程来说,MI很有用,甚至是必不可少的;也有一些人建议谨慎、适度地使用MI。
下面来看一个例子,并介绍有哪些问题以及如何解决它们。要使用MI,需要几个类。我们将定义一个抽象基类Worker,并使用它派生出Waiter类和Singer类。然后,便可以使用MI从Waiter类和Singer类派生出SingingWaiter类(参见图14.3)。这里使用两个独立的派生来使基类(Worker)被继承,这将导致MI的大多数麻烦。首先声明Worker、Waiter和Singer类,如程序清单14.7所示。
图14.3 祖先相同的MI
程序清单14.7 Worker0.h
程序清单14.7的类声明中包含一些表示声音类型的内部常量。一个枚举用符号常量alto、contralto等表示声音类型,静态数组pv存储了指向相应C-风格字符串的指针,程序清单14.8初始化了该数组,并提供了方法的定义。
程序清单14.8 worker0.cpp
程序清单14.9是一个简短的程序,使用一个多态指针数组对这些类进行了测试。
程序清单14.9 worktest.cpp
注意:如果您的系统不能正确地支持string类的友元函数getline(),将无法正确地运行该程序。在这种情况下,可以修改程序清单14.8,在其中使用operator>>()。由于该有元函数只读取一个单词,因此可能需要修改提示,要求用户输入姓。
下面是程序清单14.7~14.9组成的程序的输出:
这种设计看起来是可行的:使用Waiter指针来调用Waiter::Show()和Waiter::Set();使用Singer指针来调用Singer::Show()和Singer::Set()。然后,如果添加一个从Singer和Waiter类派生出的SingingWaiter类后,将带来一些问题。具体地说,将出现以下几个问题:
● 有多少Worker?
● 哪个方法?
14.3.1 有多少Worker
假设首先从Singer和Waiter公有派生出SingingWaiter:
class SingingWaiter: public Singer, public Waiter {…};
因为Singer和Waiter都继承了一个Worker组件,因此SingingWaiter将包含两个Worker组件(参见图14.4)。
图14.4 继承两个基类对象
正如预期的,这将引起问题。例如,通常可以将派生类对象的地址赋给基类指针,但是现在将出现二义性:
通常,这种赋值将把基类指针设置为派生对象中的基类对象的地址。但ed中包含两个Worker对象,有两个地址可供选择,所以应使用类型转换来指定对象:
这将使得使用基类指针来引用不同的对象(多态性)复杂化。
包含两个Worker对象拷贝还会导致其他的问题。不过,真正的问题是:为什么需要Worker对象的两个拷贝?唱歌的侍者和其他Worker对象一样,也应只包含一个姓名和一个ID。当C++引入多重继承的同时,它引入了一种新技术——虚基类(virtual base class),使MI成为可能。
1.虚基类
虚基类使得从多个类(它们的基类相同)派生出的对象只继承一个基类对象。例如,通过在类声明中使用关键字virtual,可以使Worker被用作Singer和Waiter的虚基类(virtual和public的次序无关紧要):
然后,可以将SingingWaiter类定义为:
class SingingWaiter: public Singer, public Waiter {…};
现在,SingingWaiter对象将只包含Worker对象的一个拷贝。从本质上说,继承的Singer和Waiter对象共享一个Worker对象,而不是各自引入自己的Worker对象拷贝(请参见图14.5)。因为SingingWaiter现在只包含了一个Worker子对象,所以可以使用多态。
读者可能会有这样的疑问:
● 为什么使用术语虚拟?
图14.5 虚基类继承
● 为什么不抛弃将基类声明为虚拟的这种方式,而使虚拟行为成为多MI的准则呢?
● 是否存在麻烦呢?
首先,为什么使用术语虚拟?毕竟,在虚函数和虚基类之间并不存在明显的联系。C++用户强烈反对引入新的关键字,因为这将给他们带来很大的压力。例如,如果新关键字与重要程序中的重要函数或变量的名称相同,这将非常麻烦。因此,C++对这种新特性也使用关键字virtual——有点像关键字重载。
其次,为什么不抛弃将基类声明为虚拟的这种方式,而使虚拟行为成为MI的准则呢?第一,在一些情况下,可能需要基类的多个拷贝;第二,将基类作为虚拟的要求程序完成额外的计算,为不需要的工具付出代价是不应当的;第三,这样做有其缺点,将在下一段介绍。
最后,是否存在麻烦?是的。为使虚基类能够工作,需要对C++规则进行调整,必须以不同的方式编写一些代码。另外,使用虚基类还可能需要修改已有的代码。例如,将SingingWaiter类添加到Worker集成层次中时,需要在Singer和Waiter类中添加关键字virtual。
2.新的构造函数规则
使用虚基类时,需要对类构造函数采用一种新的方法。对于非虚基类,惟一可以出现在初始化列表中的构造函数是即时基类构造函数。但这些构造函数可能需要将信息传递给其基类。例如,可能有下面一组构造函数:
C类的构造函数只能调用B类的构造函数,而B类的构造函数只能调用A类的构造函数。这里,C类的构造函数使用值q,并将值m和n传递给B类的构造函数;而B类的构造函数使用值m,并将值n传递给A类的构造函数。
如果Worker是虚基类,则这种信息自动传递将不起作用。例如,对于下面的MI构造函数:
存在的问题是,自动传递信息时,将通过2条不同的途径(Waiter和Singer)将wk传递给Worker对象。为避免这种冲突,C++在基类是虚拟的时,禁止信息通过中间类自动传递给基类。因此,上述构造函数将初始化成员panache和voice,但wk参数中的信息将不会传递给子对象Waiter。不过,编译器必须在构造派生对象之前构造基类对象组件;在上述情况下,编译器将使用Worker的默认构造函数。
如果不希望默认构造函数来构造虚基类对象,则需要显式地调用所需的基类构造函数。因此,构造函数应该是这样:
上述代码将显式地调用构造函数worker (const Worker &)。请注意,这种用法是合法的,对于虚基类,必须这样做;但对于非虚基类,则是非法的。
警告:如果类有间接虚基类,则除非只需使用该虚基类的默认构造函数,否则必须显式地调用该虚基类的某个构造函数。
14.3.2 哪个方法
除了修改类构造函数规则外,MI通常还要求调整其他代码。假设要在SingingWaiter类中扩展Show()方法。因为SingingWaiter对象没有新的数据成员,所以可能会认为它只需使用继承的方法即可。这引出了第一个问题。假设没有在SingingWaiter类中重新定义Show()方法,并试图使用SingingWaiter对象调用继承的Show()方法:
对于单继承,如果没有重新定义Show(),则将使用最近祖先中的定义。而在多重继承中,每个直接祖先都有一个Show()函数,这使得上述调用是二义性的。
警告:多重继承可能导致函数调用的二义性。例如,BadDude类可能从Gunslinger类和PokerPlayer类那里继承两个完全不同的Draw()方法。
可以使用作用域解析操作符来澄清编程者的意图:
不过,更好的方法是在SingingWaiter中重新定义Show(),并指出要使用哪个Show()。例如,如果希望SingingWaiter对象使用Singer版本的Show(),则可以这样做:
对于单继承来说,让派生方法调用基类的方法是可以的。例如,假设HeadWaiter类是从Waiter类派生而来的,则可以使用下面的定义序列,其中每个派生类使用其基类显示信息,并添加自己的信息:
不过,这种递增的方式对SingingWaiter范例无效。方法:
将无效,因为它忽略了Waiter组件。可以通过同时调用Waiter版本的Show()来补救:
这将显示姓名和ID两次,因为Singer::Show()和Waiter::Show()都调用了Worker::Show()。
如何解决呢?一种办法是使用模块化方式,而不是递增方式,即提供一个只显示Worker组件的方法和一个只显示Waiter组件或Singer组件(而不是Waiter和Worker组件)的方法。然后,在SingingWaiter:: Show()方法中将组件组合起来。例如,可以这样做:
与此相似,其他Show()方法可以组合适当的Data()组件。
采用这种方式,对象仍可使用Show()方法。而Data()方法只在类内部可用,作为协助公有接口的辅助方法。不过,使Data()方法成为私有的将阻止Waiter中的代码使用Worker::Data(),这正是保护访问类的用武之地。如果Data()方法是保护的,则只能在继承层次结构中的类中使用它,在其他地方则不能使用。
另一种办法是将所有的数据组件都设置为保护的,而不是私有的,不过使用保护方法(而不是保护数据)将可以更严格地控制对数据的访问。
Set()方法取得数据,以设置对象值,该方法也有类似的问题。例如,SingingWaiter::Set()应请求Worker信息一次,而不是两次。对此,可以使用前面的解决方法。可以提供一个受保护的Get()方法,该方法只请求一个类的信息,然后将使用Get()方法作为构造块的Set()方法集合起来。
简而言之,在祖先相同时,使用MI必须引入虚基类,并修改构造函数初始化列表的规则。另外,如果在编写这些类时没有考虑到MI,则还可能需要重新编写它们。程序清单14.10列出了修改后的类声明,程序清单14.11列出实现。
程序清单14.10 workermi.h
程序清单14.11 workermi.cpp
当然,好奇心要求我们测试这些类,程序清单14.12提供了测试代码。注意,该程序使用了多态属性,将各种类的地址赋给基类指针。另外,该程序还在下面的检测中使用了C-风格字符串库函数strchr():
while (strchr("wstq", choice) == NULL)
该函数返回参数choice指定的字符在字符串“wstq”中第一次出现的地址,如果没有这样的字符,则返回NULL指针。使用这种检测比使用if语句将choice指定的字符同每个字符进行比较简单。
请将程序清单14.12与workermi.cpp一起编译。
程序清单14.12 workmi.cpp
注意:如果您的系统不能正确地支持string类的友元函数getline(),将无法正确地运行该程序。在这种情况下,可以修改程序清单14.11,在其中使用operator>>()。由于该有元函数只读取一个单词,因此可能需要修改提示,要求用户输入姓。
下面是程序清单14.10~14.12组成的程序的运行情况:
下面介绍其他一些有关MI的问题。
1.混合使用虚基类和非虚基类
再来看一下通过多种途径继承一个基类的派生类的情况。如果基类是虚基类,派生类将包含基类的一个子对象;如果基类不是虚基类,派生类将包含多个子对象。当虚基类和非虚基类混合时,情况将如何呢?例如,假设类B被用作类C和D的虚基类,同时被用作类X和Y的非虚基类,而类M是从C、D、X和Y派生而来的。在这种情况下,类M从虚拟派生祖先(即类C和D)那里共继承了一个B类子对象,并从每一个非虚拟派生祖先(即类X和Y)分别继承了一个B类子对象。因此,它包含三个B类子对象。当类通过多条虚拟途径和非虚拟途径继承某个特定的基类时,该类将包含一个表示所有的虚拟途径的基类子对象和分别表示各条非虚拟途径的多个基类子对象。
2.虚基类和支配
使用虚基类将改变C++解析二义性的方式。使用非虚基类时,规则很简单。如果类从不同的类那里继承了两个或更多的同名成员(数据或方法),则使用该成员名时,如果没有用类名进行限定,将导致二义性。但如果使用的是虚基类,则这样做不一定会导致二义性。在这种情况下,如果某个名称优先于(dominates)其他所有名称,则使用它时,即便不使用限定符,也不会导致二义性。
那么,一个成员名如何优先于另一个成员名呢?派生类中的名称优先于直接或间接祖先类中的相同名称。例如,在下面的定义中:
类C中的q()定义优先于类B中的q()定义,因为类C是从类B派生而来的。因此,F中的方法可以使用q()来表示C::q()。而任何一个omb()定义都不优先于其他omb()定义,因为C和E都不是对方的基类。所以,在F中使用非限定的omb()将导致二义性。
虚拟二义性规则与访问规则无关,也就是说,即使E::omb()是私有的,不能在F类中直接访问,但使用omb()仍将导致二义性。同样,即使C::q()是私有的,它也将优先于D::q()。在这种情况下,可以在类F中调用B::q(),但如果不限定q(),则将意味着要调用不可访问的C::q()。
14.3.3 MI小结
首先复习一下不使用虚基类的MI。这种形式的MI不会引入新的规则。不过,如果一个类从两个不同的类那里继承了两个同名的成员,则需要在派生类中使用类限定符来区分它们。即在从GunSlinger和PokerPlayer派生而来的BadDude类中,将分别使用Gunslinger::draw()和PokerPlayer::draw()来区分从这两个类那里继承的draw()方法。否则,编译器将指出二义性。
如果一个类通过多种途径继承了一个非虚基类,则该类从每种途径分别继承非虚基类的一个实例。在某些情况下,这可能正是所希望的,但通常情况下,多个基类实例都是问题。
接下来看一看使用虚基类的MI。当派生类使用关键字virtual来指示派生时,基类就成为虚基类:
class marketing: public virtual reality {…};
主要变化(同时也是使用虚基类的原因)是,从虚基类的一个或多个实例派生而来的类将只继承了一个基类对象。为实现这种特性,必须满足其他要求:
● 有间接虚基类的派生类包含直接调用间接基类构造函数的构造函数,这对于间接非虚基类来说是非法的。
正如您看到的,MI会增加编程的复杂程度。不过,这种复杂性主要是由于派生类通过多条途径继承同一个基类引起的。避免这种情况后,惟一需要注意的是,在必要时对继承的名称进行限定。
14.4 类模板
继承(公有、私有或保护)和包含并不总是能够满足重用代码的需要。例如,Stack类(参见第10章)和Queue类(参见第12章)都是容器类(container class),容器类被设计用来存储其他对象或数据类型。例如,第10章的Stack类设计用于存储unsigned long值。可以定义专门用于存储double值或string对象的Stack类,除了保存的对象类型不同外,这两种Stack类的代码是相同的。不过,与其编写新的类声明,不如编写一个通用(即独立于类型)的堆栈,然后将具体的类型作为参数传递给这个类。这样就可以使用通用的代码生成存储不同类型值的堆栈。第10章的Stack范例使用typedef处理这种需求。不过,这种方法有两个缺点:首先,每次修改类型时都需要编辑头文件;其次,在每个程序中只能使用这种技术生成一种堆栈,即不能让typedef同时代表两种不同的类型,因此不能使用这种方法在同一个程序中同时定义int堆栈和string堆栈。
C++的类模板为生成通用的类声明提供了一种更好的方法(C++最初不支持模板,但模板被引入后,就一直在演化,因此有的编译器可能不支持这里介绍的所有特性)。模板提供参数化(parameterized)类型,即能够将类型名作为参数传递给接收方来建立类或函数。例如,将类型名int传递给Queue模板,可以让编译器构造一个对int进行排队的Queue类。
C++库提供了多个模板类,本章前面使用了模板类valarray。将在第16章讨论的C++标准模板库(STL)提供了几个功能强大的、灵活的容器类模板实现。本章将介绍如何设计一些基本的特性。
14.4.1 定义类模板
我们以第10章的Stack类为基础来建立模板。原来的类声明如下:
采用模板时,将使用模板定义替换Stack声明,使用模板成员函数替换Stack的成员函数。和模板函数一样,模板类以下面这样的代码开头:
template <class Type>
关键字template告诉编译器,将要定义一个模板。尖括号中的内容相当于函数的参数列表。可以把关键字class看作是变量的类型名,该变量接受类型作为其值,把Type看作是该变量的名称。
这里使用class,并不意味着Type必须是一个类;而只是表明Type是一个通用的类型说明符,在使用模板时,将使用实际的类型替换它。较新的C++实现允许在这种情况下使用不太容易混淆的关键字typename代替class:
template <typename Type> // newer choice
可以使用自己的通用类型名代替Type,其命名规则与其他标识符相同。当前流行的选项包括T和Type,我们将使用后者。当模板被调用时,Type将被具体的类型值(如int或string)取代。在模板定义中,可以使用通用类型名来标识要存储在堆栈中的类型。对于Stack来说,这意味着应将声明中标识Item的所有typedef替换为Type。例如,
Item items[MAX]; // holds stack items
应改为:
Type items[MAX]; // holds stack items
同样,可以使用模板成员函数替换原有类的类方法。每个函数头都将以相同的模板声明打头:
template <class Type>
同样应使用通用类型名Type替换typedef标识符Item。另外,还需将类限定符从Stack::改为Stack<Type>::。例如:
应该为:
如果在类声明中定义了方法(内联定义),则可以省略模板前缀和类限定符。
程序清单14.13列出了类模板和成员函数模板。知道这些模板不是类和成员函数定义至关重要。它们是C++编译器指令,说明了如何生成类和成员函数定义。模板的具体实现——例如用来处理string对象的堆栈类——被称为实例化(instantiation)或具体化(specialization)。除非编译器实现了新的export关键字,否则将模板成员函数放置在一个独立的实现文件中将无法运行。因为模板不是函数,它们不能单独编译。模板必须与特定的模板实例化请求一起使用。为此,最简单的方法是将所有模板信息放在一个头文件中,并在要使用这些模板的文件中包含该头文件。
程序清单14.13 stackpt.h
如果编译器实现了新的export关键字,则可以将模板方法定义放在一个独立的文件中,条件是每个模板声明都以export开始:
然后按常规类的方式进行:
1.将模板类声明(包含关键字export)放在一个头文件中,并使用#include编译指令使程序能够使用这些声明。
2.将模板类的方法定义放在一个源代码文件中,在该文件中包含头文件,并使用工程文件(或其他等效文件)使程序能够使用这些定义。
14.4.2 使用模板类
仅在程序包含模板并不能生成模板类时,必须请求实例化。为此,需要声明一个类型为模板类的对象,方法是使用所需的具体类型替换通用类型名。例如,下面的代码创建两个堆栈,一个用于存储int,另一个用于存储string对象:
看到上述声明后,编译器将按Stack<Type>模板来生成两个独立的类声明和两组独立的类方法。类声明Stack<int>将使用int替换模板中所有的Type,而类声明Stack<string>将用string替换Type。当然,使用的算法必须与类型一致。例如,Stack类假设可以将一个项目赋给另一个项目。这种假设对于基本类型、结构和类来说是成立的(除非将赋值操作符设置为私有的),但对于数组则不成立。
通用类型标识符——例如这里的Type——称为类型参数(type parameter),这意味着它们类似于变量,但赋给它们的不能是数字,而只能是类型。因此,在kernel声明中,类型参数Type的值为int。
注意,必须显式地提供所需的类型,这与常规的函数模板是不同的,因为编译器可以根据函数的参数类型来确定要生成哪种函数:
程序清单14.14修改了原来的堆栈测试程序(程序清单10.12),使用字符串而不是unsigned long值作为购货订单ID。
程序清单14.14 stacktem.cpp
注意:如果所用的C++实现不提供cctype,请使用旧的ctype.h头文件。
程序清单14.14中程序的运行情况如下:
14.4.3 深入探讨模板类
可以将内置类型或类对象用作类模板Stack<Type>的类型。指针可以吗?例如,可以使用char指针替换程序清单14.14中的string对象吗?毕竟,这种指针是处理C++字符串的内置方式。答案是可以创建指针堆栈,但是如果不对程序做重大修改,将无法很好地工作。编译器可以创建类,不过使用效果如何就因人而异了。下面解释程序清单14.14不太适合使用指针堆栈的原因,然后介绍一个指针堆栈很有用的例子。
1.不正确地使用指针堆栈
我们将简要地介绍3个试图对程序清单14.14进行修改,使之使用指针堆栈的简单(但有缺陷的)范例。这几个范例揭示了设计模板时应牢记的一些教训,切忌盲目使用模板。这三个范例都以完全正确的Stack<Type>模板为基础:
Stack<char *> st; // create a stack for pointers-to-char
版本1将:
string po;
替换为:
char * po;
旨在用char指针而不是string对象来接收键盘输入。这种方法很快就失败了,因为仅仅创建指针,没有创建用于保存输入字符串的空间(程序将通过编译,但在cin试图将输入保存在某些不合适的内存单元中时崩溃)。
版本2将:
string po;
替换为:
char po[40];
这为输入的字符串分配了空间。另外,po的类型为char*,因此可以被放在堆栈中。但数组完全与pop()方法的假设相冲突:
首先,引用变量item必须引用某种类型的左值,而不是数组名。其次,代码假设可以给item赋值。即使item能够引用数组,也不能为数组名赋值。因此这种方法失败了。
版本3将:
string po;
替换为:
char * po = new char[40];
这为输入的字符串分配了空间。另外,po是变量,因此与pop()的代码兼容。不过,这里将会遇到最基本的问题:只有一个pop变量,该变量总是指向相同的内存单元。确实,在每当读取新字符串时,内存的内容都将发生改变,但每次执行压入操作时,加入到堆栈中的地址都相同。因此,对堆栈执行弹出操作时,得到的地址总是相同的,它总是指向读入的最后一个字符串。具体地说,堆栈并没有保存每一个新字符串,因此没有任何用途。
2.正确使用指针堆栈
使用指针堆栈的方法之一是,让调用程序提供一个指针数组,其中每个指针都指向不同的字符串。把这些指针放在堆栈中是有意义的,因为每个指针都将指向不同的字符串。注意,创建不同指针是调用程序的职责,而不是堆栈的职责。堆栈的任务是管理指针,而不是创建指针。
例如,假设我们要模拟下面的情况。某人将一车文件夹交付给了Plodson。如果Plodson的收取篮(in-basket)是空的,他将取出车中最上面的文件夹,将其放入收取篮;如果收取篮是满的,Plodson将取出篮中最上面的文件,对它进行处理,然后放入发出篮(out-basket)中。如果收取篮既不是空的也不是满的,Plodson将处理收取篮中最上面的文件,也可能取出车中的下一个文件,把它放入收取篮。他采取了自认为是比较鲁莽的行动——扔硬币来决定要采取的措施。下面来讨论他的方法对原始文件处理顺序的影响。
我们可以用一个指针数组来模拟这种情况,其中的指针指向表示车中文件的字符串。每个字符串都包含文件所描述的人的姓名。可以用堆栈表示收取篮,并使用第二个指针数组来表示发出篮。通过将指针从输入数组压入到堆栈中来表示将文件添加到收取篮中,同时通过从堆栈中弹出项目,并将它添加到发出篮中来表示处理文件。
应考虑该问题的各个方面,因此堆栈的大小必须是可变的。程序清单14.15重新定义了Stack<Type>类,使Stack构造函数能够接受一个可选大小的参数。这涉及在内部使用动态数组,因此,Stack类需要包含一个析构函数、一个复制构造函数和一个赋值操作符。另外,通过将多个方法作为内联函数,精减了代码。
程序清单14.15 stcktp1.h
注意:有些C++实现可能不支持explicit。
原型将赋值操作符函数的返回类型声明为Stack引用,而实际的模板函数定义将类型定义为Stack<Type>。前者是后者的缩写,但只能在类中使用。即可以在模板声明或模板函数定义内使用Stack,但在类的外面,即指定返回类型或使用作用域解析操作符时,必须使用完整的Stack<Type>。
程序清单14.16中的程序使用新的堆栈模板来实现Plodson模拟,它像以前介绍的模拟那样使用rand()、srand()和time()来生成随机数,这里是随机生成0和1,来模拟抛掷硬币的结果。
程序清单14.16 stkoptr1.cpp
注意:对于有些C++实现,需剩吏用stdlib.h替换cstdlib,使用time.h替换ctime。
下面是程序清单14.16中程序的两次运行情况。注意,由于使用了随机特性,每次运行时,文件最后的顺序都可能不同,即使堆栈大小保持不变。
程序说明
在程序清单14.16中,字符串本身永远不会移动。把字符串压入堆栈实际上是新建一个指向该字符串的指针,即创建一个指针,该指针的值是现有字符串的地址。从堆栈弹出字符串将把地址值复制到out数组中。
该程序使用的类型是const char*,因为指针数组将被初始化为一组字符串常量。
堆栈的析构函数对字符串有何影响呢?没有。构造函数使用new创建一个用于保存指针的数组,析构函数将删除该数组,而不是数组元素指向的字符串。
14.4.4 数组模板范例和非类型参数
模板常被用作容器类,这是因为类型参数的概念非常适合于将相同的存储方案用于不同的类型。确实,为容器类提供可重用代码是引入模板的主要动机,所以我们来看看另一个例子,深入探讨模板设计和使用的其他几个方面。具体地说,将探讨一些非类型(或表达式)参数以及如何使用数组来处理继承族。
首先介绍一个允许指定数组大小的简单数组模板。一种方法是在类中使用动态数组和构造函数参数来提供元素数目,最后一个版本的Stack模板采用这种方法。另一种方法是使用模板参数来提供常规数组的大小,程序清单14.17演示了如何做。
程序清单14.17 arraytp.h
请注意程序清单14.17中的模板头:
template <class T, int n>
关键字class(或在此环境中等价的关键字typename)指出T为类型参数,int指出n的类型为int。这种参数——指定特殊的类型而不是用作通用类型名,称为非类型(non-type)或表达式(expression)参数。下面的声明:
ArrayTP<double, 12> eggweights;
将导致编译器定义名为ArrayTP<double, 12>的类,并创建一个类型为ArrayTP<double, 12>的eggweight对象。定义类时,编译器将使用double替换T,使用12替换n
表达式参数有一些限制。表达式参数可以是整型、枚举、引用或指针。因此,double m是不合法的,但double rm和double pm是合法的。另外,模板代码不能修改参数的值,也不能使用参数的地址。所以,在ArrayTP模板中不能使用诸如n++和&n等表达式。另外,在实例化模板时,用作表达式参数的值必须是常量表达式。
与Stack中使用的构造函数方法相比,这种改变数组大小的方法有一个优点。构造函数方法使用的是通过new和delete管理的堆内存,而表达式参数方法使用的是为自动变量维护的内存栈。这样,执行速度将更快,尤其是在使用了许多小型数组时。
表达式参数方法的主要缺点是,每种数组大小都将生成自己的模板。也就是说,下面的声明:
将生成两个独立的类声明。但下面的声明:
只能生成一个类声明,并将数组大小信息传递给类的构造函数。
另一个区别是,构造函数方法更通用,这是因为数组大小是作为类成员(而不是硬编码)存储在定义中的。这样可以将一种尺寸的数组赋给另一种尺寸的数组,也可以创建允许数组大小可变的类。
14.4.5 模板多功能性
可以将用于常规类的技术用于模板类。模板类可用作基类,也可用作组件类,还可用作其他模板的类型参数。例如,可以使用数组模板实现堆栈模板,也可以使用数组模板来构造数组——数组元素是基于堆栈模板的堆栈。即可以编写下面的代码:
在最后一条语句中,必须使用至少一个空白字符将两个>符号分开,以避免与>>操作符混淆。
1.递归使用模板
另一个模板多功能性的例子是,可以递归使用模板。例如,对于前面的数组模板定义,可以这样使用它:
ArrayTP< ArrayTP<int, 5>, 10> twodee;
这使得twodee是一个包含10个元素的数组,其中每个元素都是一个包含5个int元素的数组。与之等价的常规数组声明如下:
int twodee[10][5];
请注意,在模板句法中,维的顺序与等价的二维数组相反。程序清单14.18使用了这种方法,同时使用ArrayTP模板创建了一维数组,来分别保存这10个组(每组包含5个数)的总数和平均值。方法调用cout.width(2),以两个字符的宽度来显示下一个条目(如果整个数字的宽度不超过两个字符)。
程序清单14.18 twod.cpp
下面是程序清单14.18中程序的输出。在twodee的10个元素(每个元素又是一个包含5个元素的数组)中,每个元素对应于1行,其中列出了其包含的值、这些值的总和以及平均值。
2.使用多个类型参数
模板可以包含多个类型参数。例如,假设希望类可以保存两种值,则可以创建并使用Pair模板来保存两个不同的值(标准模板库提供了类似的模板,名为pair)。程序清单14.19中的小程序是一个这样的范例。其中,方法first() const和second() const报告存储的值,由于这两个方法返回Pair数据成员的引用,因此让您能够通过赋值重新设置被存储的值。
程序清单14.19 pairs.cpp
对于程序清单14.19,需要注意的一点是,在main()中必须使用Pair<string,int>来调用构造函数,并将它作为sizeof的参数。这是因为类名是Pair<string, int>,而不是Pair。另外,Pair<char*, double>是另一个完全不同的类的名称。
下面是程序清单14.19中程序的输出:
3.默认类型模板参数
类模板的另一项新特性是,可以为类型参数提供默认值:
template <class T1, class T2 = int> class Topo {…};
这样,如果省略T2的值,编译器将使用int:
标准模板库(将在第16章讨论)经常使用该特性,将默认类型设置为类。
虽然可以为类模板类型参数提供默认值,但不能为函数模板参数提供默认值。不过,可以为非类型参数提供默认值,这对于类模板和函数模板都是适用的。
14.4.6模板的具体化
类模板与函数模板很相似,因为可以有隐式实例化、显式实例化和显式具体化,它们统称为具体化(specialization)。模板以通用类型的方式描述类,而具体化是使用具体的类型生成类声明。
1.隐式实例化
到目前为止,本章所有的模板范例使用的都是隐式实例化(implicit instantiation),即它们声明一个或多个对象,指出所需的类型,而编译器使用通用模板提供的处方生成具体的类定义:
ArrayTP<int, 100> stuff; // implicit instantiation
编译器在需要对象之前,不会生成类的隐式实例化:
第二条语句导致编译器生成类定义,并根据该定义创建一个对象。
2.显式实例化
当使用关键字template并指出所需类型来声明类时,编译器将生成类声明的显式实例化(explicit instantiation)。声明必须位于模板定义所在的名称空间中。例如,下面的声明:
template class ArrayTP<string, 100>; // generate ArrayTP<string, 100> class
将ArrayTP<string, 100>声明为一个类。在这种情况下,虽然没有创建或提及类对象,编译器也将生成类声明(包括方法定义)。和隐式实例化一样,也将根据通用模板来生成具体化。
3.显式具体化
显式具体化(explicit specialization)是特定类型(用于替换模板中的通用类型)的定义。有时候,可能需要在为特殊类型实例化时,对模板进行修改,使其行为不同。在这种情况下,可以创建显式具体化。例如,假设已经为用于表示排序后数组的类(元素在加入时被排序)定义了一个模板:
另外,假设模板使用>操作符来对值进行比较。对于数字,这管用;如果T表示一种类,则只要定义了T::operator>()方法,这也管用;但如果T是由char*表示的字符串,这将不管用。实际上,模板倒是可以正常工作,但字符串将按地址(按照字母顺序)排序。这要求类定义使用strcmp(),而不是>来对值进行比较。在这种情况下,可以提供一个显式模板具体化,这将采用为具体类型定义的模板,而不是为通用类型定义的模板。当具体化模板和通用模板都与实例化请求匹配时,编译器将使用具体化版本。
具体化类模板定义的格式如下:
template <> class Classname<specialized-type-name> {…};
早期的编译器可能只能识别早期的格式,这种格式不包括template<>:
class Classname<specialized-type-name> {…};
要使用新的表示法提供一个专供char *类型使用的SortedArray模板,可以使用与下面类似的代码:
其中的实现代码将使用strcmp()(而不是>)来比较数组值。现在,当请求char *类型的SortedArray模板时,编译器将使用上述专用的定义,而不是通用的模板定义:
4.部分具体化
C++还允许部分具体化(partial specialization),即部分限制模板的通用性。例如,部分具体化可以给类型参数之一指定具体的类型:
关键字template后面的<>声明的是没有被具体化的类型参数。因此,上述第二个声明将T2具体化为int,但T1保持不变。注意,如果指定所有的类型,则<>内将为空,这将导致显式具体化:
如果有多个模板可供选择,则编译器将使用具体化程度最高的模板:
也可以通过为指针提供特殊版本来部分具体化现有的模板:
class Feeb {…}; // modified code
如果提供的类型不是指针,则编译器将使用通用版本;如果提供的是指针,则编译器将使用指针具体化版本:
如果没有进行部分具体化,则第二个声明将使用通用模板,将T转换为char *类型。如果进行了部分具体化,则第二个声明将使用具体化模板,将T转换为char。
部分具体化特性使得能够设置各种限制。例如,可以这样做:
根据上述声明,编译器将作出如下选择:
14.4.7 成员模板
C++模板支持的另一个新特性是:模板可用作结构、类或模板类的成员。要完全实现STL的设计,必须使用这项特性。程序清单14.20是一个简短的模板类范例,该模板类将另一个模板类和模板函数作为其成员。
程序清单14.20 tempmemb.cpp
在程序清单14.20中,hold模板是在私有部分声明的,因此只能在beta类中访问它。beta类使用hold模板声明了两个数据成员:
n是基于int类型的hold对象,而q成员是基于T类型(beta模板参数)的hold对象。在main()中,下述声明:
beta<double> guy (3.5, 3);
使得T表示的是double,因此q的类型为hold<double>。
blab()方法的U类型由该方法被调用时的参数值显式确定,T类型由对象的实例化类型确定。
在这个例子中,guy的声明将T的类型设置为double,而下述方法调用的第一个参数将U的类型设置为int(参数10对应的类型):
cout << guy.blab (10, 2.5) << endl;
因此,虽然混合类型引起的自动类型转换将导致blab()中的计算以double类型进行,但返回值的类型为U(即int),如下面的程序输出所示:
如果在调用guy.blab()时使用10.0代替10,则U将被设置为double,这使得返回类型为double,因此输出将如下所示:
正如前面指出的,guy对象的声明将第二个参数的类型设置为double。与第一个参数不同的是,第二个参数的类型不是由函数调用设置的。例如,下面的语句:
cout << guy.blab (10, 3) << endl;
仍将blah()实现为blah (int,double),并根据常规函数原型规则将3转换为类型double。
可以在beta模板中声明hold类和blah方法,并在beta模板的外面定义它们。不过,因为实现模板仍然是一个新过程,所以让编译器接受这种格式将更为困难。一些编译器根本不接受模板成员,而另一些编译器接受模板成员(如程序清单14.20所示),但不接受类外面的定义。不过,如果所用的编译器接受类外面的定义,则在beta模板之外定义模板方法的代码如下:
上述定义将T、V和U用作模板参数。因为模板是嵌套的,因此必须使用句法:
而不是句法:
template<typename T, typename V>
定义还必须指出hold和blab是beta<T>类的成员,这是通过使用作用域解析操作符来完成的。
14.4.8 将模板用作参数
已经知道,模板可以包含类型参数(如typename T)和非类型参数(例如int n)。模板还可以包含本身就是模板的参数。这种参数是模板新增的特性,用于实现STL。
在程序清单14.21所示的范例中,模板参数是template <typename T>class Thing,其中template <typename T>class是类型,Thing是参数。这意味着什么呢?假设有下面的声明:
Crab<King> legs;
为使上述声明被接受,模板参数king必须是一个模板类,其声明与模板参数Thing的声明匹配:
Crab的声明声明了两个对象:
前面的legs声明将用King<int>替换Thing<int>,用King<double>替换Thing<double>。不过,程序清单14.21包含下面的声明:
Crab<Stack> nebula;
因此,Thing<int>将被实例化为Stack<int>,而Thing<double>将被实例化为Stack<double>。简而言之,模板参数Thing将被替换为声明Crab对象时被用作模板参数的模板类型。
Crab类的声明对Thing代表的模板类做了另外三个假设,即这个类包含一个push()方法和一个pop()方法,且这些方法有特定的接口。Crab类可以使用任何与Thing类型声明匹配,并包含方法push()和pop()的模板类。本章恰巧有一个这样的类——stacktp.h中定义的Stack模板,因此这个例子将使用它。
程序清单14.21 tempparm.cpp
下面是程序清单14.21中程序的运行情况:
可以混合使用模板参数和常规参数,例如,Crab类的声明可以像下面这样打头:
现在,成员s1和s2可存储的数据类型为通用类型,而不是用硬编码指定的类型。这要求将程序中nebula的声明修改成下面这样:
Crab<Stack, int, double> nebula; // T=Stack, U=int, V=double
模板参数T表示一种模板类型,而类型参数U和V表示非模板类型。
14.4.9 模板类和友元
模板类声明也可以有友元。模板的友元分3类:
● 约束(bound)模板友元,即友元的类型取决于类被实例化时的类型。
● 非约束(unbound)模板友元,即友元的所有具体化都是类的每一个具体化的友元。
下面分别介绍它们。
1.模板类的非模板友元函数
我们在模板类中将一个常规函数声明为友元:
上述声明使counts()函数成为模板所有实例化的友元。例如,它将是类hasFriend<int>和HasFriend<string>的友元。
counts()函数不是通过对象调用的(它是友元,不是成员函数),也没有对象参数,那么它如何访问HasFriend对象呢?有很多种可能性。它可以访问全局对象;可以使用全局指针访问非全局对象;可以创建自己的对象;可以访问独立于对象的模板类的静态数据成员。
假设要为友元函数提供模板类参数,可以如下所示来进行友元声明吗?
friend void report (HasFriend &); // possible?
答案是不可以。原因是不存在HasFriend这样的对象,而只有特定的具体化,如HasFriend<short>。要提供模板类参数,必须指明具体化。例如,可以这样做:
为理解上述代码的功能,设想一下声明一个特定类型的对象时,将生成的具体化:
HasFriend<int> hf;
编译器将用int替代模板参数T,因此友元声明的格式如下:
也就是说,带HasFriend<int>参数的report()将成为HasFriend<int>类的友元。同样,带HasFriend<double>参数的report()将是report()的一个重载版本——它是Hasfriend<double>类的友元。
注意,report()本身并不是模板函数,而只是使用一个模板作参数。这意味着必须为要使用的友元定义显式具体化:
程序清单14.22说明了上面的几点。HasFriend模板有一个静态成员ct。这意味着这个类的每一个特定的具体化都将有自己的静态成员。count()方法是所有HasFriend具体化的友元,它报告两个特定的具体化(HasFriend<int>和HasFriend<double>)的ct的值。该程序还提供两个report()函数,它们分别是某个特定HasFriend具体化的友元。
程序清单14.22 frnd2tmp.cpp
下面是程序清单14.22中程序的输出:
注意:很多C++编译器不支持模板类的友元函数。
2.模板类的约束模板友元函数
可以修改前一个范例,使友元函数本身成为模板。具体地说,为约束模板友元作准备,要使类的每一个具体化都获得与友元匹配的具体化。这比非模板友元复杂一些,包含以下3步。
首先,在类定义的前面声明每个模板函数。
然后在函数中再次将模板声明为友元。这些语句根据类模板参数的类型声明具体化:
声明中的<>指出这是模板具体化。对于report(),<>可以为空,这是因为可以从函数参数推断出模板类型参数(HasFriendT<TT>)。不过,也可以使用report<HasFriend<TT>>(HasFriendT<TT> &)。但counts()函数没有参数,因此必须使用模板参数句法(<TT>)来指明其具体化。还需要注意的是,TT是HasFriendT类的参数类型。
同样,理解这些声明的最佳方式也是设想声明一个特定具体化的对象时,它们将变成什么样。例如,假设声明了这样一个对象:
HasFriendT<int> squack;
则编译器将用int替换TT,并生成下面的类定义:
基于TT的具体化将变为int,基于HasFriend<TT>的具体化将变为HasFriend<int>。因此,模板具体化counts<int>()和report<HasFriendT<int>>()被声明为HasFriendT<int>类的友元。
程序必须满足的第三个要求是,为友元提供模板定义。程序清单14.23说明了这三个方面。请注意,程序清单14.22包含1个count()函数,它是所有HasFriend类的友元;而程序清单14.23包含两个count()函数,它们分别是某个被实例化的类类型的友元。因为count()函数调用没有可被编译器用来推断出所需具体化的函数参数,所以这些调用使用count<int>()和coount<double>()指明具体化。但对于report()调用,编译器可以从参数类型推断出要使用的具体化。使用<>格式也能获得同样的效果:
report<HasFriendT<int> > (hfi2); // same as report (hfi2);
程序清单14.23 tmp2tmp.cpp
下面是程序清单14.23中程序的输出:
正如读者看到的,counts<double>和counts<int>报告的模板大小不同,这表明每种T类型都有自己的友元函数count()。
3.模板类的非约束模板友元函数
前一节中的约束模板友元函数是在类外面声明的模板的具体化。int类具体化获得int函数具体化,依此类推。通过在类内部声明模板,可以创建非约束友元函数,即每个函数具体化都是每个类具体化的友元。对于非约束友元,友元模板类型参数与模板类类型参数是不同的:
程序清单14.24是一个使用非约束友元的例子。其中,函数调用show2 (hfi1, hfi2)与下面的具体化匹配:
因为它是所有ManyFriend具体化的友元,所以能够访问所有具体化的item成员,但它只访问了ManyFriend<int>对象。
同样,show2 (hfd, hfi2)与下面具体化匹配:
它也是所有ManyFriend具体化的友元,并访问了ManyFriend<int>对象的item成员和ManyFriend<double>对象的item成员。
程序清单14.24 manyfrnd.cpp
注意:有几个编译器不支持模板类的友元函数。
14.5 总结
C++提供了几种重用代码的手段。第13章介绍的公有继承能够建立is-a关系,这样派生类可以重用基类的代码。私有继承和保护继承也使得能够重用基类的代码,但建立的是has-a关系。使用私有继承时,基类的公有成员和保护成员将成为派生类的私有成员;使用保护继承时,基类的公有成员和保护成员将成为派生类的保护成员。无论使用哪种继承,基类的公有接口都将成为派生类的内部接口。这有时候被称为继承实现,但并不继承接口,因为派生类对象不能显式地使用基类的接口。因此,不能将派生对象看作是一种基类对象。由于这个原因,在不进行显式类型转换的情况下,基类指针或引用将不能指向派生类对象。
还可以通过开发包含对象成员的类来重用类代码。这种方法被称为包含、层次化或组合,它建立的也是has-a关系。与私有继承和保护继承相比,包含更容易实现和使用,所以通常优先采用这种方式。不过,私有继承和保护继承与包含有一些不同的功能。例如,继承允许派生类访问基类的保护成员;还允许派生类重新定义从基类那里继承的虚函数。因为包含不是继承,所以通过包含来重用类代码时,不能使用这些功能。另一方面,如果需要使用某个类的几个对象,则用包含更适合。例如,State类可以包含一组County对象。
多重继承(MI)使得能够在类设计中重用多个类的代码。私有MI或保护MI建立has-a关系,而公有MI建立is-a关系。MI会带来一些问题,即多次定义同一个名称,继承多个基类对象。可以使用类限定符来解决名称二义性的问题,使用虚基类来避免继承多个基类对象的问题。但使用虚基类后,就需要为编写构造函数初始化列表以及解决二义性问题引入新的规则。
类模板使得能够创建通用的类设计,其中类型(通常是成员类型)由类型参数表示。典型的模板如下:
其中,T是类型参数,用作以后将指定的实际类型的占位符(这个参数可以是任意有效的C++名称,但通常使用T和Type)。在这种环境下,也可以使用typename代替class:
类定义(实例化)在声明类对象并指定特定类型时生成。例如,下面的声明:
class Ic<short> sic; // implicit instantiation
导致编译器生成类声明,用声明中的实际类型short替换模板中的所有类型参数T。这里,类名为Ic<short>,而不是Ic。Ic<short>称为模板具体化。具体地说,这是一个隐式实例化。
使用关键字template声明类的特定具体化时,将发生显式实例化:
template class IC<int>; // explicit instantiation
在这种情况下,编译器将使用通用模板生成一个int具体化——Ic<int>,虽然尚未请求这个类的对象。
可以提供显式具体化——覆盖模板定义的具体类声明。方法是以template<>打头,然后是模板类名称,再加上尖括号(其中包含要具体化的类型)。例如,为字符指针提供专用Ic类的代码如下:
这样,下面这样的声明:
class Ic<char *> chic;
对于chic,将使用专用定义,而不是通用模板。
类模板可以指定多个通用类型,也可以有非类型参数:
下面的声明:
Pals<double, string, 6> mix;
将生成一个隐式实例化,用double代替T,用string代替TT,用6代替n。类模板还可以包含本身就是模板的参数:
其中z是一个int值,U为类型名,CL表示为一个使用template<typename, T>声明的类模板。
类模板可以被部分具体化:
第一个声明为两个类型相同,且n的值为6的情况创建了一个具体化。同样,第二个声明为n等于100的情况创建一个具体化;第三个声明为第二个类型是指向第一个类型的指针的情况创建了一个具体化。
模板类可用作其他类、结构和模板的成员。
所有这些机制的目的都是为了让程序员能够重用经过测试的代码,而不用手工复制它们。这样可以简化编程工作,提供程序的可靠性。
14.6 复习题
1.以左边的类为基类时,右边的类采用公有派生还是私有派生更合适。
2.假设有下面的定义:
假设Gloam版本的tell()应显示glip和fb的值,请为这3个Gloam方法提供定义。
3.假设有下面的定义:
假设Gloam版本的tell()应显示glip和fab的值,请为这3个Gloam方法提供定义。
4.假设有下面的定义,它是基于程序清单14.13中的Stack模板和程序清单14.10中的Woker类的:
Stack<Worker *> sw;
请写出将生成的类声明。只实现类声明,不实现非内联类方法。
5.使用本章中的模板定义对下面的内容进行定义:
● string对象数组。
● double数组堆栈。
● 指向Worker对象的指针的堆栈数组。
程序清单14.18生成了多少个模板类定义?
6.指出虚基类与非虚基类之间的区别。
14.7 编程练习
1.Wine类有一个string类对象成员(参见第4章)和一个Pair对象(参见本章);其中前者用于存储葡萄酒的名称,而后者有2个valarray<int>对象(参见本章),这两个valarray<int>对象分别保存了葡萄酒的酿造年份和该年生产的瓶数。例如,Pair对象的第1个valarray<int>对象可能为1988、1992和1996年,第2个valarray<int>对象可能为24、48和144瓶。Wine最好有1个int成员用于存储年数。另外,一些typedef可能有助于简化编程工作:
这样,PairArray表示的是类型Pair<std::valarray<int>, std::valarray<int> >。使用包含来实现Wine类,并用一个简单的程序对其进行测试。Wine类应该有一个默认构造函数以及如下构造函数:
Wine类应该有一个GetBottles()方法,它根据Wine对象能够存储几种年份(y),提示用户输入年份和瓶数。方法Label()返回一个指向葡萄酒名称的引用。sum()方法返回Pair对象中第二个valarray<int>对象中的瓶数总和。
测试程序应提示用户输入葡萄酒名称、元素个数以及每个元素存储的年份和瓶数等信息。程序将使用这些数据来构造一个Wine对象,然后显示对象中保存的信息。
下面是一个简单的测试程序:
下面是该程序的运行情况:
2.采用私有继承而不是包含来完成编程练习1。同样,一些typedef可能会有所帮助,另外,您可能还需要考虑诸如下面这样的语句的含义:
您设计的类应该可以使用编程练习1中的测试程序进行测试。
3.定义一个QueueTp模板。然后在一个类似于程序清单14.12的程序中创建一个指向Worker的指针队列(参见程序清单14.10中的定义),并使用该队列来测试它。
4.Person类保存人的名和姓。除了构造函数外,它还有Show()方法,用于显示名和姓。Gunslinger类以Person类为虚基类派生而来,它包含一个Draw()成员,该方法返回一个double的值,表示枪手的拔枪时间。这个类还包含一个int成员,表示枪手枪上的刻痕数。最后,这个类还包含一个Show()函数,用于显示所有这些信息。
PokerPlayer类以Person类为虚基类派生而来。它包含一个Draw()成员,该函数返回一个1~52之间的随机数,用于表示扑克牌的值(也可以定义一个Card类,其中包含花色和面值成员,然后让Draw()返回一个Card对象)。PokerPlayer类使用Person类的show()函数。BadDude类从Gunslinger和PokerPlayer类公有派生而来。它包含Gdraw()成员(返回坏蛋拔枪的时间)和Cdraw()成员(返回下一张扑克牌),另外还有一个合适的Show()函数。请定义这些类和方法以及其他必要的方法(如用于设置对象值的方法),并使用一个类似于程序清单14.12的简单程序对它们进行测试。
5.下面是一些类声明:
注意,该类层次结构使用了带虚基类的MI,所以要牢记这种情况下用于构造函数初始化列表的特殊规则。还需要注意的是,有些方法被声明为保护的。这可以简化一些highfink方法的代码(例如,如果highfmk::ShowAll()只是调用fink::ShowAll()和manager::ShwAll(),则它将调用abstr_emp::ShowAll()两次)。请提供类方法的实现,并在一个程序中对这些类进行测试。下面是一个小型测试程序:
为什么没有定义赋值操作符?
为什么要将ShowAll()和SetAll()定义为虚拟的?
为什么要将abstr_emp定义为虚基类?
为什么highfink类没有数据部分?
为什么只需要一个operator<<()版本?
如果使用下面的代码替换程序的结尾部分,将会发生什么情况?