- 类
- 简介
- 7.1 定义抽象数据类型
- 7.1.1 设计Sales_data类
- 7.1.2 定义改进的Sales_data类
- 7.1.3 定义类相关的非成员函数
- 7.1.4 构造函数
- 7.2 访问控制与封装
- 7.3 类的其他特性
- 7.4 类的作用域
- 7.5 构造函数再探
- 7.6 类的静态成员
- 小结
- 习题解答
- 7.1.1节练习(仍有问题)
- 7.1.2节练习
- 7.1.3节练习
在C++语言中,我们使用类定义自己的数据类型。通过定义新的数据类型来反映待解决问题中的各种概念,可以使我们更容易编写、调试和修改程序。
本章是第二章关于类的话题的延续,主要关注数据抽象的重要性。数据抽象能帮助我们将对象的具体实现与对象所能执行的操作分离开来。我们将在第13章讨论如何控制对象拷贝、移动、赋值和销毁等行为,在第14章中我们将学习如何自定义运算符。
类的基本思想是数据抽象(data abstraction)和封装(encapsulation)。数据抽象是一种依赖于接口(interface)和实现(implementation)分离的编程(以及设计)技术。类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。
类要实现数据抽象和封装,需要首先定义一个抽象数据类型(abstractdata type)。在抽象数据类型中,由类的设计者负责考虑类的实现过程;使用该类的程序员则只需要抽象地思考类型做了什么,而无须了解类型的工作细节。
我们在第一章使用的Sales_item类是一个抽象数据类型,我们可以使用它的接口,但是不能访问它的数据成员,事实上,我们甚至不知道这个类有哪些数据成员。
与之相反,Sales_data 类不是抽象数据类型。我们可以直接访问他的数据类型,并且由用户来编写操作。如果我们想把Sales_data变成抽象数据类型,就需要定义一些操作以供类的用户使用。一旦Sales_data定义了它自己的操作,我们就可以封装(隐藏)它的数据成员了。
我们的最终目的是令Sales_data支持与Sales_item类完全一样的操作集合。Sales_item类有一个名为isbn的成员函数(memberfucnton),并且支持+、+=、=、<<和>>运算符。
自定义运算符将在第14章进行学习。现在,我们先为这些运算定义 普通(命名的)函数形式。由于14.1节要解释的原因,执行加法和IO的函数不作为Sales_data的成员,相反的,我们将其定义成普通函数;执行复合赋值运算 函数是成员函数。Sales_data类无须专门定义赋值运算,其原因将在7.1.5节介绍。
综上所述,Sales_data 的接口应该包含以下操作:
1.一个isbn成员函数,用于返回对象的ISBN编号
2.一个combine成员函数,用于将一个Sales_data对象加到另一个对象上
3.一个名为add的函数,执行两个Sales_data对象的加法
4.一个read函数,将数据从istream读入到Sales_data对象中
5.一个print函数,将Sales_data对象的值输出到ostream
使用改进的Sales_data 类
在考虑如何实现我们的类之前,首先来看看应该如何使用上面这些接口函数。举个例子,我们使用这些函数编写1.6节书店程序的另外一个版本,其中不再使用Sales_item对象,而是使用Sales_data对象:
Sales_data total;//保存当前求和结果
if(read(cin,total)){//读入第一笔交易
Sales_data trans;//用于保存下一条交易数据
while(read(cin,trans)){//读入剩余的交易
if(total.isbn==trans.isbn())//检查isbn
total.combine(trans);//更新total的值
else {
print(cout,total)<
7.1.2 定义改进的Sales_data类
改进之后的类的数据成员将与2.6.1节定义的版本保持一致。它们包括:bookNo,string类型,表示ISBN编号;units_sold,unsigned类型,表示某本书的销量;以及revenue,double类型,表示这本书的总销售收入。
如前所述,我们的类包括两个成员函数:combine和isbn。此外,我们还将赋予Sales_data另一个成员函数用于返回售出书籍的平均价格,这个函数被命名为avg_price。因为avg_price的目的并非通用,所以它应该属于类的实现的一部分,而非接口的一部分。
定义和声明成员函数的方式与普通函数差不多。成员函数的声明必须在类的内部,它的定义既可以在类的内部也可以在类的外部。作为接口组成部分的非成员函数,例如add、read和print等,它们的定义和声明都在类的外部。
由此可知,改进的Sales_data类应该如下所示:
struct Sales_data{
//新成员:关于Sales_data对象的操作
std::string isbn() const {return bookNo;}
Sales_data& combine(const Sales_data&);
double avg_price() const;
//数据成员和2.6.1节相比没有改变
std::string bookNo;
unsigned units_sold = 0;
double revenue =0.0;
};
//Sales_data的非成员接口函数
Sales_data add(const Sales_data&,const Sales_data&);
std::ostream &print(std::ostream&,const Sales_data&);
std::istream &read(std::istream&,Sales_data&);
需要注意:定义在类内部的函数是隐式的inline函数。
定义成员函数
尽管所有成员都必须在类的内部声明,但是成员函数体可以定义在类内也可以定义在类外。对于Sales_data类来说,isbn函数定义在了类内,而combine和avg_price定义在了类外。
我们首先介绍isbn函数,它的参数列表为空,返回值是一个string对象:
std::string isbn() const {return bookNo;}
和其他函数一样,成员函数体也是一个块。在此例中,块只有一条return语句,用于返回Sales_data对象的bookNo数据成员。关于isbn函数有一件有意思的事情是:它是如何获得bookNo成员所依赖的对象的呢?
引入this
让我们再一次观察对isbn成员函数的调用:
total.isbn()
在这里,我们使用点运算符来访问total对象的isbn成员,然后调用它。
7.6节将介绍一种例外的形式,当我们调用成员函数时,实际上是在替某个对象调用它。如果isbn指向Sales_data成员(例如bookNo),则它隐式地指向调用该函数的对象的成员。在上面的调用中,当isbn返回bookNo时,实际上它隐式的返回total.bookNo。
成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化this。例如,如果调用
total.isbn()
则编译器负责把total的地址传给isbn隐式形参this,可以等价地认为编译器将该调用重写成了如下形式:
//伪代码,用于说明调用成员函数的实际执行过程
Sales_data::isbn(&total)
其中,调用Sales_data的isbn成员时传入了total的地址。
在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符来做到这一点,因为this所指的正是这个对象。任何对类成员的直接访问都被看作this的隐式引用,也就是说,当isbn使用bookNo时,它隐式地使用this指向的成员,就像我们书写this->bookNo一样。
对于我们来说,this形参是隐式定义的。实际上,任何自定义名为this的参数或变量的行为都是非法的。我们可以在成员函数体内部使用this,因此尽管没有必要,但我们还是能够把isbn定义成如下的形式:
std::string isbn() const{return this->bookNo;}
因为,this的目的总是指向这个对象,所以this是一个常量指针,我们不允许改变this中保存的地址。
引入const成员函数
isbn函数的另一个关键指出是紧随参数列表之后的const关键字,这里,const的作用是修改隐式指针this指针的类型。
默认情况下,this的类型是指向类类型非常量版本的常量指针。例如在Sales_data 成员函数中,this的类型是Sales_data *const。尽管this是隐式的,但他仍然需要遵循初始化规则,这意味着(在默认情况下)我们不能把this绑定到一个常量对象上。这一情况也就使得我们不能在一个常量对象上调用普通的成员函数。
如果isbn是一个普通函数而且this是一个普通的指针参数,则我们应该把this声明成cosnt Sales_data *const 。毕竟,在isbn的函数体内不会改变this所指的对象,所以把this设置为指向常量的指针有助于提高函数的灵活性。
然而,this是隐式的并且不会出现在参数列表中,所以在哪儿将this声明成指向常量的指针就成为我们必须面对的问题。C++语言的做法是允许把const关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的const表示this是一个指向常量的指针。像这样使用const的成员函数被称为常量成员函数(const member function)。
可以把isbn函数体想象成如下的形式:
//伪代码,说明隐式的this指针是如何使用的
//下面的代码是非法的,因为我们不能显式的定义自己的this指针
//谨记此处的this是一个指向常量的指针,因为isbn是一个常量成员
std::string Sales_data::isbn(const Sales_data *const this){return this->isbn;}
因为this是指向常量的指针,所以常量成员函数不能改变调用它的对象的内容。在上例中,isbn可以读取调用它的对象的数据成员,但是不能写入新值。
注意:常量对象以及常量对象的引用或指针都只能调用常量成员函数。
在类的外部定义成员函数
像其他函数一样,当我们在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配。也就是说,返回类型、参数列表和函数名都得与类内部的声明保持一致。如果成员被声明成常量成员函数,那么它的定义也必须在参数列表后明确指定const属性。同时,类外部定义的成员的名字必须包含它所属的类名:
double Sales_data::avg_price() const{
if(units_sold)
return revenue/units_sold;
else
return 0;
}
函数名Sales_data::avg_price使用作用域运算符来说明以下事实:我们定义了一个名为avg_price的函数,并且该函数被声明在类Sales_data的作用域内。一旦编译器看见这个函数名,就能理解剩余的代码是位于类的作用域内的。因此,当avg_price使用revenue和units_sold时,实际上它隐式地使用了Sales_data的成员。
定义一个返回this对象的函数
函数combine的设计初衷类似于复合赋值运算+=,调用该函数的对象代表的是赋值运算符左侧的运算对象,右侧运算对象则通过显式的实参被传入函数:
Sales_data& Sales_data::combine(const Sales_data &rhs){
units_sold += rhs.units_sold;//把rhs的成员加到this对象的成员上
revenue += rhs.revenue;
return *this;//返回调用该函数的对象
}
当我们的处理程序调用如下的函数时,
total.combine(trans);//更新变量total的值
total的地址被绑定到隐式的this参数上,而rhs绑定到了trans上。因此,当combine执行下面的语句时,
units_sold += rhs.units_sold;
效果等同于求tatal.units_sold和trans.unit_sold的和,然后把结果保存到total.units_sold中。
该函数一个值得关注的部分是它的返回类型和返回语句。一般来说,当我们定义的函数类似于某个内置运算符时,应该令该函数的行为尽量模仿这个运算符。内置的赋值运算符把它的左侧运算对象当成左值返回,因此为了与它保持一致,combine函数必须返回引用类型。因为此时的左侧运算对象是一个Sales_data的对象,所以返回的类型应该是Sales_data&。
如前所述,我们无须使用隐式的this指针访问函数调用者的某个具体成员,而是需要把调用函数的对象当成一个整体来访问:
return *this;
其中,return语句解引用this指针以获得执行该函数的对象,换句话说,上面的这个调用返回total的引用。
7.1.3 定义类相关的非成员函数
类的作者常常需要定义一些辅助函数,比如add,read和print等。尽管这些函数定义的操作从概念上来说属于类的接口的组成部分,但它实际上并不属于类本身。
我们定义非成员函数的方式与定义其他函数一样,通常是把函数的声明和定义分离开来。如果函数在概念上属于类但不是不定义在类中,则它一般应与类声明(而非定义)在同一个头文件内。在这种方式下,用户使用接口的任何部分都只需要引入一个文件。一般来说,如果非成员函数是类接口的组成部分,则这些函数的声明应该与类同一个头文件内。
定义read和print函数
下面的read和print函数与2.6.2节中的代码作用一样,而且代码本身也非常相似:
//输入的交易信息包括ISBN、售出总数和售出价格
istream &read(istream &is,Sales_data &item){
double price = 0;
is>>item.bookNo>>item.units_sold>>price;
item.revenue=price*item.units_sold;
return is;
}
ostream &print(ostream &os,Sales_data &item){
os<
read函数从给定流中将数据读到给定的对象中,print函数则负责将给定对象的内容打印到给定的流中。
除此之外,关于上面两个函数还有两点非常重要:1.read和print分别接受一个各自IO类型的引用作为其参数,这是因为IO类属于不能被拷贝的类型,因此我们只能通过引用来传递它们。而且,因为读取和写入的操作会改变流的内容,所以两个函数都接受的是普通引用,而非对常量的引用。
2.print函数不负责换行。一般来说,执行输出任务的函数应该尽量减少对格式的控制,这样就可以确保由用户代码来决定是否换行。
定义add函数
add函数接受两个Sales_data对象作为其参数,返回值是一个新的Sales_data,用于表示前两个对象的和:
Sales_data add(const Sales_data &lhs,const Sales_data &rhs){
Sales_data sum=lhs;//将lhs的数据成员拷贝给sum
sum.combine(rhs);//把rhs的数据成员加到sum当中
return sum;
}
在上面这个函数中,我们定义了一个新的Sales_data对象并将其命名为sum。sum用来存放两笔交易的和,我们用lhs的副本来初始化sum。默认情况下,拷贝类的对象其实拷贝的是对象的数据成员。在拷贝工作完成后,sum的bookNo、units_sold和revenue将和lhs一致。接下来我们调用combine函数,将rhs的units_sold和revenue添加给sum。最后函数返回sum的副本。
7.1.4 构造函数
每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的函数来控制其对象的初始化过程,这些函数叫做构造函数(constructor)。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。
在这一节中,我们将介绍定义构造函数的基础知识。构造函数是一个非常复杂的问题,我们还会在7.5节、15.7节、18.1.3节和第13章介绍更多关于构造函数的知识。
构造函数的名字和其他类名相同。和其他函数不一样的是,构造函数没有返回类型;除此之外类似于其他的函数,构造函数也有一个(可能为空的)参数列表和一个(可能为空的)函数体。类可以包含多个构造函数,和其他重载函数差不多,不同构造函数之间必须在参数数量或参数类型上有所不同。
不同于其他成员函数,构造函数不能被声明成const。当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性。因此,构造函数在const对象的构造过程中可以向其写值。
合成的默认构造函数
我们的Sales_data类并没有定义任何构造函数,可是之前使用了Sales_data对象的程序仍然可以正确地编译和运行。举个例子,第227页的程序定义了两个对象:
Sales_data total;
Sales_data trans;
这是两个对象是如何初始化的呢?
我们并没有为这些对象提供初始值,因此我们知道它们执行了默认初始化。类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数(default constructor)。默认构造函数无须任何实参。
如我们所见,默认构造函数在很多方面都有其特殊性。其中之一是,如果我们的类没有显式地定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。
编译器创建的构造函数又被称为合成的默认构造函数(synthesized default constructor)。对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:
1.如果存在类内的初始值,用它来初始化成员。
2.否则,默认初始化该成员。
因为Sales_data为units_sold和revenue提供了初始值,所以合成的默认构造函数将使用这些值来初始化这两个成员;同时,它把bookNo默认初始化成一个空字符串。
某些类不能依赖于合成的默认构造函数
合成的默认构造函数只适合非常简单的类,比如我们定义的Sales_data版本。对于一个普通的类来说,必须定义它自己的默认构造函数,原因有三:
第一个原因也是最容易理解的原因就是**编译器只有在发现类不包含任何构造函数时才会为我们生成一个默认的构造函数。一旦我们定义了一些其他的构造函数,那么除非我们再定义一个默认的构造函数,否则类将没有默认构造函数**。这条规则的依据是,如果一个类在某种情况下需要控制对象初始化,那么该类很可能在所有情况下都需要控制。只有当类没有声明任何构造函数时,编译器才会自动地生成默认构造函数。
第二个原因是对于某些类来说,合成的默认构造函数可能执行错误的操作。回忆我们之前介绍过的,如果定义在块中的内置类型或复合类型(比如数组和指针)的对象被默认初始化,则它们的值将是未定义的。该准则同样适用于默认初始化的内置类型成员。因此,含有内置类型或复合类型成员的类应该在类的内部初始化这些成员,或者定义一个自己的默认构造函数。否则用户在创建类的对象时就可能得到未定义的值。
如果类包含有内置类型或复合类型的成员,则只有当这些成员全部都被赋予了类内的初始值时,这个类才适合于使用合成的默认构造函数。
第三个原因是有的时候编译器不能为某些类合成默认的构造函数。例如,如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。对于这样的类来说,我们必须自定义默认构造函数,否则该类将没有可以用的默认构造函数。在13.1.6节中我们将看到还有其他一些情况也会导致编译器无法生成一个正确的默认构造函数。
7.2 访问控制与封装
7.3 类的其他特性
7.4 类的作用域
7.5 构造函数再探
7.6 类的静态成员
小结
习题解答
7.1.1节练习(仍有问题)
练习7.1 使用 2.6.1节练习定义的Sales_data类为1.6节的交易处理程序编写一个新版本。
#include
#include "Sales_data.h"
using namespace std;
int main(){
cout<<"请输入交易记录(ISBN,销售量,原价、实际售价):"<>total){
Sales_data trans;
while(cin>>trans){
if(total.isbn()==trans.isbn())
total+=trans;
else{
cout<
7.1.2节练习
练习7.2 曾在2.6.2节练习中编写了一个Sales_data类,请向这个类添加combine和isbn成员。
成员函数的声明必须在类的内部,它的定义可以在类的内部和外部。在本题中,我们把成员函数的定义放在类的内部。
#include
using namespace std;
class Sales_data{
private://定义私有数据成员
string bookNo;//书籍编号,隐式初始化为空串
unsigned units_sold = 0;//销售量,显式初始化为0
double sellingprice = 0.0;//原始价格,显式初始化为0.0
double saleprice = 0.0;//实售价格,显式初始化为0.0
double discount = 0.0;//折扣,显式初始化为0.0
public:
//isbn函数只有一条语句,返回bookNo
string isbn() const {return bookNo;}
//combine函数用于把两个ISBN相同的销售记录合并在一起
Sales_data& combine(const Sales_data &rhs){
units_sold+=rhs.units_sold;//累加书籍的销售额
saleprice=(rhs.saleprice*rhs.units_sold+saleprice*units_sold)/(rhs.units_sold+units_sold);
// 重新计算实际销售价格
if(sellingprice !=0)
discount = saleprice /sellingprice;//重新计算实际折扣
return *this;//返回合并后的结果
}
};
练习7.3 修改7.1.1节的交易处理程序,令其使用这些成员。
解答:
该程序的作用是累加相同编号的书籍销售记录并输出,直至遇到下一个编号为止。改写程序时,用上一题定义的isbn函数获取书籍的编号,用combine函数把两条销售记录相加。
#include
#include "Sales_data.h"
using namespace std;
int main(){
cout<<"请输入交易记录(ISBN,销售量,原价、实际售价):"<>total){
Sales_data trans;
while(cin>>trans){
if(total.isbn()==trans.isbn())
total.combine(trans);//更新销售额
else{
cout<
练习7.4 编写一个名为Person的类,使其表示人员的姓名和住址。使用string对象存放这些元素,接下来的练习将不断充实这个类的其他特征。
class Person{
private:
string strName;//姓名
string strAddress;//地址
};
练习7.5 在你的Person类中提供一些操作使其能够返回姓名和住址。这些函数是否应该是const的?解释原因。
class Person{
private:
string strName;//姓名
string strAddress;//地址
public:
string getName() const {return strName;}//返回姓名
string getAddress() const {return strAddress;} //返回地址
};
上述两个成员函数都应该被定义为常量成员函数,因为我们只需读取数据成员的值,而不用做任何改变。
7.1.3节练习
练习7.6 对于函数add、read和print,定义你自己的版本。
Sales_data add(const Sales_data &lhs,const Sales_data &rhs){
Sales_data sum=lhs;
sum.combine(rhs);
return sum;
}
std::istream &read(std::istream &is,Sales_data &item){
is>>item.bookNo>>item.units_sold>>item.sellingprice>>item.saleprice;
return is;
}
std::ostream &print(std::ostream &os,Sales_data &item){
os<
练习7.7 使用这些新函数重写7.1.2节练习中的交易处理程序。
#include
#include "Sales_data.h"
using namespace std;
int main(){
cout<<"请输入交易记录(ISBN,销售量,原价、实际售价):"<
练习7.8 为什么将read函数将其Sales_data参数定义成普通引用,而print将其定义成常量引用?
解答:
read函数将其Sales_data参数定义成普通的引用是因为我们需要从标准输入流中读取数据并将其写入到给定的Sales_data对象,因此需要有修改对象的权限。而print函数将其参数定义成常量引用是因为它只负责数据的输出,不对其做任何更改。
练习7.9 对于7.1.2节练习中的代码,添加读取和打印Person对象的操作。
std::istream &read(std::istream &is,Person &per){
is>>per.strName>>per.strAddress;
return is;
}
std::ostream &print(std::ostream &os,const Person &per){
os<
练习7.10 在下面这条if语句中,条件部分的作用是什么?
if(read(read(cin,data1),data2))
解答:
我们要注意read函数返回的类型是std::istream &。因为read函数返回的类型是引用,所以read(cin,data1)的返回值还可以继续作为外层read函数的实参使用。该条件检验读入的data1和data2的过程是否正确,如果正确条件满足,否则条件不满足。



