类和对象
8.1 面向对象程序设计方法概述
到目前为止,我们介绍的是C++在面向过程的程序设计中的应用。对于规模比较小的程序,编程者可以直接编写出一个面向过程的程序,详细地描述每一瞬时的数据结构及对其的操作过程。但是当程序规模较大时,就显得力不从心了。C++就是为了解决编写大程序过程中的困难而产生的。
8.1.1 什么是面向对象的程序设计
面向对象的程序设计的思路和人们日常生活中处理问题的思路是相似的。在自然世界和社会生活中,一个复杂的事物总是由许多部分组成的。当人们生产汽车时,分别设计和制造发动机、底盘、车身和*,最后把它们组装在一起。在组装时,各部分之间有一定的联系,以便协调工作。这就是面向对象的程序设计的基本思路。
1. 对象 客观世界中任何一个事物都可以看成一个对象(object)。对象可大可小。对象是构成系统的基本单位。任何一个对象都应当具有这两个要素,即属性(attribute)和行为(behavior),它能根据外界给的信息进行相应的操作。一个对象往往是由一组属性和一组行为构成的。一般来说,凡是具备属性和行为这两种要素的,都可以作为对象。
2. 封装与信息隐蔽
可以对一个对象进行封装处理,把它的一部分属性和功能对外界屏蔽,也就是说从外界是看不到的,甚至是不可知的。这样做的好处是大大降低了操作对象的复杂程度。面向对象程序设计方法的一个重要特点就是“封装性” (encapsulation),所谓“封装”,指两方面的含义: 一是将有关的数据和操作代码封装在一个对象中,形成一个基本单位,各个对象之间相对独立,互不干扰。二是将对象中某些部分对外隐蔽,即隐蔽其内部细节,只留下少量接口,以便与外界联系,接收外界的消息。这种对外界隐蔽的做法称为信息隐蔽(imformation hiding)。信息隐蔽还有利于数据安全,防止无关的人了解和修改数据。C++的对象中的函数名就是对象的对外接口,外界可以通过函数名来调用这些函数来实现某些行为(功能)。
3. 抽象
在程序设计方法中,常用到抽象(abstraction)这一名词。抽象的过程是将有关事物的共性归纳、集中的过程。抽象的作用是表示同一类事物的本质。C和C++中的数据类型就是对一批具体的数的抽象。对象是具体存在的,如一个三角形可以作为一个对象,10个不同尺寸的三角形是10个对象。如果这10个三角形对象有相同的属性和行为,可以将它们抽象为一种类型,称为三角形类型。在C++中,这种类型就称为“类(class)”。这10个三角形就是属于同一“类”的对象。类是对象的抽象,而对象则是类的特例,或者说是类的具体表现形式。
4. 继承与重用
如果在软件开发中已经建立了一个名为A的“类”,又想另外建立一个名为B的“类”,而后者与前者内容基本相同,只是在前者的基础上增加一些属性和行为,只需在类A的基础上增加一些新内容即可。这就是面向对象程序设计中的继承机制。利用继承可以简化程序设计的步骤。
“白马”继承了“马”的基本特征,又增加了新的特征(颜色),“马”是父类,或称为基类,“白马”是从“马”派生出来的,称为子类或派生类。
C++提供了继承机制,采用继承的方法可以很方便地利用一个已有的类建立一个新的类。这就是常说的“软件重用”(software reusability) 的思想。
5. 多态性
如果有几个相似而不完全相同的对象,有时人们要求在向它们发出同一个消息时,它们的反应各不相同,分别执行不同的操作。这种情况就是多态现象。如,在Windows环境下,用鼠标双击一个文件对象(这就是向对象传送一个消息),如果对象是一个可执行文件,则会执行此程序,如果对象是一个文本文件,则启动文本编辑器并打开该文件。
在C++中,所谓多态性(polymorphism)是指: 由继承而产生的相关的不同的类,其对象对同一消息会作出不同的响应。多态性是面向对象程序设计的一个重要特征,能增加程序的灵活性。
8.1.2 面向对象程序设计的特点
传统的面向过程程序设计是围绕功能进行的,用一个函数实现一个功能。所有的数据都是公用的,一个函数可以使用任何一组数据,而一组数据又能被多个函数所使。
面向对象程序设计采取的是另外一种思路。它面对的是一个个对象。实际上,每一组数据都是有特定的用途的,是某种操作的对象。也就是说,一组操作调用一组数据。
程序设计者的任务包括两个方面: 一是设计所需的各种类和对象,即决定把哪些数据和操作封装在一起;二是考虑怎样向有关对象发送消息,以完成所需的任务。这时他如同一个总调度,不断地向各个对象发出命令,让这些对象活动起来(或者说激活这些对象),完成自己职责范围内的工作。各个对象的操作完成了,整体任务也就完成了。显然,对一个大型任务来说,面向对象程序设计方法是十分有效的,它能大大降低程序设计人员的工作难度,减少出错机会。
8.1.3 类和对象的作用
类是C++中十分重要的概念,它是实现面向对象程序设计的基础。类是所有面向对象的语言的共同特征,所有面向对象的语言都提供了这种类型。一个有一定规模的C++程序是由许多类所构成的。
C++支持面向过程的程序设计,也支持基于对象的程序设计,又支持面向对象的程序设计。包括类和对象的概念、类的机制和声明、类对象的定义与使用等。这是面向对象的程序设计的基础。
基于对象就是基于类。与面向过程的程序不同,基于对象的程序是以类和对象为基础的,程序的操作是围绕对象进行的。在此基础上利用了继承机制和多态性,就成为面向对象的程序设计。
基于对象程序设计所面对的是一个个对象。所有的数据分别属于不同的对象。
在面向过程的结构化程序设计中,人们常使用这样的公式来表述程序:
程序=算法+数据结构
算法和数据结构两者是互相独立、分开设计的,面向过程的程序设计是以算法为主体的。在实践中人们逐渐认识到算法和数据结构是互相紧密联系不可分的,应当以一个算法对应一组数据结构,而不宜提倡一个算法对应多组数据结构,以及一组数据结构对应多个算法。
8.1.4 面向对象的软件开发
随着软件规模的迅速增大,软件人员面临的问题十分复杂。需要规范整个软件开发过程,明确软件开发过程中每个阶段的任务,在保证前一个阶段工作的正确性的情况下,再进行下一阶段的工作。这就是软件工程学需要研究和解决的问题。
面向对象的软件工程包括以下几个部分:
1. 面向对象分析(object oriented analysis,OOA)
软件工程中的系统分析阶段,系统分析员要和用户结合在一起,对用户的需求作出精确的分析和明确的描述,从宏观的角度概括出系统应该做什么。面向对象的分析,要按照面向对象的概念和方法,在对任务的分析中,从客观存在的事物和事物之间的关系,归纳出有关的对象(包括对象的属性和行为)以及对象之间的联系,并将具有相同属性和行为的对象用一个类(class)来表示。建立一个能反映真实工作情况的需求模型。
2. 面向对象设计(object oriented design,OOD)
根据面向对象分析阶段形成的需求模型,对每一部分分别进行具体的设计,首先是进行类的设计,类的设计可能包含多个层次(利用继承与派生)。然后以这些类为基础提出程序设计的思路和方法,包括对算法的设计。在设计阶段,并不牵涉某一种具体的计算机语言,而是用一种更通用的描述工具(如伪代码或流程图)来描述。
3. 面向对象编程(object oriented programming,OOP)
根据面向对象设计的结果,用一种计算机语言把它写成程序,显然应当选用面向对象的计算机语言(例如C++),否则无法实现面向对象设计的要求。
4. 面向对象测试(object oriented test,OOT)
在写好程序后交给用户使用前,必须对程序进行严格的测试。测试的目的是发现程序中的错误并改正它。面向对象测试是用面向对象的方法进行测试,以类作为测试的基本单元。
5. 面向对象维护(object oriented soft maintenance,OOSM)
因为对象的封装性,修改一个对象对其他对象影响很小。利用面向对象的方法维护程序,大大提高了软件维护的效率。
现在设计一个大的软件,是严格按照面向对象软件工程的5个阶段进行的,这5个阶段的工作不是由一个人从头到尾完成的,而是由不同的人分别完成的。这样,OOP阶段的任务就比较简单了,程序编写者只需要根据OOD提出的思路用面向对象语言编写出程序即可。在一个大型软件的开发中,OOP只是面向对象开发过程中的一个很小的部分。
如果所处理的是一个较简单的问题,可以不必严格按照以上5个阶段进行,往往由程序设计者按照面向对象的方法进行程序设计,包括类的设计(或选用已有的类)和程序的设计。
8.2 类的声明和对象的定义
8.2.1 类和对象的关系
每一个实体都是对象。有一些对象是具有相同的结构和特性的。每个对象都属于一个特定的类型。
在C++中对象的类型称为类(class)。类代表了某一批对象的共性和特征。前面已说明: 类是对象的抽象,而对象是类的具体实例(instance)。正如同结构体类型和结构体变量的关系一样,人们先声明一个结构体类型,然后用它去定义结构体变量。同一个结构体类型可以定义出多个不同的结构体变量。
在C++中也是先声明一个类类型,然后用它去定义若干个同类型的对象。对象就是类类型的一个变量。可以说类是对象的模板,是用来定义对象的一种抽象类型。
类是抽象的,不占用内存,而对象是具体的,占用存储空间。在一开始时弄清对象和类的关系是十分重要的。
8.2.2 声明类类型
类是用户自己指定的类型。如果程序中要用到类类型,必须自己根据需要进行声明,或者使用别人已设计好的类。C++标准本身并不提供现成的类的名称、结构和内容。
在C++中声明一个类类型和声明一个结构体类型是相似的。
下面是声明一个结构体类型的方法:
struct Student //声明了一个名为Student的结构体类型
{ int num;
char name[20];
char sex;
};
Student stud1,stud2; //定义了两个结构体变量stud1和stud2
它只包括数据,没有包括操作。现在声明一个类:
class Student //以class开头
{ int num;
char name[20];
char sex; //以上3行是数据成员
void display( ) //这是成员函数
{cout<<″num:″<<num<<endl;
cout<<″name:″<<name<<endl;
cout<<″sex:″<<sex<<endl; //以上4行是函数中的操作语句
}
};
Student stud1,stud2; //定义了两个Student 类的对象stud1和stud2
可以看到声明类的方法是由声明结构体类型的方法发展而来的。
可以看到,类(class)就是对象的类型。实际上,类是一种广义的数据类型。类这种数据类型中的数据既包含数据,也包含操作数据的函数。
不能把类中的全部成员与外界隔离,一般是把数据隐蔽起来,而把成员函数作为对外界的接口。
可以将上面类的声明改为
class Student //声明类类型
{ private: //声明以下部分为私有的
int num;
char name[20];
char sex;
public: //声明以下部分为公用的
void display( )
{cout<<″num:″<<num<<endl;
cout<<″name:″<<name<<endl;
cout<<″sex:″<<sex<<endl; }
};
Student stud1,stud2; //定义了两个Student 类的对象
如果在类的定义中既不指定private,也不指定public,则系统就默认为是私有的。
归纳以上对类类型的声明,可得到其一般形式如下:
class 类名
{ private:
私有的数据和成员函数;
public:
公用的数据和成员函数;
};
private和public称为成员访问限定符(member access specifier)。
除了private和public之外,还有一种成员访问限定符protected(受保护的),用protected声明的成员称为受保护的成员,它不能被类外访问(这点与私有成员类似),但可以被派生类的成员函数访问。
在声明类类型时,声明为private的成员和声明为public的成员的次序任意,既可以先出现private部分,也可以先出现public部分。如果在类体中既不写关键字private,又不写public,就默认为private。在一个类体中,关键字private和public可以分别出现多次。每个部分的有效范围到出现另一个访问限定符或类体结束时(最后一个右花括号)为止。但是为了使程序清晰,应该养成这样的习惯: 使每一种成员访问限定符在类定义体中只出现一次。
在以前的C++程序中,常先出现private部分,后出现public部分,如上面所示。现在的C++程序多数先写public部分,把private部分放在类体的后部。这样可以使用户将注意力集中在能被外界调用的成员上,使阅读者的思路更清晰一些。
在C++程序中,经常可以看到类。为了用户方便,常用的C++编译系统往往向用户提供类库(但不属于C++语言的组成部分),内装常用的基本的类,供用户使用。不少用户也把自己或本单位经常用到的类放在一个专门的类库中,需要用时直接调用,这样就减少了程序设计的工作量。
8.2.3 定义对象的方法
1. 先声明类类型,然后再定义对象
前面用的就是这种方法,如
Student stud1,stud2; //Student是已经声明的类类型
在C++中,声明了类类型后,定义对象有两种形式。
(1) class 类名 对象名
如 class Student stud1,stud2;
把class和Student合起来作为一个类名,用来定义对象。
(2) 类名 对象名
如 Student stud1,stud2;
直接用类名定义对象。这两种方法是等效的。第1种方法是从C语言继承下来的,第2种方法是C++的特色,显然第2种方法更为简捷方便。
2. 在声明类类型的同时定义对象
class Student //声明类类型
{ public: //先声明公用部分
void display( )
{cout<<″num:″<<num<<endl;
cout<<″name:″<<name<<endl;
cout<<″sex:″<<sex<<endl; }
private: //后声明私有部分
int num;
char name[20];
char sex;
}stud1,stud2; //定义了两个Student类的对象
在定义Student类的同时,定义了两个Student 类的对象。
3. 不出现类名,直接定义对象
class //无类名
{private: //声明以下部分为私有的
┆
public: //声明以下部分为公用的
┆
}stud1,stud2; //定义了两个无类名的类对象
直接定义对象,在C++中是合法的、允许的,但却很少用,也不提倡用。在实际的程序开发中,一般都采用上面3种方法中的第1种方法。在小型程序中或所声明的类只用于本程序时,也可以用第2种方法。
在定义一个对象时,编译系统会为这个对象分配存储空间,以存放对象中的成员。
8.2.4 类和结构体类型的异同
C++增加了class类型后,仍保留了结构体类型(struct),而且把它的功能也扩展了。C++允许用struct来定义一个类型。如可以将前面用关键字class声明的类类型改为用关键字struct:
struct Student //用关键字struct来声明一个类类型
{private: //声明以下部分为私有的
int num; //以下3行为数据成员
char name[20];
char sex;
public: //声明以下部分为公用的
void display( ) //成员函数
{cout<<″num:″<<num<<endl;
cout<<″name:″<<name<<endl;
cout<<″sex:″<<sex<<endl; }
};
Student stud1,stud2; //定义了两个Student类的对象
为了使结构体类型也具有封装的特征,C++不是简单地继承C的结构体,而是使它也具有类的特点,以便于用于面向对象程序设计。用struct声明的结构体类型实际上也就是类。
用struct声明的类,如果对其成员不作private或public的声明,系统将其默认为public。如果想分别指定私有成员和公用成员,则应用private或public作显式声明。而用class定义的类,如果不作private或public声明,系统将其成员默认为private,在需要时也可以自己用显式声明改变。
如果希望成员是公用的,使用struct比较方便,如果希望部分成员是私有的,宜用class。建议尽量使用class来建立类,写出完全体现C++风格的程序。
8.3 类的成员函数
8.3.1 成员函数的性质
类的成员函数(简称类函数)是函数的一种,它的用法和作用和第4章介绍过的函数基本上是一样的,它也有返回值和函数类型,它与一般函数的区别只是: 它是属于一个类的成员,出现在类体中。它可以被指定为private(私有的)、public(公用的)或protected(受保护的)。在使用类函数时,要注意调用它的权限(它能否被调用)以及它的作用域(函数能使用什么范围中的数据和函数)。例如私有的成员函数只能被本类中的其他成员函数所调用,而不能被类外调用。
成员函数可以访问本类中任何成员(包括私有的和公用的),可以引用在本作用域中有效的数据。
一般的做法是将需要被外界调用的成员函数指定为public,它们是类的对外接口。但应注意,并非要求把所有成员函数都指定为public。有的函数并不是准备为外界调用的,而是为本类中的成员函数所调用的,就应该将它们指定为private。这种函数的作用是支持其他函数的操作,是类中其他成员的工具函数(utility function),类外用户不能调用这些私有的工具函数。
类的成员函数是类体中十分重要的部分。如果一个类中不包含成员函数,就等同于C语言中的结构体了,体现不出类在面向对象程序设计中的作用。
8.3.2 在类外定义成员函数
在前面已经看到成员函数是在类体中定义的。也可以在类体中只写成员函数的声明,而在类的外面进行函数定义。如
class Student
{ public:
void display( ); //公用成员函数原型声明
private:
int num;
string name;
char sex; //以上3行是私有数据成员
};
void Student∷display( ) //在类外定义display类函数
{cout<<″num:″<<num<<endl; //函数体
cout<<″name:″<<name<<endl;
cout<<″sex:″<<sex<<endl;
}
Student stud1,stud2; //定义两个类对象
注意: 在类体中直接定义函数时,不需要在函数名前面加上类名,因为函数属于哪一个类是不言而喻的。但成员函数在类外定义时,必须在函数名前面加上类名,予以限定(qualifed),“∷”是作用域限定符(field qualifier)或称作用域运算符,用它声明函数是属于哪个类的。
如果在作用域运算符“∷”的前面没有类名,或者函数名前面既无类名又无作用域运算符“∷”,如
∷display( ) 或 display( )
则表示display函数不属于任何类,这个函数不是成员函数,而是全局函数,即非成员函数的一般普通函数。
类函数必须先在类体中作原型声明,然后在类外定义,也就是说类体的位置应在函数定义之前,否则编译时会出错。
虽然函数在类的外部定义,但在调用成员函数时会根据在类中声明的函数原型找到函数的定义(函数代码),从而执行该函数。
在类的内部对成员函数作声明,而在类体外定义成员函数,这是程序设计的一种良好习惯。如果一个函数,其函数体只有2~3行,一般可在声明类时在类体中定义。多于3行的函数,一般在类体内声明,在类外定义。
8.3.3 inline 成员函数
关于内置(inline)函数,已在第4章第4.5节中作过介绍。类的成员函数也可以指定为内置函数。
在类体中定义的成员函数的规模一般都很小,而系统调用函数的过程所花费的时间开销相对是比较大的。调用一个函数的时间开销远远大于小规模函数体中全部语句的执行时间。为了减少时间开销,如果在类体中定义的成员函数中不包括循环等控制结构,C++系统会自动将它们作为内置(inline)函数来处理。也就是说,在程序调用这些成员函数时,并不是真正地执行函数的调用过程(如保留返回地址等处理),而是把函数代码嵌入程序的调用点。这样可以大大减少调用成员函数的时间开销。
C++要求对一般的内置函数要用关键字inline声明,但对类内定义的成员函数,可以省略inline,因为这些成员函数已被隐含地指定为内置函数。如
class Student
{public:
void display( )
{cout<<″num:″<<num<<endl;
cout<<″name:″<<name<<endl;
cout<<″sex:″<<sex<<endl;
}
private:
int num;
string name;
char sex;
};
其中第3行
void display( )
也可以写成
inline void display( )
将display函数显式地声明为内置函数。以上两种写法是等效的。对在类体内定义的函数,一般都省写inline。
应该注意的是: 如果成员函数不在类体内定义,而在类体外定义,系统并不把它默认为内置(inline)函数,调用这些成员函数的过程和调用一般函数的过程是相同的。如果想将这些成员函数指定为内置函数,应当用inline作显式声明。如
class Student
{ public:
inline void display( ); //声明此成员函数为内置函数
private:
int num;
string name;
char sex;
};
inline void Student∷display( ) // 在类外定义display函数为内置函数
{cout<<″num:″<<num<<endl;
cout<<″name:″<<name<<endl;
cout<<″sex:″<<sex<<endl;
}
在第4章第4.5节曾提到过,在函数的声明或函数的定义两者之一作inline声明即可。值得注意的是: 如果在类体外定义inline函数,则必须将类定义和成员函数的定义都放在同一个头文件中(或者写在同一个源文件中),否则编译时无法进行置换(将函数代码的拷贝嵌入到函数调用点)。但是这样做,不利于类的接口与类的实现分离,不利于信息隐蔽。虽然程序的执行效率提高了,但从软件工程质量的角度来看,这样做并不是好的办法。
只有在类外定义的成员函数规模很小而调用频率较高时,才将此成员函数指定为内置函数。
8.3.4 成员函数的存储方式
用类去定义对象时,系统会为每一个对象分配存储空间。如果一个类包括了数据和函数,要分别为数据和函数的代码分配存储空间。按理说,如果用同一个类定义了10个对象,那么就需要分别为10个对象的数据和函数代码分配存储单元,如图8.4所示。
class Time
{public:
int hour;
int minute;
int sec;
void set( )
{cin>>a>>b>>c;}
};
可以用下面的语句来输出该类对象所占用的字节数:
cout<<sizeof(Time)<<endl;
输出的值是12。这就证明了一个对象所占的空间大小只取决于该对象中数据成员所占的空间,而与成员函数无关。函数代码是存储在对象空间之外的。如果对同一个类定义了10个对象,这些对象的成员函数对应的是同一个函数代码段,而不是10个不同的函数代码段。
需要注意的是: 虽然调用不同对象的成员函数时都是执行同一段函数代码,但是执行结果一般是不相同的。不同的对象使用的是同一个函数代码段,它怎么能够分别对不同对象中的数据进行操作呢?原来C++为此专门设立了一个名为this的指针,用来指向不同的对象。
需要说明:
(1) 不论成员函数在类内定义还是在类外定义,成员函数的代码段都用同一种方式存储。
(2) 不要将成员函数的这种存储方式和inline(内置)函数的概念混淆。
(3) 应当说明: 常说的“某某对象的成员函数”,是从逻辑的角度而言的,而成员函数的存储方式,是从物理的角度而言的,二者是不矛盾的。
8.4 对象成员的引用
在程序中经常需要访问对象中的成员。访问对象中的成员可以有3种方法:
通过对象名和成员运算符访问对象中的成员;
通过指向对象的指针访问对象中的成员;
通过对象的引用变量访问对象中的成员。
8.4.1 通过对象名和成员运算符访问对象中的成员
例如在程序中可以写出以下语句:
stud1.num=1001; //假设num已定义为公用的整型数据成员
表示将整数1001赋给对象stud1中的数据成员num。其中“.”是成员运算符,用来对成员进行限定,指明所访问的是哪一个对象中的成员。注意不能只写成员名而忽略对象名。
访问对象中成员的一般形式为
对象名.成员名
不仅可以在类外引用对象的公用数据成员,而且还可以调用对象的公用成员函数,但同样必须指出对象名,如
stud1.display( ); //正确,调用对象stud1的公用成员函数
display( ); //错误,没有指明是哪一个对象的display函数
由于没有指明对象名,编译时把display作为普通函数处理。
应该注意所访问的成员是公用的(public)还是私有的(private)。只能访问public成员,而不能访问private成员,如果已定义num为私有数据成员,下面的语句是错误的:
stud1.num=10101; //num是私有数据成员,不能被外界引用
在类外只能调用公用的成员函数。在一个类中应当至少有一个公用的成员函数,作为对外的接口,否则就无法对对象进行任何操作。
8.4.2 通过指向对象的指针访问对象中的成员
在第7章第7.1.5节中介绍了指向结构体变量的指针,可以通过指针引用结构体中的成员。用指针访问对象中的成员的方法与此类似。如果有以下程序段:
class Time
{public: //数据成员是公用的
int hour;
int minute;
};
Time t,*p; //定义对象t和指针变量p
p=&t; //使p指向对象t
cout<<p->hour; //输出p指向的对象中的成员hour
在p指向t的前提下,p->hour,(*p).hour和t.hour三者等价。
8.4.3 通过对象的引用变量来访问对象中的成员
如果为一个对象定义了一个引用变量,它们是共占同一段存储单元的,实际上它们是同一个对象,只是用不同的名字表示而已。因此完全可以通过引用变量来访问对象中的成员。
如果已声明了Time类,并有以下定义语句:
Time t1; //定义对象t1
Time &t2=t1; //定义Time类引用变量t2,并使之初始化为t1
cout<<t2.hour; //输出对象t1中的成员hour
由于t2与t1共占同一段存储单元(即t2是t1的别名),因此t2.hour就是t1.hour。
本章第8.6节的例8.2中的程序(b),介绍的是引用变量作为形参的情况,读者可以参考。
8.5 类的封装性和信息隐蔽
8.5.1 公用接口与私有实现的分离
从前面的介绍已知: C++通过类来实现封装性,把数据和与这些数据有关的操作封装在一个类中,或者说,类的作用是把数据和算法封装在用户声明的抽象数据类型中。
在声明了一个类以后,用户主要是通过调用公用的成员函数来实现类提供的功能(例如对数据成员设置值,显示数据成员的值,对数据进行加工等)。因此,公用成员函数是用户使用类的公用接口(public interface),或者说是类的对外接口。
当然并不一定要把所有成员函数都指定为public(公用)的,但这时这些成员函数就不是公用接口了。在类外虽然不能直接访问私有数据成员,但可以通过调用公用成员函数来引用甚至修改私有数据成员。
用户可以调用公用成员函数来实现某些功能,而这些功能是在声明类时已指定的,用户可以使用它们而不应改变它们。实际上用户往往并不关心这些功能是如何实现的细节,而只需知道调用哪个函数会得到什么结果,能实现什么功能即可。
通过成员函数对数据成员进行操作称为类的实现,为了防止用户任意修改公用成员函数,改变对数据进行的操作,往往不让用户看到公用成员函数的源代码,显然更不能修改它,用户只能接触到公用成员函数的目标代码(详见8.5.2节)。
可以看到: 类中被操作的数据是私有的,实现的细节对用户是隐蔽的,这种实现称为私有实现(private implementation)。这种“类的公用接口与私有实现的分离”形成了信息隐蔽。
软件工程的一个最基本的原则就是将接口与实现分离,信息隐蔽是软件工程中一个非常重要的概念。它的好处在于:
(1) 如果想修改或扩充类的功能,只需修改本类中有关的数据成员和与它有关的成员函数,程序中类外的部分可以不必修改。
(2) 如果在编译时发现类中的数据读写有错,不必检查整个程序,只需检查本类中访问这些数据的少数成员函数。
8.5.2 类声明和成员函数定义的分离
在面向对象的程序开发中,一般做法是将类的声明(其中包含成员函数的声明)放在指定的头文件中,用户如果想用该类,只要把有关的头文件包含进来即可,不必在程序中重复书写类的声明,以减少工作量,节省篇幅,提高编程的效率。
由于在头文件中包含了类的声明,因此在程序中就可以用该类来定义对象。由于在类体中包含了对成员函数的声明,在程序中就可以调用这些对象的公用成员函数。为了实现上一节所叙述的信息隐蔽,对类成员函数的定义一般不放在头文件中,而另外放在一个文件中。
例如,可以分别写两个文件:
//student.h (这是头文件,在此文件中进行类的声明)
class Student //类声明
{ public:
void display( ); //公用成员函数原型声明
private:
int num;
char name[20];
char sex;
};
//student.cpp //在此文件中进行函数的定义
#include <iostream>
#include ″student.h″ //不要漏写此行,否则编译通不过
void Student∷display( ) //在类外定义display类函数
{cout<<″num:″<<num<<endl;
cout<<″name:″<<name<<endl;
cout<<″sex:″<<sex<<endl;
}
为了组成一个完整的源程序,还应当有包括主函数的源文件:
//main.cpp 主函数模块
#include <iostream>
#include ″student.h″ //将类声明头文件包含进来
int main( )
{Student stud; //定义对象
stud.display( ); //执行stud对象的display函数
return 0;
}
这是一个包括3个文件的程序,组成两个文件模块: 一个是主模块main.cpp,一个是student.cpp。在主模块中又包含头文件student.h。在预编译时会将头文件student.h中的内容取代#include ″student.h″行。
在运行程序时调用stud中的display函数,输出各数据成员的值。
如果一个类声明多次被不同的程序所选用,每次都要对包含成员函数定义的源文件(如上面的student.cpp)进行编译,这是否可以改进呢?的确,可以不必每次都对它重复进行编译,而只需编译一次即可。把第一次编译后所形成的目标文件保存起来,以后在需要时把它调出来直接与程序的目标文件相连接即可。这和使用函数库中的函数是类似的。
这也是把成员函数的定义不放在头文件中的一个好处。
在实际工作中,并不是将一个类声明做成一个头文件,而是将若干个常用的功能相近的类声明集中在一起,形成类库。
类库有两种: 一种是C++编译系统提供的标准类库;一种是用户根据自己的需要做成的用户类库,提供给自己和自己授权的人使用,这称为自定义类库。在程序开发工作中,类库是很有用的,它可以减少用户自己对类和成员函数进行定义的工作量。
类库包括两个组成部分: (1)类声明头文件; (2)已经过编译的成员函数的定义,它是目标文件。用户只需把类库装入到自己的计算机系统中(一般装到C++编译系统所在的子目录下),并在程序中用#include命令行将有关的类声明的头文件包含到程序中,就可以使用这些类和其中的成员函数,顺利地运行程序。
这和在程序中使用C++系统提供的标准函数的方法是一样的,例如用户在调用sin函数时只需将包含声明此函数的头文件包含到程序中,即可调用该库函数,而不必了解sin函数是怎么实现的(函数值是怎样计算出来的)。当然,前提是系统已装了标准函数库。在用户源文件经过编译后,与系统库(是目标文件)相连接。
在用户程序中包含类声明头文件,类声明头文件就成为用户使用类的公用接口,在头文件的类体中还提供了成员函数的函数原型声明,用户只有通过头文件才能使用有关的类。用户看得见和接触到的是这个头文件,任何要使用这个类的用户只需包含这个头文件即可。包含成员函数定义的文件就是类的实现。请特别注意: 类声明和函数定义一般是分别放在两个文本中的。
由于要求接口与实现分离,为软件开发商向用户提供类库创造了很好的条件。开发商把用户所需的各种类的声明按类放在不同的头文件中,同时对包含成员函数定义的源文件进行编译,得到成员函数定义的目标代码。软件商向用户提供这些头文件和类的实现的目标代码(不提供函数定义的源代码)。用户在使用类库中的类时,只需将有关头文件包含到自己的程序中,并且在编译后连接成员函数定义的目标代码即可。
由于类库的出现,用户可以像使用零件一样方便地使用在实践中积累的通用的或专用的类,这就大大减少了程序设计的工作量,有效地提高了工作效率。
8.5.3 面向对象程序设计中的几个名词
类的成员函数在面向对象程序理论中被称为“方法”(method),“方法”是指对数据的操作。一个“方法”对应一种操作。显然,只有被声明为公用的方法(成员函数)才能被对象外界所激活。外界是通过发“消息”来激活有关方法的。所谓“消息”,其实就是一个命令,由程序语句来实现。前面的stud.display( );就是向对象stud发出的一个“消息”,通知它执行其中的display“方法”(即display函数)。上面这个语句涉及3个术语: 对象、方法和消息。stud是对象,display( )是方法,语句“stud.display( );”是消息。
8.6 类和对象的简单应用举例
例8.1 最简单的例子。
#include <iostream>
using namespace std;
class Time //定义Time类
{public: //数据成员为公用的
int hour;
int minute;
int sec;
};
int main( )
{ Time t1; //定义t1为Time类对象
cin>>t1.hour; //输入设定的时间
cin>>t1.minute;
cin>>t1.sec;
cout<<t1.hour<<″:″<<t1.minute<<″:″<<t1.sec<<endl; //输出时间
return 0;
}
运行情况如下:
1232 43↙
12:32:43
注意:
(1) 在引用数据成员hour,minute,sec时不要忘记在前面指定对象名。
(2) 不要错写为类名,如写成Time.hour,Time.minute,Time.sec是不对的。因为类是一种抽象的数据类型,并不是一个实体,也不占存储空间,而对象是实际存在的实体,是占存储空间的,其数据成员是有值的,可以被引用的。
(3) 如果删去主函数的3个输入语句,即不向这些数据成员赋值,则它们的值是不可预知的。
例8.2 引用多个对象的成员。
(1) 程序(a)
#include <iostream>
using namespace std;
class Time
{public:
int hour;
int minute;
int sec;
};
int main( )
{Time t1; //定义对象t1
cin>>t1.hour; //向t1的数据成员输入数据
cin>>t1.minute;
cin>>t1.sec;
cout<<t1.hour<<″:″<<t1.minute<<″:″<<t1.sec<<endl; //输出t1中数据成员的值
Time t2; //定义对象t2
cin>>t2.hour; //向t2的数据成员输入数据
cin>>t2.minute;
cin>>t2.sec;
cout<<t2.hour<<″:″<<t2.minute<<″:″<<t2.sec<<endl; //输出t2中数据成员的值
return 0;
}
运行情况如下:
1032 43↙
10:32:43
22 32 43↙
22:32:43
程序是清晰易懂的,但是在主函数中对不同的对象一一写出有关操作,会使程序冗长。为了解决这个问题,可以使用函数来进行输入和输出。见程序(b)。
(2) 程序(b)
#include <iostream>
using namespace std;
class Time
{public:
int hour;
int minute;
int sec;
};
int main( )
{
void set_time(Time&); //函数声明
void show_time(Time&); //函数声明
Time t1; //定义t1为Time类对象
set_time(t1); //调用set_time函数,向t1对象中的数据成员输入数据
show_time(t1); //调用show_time函数,输出t1对象中的数据
Time t2; //定义t2为Time类对象
set_time(t2); //调用set_time函数,向t2对象中的数据成员输入数据
show_time(t2); //调用show_time函数,输出t2对象中的数据
return 0;
}
void set_time(Time& t) //定义函数set_time,形参t是引用变量
{
cin>>t.hour; //输入设定的时间
cin>>t.minute;
cin>>t.sec;
}
void show_time(Time& t) //定义函数show_time,形参t是引用变量
{
cout<<t.hour<<″:″<<t.minute<<″:″<<t.sec<<endl; //输出对象中的数据
}
运行情况与程序(a)相同。
(3) 程序(c)
可以对上面的程序作一些修改,数据成员的值不再由键盘输入,而在调用函数时由实参给出,并在函数中使用默认参数。将程序(b)第8行以下部分改为
int main( )
{
void set_time(Time&,int hour=0,int minute=0,int sec=0); //函数声明
void show_time(Time&); //函数声明
Time t1;
set_time(t1,12,23,34); //通过实参传递时、分、秒的值
show_time(t1);
Time t2;
set_time(t2); //使用默认的时、分、秒的值
show_time(t2);
return 0;
}
void set_time(Time& t,int hour,int minute,int sec)
{
t.hour=hour;
t.minute=minute;
t.sec=sec;
}
void show_time(Time& t)
{
cout<<t.hour<<″:″<<t.minute<<″:″<<t.sec<<endl;
}
程序运行时的输出为
12:23:34 (t1中的时、分、秒)
0:0:0 (t2中的时、分、秒)
以上两个程序中定义的类都只有数据成员,没有成员函数,这显然没有体现出使用类的优越性。在下面的例子中,类体中就包含了成员函数。
例8.3 将例8.2的程序改用含成员函数的类来处理。
#include <iostream>
using namespace std;
class Time
{public:
void set_time( ); //公用成员函数
void show_time( ); //公用成员函数
private: //数据成员为私有
int hour;
int minute;
int sec;
};
int main( )
{
Time t1; //定义对象t1
t1.set_time( ); //调用对象t1的成员函数set_time,向t1的数据成员输入数据
t1.show_time( ); //调用对象t1的成员函数show_time,输出t1的数据成员的值
Time t2; //定义对象t2
t2.set_time( ); //调用对象t2的成员函数set_time,向t2的数据成员输入数据
t2.show_time( ); //调用对象t2的成员函数show_time,输出t2的数据成员的值
return 0;
}
void Time∷set_time( ) //在类外定义set_time函数
{
cin>>hour;
cin>>minute;
cin>>sec;
}
void Time∷show_time( ) //在类外定义show_time函数
{
cout<<hour<<″:″<<minute<<″:″<<sec<<endl;
}
运行情况与例8.2中的程序(a)相同。
注意:
(1) 在主函数中调用两个成员函数时,应指明对象名(t1,t2)。表示调用的是哪一个对象的成员函数。
(2) 在类外定义函数时,应指明函数的作用域(如void Time∷set_time( ))。在成员函数引用本对象的数据成员时,只需直接写数据成员名,这时C++系统会把它默认为本对象的数据成员。也可以显式地写出类名并使用域运算符。
(3) 应注意区分什么场合用域运算符“∷”,什么场合用成员运算符“.”,不要搞混。
例8.4 找出一个整型数组中的元素的最大值。
这个问题可以不用类的方法来解决,现在用类来处理,读者可以比较不同方法的特点。
#include <iostream>
using namespace std;
class Array_max //声明类
{public: //以下3行为成员函数原型声明
void set_value( ); //对数组元素设置值
void max_value( ); //找出数组中的最大元素
void show_value( ); //输出最大值
private:
int array[10]; //整型数组
int max; //max用来存放最大值
};
void Array_max∷set_value( ) //成员函数定义,向数组元素输入数值
{ int i;
for (i=0;i<10;i++)
cin>>array[i];
}
void Array_max∷max_value( ) //成员函数定义,找数组元素中的最大值
{int i;
max=array[0];
for (i=1;i<10;i++)
if(array[i]>max) max=array[i];
}
void Array_max∷show_value( ) //成员函数定义,输出最大值
{cout<<″max=″<<max;}
int main( )
{Array_max arrmax; //定义对象arrmax
arrmax.set_value( ); //调用arrmax的set_value函数,向数组元素输入数值
arrmax.max_value( ); //调用arrmax的max_value函数,找出数组元素中的最大值
arrmax.show_value( ); //调用arrmax的show_value函数,输出数组元素中的最大值
return 0;
}
运行结果如下:
12 12 39 -34 17 134 045 -91 76↙ (输入10个元素的值)
max=134 (输入10个元素中的最大值)
请注意成员函数定义与调用成员函数的关系,定义成员函数只是设计了一组操作代码,并未实际执行,只有在被调用时才真正地执行这一组操作。
可以看出: 主函数很简单,语句很少,只是调用有关对象的成员函数,去完成相应的操作。在大多数情况下,主函数中甚至不出现控制结构(判断结构和循环结构),而在成员函数中使用控制结构。在面向对象的程序设计中,最关键的工作是类的设计。所有的数据和对数据的操作都体现在类中。只要把类定义好,编写程序的工作就显得很简单了。
9.1 构造函数
9.1.1 对象的初始化
在建立一个对象时,常常需要作某些初始化的工作,例如对数据成员赋初值。如果一个数据成员未被赋值,则它的值是不可预知的,因为在系统为它分配内存时,保留了这些存储单元的原状,这就成为了这些数据成员的初始值。这种状况显然是与人们的要求不相符的,对象是一个实体,它反映了客观事物的属性(例如时钟的时、分、秒的值),是应该有确定的值的。
注意: 类的数据成员是不能在声明类时初始化的。
如果一个类中所有的成员都是公用的,则可以在定义对象时对数据成员进行初始化。如
class Time
{public: //声明为公用成员
hour;
minute;
sec;
};
Time t1={14,56,30}; //将t1初始化为14:56:30
这种情况和结构体变量的初始化是差不多的,在一个花括号内顺序列出各公用数据成员的值,两个值之间用逗号分隔。但是,如果数据成员是私有的,或者类中有private或protected的成员,就不能用这种方法初始化。
在第8章的几个例子中,是用成员函数来对对象中的数据成员赋初值的(例如例8.3中的set_time函数)。从例8.3中可以看到,用户在主函数中调用set_time函数来为数据成员赋值。如果对一个类定义了多个对象,而且类中的数据成员比较多,那么,程序就显得非常臃肿烦琐。
9.1.2 构造函数的作用
为了解决这个问题,C++提供了构造函数(constructor)来处理对象的初始化。构造函数是一种特殊的成员函数,与其他成员函数不同,不需要用户来调用它,而是在建立对象时自动执行。构造函数的名字必须与类名同名,而不能由用户任意命名,以便编译系统能识别它并把它作为构造函数处理。它不具有任何类型,不返回任何值。构造函数的功能是由用户定义的,用户根据初始化的要求设计函数体和函数参数。
例9.1 在例8.3基础上定义构造成员函数。
#include <iostream>
using namespace std;
class Time
{public:
Time( ) //定义构造成员函数,函数名与类名相同
{hour=0; //利用构造函数对对象中的数据成员赋初值
minute=0;
sec=0;
}
void set_time( ); //函数声明
void show_time( ); //函数声明
private:
int hour; //私有数据成员
int minute;
int sec;
};
void Time∷set_time( ) //定义成员函数,向数据成员赋值
{cin>>hour;
cin>>minute;
cin>>sec;
}
void Time∷show_time( ) //定义成员函数,输出数据成员的值
{
cout<<hour<<″:″<<minute<<″:″<<sec<<endl;
}
int main( )
{
Time t1; //建立对象t1,同时调用构造函数t1.Time( )
t1.set_time( ); //对t1的数据成员赋值
t1.show_time( ); //显示t1的数据成员的值
Time t2; //建立对象t2,同时调用构造函数t2.Time( )
t2.show_time( ); //显示t2的数据成员的值
return 0;
}
有关构造函数的使用,有以下说明:
(1) 在类对象进入其作用域时调用构造函数。
(2) 构造函数没有返回值,因此也不需要在定义构造函数时声明类型,这是它和一般函数的一个重要的不同之点。
(3) 构造函数不需用户调用,也不能被用户调用。
(4) 在构造函数的函数体中不仅可以对数据成员赋初值,而且可以包含其他语句。但是一般不提倡在构造函数中加入与初始化无关的内容,以保持程序的清晰。
(5) 如果用户自己没有定义构造函数,则C++系统会自动生成一个构造函数,只是这个构造函数的函数体是空的,也没有参数,不执行初始化操作。
9.1.3 带参数的构造函数
在例9.1中构造函数不带参数,在函数体中对数据成员赋初值。这种方式使该类的每一个对象都得到同一组初值(例如例9.1中各数据成员的初值均为0)。但是有时用户希望对不同的对象赋予不同的初值。
可以采用带参数的构造函数,在调用不同对象的构造函数时,从外面将不同的数据传递给构造函数,以实现不同的初始化。构造函数首部的一般格式为
构造函数名(类型 1 形参1,类型2 形参2,…)
前面已说明: 用户是不能调用构造函数的,因此无法采用常规的调用函数的方法给出实参。实参是在定义对象时给出的。定义对象的一般格式为
类名 对象名(实参1,实参2,…);
例9.2 有两个长方柱,其长、宽、高分别为: (1)12,20,25;(2)10,14,20。求它们的体积。编一个基于对象的程序,在类中用带参数的构造函数。
#include <iostream>
using namespace std;
class Box
{public:
Box(int,int,int); //声明带参数的构造函数
int volume( ); //声明计算体积的函数
private:
int height;
int width;
int length;
};
Box∷Box(int h,int w,int len) //在类外定义带参数的构造函数
{height=h;
width=w;
length=len;
}
int Box∷volume( ) //定义计算体积的函数
{return(height*width*length);
}
int main( )
{Box box1(12,25,30); //建立对象box1,并指定box1长、宽、高的值
cout<<″The volume of box1 is ″<<box1.volume( )<<endl;
Box box2(15,30,21); //建立对象box2,并指定box2长、宽、高的值
cout<<″The volume of box2 is ″<<box2.volume( )<<endl;
return 0;
}
程序运行结果如下:
The volume of box1 is 9000
The volume of box2 is 9450
可以知道:
(1) 带参数的构造函数中的形参,其对应的实参在定义对象时给定。
(2) 用这种方法可以方便地实现对不同的对象进行不同的初始化。
9.1.4 用参数初始化表对数据成员初始化
在9.1.3节中介绍的是在构造函数的函数体内通过赋值语句对数据成员实现初始化。C++还提供另一种初始化数据成员的方法——参数初始化表来实现对数据成员的初始化。这种方法不在函数体内对数据成员初始化,而是在函数首部实现。例如例9.2中定义构造函数可以改用以下形式:
Box∷Box(int h,int w,int len):height(h),width(w),length(len){ }
这种写法方便、简练,尤其当需要初始化的数据成员较多时更显其优越性。甚至可以直接在类体中(而不是在类外)定义构造函数。
9.1.5 构造函数的重载
在一个类中可以定义多个构造函数,以便对类对象提供不同的初始化的方法,供用户选用。这些构造函数具有相同的名字,而参数的个数或参数的类型不相同。这称为构造函数的重载。在第4章第4.6节中所介绍的函数重载的知识也适用于构造函数。
通过下面的例子可以了解怎样应用构造函数的重载。
例9.3 在例9.2的基础上,定义两个构造函数,其中一个无参数,一个有参数。
#include <iostream>
using namespace std;
class Box
{public:
Box( ); //声明一个无参的构造函数
Box(int h,int w,int len):height(h),width(w),length(len){ }
//声明一个有参的构造函数,用参数的初始化表对数据成员初始化
int volume( );
private:
int height;
int width;
int length;
};
Box∷Box( ) //定义一个无参的构造函数
{height=10;
width=10;
length=10;
}
int Box∷volume( )
{return(height*width*length);
}
int main( )
{
Box box1; //建立对象box1,不指定实参
cout<<″The volume of box1 is ″<<box1.volume( )<<endl;
Box box2(15,30,25); //建立对象box2,指定3个实参
cout<<″The volume of box2 is ″<<box2.volume( )<<endl;
return 0;
}
在本程序中定义了两个重载的构造函数,其实还可以定义其他重载构造函数,其原型声明可以为
Box∷Box(int h); //有1个参数的构造函数
Box∷Box(int h,int w); //有两个参数的构造函数
在建立对象时分别给定1个参数和2个参数。
说明:
(1) 调用构造函数时不必给出实参的构造函数,称为默认构造函数(default constructor)。显然,无参的构造函数属于默认构造函数。一个类只能有一个默认构造函数。
(2) 如果在建立对象时选用的是无参构造函数,应注意正确书写定义对象的语句。
(3) 尽管在一个类中可以包含多个构造函数,但是对于每一个对象来说,建立对象时只执行其中一个构造函数,并非每个构造函数都被执行。
9.1.6 使用默认参数的构造函数
构造函数中参数的值既可以通过实参传递,也可以指定为某些默认值,即如果用户不指定实参值,编译系统就使形参取默认值。
在第4章第4.8节中介绍过在函数中可以使用有默认值的参数。在构造函数中也可以采用这样的方法来实现初始化。
例9.3的问题也可以使用包含默认参数的构造函数来处理。
例9.4 将例9.3程序中的构造函数改用含默认值的参数,长、宽、高的默认值均为10。
在例9.3程序的基础上改写如下:
#include <iostream>
using namespace std;
class Box
{public:
Box(int h=10,int w=10,int len=10); //在声明构造函数时指定默认参数
int volume( );
private:
int height;
int width;
int length;
};
Box∷Box(int h,int w,int len) //在定义函数时可以不指定默认参数
{height=h;
width=w;
length=len;
}
int Box∷volume( )
{return(height*width*length);
}
int main( )
{
Box box1; //没有给实参
cout<<″The volume of box1 is ″<<box1.volume( )<<endl;
Box box2(15); //只给定一个实参
cout<<″The volume of box2 is ″<<box2.volume( )<<endl;
Box box3(15,30); //只给定2个实参
cout<<″The volume of box3 is ″<<box3.volume( )<<endl;
Box box4(15,30,20); //给定3个实参
cout<<″The volume of box4 is ″<<box4.volume( )<<endl;
return 0;
}
程序运行结果为
The volume of box1 is 1000
The volume of box2 is 1500
The volume of box3 is 4500
The volume of box4 is 9000
程序中对构造函数的定义(第12~16行)也可以改写成参数初始化表的形式:
Box∷Box(int h,int w,int len):height(h),width(w),length(len){ }
可以看到: 在构造函数中使用默认参数是方便而有效的,它提供了建立对象时的多种选择,它的作用相当于好几个重载的构造函数。它的好处是: 即使在调用构造函数时没有提供实参值,不仅不会出错,而且还确保按照默认的参数值对对象进行初始化。尤其在希望对每一个对象都有同样的初始化状况时用这种方法更为方便。
说明:
(1) 应该在声明构造函数时指定默认值,而不能只在定义构造函数时指定默认值。
(2) 程序第5行在声明构造函数时,形参名可以省略。
(3) 如果构造函数的全部参数都指定了默认值,则在定义对象时可以给一个或几个实参,也可以不给出实参。
(4) 在一个类中定义了全部是默认参数的构造函数后,不能再定义重载构造函数。
9.2 析构函数
析构函数(destructor)也是一个特殊的成员函数,它的作用与构造函数相反,它的名字是类名的前面加一个“~”符号。在C++中“~”是位取反运算符,从这点也可以想到: 析构函数是与构造函数作用相反的函数。
当对象的生命期结束时,会自动执行析构函数。具体地说如果出现以下几种情况,程序就会执行析构函数: ①如果在一个函数中定义了一个对象(它是自动局部对象),当这个函数被调用结束时,对象应该释放,在对象释放前自动执行析构函数。
②static局部对象在函数调用结束时对象并不释放,因此也不调用析构函数,只在main函数结束或调用exit函数结束程序时,才调用static局部对象的析构函数。③如果定义了一个全局对象,则在程序的流程离开其作用域时(如main函数结束或调用exit函数) 时,调用该全局对象的析构函数。④如果用new运算符动态地建立了一个对象,当用delete运算符释放该对象时,先调用该对象的析构函数。
析构函数的作用并不是删除对象,而是在撤销对象占用的内存之前完成一些清理工作,使这部分内存可以被程序分配给新对象使用。程序设计者事先设计好析构函数,以完成所需的功能,只要对象的生命期结束,程序就自动执行析构函数来完成这些工作。
析构函数不返回任何值,没有函数类型,也没有函数参数。因此它不能被重载。一个类可以有多个构造函数,但只能有一个析构函数。
实际上,析构函数的作用并不仅限于释放资源方面,它还可以被用来执行“用户希望在最后一次使用对象之后所执行的任何操作”,例如输出有关的信息。这里说的用户是指类的设计者,因为,析构函数是在声明类的时候定义的。也就是说,析构函数可以完成类的设计者所指定的任何操作。
一般情况下,类的设计者应当在声明类的同时定义析构函数,以指定如何完成“清理”的工作。如果用户没有定义析构函数,C++编译系统会自动生成一个析构函数,但它只是徒有析构函数的名称和形式,实际上什么操作都不进行。想让析构函数完成任何工作,都必须在定义的析构函数中指定。
例9.5 包含构造函数和析构函数的C++程序。
#include<string>
#include<iostream>
using namespace std;
class Student //声明Student类
{public:
student(int n,string nam,char s ) //定义构造函数
{num=n;
name=nam;
sex=s;
cout<<″Constructor called.″<<endl; //输出有关信息
}
~Student( ) //定义析构函数
{cout<<″Destructor called.″<<endl;} //输出有关信息
void display( ) //定义成员函数
{cout<<″num: ″<<num<<endl;
cout<<″name: ″<<name<<endl;
cout<<″sex: ″<<sex<<endl<<endl; }
private:
int num;
char name[10];
char sex;
};
int main( )
{Student stud1(10010,″Wang_li″,′f′); //建立对象stud1
stud1.display( ); //输出学生1的数据
Student stud2(10011,″Zhang_fun″,′m′); //定义对象stud2
stud2.display( ); //输出学生2的数据
return 0;
}
程序运行结果如下:
Constructor called. (执行stud1的构造函数)
num: 10010 (执行stud1的display函数)
name:Wang_li
sex: f
Constructor called. (执行stud2的构造函数)
num: 10011 (执行stud2的display函数)
name:Zhang_fun
sex:m
Destructor called. (执行stud2的析构函数)
Destructor called. (执行stud1的析构函数)
9.3 调用构造函数和析构函数的顺序
在使用构造函数和析构函数时,需要特别注意对它们的调用时间和调用顺序。
在一般情况下,调用析构函数的次序正好与调用构造函数的次序相反: 最先被调用的构造函数,其对应的(同一对象中的)析构函数最后被调用,而最后被调用的构造函数,其对应的析构函数最先被调用。如图9.1示意。
但是,并不是在任何情况下都是按这一原则处理的。在第4章第4.11和4.12节中曾介绍过作用域和存储类别的概念,这些概念对于对象也是适用的。对象可以在不同的作用域中定义,可以有不同的存储类别。这些会影响调用构造函数和析构函数的时机。
下面归纳一下什么时候调用构造函数和析构函数:
(1) 在全局范围中定义的对象(即在所有函数之外定义的对象),它的构造函数在文件中的所有函数(包括main函数)执行之前调用。但如果一个程序中有多个文件,而不同的文件中都定义了全局对象,则这些对象的构造函数的执行顺序是不确定的。当main函数执行完毕或调用exit函数时(此时程序终止),调用析构函数。
(2) 如果定义的是局部自动对象(例如在函数中定义对象),则在建立对象时调用其构造函数。如果函数被多次调用,则在每次建立对象时都要调用构造函数。在函数调用结束、对象释放时先调用析构函数。
(3) 如果在函数中定义静态(static)局部对象,则只在程序第一次调用此函数建立对象时调用构造函数一次,在调用结束时对象并不释放,因此也不调用析构函数,只在main函数结束或调用exit函数结束程序时,才调用析构函数。
构造函数和析构函数在面向对象的程序设计中是相当重要的。以上介绍了最基本的、使用最多的普通构造函数,在本章第9.8节中将会介绍复制构造函数,在第10章第10.7节中还要介绍转换构造函数。
9.4 对象数组
数组不仅可以由简单变量组成(例如整型数组的每一个元素都是整型变量),也可以由对象组成(对象数组的每一个元素都是同类的对象)。
在日常生活中,有许多实体的属性是共同的,只是属性的具体内容不同。例如一个班有50个学生,每个学生的属性包括姓名、性别、年龄、成绩等。如果为每一个学生建立一个对象,需要分别取50个对象名。用程序处理很不方便。这时可以定义一个“学生类”对象数组,每一个数组元素是一个“学生类”对象。例如
Student stud[50]; //假设已声明了Student类,定义stud数组,有50个元素
在建立数组时,同样要调用构造函数。如果有50个元素,需要调用50次构造函数。在需要时可以在定义数组时提供实参以实现初始化。如果构造函数只有一个参数,在定义数组时可以直接在等号后面的花括号内提供实参。如
Student stud[3]={60,70,78}; //合法,3个实参分别传递给3个数组元素的构造函数
如果构造函数有多个参数,则不能用在定义数组时直接提供所有实参的方法,因为一个数组有多个元素,对每个元素要提供多个实参,如果再考虑到构造函数有默认参数的情况,很容易造成实参与形参的对应关系不清晰,出现歧义性。例如,类Student的构造函数有多个参数,且为默认参数:
Student∷ Student(int=1001,int=18,int=60); //定义构造函数,有多个参数,且为默认参数
如果定义对象数组的语句为
Student stud[3]={1005,60,70};
在程序中最好不要采用这种容易引起歧义性的方法。
编译系统只为每个对象元素的构造函数传递一个实参,所以在定义数组时提供的实参个数不能超过数组元素个数,如
Student stud[3]={60,70,78,45}; //不合法,实参个数超过对象数组元素个数
那么,如果构造函数有多个参数,在定义对象数组时应当怎样实现初始化呢?回答是: 在花括号中分别写出构造函数并指定实参。如果构造函数有3个参数,分别代表学号、年龄、成绩。则可以这样定义对象数组:
Student Stud[3]={ //定义对象数组
Student(1001,18,87), //调用第1个元素的构造函数,为它提供3个实参
Student(1002,19,76), //调用第2个元素的构造函数,为它提供3个实参
Student(1003,18,72) //调用第3个元素的构造函数,为它提供3个实参
};
在建立对象数组时,分别调用构造函数,对每个元素初始化。每一个元素的实参分别用括号包起来,对应构造函数的一组形参,不会混淆。
例9.6 对象数组的使用方法。
#include <iostream>
using namespace std;
class Box
{public:
Box(int h=10,int w=12,int len=15): height(h),width(w),length(len){ }
//声明有默认参数的构造函数,用参数初始化表对数据成员初始化
int volume( );
private:
int height;
int width;
int length;
};
int Box∷volume( )
{return(height*width*length);
}
int main( )
{ Box a[3]={ //定义对象数组
Box(10,12,15), //调用构造函数Box,提供第1个元素的实参
Box(15,18,20), //调用构造函数Box,提供第2个元素的实参
Box(16,20,26) //调用构造函数Box,提供第3个元素的实参
};
cout<<″volume of a[0] is ″<<a[0].volume( )<<endl; //调用a[0]的volume函数
cout<<″volume of a[1] is ″<<a[1].volume( )<<endl; //调用a[1] 的volume函数
cout<<″volume of a[2] is ″<<a[2].volume( )<<endl; //调用a[2] 的volume函数
}
运行结果如下:
volume of a[0] is 1800
volume of a[1] is 5400
volume of a[2] is 8320
9.5 对象指针
9.5.1 指向对象的指针
在建立对象时,编译系统会为每一个对象分配一定的存储空间,以存放其成员。对象空间的起始地址就是对象的指针。可以定义一个指针变量,用来存放对象的指针。如果有一个类:
class Time
{public:
int hour;
int minute;
int sec;
void get_time( );
};
void Time∷get_time( )
{cout<<hour<<″:″<<minute<<″:″<<sec<<endl;}
在此基础上有以下语句:
Time *pt; //定义pt为指向Time类对象的指针变量
Time t1; //定义t1为Time类对象
pt=&t1; //将t1的起始地址赋给pt
这样,pt就是指向Time类对象的指针变量,它指向对象t1。
定义指向类对象的指针变量的一般形式为
类名 *对象指针名;
可以通过对象指针访问对象和对象的成员。如
*pt pt所指向的对象,即t1。
(*pt).hour pt所指向的对象中的hour成员,即t1.hour
pt->hour pt所指向的对象中的hour成员,即t1.hour
(*pt).get_time ( ) 调用pt所指向的对象中的get_time函数,即t1.get_time
pt->get_time ( ) 调用pt所指向的对象中的get_time函数,即t1.get_time
9.5.2 指向对象成员的指针
对象有地址,存放对象初始地址的指针变量就是指向对象的指针变量。对象中的成员也有地址,存放对象成员地址的指针变量就是指向对象成员的指针变量。
1. 指向对象数据成员的指针
定义指向对象数据成员的指针变量的方法和定义指向普通变量的指针变量方法相同。例如
int *p1; //定义指向整型数据的指针变量
定义指向对象数据成员的指针变量的一般形式为
数据类型名 *指针变量名;
如果Time类的数据成员hour为公用的整型数据,则可以在类外通过指向对象数据成员的指针变量访问对象数据成员hour。
p1=&t1.hour; //将对象t1的数据成员hour的地址赋给p1,p1指向t1.hour
cout<<*p1<<endl; //输出t1.hour的值
2. 指向对象成员函数的指针
需要提醒读者注意: 定义指向对象成员函数的指针变量的方法和定义指向普通函数的指针变量方法有所不同。
成员函数与普通函数有一个最根本的区别: 它是类中的一个成员。编译系统要求在上面的赋值语句中,指针变量的类型必须与赋值号右侧函数的类型相匹配,要求在以下3方面都要匹配: ①函数参数的类型和参数个数;②函数返回值的类型;③所属的类。
定义指向成员函数的指针变量应该采用下面的形式:
void (Time∷*p2)( ); //定义p2为指向Time类中公用成员函数的指针变量
定义指向公用成员函数的指针变量的一般形式为
数据类型名 (类名∷*指针变量名)(参数表列);
可以让它指向一个公用成员函数,只需把公用成员函数的入口地址赋给一个指向公用成员函数的指针变量即可。如
p2=&Time∷get_time;
使指针变量指向一个公用成员函数的一般形式为
指针变量名=&类名∷成员函数名;
例9.7 有关对象指针的使用方法。
#include <iostream>
using namespace std;
class Time
{public:
Time(int,int,int);
int hour;
int minute;
int sec;
void get_time( ); //声明公有成员函数
};
Time∷Time(int h,int m,int s)
{hour=h;
minute=m;
sec=s;
}
void Time∷get_time( ) //定义公有成员函数
{cout<<hour<<″:″<<minute<<″:″ <<sec<<endl;}
int main( )
{Time t1(10,13,56); //定义Time类对象t1
int *p1=&t1.hour; //定义指向整型数据的指针变量p1,并使p1指向t1.hour
cout<<*p1<<endl; //输出p1所指的数据成员t1.hour
t1.get_time( ); //调用对象t1的成员函数get_time
Time *p2=&t1; //定义指向Time类对象的指针变量p2,并使p2指向t1
p2->get_time( ); //调用p2所指向对象(即t1)的get_time函数
void (Time∷*p3)( ); //定义指向Time类公用成员函数的指针变量p3
p3=&Time∷get_time; //使p3指向Time类公用成员函数get_time
(t1.*p3)( ); //调用对象t1中p3所指的成员函数(即t1.get_time( ))
}
程序运行结果为
10 (main函数第4行的输出)
10:13:56 (main函数第5行的输出)
10:13:56 (main函数第7行的输出)
10:13:56 (main函数第10行的输出)
可以看到为了输出t1中hour,minute和sec的值,可以采用3种不同的方法。
说明:
(1) 从main函数第9行可以看出: 成员函数的入口地址的正确写法是: &类名∷成员函数名。
(2) main函数第8、9两行可以合写为一行:
void (Time∷*p3)( )=&Time∷get_time; //定义指针变量时指定其指向
9.5.3 this 指针
在第8章中曾经提到过: 每个对象中的数据成员都分别占有存储空间,如果对同一个类定义了n个对象,则有n组同样大小的空间以存放n个对象中的数据成员。但是,不同对象都调用同一个函数代码段。
那么,当不同对象的成员函数引用数据成员时,怎么能保证引用的是所指定的对象的数据成员呢?假如,对于例9.6程序中定义的Box类,定义了3个同类对象a,b,c。如果有a.volume( ),应该是引用对象a中的height,width和length,计算出长方体a的体积。如果有b.volume( ),应该是引用对象b中的height,width和length,计算出长方体b的体积。而现在都用同一个函数段,系统怎样使它分别引用a或b中的数据成员呢?
在每一个成员函数中都包含一个特殊的指针,这个指针的名字是固定的,称为this。它是指向本类对象的指针,它的值是当前被调用的成员函数所在的对象的起始地址。例如,当调用成员函数a.volume时,编译系统就把对象a的起始地址赋给this指针,于是在成员函数引用数据成员时,就按照this的指向找到对象a的数据成员。例如volume函数要计算height*width*length的值,实际上是执行:
(this->height)*(this->width)*(this->length)
由于当前this指向a,因此相当于执行:
(a.height)*(a.width)*(a.length)
这就计算出长方体a的体积。同样如果有b.volume( ),编译系统就把对象b的起始地址赋给成员函数volume的this指针,显然计算出来的是长方体b的体积。
this指针是隐式使用的,它是作为参数被传递给成员函数的。本来,成员函数volume的定义如下:
int Box∷volume( )
{return (height*width*length);
}
C++把它处理为
int Box∷volume(Box *this)
{return(this->height * this->width * this->length);
}
即在成员函数的形参表列中增加一个this指针。在调用该成员函数时,实际上是用以下方式调用的:
a.volume(&a);
将对象a的地址传给形参this指针。然后按this的指向去引用其他成员。
需要说明: 这些都是编译系统自动实现的,编程序者不必人为地在形参中增加this指针,也不必将对象a的地址传给this指针。
在需要时也可以显式地使用this指针。例如在Box类的volume函数中,下面两种表示方法都是合法的、相互等价的。
return(height * width * length); //隐含使用this指针
return(this->height * this->width * this->length); //显式使用this指针
可以用*this表示被调用的成员函数所在的对象,*this就是this所指向的对象,即当前的对象。例如在成员函数a.volume( )的函数体中,如果出现*this,它就是本对象a。上面的return语句也可写成
return((*this).height * (*this).width * (*this).length);
注意*this两侧的括号不能省略,不能写成*this.height。
所谓“调用对象a的成员函数f”,实际上是在调用成员函数f时使this指针指向对象a,从而访问对象a的成员。在使用“调用对象a的成员函数f”时,应当对它的含义有正确的理解。
9.6 共用数据的保护
C++虽然采取了不少有效的措施(如设private保护)以增加数据的安全性,但是有些数据却往往是共享的,人们可以在不同的场合通过不同的途径访问同一个数据对象。有时在无意之中的误操作会改变有关数据的状况,而这是人们所不希望出现的。
既要使数据能在一定范围内共享,又要保证它不被任意修改,这时可以使用const,即把有关的数据定义为常量。
9.6.1 常对象
在定义对象时指定对象为常对象。常对象必须要有初值,如
Time const t1(12,34,46); //t1是常对象
这样,在所有的场合中,对象t1中的所有成员的值都不能被修改。凡希望保证数据成员不被改变的对象,可以声明为常对象。
定义常对象的一般形式为
类名 const 对象名[(实参表列)];
也可以把const写在最左面:
const 类名 对象名[(实参表列)];
二者等价。
如果一个对象被声明为常对象,则不能调用该对象的非const型的成员函数(除了由系统自动调用的隐式的构造函数和析构函数)。例如,对于例9.7中已定义的Time类,如果有
const Time t1(10,15,36); //定义常对象t1
t1.get_time( ); //企图调用常对象t1中的非const型成员函数,非法
这是为了防止这些函数会修改常对象中数据成员的值。不能仅依靠编程者的细心来保证程序不出错,编译系统充分考虑到可能出现的情况,对不安全的因素予以拦截。
现在,编译系统只检查函数的声明,只要发现调用了常对象的成员函数,而且该函数未被声明为const,就报错,提请编程者注意。
引用常对象中的数据成员很简单,只需将该成员函数声明为const即可。如
void get_time( ) const; //将函数声明为const
这表示get_time是一个const型函数,即常成员函数。常成员函数可以访问常对象中的数据成员,但仍然不允许修改常对象中数据成员的值。
有时在编程时有要求,一定要修改常对象中的某个数据成员的值,ANSI C++考虑到实际编程时的需要,对此作了特殊的处理,对该数据成员声明为mutable,如
mutable int count;
把count声明为可变的数据成员,这样就可以用声明为const的成员函数来修改它的值。
9.6.2 常对象成员
可以将对象的成员声明为const,包括常数据成员和常成员函数。
1. 常数据成员
其作用和用法与一般常变量相似,用关键字const来声明常数据成员。常数据成员的值是不能改变的。有一点要注意: 只能通过构造函数的参数初始化表对常数据成员进行初始化。如在类体中定义了常数据成员hour:
const int hour; //声明hour为常数据成员
不能采用在构造函数中对常数据成员赋初值的方法。
在类外定义构造函数,应写成以下形式:
Time∷Time(int h):hour(h){} //通过参数初始化表对常数据成员hour初始化
常对象的数据成员都是常数据成员,因此常对象的构造函数只能用参数初始化表对常数据成员进行初始化。
2. 常成员函数
前面已提到: 一般的成员函数可以引用本类中的非const数据成员,也可以修改它们。如果将成员函数声明为常成员函数,则只能引用本类中的数据成员,而不能修改它们,例如只用于输出数据等。如
void get_time( ) const; //注意const的位置在函数名和括号之后
const是函数类型的一部分,在声明函数和定义函数时都要有const关键字,在调用时不必加const。常成员函数可以引用const数据成员,也可以引用非const的数据成员。const数据成员可以被const成员函数引用,也可以被非const的成员函数引用。具体情况可以用书中表9.1表示。
怎样利用常成员函数呢?
(1) 如果在一个类中,有些数据成员的值允许改变,另一些数据成员的值不允许改变,则可以将一部分数据成员声明为const,以保证其值不被改变,可以用非const的成员函数引用这些数据成员的值,并修改非const数据成员的值。
(2) 如果要求所有的数据成员的值都不允许改变,则可以将所有的数据成员声明为const,或将对象声明为const(常对象),然后用const成员函数引用数据成员,这样起到“双保险”的作用,切实保证了数据成员不被修改。
(3) 如果已定义了一个常对象,只能调用其中的const成员函数,而不能调用非const成员函数(不论这些函数是否会修改对象中的数据)。这是为了保证数据的安全。如果需要访问对象中的数据成员,可将常对象中所有成员函数都声明为const成员函数,但应确保在函数中不修改对象中的数据成员。
不要误认为常对象中的成员函数都是常成员函数。常对象只保证其数据成员是常数据成员,其值不被修改。如果在常对象中的成员函数未加const声明,编译系统把它作为非const成员函数处理。
还有一点要指出: 常成员函数不能调用另一个非const成员函数。
9.6.3 指向对象的常指针
将指针变量声明为const型,这样指针值始终保持为其初值,不能改变。如
Time t1(10,12,15),t2; //定义对象
Time * const ptr1; //const位置在指针变量名前面,规定ptr1的值是常值
ptr1=&t1; //ptr1指向对象t1,此后不能再改变指向
ptr1=&t2; //错误,ptr1不能改变指向
定义指向对象的常指针的一般形式为
类名 * const 指针变量名;
也可以在定义指针变量时使之初始化,如将上面第2,3行合并为
Time * const ptr1=&t1; //指定ptr1指向t1
请注意: 指向对象的常指针变量的值不能改变,即始终指向同一个对象,但可以改变其所指向对象(如t1)的值。
如果想将一个指针变量固定地与一个对象相联系(即该指针变量始终指向一个对象),可以将它指定为const型指针变量。
往往用常指针作为函数的形参,目的是不允许在函数执行过程中改变指针变量的值,使其始终指向原来的对象。
9.6.4 指向常对象的指针变量
为了更容易理解指向常对象的指针变量的概念和使用,首先了解指向常变量的指针变量,然后再进一步研究指向常对象的指针变量。
下面定义了一个指向常变量的指针变量
ptr: const char *ptr;
注意const的位置在最左侧,它与类型名char紧连,表示指针变量ptr指向的char变量是常变量,不能通过ptr来改变其值的。
定义指向常变量的指针变量的一般形式为
const 类型名 *指针变量名;
说明:
(1) 如果一个变量已被声明为常变量,只能用指向常变量的指针变量指向它,而不能用一般的(指向非const型变量的)指针变量去指向它。
(2) 指向常变量的指针变量除了可以指向常变量外,还可以指向未被声明为const的变量。此时不能通过此指针变量改变该变量的值。如果希望在任何情况下都不能改变c1的值,则应把它定义为const型。
(3) 如果函数的形参是指向非const型变量的指针,实参只能用指向非const变量的指针,而不能用指向const变量的指针,这样,在执行函数的过程中可以改变形参指针变量所指向的变量(也就是实参指针所指向的变量)的值。
如果函数的形参是指向const型变量的指针,在执行函数过程中显然不能改变指针变量所指向的变量的值,因此允许实参是指向const变量的指针,或指向非const变量的指针。
使用形参和实参的对应关系见书中表9.2。
以上的对应关系与在(2)中所介绍的指针变量和其所指向的变量的关系是一致的: 指向常变量的指针变量可以指向const和非const型的变量,而指向非const型变量的指针变量只能指向非const的变量。
以上介绍的是指向常变量的指针变量,指向常对象的指针变量的概念和使用是与此类似的,只要将“变量”换成“对象”即可。
(1) 如果一个对象已被声明为常对象,只能用指向常对象的指针变量指向它,而不能用一般的(指向非const型对象的)指针变量去指向它。
(2) 如果定义了一个指向常对象的指针变量,并使它指向一个非const的对象,则其指向的对象是不能通过指针来改变的。如果希望在任何情况下t1的值都不能改变,则应把它定义为const型。
(3) 指向常对象的指针最常用于函数的形参,目的是在保护形参指针所指向的对象,使它在函数执行过程中不被修改。
请记住这样一条规则: 当希望在调用函数时对象的值不被修改,就应当把形参定义为指向常对象的指针变量,同时用对象的地址作实参(对象可以是const或非const型)。如果要求该对象不仅在调用函数过程中不被改变,而且要求它在程序执行过程中都不改变,则应把它定义为const型。
(1) 如果定义了一个指向常对象的指针变量,是不能通过它改变所指向的对象的值的,但是指针变量本身的值是可以改变的。
9.6.5 对象的常引用
过去曾介绍: 一个变量的引用就是变量的别名。实质上,变量名和引用名都指向同一段内存单元。如果形参为变量的引用名,实参为变量名,则在调用函数进行虚实结合时,并不是为形参另外开辟一个存储空间(常称为建立实参的一个拷贝),而是把实参变量的地址传给形参(引用名),这样引用名也指向实参变量。
例9.8 对象的常引用。
#include <iostream>
using namespace std;
class Time
{public:
Time(int,int,int);
int hour;
int minute;
int sec;
};
Time∷Time(int h,int m,int s) //定义构造函数
{hour=h;
minute=m;
sec=s;
}
void fun(Time &t) //形参t是Time类对象的引用
{t.hour=18;}
int main( )
{Time t1(10,13,56); // t1是Time类对象
fun(t1); //实参是Time类对象,可以通过引用来修改实参t1的值
cout<<t1.hour<<endl; //输出t1.hour的值为18
return 0;
}
如果不希望在函数中修改实参t1的值,可以把引用变量t声明为const(常引用),函数原型为
void fun(const Time &t);
则在函数中不能改变t的值,也就是不能改变其对应的实参t1的值。
在C++面向对象程序设计中,经常用常指针和常引用作函数参数。这样既能保证数据安全,使数据不能被随意修改,在调用函数时又不必建立实参的拷贝。用常指针和常引用作函数参数,可以提高程序运行效率。
9.6.6 const型数据的小结
9.7 对象的动态建立和释放
用前面介绍的方法定义的对象是静态的,在程序运行过程中,对象所占的空间是不能随时释放的。但有时人们希望在需要用到对象时才建立对象,在不需要用该对象时就撤销它,释放它所占的内存空间以供别的数据使用。这样可提高内存空间的利用率。
在第7章7.1.7节中介绍了用new运算符动态地分配内存,用delete运算符释放这些内存空间。这也适用于对象,可以用new运算符动态建立对象,用delete运算符撤销对象。
如果已经定义了一个Box类,可以用下面的方法动态地建立一个对象:
new Box;
编译系统开辟了一段内存空间,并在此内存空间中存放一个Box类对象,同时调用该类的构造函数,以使该对象初始化(如果已对构造函数赋予此功能的话)。但是此时用户还无法访问这个对象,因为这个对象既没有对象名,用户也不知道它的地址。这种对象称为无名对象,它确实是存在的,但它没有名字。
用new运算符动态地分配内存后,将返回一个指向新对象的指针的值,即所分配的内存空间的起始地址。用户可以获得这个地址,并通过这个地址来访问这个对象。需要定义一个指向本类的对象的指针变量来存放该地址。如
Box *pt; //定义一个指向Box类对象的指针变量pt
pt=new Box; //在pt中存放了新建对象的起始地址
在程序中就可以通过pt访问这个新建的对象。如
cout<<pt->height; //输出该对象的height成员
cout<<pt->volume( ); //调用该对象的volume函数,计算并输出体积
C++还允许在执行new时,对新建立的对象进行初始化。如
Box *pt=new Box(12,15,18);
这种写法是把上面两个语句(定义指针变量和用new建立新对象)合并为一个语句,并指定初值。这样更精炼。新对象中的height,width和length分别获得初值12,15,18。
调用对象既可以通过对象名,也可以通过指针。用new建立的动态对象一般是不用对象名的,是通过指针访问的,它主要应用于动态的数据结构,如链表。访问链表中的结点,并不需要通过对象名,而是在上一个结点中存放下一个结点的地址,从而由上一个结点找到下一个结点,构成链接的关系。
在执行new运算时,如果内存量不足,无法开辟所需的内存空间,目前大多数C++编译系统都使new返回一个0指针值。只要检测返回值是否为0,就可判断分配内存是否成功。ANSI C++标准提出,在执行new出现故障时,就“抛出”一个“异常”,用户可根据异常进行有关处理。但C++标准仍然允许在出现new故障时返回0指针值。当前,不同的编译系统对new故障的处理方法是不同的。
在不再需要使用由new建立的对象时,可以用delete运算符予以释放。如
delete pt; //释放pt指向的内存空间
这就撤销了pt指向的对象。此后程序不能再使用该对象。如果用一个指针变量pt先后指向不同的动态对象,应注意指针变量的当前指向,以免删错了对象。
在执行delete运算符时,在释放内存空间之前,自动调用析构函数,完成有关善后清理工作。
9.8 对象的赋值和复制
9.8.1 对象的赋值
如果对一个类定义了两个或多个对象,则这些同类的对象之间可以互相赋值,或者说,一个对象的值可以赋给另一个同类的对象。这里所指的对象的值是指对象中所有数据成员的值。
对象之间的赋值也是通过赋值运算符“=”进行的。本来,赋值运算符“=”只能用来对单个的变量赋值,现在被扩展为两个同类对象之间的赋值,这是通过对赋值运算符的重载实现的。实际这个过程是通过成员复制来完成的,即将一个对象的成员值一一复制给另一对象的对应成员。对象赋值的一般形式为
对象名1 = 对象名2;
注意对象名1和对象名2必须属于同一个类。
例如
Student stud1,stud2; //定义两个同类的对象
┆
stud2=stud1; //将stud1赋给stud2
通过下面的例子可以了解怎样进行对象的赋值。
例9.9 对象的赋值。
#include <iostream>
using namespace std;
class Box
{public:
Box(int=10,int=10,int=10); //声明有默认参数的构造函数
int volume( );
private:
int height;
int width;
int length;
};
Box∷Box(int h,int w,int len)
{height=h;
width=w;
length=len;
}
int Box∷volume( )
{return(height*width*length); //返回体积
}
int main( )
{Box box1(15,30,25),box2; //定义两个对象box1和box2
cout<<″The volume of box1 is ″<<box1.volume( )<<endl;
box2=box1; //将box1的值赋给box2
cout<<″The volume of box2 is ″<<box2.volume( )<<endl;
return 0;
}
运行结果如下:
The volume of box1 is 11250
The volume of box2 is 11250
说明:
(1) 对象的赋值只对其中的数据成员赋值,而不对成员函数赋值。
(2) 类的数据成员中不能包括动态分配的数据,否则在赋值时可能出现严重后果。
9.8.2 对象的复制
有时需要用到多个完全相同的对象。此外,有时需要将对象在某一瞬时的状态保留下来。这就是对象的复制机制。用一个已有的对象快速地复制出多个完全相同的对象。如
Box box2(box1);
其作用是用已有的对象box1去克隆出一个新对象box2。
其一般形式为
类名 对象2(对象1);
用对象1复制出对象2。
可以看到: 它与前面介绍过的定义对象方式类似,但是括号中给出的参数不是一般的变量,而是对象。在建立对象时调用一个特殊的构造函数——复制构造函数(copy constructor)。这个函数的形式是这样的:
//The copy constructor definition.
Box∷Box(const Box& b)
{height=b.height;
width=b.width;
length=b.length;
}
复制构造函数也是构造函数,但它只有一个参数,这个参数是本类的对象(不能是其他类的对象),而且采用对象的引用的形式(一般约定加const声明,使参数值不能改变,以免在调用此函数时因不慎而使对象值被修改)。
此复制构造函数的作用就是将实参对象的各成员值一一赋给新的对象中对应的成员。
回顾复制对象的语句
Box box2(box1);
这实际上也是建立对象的语句,建立一个新对象box2。由于在括号内给定的实参是对象,因此编译系统就调用复制构造函数(它的形参也是对象),而不会去调用其他构造函数。实参box1的地址传递给形参b(b是box1的引用),因此执行复制构造函数的函数体时,将box1对象中各数据成员的值赋给box2中各数据成员。
如果用户自己未定义复制构造函数,则编译系统会自动提供一个默认的复制构造函数,其作用只是简单地复制类中每个数据成员。
C++还提供另一种方便用户的复制形式,用赋值号代替括号,如
Box box2=box1; //用box1初始化box2
其一般形式为
类名 对象名1 = 对象名2;
可以在一个语句中进行多个对象的复制。如
Box box2=box1,box3=box2;
按box1来复制box2和box3。可以看出: 这种形式与变量初始化语句类似,请与下面定义变量的语句作比较:
int a=4,b=a;
这种形式看起来很直观,用起来很方便。但是其作用都是调用复制构造函数。
请注意对象的复制和9.8.1节介绍的对象的赋值在概念上和语法上的不同。对象的赋值是对一个已经存在的对象赋值,因此必须先定义被赋值的对象,才能进行赋值。而对象的复制则是从无到有地建立一个新对象,并使它与一个已有的对象完全相同(包括对象的结构和成员的值)。
可以对例9.7程序中的主函数作一些修改:
int main( )
{Box box1(15,30,25); //定义box1
cout<<″The volume of box1 is ″<<box1.volume( )<<endl;
Box box2=box1,box3=box2; //按box1来复制box2,box3
cout<<″The volume of box2 is ″<<box2.volume( )<<endl;
cout<<″The volume of box3 is ″<<box3.volume( )<<endl;
}
执行完第3行后,3个对象的状态完全相同。
请注意普通构造函数和复制构造函数的区别。
(1) 在形式上
类名(形参表列); //普通构造函数的声明,如Box(int h,int w,int len);
类名(类名& 对象名); //复制构造函数的声明,如Box(Box &b);
(2) 在建立对象时,实参类型不同。系统会根据实参的类型决定调用普通构造函数或复制构造函数。如
Box box1(12,15,16); //实参为整数,调用普通构造函数
Box box2(box1); //实参是对象名,调用复制构造函数
(3) 在什么情况下被调用
普通构造函数在程序中建立对象时被调用。
复制构造函数在用已有对象复制一个新对象时被调用,在以下3种情况下需要克隆对象:
① 程序中需要新建立一个对象,并用另一个同类的对象对它初始化,如前面介绍的那样。
② 当函数的参数为类的对象时。在调用函数时需要将实参对象完整地传递给形参,也就是需要建立一个实参的拷贝,这就是按实参复制一个形参,系统是通过调用复制构造函数来实现的,这样能保证形参具有和实参完全相同的值。如
void fun(Box b) //形参是类的对象
{ }
int main( )
{Box box1(12,15,18);
fun(box1); //实参是类的对象,调用函数时将复制一个新对象b
return 0;
}
③ 函数的返回值是类的对象。在函数调用完毕将返回值带回函数调用处时。此时需要将函数中的对象复制一个临时对象并传给该函数的调用处。如
Box f( ) //函数f的类型为Box类类型
{Box box1(12,15,18);
return box1; //返回值是Box类的对象
}
int main( )
{Box box2; //定义Box类的对象box2
box2=f( ); //调用f函数,返回Box类的临时对象,并将它赋值给box2
}
以上几种调用复制构造函数都是由编译系统自动实现的,不必由用户自己去调用,读者只要知道在这些情况下需要调用复制构造函数就可以了。
9.9 静态成员
如果有n个同类的对象,那么每一个对象都分别有自己的数据成员,不同对象的数据成员各自有值,互不相干。但是有时人们希望有某一个或几个数据成员为所有对象所共有。这样可以实现数据共享。
在第7章中曾介绍过全局变量,它能够实现数据共享。如果在一个程序文件中有多个函数,在每一个函数中都可以改变全局变量的值,全局变量的值为各函数共享。但是用全局变量的安全性得不到保证,由于在各处都可以*地修改全局变量的值,很有可能偶一失误,全局变量的值就被修改,导致程序的失败。因此在实际工作中很少使用全局变量。
如果想在同类的多个对象之间实现数据共享,也不要用全局对象,可以用静态的数据成员。
9.9.1 静态数据成员
静态数据成员是一种特殊的数据成员。它以关键字static开头。例如
class Box
{public:
int volume( );
private:
static int height; //把height定义为静态的数据成员
int width;
int length;
};
如果希望各对象中的height的值是一样的,就可以把它定义为静态数据成员,这样它就为各对象所共有,而不只属于某个对象的成员,所有对象都可以引用它。静态的数据成员在内存中只占一份空间。每个对象都可以引用这个静态数据成员。静态数据成员的值对所有对象都是一样的。如果改变它的值,则在各对象中这个数据成员的值都同时改变了。这样可以节约空间,提高效率。
说明:
(1) 在第8章中曾强调: 如果只声明了类而未定义对象,则类的一般数据成员是不占内存空间的,只有在定义对象时,才为对象的数据成员分配空间。但是静态数据成员不属于某一个对象,在为对象所分配的空间中不包括静态数据成员所占的空间。静态数据成员是在所有对象之外单独开辟空间。只要在类中定义了静态数据成员,即使不定义对象,也为静态数据成员分配空间,它可以被引用。
在一个类中可以有一个或多个静态数据成员,所有的对象共享这些静态数据成员,都可以引用它。
(2) 在第7章中曾介绍了静态变量的概念: 如果在一个函数中定义了静态变量,在函数结束时该静态变量并不释放,仍然存在并保留其值。现在讨论的静态数据成员也是类似的,它不随对象的建立而分配空间,也不随对象的撤销而释放(一般数据成员是在对象建立时分配空间,在对象撤销时释放)。静态数据成员是在程序编译时被分配空间的,到程序结束时才释放空间。
(3) 静态数据成员可以初始化,但只能在类体外进行初始化。如
int Box∷height=10; //表示对Box类中的数据成员初始化
其一般形式为
数据类型类名∷静态数据成员名=初值;
不必在初始化语句中加static。
注意: 不能用参数初始化表对静态数据成员初始化。如在定义Box类中这样定义构造函数是错误的:
Box(int h,int w,int len):height(h){ } //错误,height是静态数据成员
如果未对静态数据成员赋初值,则编译系统会自动赋予初值0。
(4) 静态数据成员既可以通过对象名引用,也可以通过类名来引用。
请观察下面的程序。
例9.10 引用静态数据成员。
#include <iostream>
using namespace std;
class Box
{public:
Box(int,int);
int volume( );
static int height; //把height定义为公用的静态的数据成员
int width;
int length;
};
Box∷Box(int w,int len) //通过构造函数对width和length赋初值
{width=w;
length=len;
}
int Box∷volume( )
{return(height*width*length);
}
int Box∷height=10; //对静态数据成员height初始化
int main( )
{
Box a(15,20),b(20,30);
cout<<a.height<<endl; //通过对象名a引用静态数据成员
cout<<b.height<<endl; //通过对象名b引用静态数据成员
cout<<Box∷height<<endl; //通过类名引用静态数据成员
cout<<a.volume( )<<endl; //调用volume函数,计算体积,输出结果
}
上面3个输出语句的输出结果相同(都是10)。这就验证了所有对象的静态数据成员实际上是同一个数据成员。
请注意: 在上面的程序中将height定义为公用的静态数据成员,所以在类外可以直接引用。可以看到在类外可以通过对象名引用公用的静态数据成员,也可以通过类名引用静态数据成员。即使没有定义类对象,也可以通过类名引用静态数据成员。
这说明静态数据成员并不是属于对象的,而是属于类的,但类的对象可以引用它。
如果静态数据成员被定义为私有的,则不能在类外直接引用,而必须通过公用的成员函数引用。
(5) 有了静态数据成员,各对象之间的数据有了沟通的渠道,实现数据共享,因此可以不使用全局变量。全局变量破坏了封装的原则,不符合面向对象程序的要求。
但是也要注意公用静态数据成员与全局变量的不同,静态数据成员的作用域只限于定义该类的作用域内(如果是在一个函数中定义类,那么其中静态数据成员的作用域就是此函数内)。在此作用域内,可以通过类名和域运算符“∷”引用静态数据成员,而不论类对象是否存在。
9.9.2 静态成员函数
成员函数也可以定义为静态的,在类中声明函数的前面加static就成了静态成员函数。如
static int volume( );
和静态数据成员一样,静态成员函数是类的一部分,而不是对象的一部分。如果要在类外调用公用的静态成员函数,要用类名和域运算符“∷”。如
Box∷volume( );
实际上也允许通过对象名调用静态成员函数,如
a.volume( );
但这并不意味着此函数是属于对象a的,而只是用a的类型而已。
与静态数据成员不同,静态成员函数的作用不是为了对象之间的沟通,而是为了能处理静态数据成员。
前面曾指出: 当调用一个对象的成员函数(非静态成员函数)时,系统会把该对象的起始地址赋给成员函数的this指针。而静态成员函数并不属于某一对象,它与任何对象都无关,因此静态成员函数没有this指针。既然它没有指向某一对象,就无法对一个对象中的非静态成员进行默认访问(即在引用数据成员时不指定对象名)。
可以说,静态成员函数与非静态成员函数的根本区别是: 非静态成员函数有this指针,而静态成员函数没有this指针。由此决定了静态成员函数不能访问本类中的非静态成员。
静态成员函数可以直接引用本类中的静态数据成员,因为静态成员同样是属于类的,可以直接引用。在C++程序中,静态成员函数主要用来访问静态数据成员,而不访问非静态成员。假如在一个静态成员函数中有以下语句:
cout<<height<<endl; //若height已声明为static,则引用本类中的静态成员,合法
cout<<width<<endl; //若width是非静态数据成员,不合法
但是,并不是绝对不能引用本类中的非静态成员,只是不能进行默认访问,因为无法知道应该去找哪个对象。如果一定要引用本类的非静态成员,应该加对象名和成员运算符“.”。如
cout<<a.width<<endl; //引用本类对象a中的非静态成员
假设a已定义为Box类对象,且在当前作用域内有效,则此语句合法。
通过例9.11可以具体了解有关引用非静态成员的具体方法。
例9.11 静态成员函数的应用。
#include <iostream>
using namespace std;
class Student //定义Student类
{public:
Student(int n,int a,float s):num(n),age(a),score(s){ } //定义构造函数
void total( );
static float average( ); //声明静态成员函数
private:
int num;
int age;
float score;
static float sum; //静态数据成员
static int count; //静态数据成员
};
void Student∷total( ) //定义非静态成员函数
{sum+=score; //累加总分
count++; //累计已统计的人数
}
float Student∷average( ) //定义静态成员函数
{return(sum/count);
}
float Student∷sum=0; //对静态数据成员初始化
int Student∷count=0; //对静态数据成员初始化
int main( )
{Student stud[3]={ //定义对象数组并初始化
Student(1001,18,70),
Student(1002,19,78),
Student(1005,20,98)
};
int n;
cout<<″please input the number of students:″;
cin>>n; //输入需要求前面多少名学生的平均成绩
for(int i=0;i<n;i++) //调用3次total函数
stud[i].total( );
cout<<″the average score of ″<<n<<″ students is ″<<Student∷average( )<<endl;
//调用静态成员函数
return 0;
}
运行结果为
please input the number of students:3↙
the average score of 3 students is 82.3333
说明:
(1) 在主函数中定义了stud对象数组,为了使程序简练,只定义它含3个元素,分别存放3个学生的数据。程序的作用是先求用户指定的n名学生的总分,然后求平均成绩(n由用户输入)。
(2) 在Student类中定义了两个静态数据成员sum(总分)和count(累计需要统计的学生人数),这是由于这两个数据成员的值是需要进行累加的,它们并不是只属于某一个对象元素,而是由各对象元素共享的,可以看出: 它们的值是在不断变化的,而且无论对哪个对象元素而言,都是相同的,而且始终不释放内存空间。
(3) total是公有的成员函数,其作用是将一个学生的成绩累加到sum中。公有的成员函数可以引用本对象中的一般数据成员(非静态数据成员),也可以引用类中的静态数据成员。score是非静态数据成员,sum和count是静态数据成员。
(4) average是静态成员函数,它可以直接引用私有的静态数据成员(不必加类名或对象名),函数返回成绩的平均值。
(5) 在main函数中,引用total函数要加对象名(今用对象数组元素名),引用静态成员函数average函数要用类名或对象名。
(6) 请思考: 如果不将average函数定义为静态成员函数行不行?程序能否通过编译?需要作什么修改?为什么要用静态成员函数?请分析其理由。
(7) 如果想在average函数中引用stud[1]的非静态数据成员score,应该怎样处理?
以上是在例9.11的基础上顺便说明静态成员函数引用非静态数据成员的方法,以帮助读者理解。但是在C++程序中最好养成这样的习惯: 只用静态成员函数引用静态数据成员,而不引用非静态数据成员。这样思路清晰,逻辑清楚,不易出错。
9.10 友元
在一个类中可以有公用的(public)成员和私有的(private)成员。在类外可以访问公用成员,只有本类中的函数可以访问本类的私有成员。现在,我们来补充介绍一个例外——友元(friend)。
友元可以访问与其有好友关系的类中的私有成员。友元包括友元函数和友元类。
9.10.1 友元函数
如果在本类以外的其他地方定义了一个函数(这个函数可以是不属于任何类的非成员函数,也可以是其他类的成员函数),在类体中用friend对其进行声明,此函数就称为本类的友元函数。友元函数可以访问这个类中的私有成员。
1. 将普通函数声明为友元函数
通过下面的例子可以了解友元函数的性质和作用。
例9.12 友元函数的简单例子。
#include <iostream>
using namespace std;
class Time
{public:
Time(int,int,int);
friend void display(Time &); //声明display函数为Time类的友元函数
private: //以下数据是私有数据成员
int hour;
int minute;
int sec;
};
Time∷Time(int h,int m,int s) //构造函数,给hour,minute,sec赋初值
{hour=h;
minute=m;
sec=s;
}
void display(Time& t) //这是友元函数,形参t是Time类对象的引用
{cout<<t.hour<<″:″<<t.minute<<″:″<<t.sec<<endl;}
int main( )
{ Time t1(10,13,56);
display(t1);
return 0; //调用display函数,实参t1是Time类对象
}
程序输出结果如下:
10:13:56
由于声明了display是Time类的friend函数,所以display函数可以引用Time中的私有成员hour,minute,sec。但注意在引用这些私有数据成员时,必须加上对象名,不能写成
cout<<hour<<″:″<<minute<<″:″<<sec<<endl;
因为display函数不是Time类的成员函数,不能默认引用Time类的数据成员,必须指定要访问的对象。
2. 友元成员函数
friend函数不仅可以是一般函数(非成员函数),而且可以是另一个类中的成员函数。见例9.13。
例9.13 友元成员函数的简单应用。
在本例中除了介绍有关友元成员函数的简单应用外,还将用到类的提前引用声明,请读者注意。
#include <iostream>
using namespace std;
class Date; //对Date类的提前引用声明
class Time //定义Time类
{public:
Time(int,int,int);
void display(Date &); //display是成员函数,形参是Date类对象的引用
private:
int hour;
int minute;
int sec;
};
class Date //声明Date类
{public:
Date(int,int,int);
friend void Time∷display(Date &); //声明Time中的display函数为友元成员函数
private:
int month;
int day;
int year;
};
Time∷Time(int h,int m,int s) //类Time的构造函数
{hour=h;
minute=m;
sec=s;
}
void Time∷display(Date &d) //display的作用是输出年、月、日和时、分、秒
{cout<<d.month<<″/″<<d.day<<″/″<<d.year<<endl; //引用Date类对象中的私有数据
cout<<hour<<″:″<<minute<<″:″<<sec<<endl; //引用本类对象中的私有数据
}
Date∷Date(int m,int d,int y) //类Date的构造函数
{month=m;
day=d;
year=y;
}
int main( )
{Time t1(10,13,56); //定义Time类对象t1
Date d1(12,25,2004); //定义Date类对象d1
t1.display(d1); //调用t1中的display函数,实参是Date类对象d1
return 0;
}
运行时输出:
12/25/2004 (输出Date类对象d1中的私有数据)
10:13:56 (输出Time类对象t1中的私有数据)
在本例中定义了两个类Time和Date。程序第3行是对Date类的声明,因为在第7行和第16行中对display函数的声明和定义中要用到类名Date,而对Date类的定义却在其后面。能否将Date类的声明提到前面来呢?也不行,因为在Date类中的第4行又用到了Time类,也要求先声明Time类才能使用它。为了解决这个问题,C++允许对类作“提前引用”的声明,即在正式声明一个类之前,先声明一个类名,表示此类将在稍后声明。程序第3行就是提前引用声明,它只包含类名,不包括类体。如果没有第3行,程序编译就会出错。
在这里简要介绍有关对象提前引用的知识。在一般情况下,对象必须先声明,然后才能使用它。但是在特殊情况下(如上面例子所示的那样),在正式声明类之前,需要使用该类名。但是应当注意: 类的提前声明的使用范围是有限的。只有在正式声明一个类以后才能用它去定义类对象。如果在上面程序第3行后面增加一行:
Date d1; //企图定义一个对象
会在编译时出错。因为在定义对象时是要为这些对象分配存储空间的,在正式声明类之前,编译系统无法确定应为对象分配多大的空间。编译系统只有在“见到”类体后,才能确定应该为对象预留多大的空间。
在对一个类作了提前引用声明后,可以用该类的名字去定义指向该类型对象的指针变量或对象的引用变量(如在本例中,定义了Date类对象的引用变量)。这是因为指针变量和引用变量本身的大小是固定的,与它所指向的类对象的大小无关。
请注意程序是在定义Time∷display函数之前正式声明Date类的。如果将对Date类的声明的位置(程序13~21行)改到定义Time∷display函数之后,编译就会出错,因为在Time∷display函数体中要用到Date类的成员month,day,year。如果不事先声明Date类,编译系统无法识别成员month,day,year等成员。
在一般情况下,两个不同的类是互不相干的。在本例中,由于在Date类中声明了Time类中的display成员函数是Date类的“朋友”,因此该函数可以引用Date类中所有的数据。请注意在本程序中调用友元函数访问有关类的私有数据方法:
(1) 在函数名display的前面要加display所在的对象名(t1);
(2) display成员函数的实参是Date类对象d1,否则就不能访问对象d1中的私有数据;
(3) 在Time∷display函数中引用Date类私有数据时必须加上对象名,如d.month。
3. 一个函数(包括普通函数和成员函数)可以被多个类声明为“朋友”,这样就可以引用多个类中的私有数据
例如, 可以将例9.13程序中的display函数不放在Time类中,而作为类外的普通函数,然后分别在Time和Date类中将display声明为朋友。在主函数中调用display函数,display函数分别引用Time和Date两个类的对象的私有数据,输出年、月、日和时、分、秒。
9.10.2 友元类
不仅可以将一个函数声明为一个类的“朋友”,而且可以将一个类(例如B类)声明为另一个类(例如A类)的“朋友”。这时B类就是A类的友元类。友元类B中的所有函数都是A类的友元函数,可以访问A类中的所有成员。
在A类的定义体中用以下语句声明B类为其友元类:
friend B;
声明友元类的一般形式为
friend 类名;
关于友元,有两点需要说明:
(1) 友元的关系是单向的而不是双向的。
(2) 友元的关系不能传递。
在实际工作中,除非确有必要,一般并不把整个类声明为友元类,而只将确实有需要的成员函数声明为友元函数,这样更安全一些。
关于友元利弊的分析: 面向对象程序设计的一个基本原则是封装性和信息隐蔽,而友元却可以访问其他类中的私有成员,不能不说这是对封装原则的一个小的破坏。但是它能有助于数据共享,能提高程序的效率,在使用友元时,要注意到它的副作用,不要过多地使用友元,只有在使用它能使程序精炼,并能大大提高程序的效率时才用友元。
9.11 类模板
有时,有两个或多个类,其功能是相同的,仅仅是数据类型不同,如下面语句声明了一个类:
class Compare_int
{public:
Compare(int a,int b)
{x=a;y=b;}
int max( )
{return(x>y)?x:y;}
int min( )
{return(x<y)?x:y;}
private:
int x,y;
};
其作用是对两个整数作比较,可以通过调用成员函数max和min得到两个整数中的大者和小者。
如果想对两个浮点数(float型)作比较,需要另外声明一个类:
class Compare_float
{public:
Compare(float a,float b)
{x=a;y=b;}
float max( )
{return(x>y)?x:y;}
float min( )
{return(x<y)?x:y;}
private:
float x,y;
}
显然这基本上是重复性的工作,应该有办法减少重复的工作。C++在发展的后期增加了模板(template)的功能,提供了解决这类问题的途径。
可以声明一个通用的类模板,它可以有一个或多个虚拟的类型参数,如对以上两个类可以综合写出以下的类模板:
template<class numtype> //声明一个模板,虚拟类型名为numtype
class Compare //类模板名为Compare
{public:
Compare(numtype a,numtype b)
{x=a;y=b;}
numtype max( )
{return (x>y)?x:y;}
numtype min( )
{return (x<y)?x:y;}
private:
numtype x,y;
};
请将此类模板和前面第一个Compare_int类作一比较,可以看到有两处不同:
(1) 声明类模板时要增加一行
template <class 类型参数名>
(2) 原有的类型名int换成虚拟类型参数名numtype。在建立类对象时,如果将实际类型指定为int型,编译系统就会用int取代所有的numtype,如果指定为float型,就用float取代所有的numtype。这样就能实现“一类多用”。
由于类模板包含类型参数,因此又称为参数化的类。如果说类是对象的抽象,对象是类的实例,则类模板是类的抽象,类是类模板的实例。利用类模板可以建立含各种数据类型的类。
在声明了一个类模板后,怎样使用它?怎样使它变成一个实际的类?
先回顾一下用类来定义对象的方法:
Compare_int cmp1(4,7); // Compare_int是已声明的类
用类模板定义对象的方法与此相似,但是不能直接写成
Compare cmp(4,7); // Compare是类模板名
Compare是类模板名,而不是一个具体的类,类模板体中的类型numtype并不是一个实际的类型,只是一个虚拟的类型,无法用它去定义对象。必须用实际类型名去取代虚拟的类型,具体的做法是:
Compare <int> cmp(4,7);
即在类模板名之后在尖括号内指定实际的类型名,在进行编译时,编译系统就用int取代类模板中的类型参数numtype,这样就把类模板具体化了,或者说实例化了。这时Compare<int>就相当于前面介绍的Compare_int类。
例9.14是一个完整的例子。
例9.14 声明一个类模板,利用它分别实现两个整数、浮点数和字符的比较,求出大数和小数。
#include <iostream>
using namespace std;
template<class numtype> //定义类模板
class Compare
{public:
Compare(numtype a,numtype b)
{x=a;y=b;}
numtype max( )
{return (x>y)?x:y;}
numtype min( )
{return (x<y)?x:y;}
private:
numtype x,y;
};
int main( )
{Compare<int> cmp1(3,7); //定义对象cmp1,用于两个整数的比较
cout<<cmp1.max( )<<″ is the Maximum of two integer numbers.″<<endl;
cout<<cmp1.min( )<<″ is the Minimum of two integer numbers.″<<endl<<endl;
Compare<float> cmp2(45.78,93.6); //定义对象cmp2,用于两个浮点数的比较
cout<<cmp2.max( )<<″ is the Maximum of two float numbers.″<<endl;
cout<<cmp2.min( )<<″ is the Minimum of two float numbers.″<<endl<<endl;
Compare<char> cmp3(′a′,′A′); //定义对象cmp3,用于两个字符的比较
cout<<cmp3.max( )<<″ is the Maximum of two characters.″<<endl;
cout<<cmp3.min( )<<″ is the Minimum of two characters.″<<endl;
return 0;
}
运行结果如下:
7 is the Maximum of two integers.
3 is the Minimum of two integers.
93.6 is the Maximum of two float numbers.
45.78 is the Minimum of two float numbers.
a is the Maximum of two characters.
A is the Minimum of two characters.
还有一个问题要说明: 上面列出的类模板中的成员函数是在类模板内定义的。如果改为在类模板外定义,不能用一般定义类成员函数的形式:
numtype Compare∷max( ) {…} //不能这样定义类模板中的成员函数
而应当写成类模板的形式:
template<class numtype>
numtype Compare<numtype>∷max( )
{{return (x>y)?x:y;}
归纳以上的介绍,可以这样声明和使用类模板:
(1) 先写出一个实际的类。由于其语义明确,含义清楚,一般不会出错。
(2) 将此类中准备改变的类型名(如int要改变为float或char)改用一个自己指定的虚拟类型名(如上例中的numtype)。
(3) 在类声明前面加入一行,格式为
template<class 虚拟类型参数>,如
template<class numtype> //注意本行末尾无分号
class Compare
{…}; //类体
(4) 用类模板定义对象时用以下形式:
类模板名<实际类型名> 对象名;
类模板名<实际类型名> 对象名(实参表列);
如
Compare<int> cmp;
Compare<int> cmp(3,7);
(5) 如果在类模板外定义成员函数,应写成类模板形式:
template<class 虚拟类型参数>
函数类型 类模板名<虚拟类型参数>∷成员函数名(函数形参表列) {…}
说明:
(1) 类模板的类型参数可以有一个或多个,每个类型前面都必须加class,如
template<class T1,class T2>
class someclass
{…};
在定义对象时分别代入实际的类型名,如
someclass<int,double> obj;
(2) 和使用类一样,使用类模板时要注意其作用域,只能在其有效作用域内用它定义对象。
(3) 模板可以有层次,一个类模板可以作为基类,派生出派生模板类。