C++ 的一个主要目标是促进代码重用。公有继承是实现这种目标的机制之一,但并不是唯一的机制。本章将介绍其他方法,其中之一是使用这样的类成员:本身是另一个类的对象。这种方法称为包含(containment)、组合(composition)或层次化(layering)。另一种方法是使用私有或保护继承。通常,包含、私有继承和保护继承用于实现 has-a 关系,即新的类将包含另一个类的对象。例如,HomeTheater 类可能包含一个 BluRayPlayer 对象。多重继承使得能够使用两个或更多的基类派生出新的类,将基类的功能组合在一起。
第10章介绍了函数模板,本章将介绍类模板——另一种重用代码的方法。类模板使我们能够使用通用术语定义类,然后使用模板来创建针对特定类型定义的特殊类。例如,可以定义一个通用的栈模板,然后使用该模板创建一个用于表示 int 值栈的类和一个用于表示 double 值栈的类,甚至可以创建一个这样的类,即用于表示由栈组成的栈。
包含对象成员的类
首先介绍包含对象成员的类。有一些类(如string类和第16章将介绍的标准C++类模板)为表示类中的组件提供了方便的途径。下面来看一个具体的例子。
学生是什么?入学者?参加研究的人?残酷现实社会的避难者?有姓名和一系列考试分数的人?显然,最后一个定义完全没有表示出人的特征,但非常适合于简单的计算机表示。因此,让我们根据该定义来开发 Student 类。
将学生简化成姓名和一组考试分数后,可以使用一个包含两个成员的类来表示它:一个成员用于表示姓名,另一个成员用于表示分数。对于姓名,可以使用字符数组来表示,但这将限制姓名的长度。让然,也可以使用char指针和动态内存分配,但正如第12章指出的,这将要求提供大量的支持代码。一种更好的方法是,使用一个由他人开发好的类的对象来表示。例如,可以使用一个 string 类(参见第12章)或标准 C++ string 类的对象来表示姓名。较简单的选择是使用 string 类,因为 C++ 库提供了这个类的所有实现代码,且其实现更完美。要使用 string 类,您必须在项目中包含实现文件 string1.cpp。
对于考试分数,存在类似的选择。可以使用一个定长数组,这限制了数组的长度;可以使用动态内存分配并提供大量的支持代码;也可以涉及一个使用动态内存分配的类来表示该数组;还可以在标准 C++ 库中查找一个能够表示这种数据的类。
自己开发这样的类一点问题也没有。开发简单的版本并不那么难,因为double数组与char数组有很多相似指出,因此可以根据String类来设计表示double数组的类。事实上,本书以前的版本就这样做过。
当然,如果C++库提供了合适的类,实现起来将更简单。C++库确实提供了一个这样的类,它就是 valarray。
valarray 类简介
valarray 类是由头文件 valarray 支持的。顾名思义,这个类用于处理数值(或具有类似特征的类),它支持诸如将数组中所有元素的值相加以及在数组中找出最大和最小的值等操作。valarray 被定义为一个模板类,以便能够处理不同的数据类型。本章后面将介绍如何定义模板类,但就现在而言,您只需要知道如何使用模板类即可。
模板特性意味着声明对象时,必须指定具体的数据类型。因此,使用valarray类来声明一个对象时,需要在标识符 valarray 后面加上一对尖括号,并在其中包含所需的数据类型:
valarray<int>q_value; // an array of int
valarray<double> weight; // an array of double
第4章介绍vector和array类时,您见过这种语法,它非常简单。这些类也可用于存储数字,但它们提供的算术支持没有 valarray 多。
这是您需要学习的唯一新语法,它非常简单。
类特性意味这要使用 valarray 对象,需要了解这个类的构造函数和其他类方法。下面是几个使用其构造函数的例子:
double gpa[5] = { 3.1, 3.5, 3.8, 2.9, 3.3 };
valarray<double> v1; // an array of double, size 0
valarray<int> v2(8); // an array of 8 int elements
valarray<int> v3(10,8); // an array of 8 int elements, each set to 10
valarray<double> v4(gpa, 4); // an array of 4 elements, initialized to the first 4 elements of gpa
从中可知,可以创建长度为零的空数组、指定长度的空数组、所有元素被初始化为指定值的数组、用常规数组中的值进行初始化的数组。在C++11 中,也可使用初始化列表:
valarray<int> v5 = {20, 32, 17, 9}; // C++11
下面是这个类的一些方法。
- operator:让您能够访问各个元素。
- size():返回包含的元素数。
- sum():返回所有元素的总和。
- max():返回最大的元素。
- min():返回最小的元素。
还有很多其他的方法,其中的一些将在第16章介绍;但就这个例子而言,上述方法足够了。
Student 类的设计
至此,已经确定了 Student 类的设计计划:使用一个 string 对象来表示姓名,使用一个 valarray<double> 来表示考试分数。那么如何设计呢?您可能想以公有的方式从这两个类派生出 Student 类,这将是多重公有继承,C++允许这样做,但在这里并不适合,因为学生与这些类之间的关系不是is-a模型。学生不是姓名,也不是一组考试成绩。这里的关系是 has-a,学生有姓名,也有一组考试分数。通常,用于建立 has-a 关系的 C++ 技术是组合(包含),即创建一个包含其他类对象的类。例如,可以将 Student 类声明为如下所示:
class Student{
private:
string name; // use a string object for name
valarray<double> scores; // use a valarray<double> object for scores
...
};
同样,上述类将数据成员声明为私有的。这意味着 Student 类的成员函数可以使用 string 和 valarray<double> 类的公有接口来访问和修改 name 和 scores 对象,但在类的外面不能这样做,而只能通过Student 类的公有接口访问 name 和 score。对于这种情况,通常被描述为 Student 类获得了其成员对象的实现,但没有继承接口。例如,Student 对象使用 string 的实现,而不是 char * name 或 char name[26] 实现来保存姓名。但 Student 对象并不是天生就有使用函数 string operator+=() 的能力。
使用公有继承时,类可以继承接口,可能还有实现(基类的纯虚函数提供接口,但不提供实现)。获得接口是 is-a 关系的组成部分。而使用组合,类可以获得实现,但不能获得接口。不继承接口是 has-a 关系的组成部分。
对于 has-a 关系来说,类对象不能自动获得被包含对象的接口是一件好事。例如,string 类将+运算符重载为将两个字符串连接起来;但从概念上说,将两个 Student 对象串接起来是没有意义的。这也是这里不使用公有继承的原因之一。另一方面,被包含的类的接口部分对新类来说可能是有意义的。例如,可能希望使用 string 接口中的 operator<<() 方法将 Student 对象按姓名进行排序,为此可以定义 Student::operator<() 成员函数,它在内部使用函数 string::operator<()。下面介绍一些细节。
Student 类示例
现在需要提供 Student 类的定义,当然它应包含构造函数以及一些用作 Student 类接口的方法。下面的程序是 Student 类的定义,其中所有构造函数都被定义为内联的;它还提供了一些用于输入和输出的友元函数。
// student.h -- defining a Student class using containment
#ifndef STUDENT_H_
#define STUDENT_H_
#include<iostream>
#include<string>
#include<valarray>
class Student{
private:
typedef std::valarray<double> ArrayDb;
std::string name; // contained object
ArrayDb scores; // contained object
// private method for scores output
std::ostream & arr_out(std::ostream & os) const;
public:
Student() : name("Null Student"), scores() {}
explicit Student(const std::string & s)
: name(s), scores() {}
explicit Student(int n) : name("Nully"), scores(n) {}
Student(const std::string & s, int n)
: name(s), scores(n) {}
Student(const std::string & s, const ArrayDb & a)
: name(s), scores(a) {}
Student(const char * str, const double * pd, int n)
: name(str), scores(pd, n) {}
~Student() {}
double Average() const;
const std::string & Name() const;
double & operator[](int i);
double operator[](int i) const;
// friends
// input
friend std::istream & operator>>(std::istream & is, Student & stu); // 1 word
friend std::istream & getline(std::istream & is, Student & stu); // 1 line
// output
friend std::ostream & operator<<(std::ostream & os, const Student & stu);
};
#endif
为简化表示,Student 类的定义中包含下述typedef:
typedef std::valarray<double> ArrayDb;
这样,在以后的代码中便可以使用表示 ArrayDb,而不是 std::valarray<double>,因此类方法和友元函数可以使用 ArrayDb 类型。将该typedef放在类定义的私有部分意味着可以在 Student 类的实现中使用它,但在 Student 类外面不能使用。
请注意关键字 explicit 的用法:
explicit Student(const std::string & s)
: name(s), scores() { }
explicit Student(int n) : name("Nully"), scores(n) { }
本书前面说过,可以用一个参数调用的构造函数将用作从参数类型到类类型的隐式转换函数;但这通常不是好主意。在上述第二个构造函数中,第一个参数表示数组的元素个数,而不是数组中的值,因此将一个构造函数用作int到Student的转换函数是没有意义的,所以使用 explicit 关闭隐式转换。如果省略该关键字,则可以编写如下所示的代码:
Student doh("Homer", 10); // store "Homer", create array of 10 elements
doh = 5; // create name to "Nully", reset to empty array of 5 elements
在这里,马虎的程序员键入了 doh 而不是 doh[0]。如果构造函数省略了 explicit,则将使用构造函数调用 Student(5) 将 5 转换为一个临时 Student 对象,并使用 “Nully" 来设置成员name的值。因此赋值操作将使用临时对象替换原来的 doh 值。使用了 explicit 后,编译器将认为上述赋值运算符是错误的。
C++ 和约束
C++包含让程序员能够限制程序结构的特性——使用 explicit 防止单参数构造函数的隐式转换,使用 const 限制方法修改数据,等等。这样做的根本原因是:在编译阶段出现错误优于在运行阶段出现错误。
- 初始化被包含的对象
构造函数全都使用您熟悉的成员初始化列表语法来初始化 name 和 score 成员对象。在前面的一些例子中,构造函数用这种语法来初始化内置类型的成员:
上述带代码在成员初始化列表中使用的是数据成员的名称(qsize)。另外,前面介绍的示例中的构造函数还使用成员初始化列表初始化派生对象的基类部分:Queue::Queue(int qs) : qsize(qs) { ... } // initialize qsize to qs
对于继承对象,构造函数在成员初始化列表中使用类名来调用特定的基类构造函数。对于成员对象,构造函数则使用成员名。例如,下面的构造函数:hasDMA::hasDMA(const hasDMA & hs) : baseDMA(hs) { ... }
因为该构造函数初始化的是成员对象,而不是继承的对象,所以在初始化列表中使用的是成员名,而不是类名。初始化列表中的每一项都调用与之匹配的构造函数,即 name(str) 调用构造函数 string(const char * ),scores(pd, n) 调用构造函数 ArrayDb(const double *, int)。Student(const char * str, const double * pd, int n) : name(str), scores(pd, n) { }
如果不使用初始化列表语法,情况将如何呢? C++ 要求在构建对象的其他部分之前,先构建继承对象的所有成员对象。如果省略初始化列表,C++ 将使用成员对象所属的默认构造函数。
初始化顺序
当初始化列表包含多个项目时,这些项目被初始化的顺序为它们被声明的顺序,而不是它们在初始化列表中的顺序。例如,假设 Student 构造函数如下:
Student(const char * str, const double * pd, int n)
: scores(pd, n), name(str) { }
则 name 成员仍将首先被初始化,因为在类定义中它首先被声明。对于这个例子来说,初始化顺序并不重要,但如果代码使用一个成员的值作为另一个成员的初始化表达式的一部分时,初始化顺序就非常重要了。
-
使用被包含对象的接口
被包含对象的接口不是公有的,但可以在类方法中使用它。例如,下面的代码说明了如何定义一个返回学生平均分数的函数:double Student::Average() const { if ( scores.size() > 0 ) return scores.sum() / scores.size(); else return 0; }
上述代码定义了可由 Student 对象调用的方法,该方法内部使用了 valarray 的方法 size() 和 sum()。这是因为 scores 是一个 valarray 对象,所以它可以调用 valarray 类的成员函数。总之,Student 对象调用 Student 的方法,而后者使用被包含的 valarray 对象来调用 valarray 类的方法。
同样,可以定义一个使用string版本的 << 运算符的友元函数:
// use string version of operator<<() ostream & operator<<(ostream & os, const Student & stu) { os << "Scores for " << stu.name << ":\n"; ... }
因为 stu.name 是一个 string 对象,所以它将调用函数 operator<<(ostream &, const string & ),该函数位于 string 类中。注意,operator<<(ostream & os, const Student & stu) 必须是 Student 类的友元函数,这样才能访问 name 成员。另一种方法是,在该函数中使用公有方法 Name(),而不是私有数据成员 name。
同样,该函数也可以使用 valarray 的 << 实现来进行输出,不幸的是没有这样的实现;因此,Student 类定义了一个私有辅助方法来处理这种任务:
// private method ostream & Student::arr_out(ostream & os) const{ int i; int lim = scores.size(); if ( lim > 0 ) { for (i = 0; i<lim; i++){ os << scores[i] << " "; if ( i % 5 == 4) os << endl; } if ( i % 5 != 0 ) os << endl; } else os << " empty array "; return os; }
通过使用这样的辅助方法,可以将零乱的细节放在一个地方,使得友元函数的编码更为整洁:
// use string version of operator<<() ostream & operator<<(ostream & os, const Student & stu){ os << "Scores for " << stu.name << ":\n"; stu.arr_out(os); // use private method for scores return os; }
辅助函数也可用作其他用户级输出函数的构建块——如果您选择提供这样的函数的话。
下面的程序是Student 类的类方法文件,其中包含了让您能够使用 [] 运算符来访问 Student 对象中各项成绩的方法。#include"14.1_student.h" #include <iterator> using std::ostream; using std::endl; using std::istream; using std::string; // public methods double Student::Average() const{ if (scores.size() > 0) return scores.sum() / scores.size(); else return 0; } const string & Student::Name() const{ return name; } double & Student::operator[](int i){ return scores[i]; } double Student::operator[](int i) const{ return scores[i]; } // private method ostream & Student::arr_out(ostream & os) const{ int i; int lim = scores.size(); if (lim > 0){ for ( i = 0; i < lim; i++ ){ os << scores[i] << " "; if ( i%5 == 4){ os << endl; } } if (i%5!=0) os << endl; } else{ os << " empty array "; } return os; } // friends // use string version of operator>>() istream & operator>>(istream & is, Student & stu){ is >> stu.name; return is; } // use string friend getline(ostream &, const string &) istream & getline(istream & is, Student & stu){ getline(is, stu.name); return is; } // use string version of operator<<() ostream & operator<<(ostream & os, const Student & stu){ os << "Scores for " << stu.name << ":\n"; stu.arr_out(os); // use private method for scores return os; }
除私有辅助方法外,上面的程序并没有新增多少代码。使用包含让你能够充分利用已有的代码。
-
使用新的 Student 类
下面编写一个小程序来测试这个新的 Student 类。出于简化的目的,该程序将使用一个只包含 3 个 Student 对象的数组,其中每个对象保存 5 个考试成绩。另外还将使用一个不复杂的输入循环,该循环不验证输入,也不让用户中途退出。下面的程序列出了该测试程序,请务必将该程序与student.cpp 一起编译// use_stuc.cpp -- using a composite class // compile with studentc.cpp #include<iostream> #include"14.1_student.h" using std::cin; using std::cout; using std::endl; void set(Student & sa, int n); const int pupils = 3; const int quizzes = 5; int main(){ Student ada[pupils] = { Student(quizzes), Student(quizzes), Student(quizzes) }; int i; for ( i = 0; i < pupils; ++i){ set(ada[i], quizzes); } cout << "\nStudent List:\n"; for (i = 0; i < pupils; ++i){ cout << endl << ada[i]; cout << "average: " << ada[i].Average() << endl; } cout << "Done.\n"; return 0; } void set(Student & sa, int n){ cout << "Please enter the student's name: "; getline(cin, sa); cout << "Please enter " << n << " quiz scores:\n"; for(int i=0; i < n; i++){ cin >> sa[i]; } while(cin.get()!='\n') continue; }