1.类是一种数据类型
我们已经了解了数据类型和变量的关系。数据类型是各个变量的归类,而变量则是某个数据类型的具体表现,我们称变量为数据类型的一个实例(Instance)。各个变量都有他们的属性:内容和占用内存空间等等;各个变量都有他们的操作:算术运算或逻辑运算等等。从这个角度来看,类和对象的关系就如同数据类型和变量的关系。我们不妨将类看作一种自定义的数据类型,那么对象就是一种属于该类型的变量。
类与结构
在第九章我们学习了结构类型,知道它是一种由用户自己定义的数据类型。我们已经能够使用结构刻画一些现实生活中的东西,但却无法让它“动起来”。所有对它的操作都要依赖于为它编写的函数。
类与结构是相似的。它也是一种由用户自己定义的数据类型;它也可以通过成员数据来刻画一些现实生活中的东西。不同的是,对它的操作并不是通过普通的函数,而是通过类的成员函数来实现的。
下面我们先来看一下,如何定义一个类和它的成员数据:class 类名 { 数据类型 成员变量1; 数据类型 成员变量2; …… };看来如果仅仅是定义成员数据,类和结构是非常相似的,唯一的不同就是把保留字struct换成了class。在这里还是要提醒一下,定义完一个类之后务必要在最后加上一个分号。
类的声明与定义
如果类的定义和主函数在同一个源文件里,那么就会可能遇到这样的问题:在类定义之前,主函数使用了这个类。这将会导致错误的发生,因为主函数还没有意识到这个类的存在。所以这个时候我们必须在主函数之前声明这个类的存在,其作用类似于函数原型。如:
class A;//类的声明 int main()//主函数 { …… } { …… };//千万别忘了这个分号我们还可以将一个类定义在一个头文件中,然后在源文件中包含这个头文件。由于包含头文件的动作在主函数运行之前,所以不必在主函数之前声明这个类。比如:
//class.h class A//类的定义 { …… }; //main.cpp #include "class.h"//要注意这里必须用双引号,而不能用<> int main() { …… }
2.共有类和私有类
在上一章,我们提到了类的封装性。那么我们是如何保证类内部的数据和操作不被外部访问或执行呢?这时候,我们就要说说什么是公有(Public)和私有(Private)了。
所谓公有,就是外部可以访问的数据或执行的操作。比如一个人的身高(数据)是可以较直接地获得的,一个人吃东西(操作)是可以受外部控制的。私有就是外部不能直接访问的数据或执行的操作。比如一个人的心跳次数(数据)和消化过程(操作),虽然他们都是客观存在,但我们却不能直接地获取心跳数据或控制消化过程。
如果一个类的所有数据和操作都是公有的,那么它将完全暴露在外,同结构一样没有安全性。如果一个类的所有数据和操作都是私有的,那么它将完全与外界隔绝,这样的类也没有存在的意义。
下面我们来看一下如何定义公有和私有的成员数据:class Node//定义一个链表结点类 { public: int idata;//数据能够被外部访问 char cdata;//数据能够被外部访问 private: Node *prior;//前驱结点的存储位置保密 Node *next;//后继结点的存储位置保密 };按照上面的写法,如果我们有一个结点对象lnode,那么lnode.idata和lnode.cdata都是可以被外界直接访问的,而lnode.prior和lnode.next则不能被外界直接访问。
需要注意的是,如果我们在定义或声明时不说明该成员数据(或成员函数)是公有的还是私有的,则默认为私有的。所以从习惯上我们总是把定义的成员数据和成员函数分为公有和私有两类,先定义公有再定义私有,方便阅读代码时能够区分。虽然在定义时可以有多个public或private保留字,但是我们不推荐那样的写法。
另外,以后我们还会遇到一个名为protected的保留,目前它和private的效果是一样的,即成员数据或成员函数不能被外界直接访问或调用。在以后的章节我们会了解到private和protected的区别。
3.成员函数
我们已经学会如何调用成员函数,那么成员函数又是如何声明和定义的呢?它和普通函数有着什么异同点呢?
普通函数在使用之前必须声明和定义,成员函数也是这样。不过成员函数是属于某一个类的,所以只能在类的内部声明,即在定义类的时候写明成员函数的函数原型,同时要注意此函数是公有的还是私有的。如果一个类的某个成员函数是私有的,那么它只能被这个类的其他成员函数调用。成员函数的函数原型和普通函数的函数原型在写法上是一样的。比如:class Node//定义一个链表结点类 { public: int readi();//通过该函数读取idata char readc();//通过该函数读取cdata bool seti(int i);//通过该函数修改idata bool setc(char c);//通过该函数修改cdata bool setp(Node *p);//通过该函数设置前驱结点 bool setn(Node *n);//通过该函数设置后继结点 private: int idata;//存储数据保密 char cdata; //存储数据保密 Node *prior;//前驱结点的存储位置保密 Node *next;//后继结点的存储位置保密 };
常成员函数
由于数据封装在类的内部,在处理一些问题的时候就需要小心翼翼,不能把成员数据破坏了。以前我们介绍使用const来保护变量(就变成了常量),或保护指针所指向的内容,那么在类中,我们也可以使用const这个保留字来保护成员数据不被成员函数改变。我们把这种成员函数称为常成员函数。它的写法就是在函数的参数表后面加上一个const,比如:
int readi() const;//通过该函数读取idata,但不能改变任何成员数据 char readc() const;//通过该函数读取cdata,但不能改变任何成员数据使用常成员函数,就保证了成员数据的安全,在此函数中任何修改成员数据的语句将被编译器拒之门外。
成员函数的重载
和普通函数类似,在一个类中也可以有成员函数重载。成员函数的重载在规则上和普通函数也并无差别,这里不再赘述。
最终,我们将链表结点类的定义修改如下:class Node//定义一个链表结点类 { public: int readi() const;//通过该函数读取idata,但不能改变任何成员数据 char readc() const;//通过该函数读取cdata,但不能改变任何成员数据 bool set(int i);//重载,通过该函数修改idata bool set(char c);// 重载,通过该函数修改cdata bool setp(Node *p);//通过该函数设置前驱结点 bool setn(Node *n);//通过该函数设置后继结点 private: int idata;//存储数据保密 char cdata; //存储数据保密 Node *prior;//前驱结点的存储位置保密 Node *next;//后继结点的存储位置保密 };
成员函数的定义
成员函数与普通函数的不同之处,在于成员函数是属于某一个类的,而不能被随意地调用。那么,我们在定义一个成员函数的时候如何来表达它是属于某一个类的呢?这个时候我们就要用到::操作符,它表示该函数是属于某一个类的,称为域解析操作符。因此在类定义结束后,定义一个成员函数的格式如下:
返回值类型类名::函数名(参数表)
{
语句;
……
}
事实上,成员函数也是可以在类的定义中定义的(此时不需要域解析操作符),但是从程序的运行效率、可读性、美观性考虑,我们建议将成员函数的定义完全放在类定义的外面。于是,链表结点类和其成员函数的定义如下://node.h class Node//定义一个链表结点类 { public: int readi() const;//通过该函数读取idata,但不能改变任何成员数据 char readc() const;//通过该函数读取cdata,但不能改变任何成员数据 bool set(int i);//重载,通过该函数修改idata bool set(char c);//重载,通过该函数修改cdata bool setp(Node *p);//通过该函数设置前驱结点 bool setn(Node *n);//通过该函数设置后继结点 private: int idata;//存储数据保密 char cdata;//存储数据保密 Node *prior;//前驱结点的存储位置保密 Node *next;//后继结点的存储位置保密 };//类定义结束,分号切勿忘记 int Node::readi() const//成员函数readi的定义 { return idata; } char Node::readc() const { return cdata; } bool Node::set(int i)//重载成员函数定义 { idata=i; return true; } bool Node::set(char c) { cdata=c; return true; } bool Node::setp(Node *p) { prior=p; return true; } bool Node::setn(Node *n) { next=n; return true; }在上面这些成员函数定义中,我们可以看出成员数据(或成员函数)在成员函数中可以直接使用。平时我们使用一个对象的公有成员数据时,我们要写作“对象名.成员数据”,但是在成员函数中不需要也不能那样写。接下来,我们就能尝试一下使用我们编写的类了:(程序14.3)
//main.cpp #include <iostream> #include "node.h"//包含我们编写好的链表结点类头文件,必须用双引号 using namespace std; int main() { Node a;//创建一个链表结点对象a a.set(1);//设置idata a.set('A');//设置cdata cout <<a.readi() <<endl; cout <<a.readc() <<endl; return 0; }运行结果:
1 A注意这个程序有两个文件,一个是头文件node.h,一个是源文件main.cpp。如果你忘记了如何创建一个头文件,那么请看本书的11.2节。
4.对象、引用和指针
我们已经知道,对象就如同一个变量。因此一个对象,也可以有对应的引用和指针。
对象的引用
在第六章中,我们说引用就像是给变量起了一个别名,对这个引用的操作就和操作这个变量本身一样。这给我们在设计程序的时候带来了方便。对象也可以有引用,声明一个对象的引用方法是:
类名 &对象名a=对象名b;
此时,对对象a的访问和操作就如同对对象b的访问和操作一样,对象a只是对象b的一个别名。例如我们已经定义好了一个链表结点类,则有以下程序段:Node b;//声明一个结点对象 Node &a=b;//声明一个引用 a.set(0);//效果与b.set(0)相同 a.readi();//效果与b.readi()相同
对象指针
为了完成一个链表的类定义,我们需要学习一下对象指针的使用方法。所谓对象指针,就是一个指向对象的指针。由于类和结构的相似性,对象指针和结构指针的使用也是相似的。我们也是使用箭头操作符->来访问该指针所指向的对象的成员数据或成员函数。例如我们已经定义好了一个链表结点类,则有以下程序段:
Node b;//声明一个结点对象 Node *a=&b;//声明一个对象指针 a->set(0);//效果与b.set(0)相同 a->readi();//效果与b.readi()相同至此,我们已经为编写一个链表类做好了准备。
5.对象初始化
在声明一个局部变量的时候,我们必须对它进行初始化,否则它的数据是一个不确定的值。同样,在声明一个对象的时候,也应该对它进行初始化。不过一个对象可能有许许多多的成员数据,对对象的初始化就意味着对许许多多的成员数据进行初始化。变量的初始化只需要一句赋值语句就能完成,而对象的初始化可能要许许多多的赋值语句才能完成。因此,我们常常把这许许多多的语句写在一个函数中。比如我们为链表结点类编写了一个名为init的初始化函数:
class Node//定义一个链表结点类 { public: …… void init(int i,char c); …… private: int idata;//存储数据保密 char cdata;//存储数据保密 Node *prior;//前驱结点的存储位置保密 Node *next;//后继结点的存储位置保密 }; void Node::init(int i,char c) { idata=i;//初始化idata cdata=c;//初始化cdata prior=NULL;//初始化前驱结点指针 next=NULL;//初始化后续结点指针 }这下好了,我们创建一个链表结点对象之后只要运行一次init函数就能将其初始化了:
Node a; a.init(0, '0');既然init函数担负着初始化对象的重任,那么它就必须和对象的声明“出双入对”了。万一忘记对对象进行初始化,程序就可能会出错。这就像在病毒肆虐的今天,保证电脑安全的病毒防火墙必须在开机之后立刻运行一样。万一哪天开了电脑忘记运行病毒防火墙,那么后果可能很严重。
不过,你使用的病毒防火墙是你每次开机以后自己去点击运行的么?那样岂不是很麻烦?你是否知道,我们使用的病毒防火墙往往是随系统一起启动的?
这给了我们一些启示:有的程序能够随着系统的启动而自动运行,那么会不会有一种函数,随着对象的创建而自动被调用呢?有!那就是构造函数(Constructor)。
6.构造函数
构造函数是一种随着对象创建而自动被调用的函数,它的主要用途是为对象作初始化。那么,构造函数到底是什么样子的呢?
构造函数的声明与定义
在C++中,规定与类同名的成员函数就是构造函数。需要注意的是,构造函数应该是一个公有的成员函数,并且构造函数没有返回值类型。以下是我们为链表结点类编写的一个构造函数:(其他成员函数定义见14.3节)
//node.h #include <iostream>//如果不包含iostream头文件,这个文件里就不能用cout using namespace std; class Node//定义一个链表结点类 { public: …… Node();//构造函数的声明,构造函数是公有的成员函数,没有返回值类型 …… private: int idata;//存储数据保密 char cdata;//存储数据保密 Node *prior;//前驱结点的存储位置保密 Node *next;//后继结点的存储位置保密 }; Node::Node()//构造函数的定义 { cout <<"Node constructor is running..." <<endl;//提示构造函数运行 idata=0;//初始化idata cdata='0';//初始化cdata prior=NULL;//初始化前驱结点指针 next=NULL;//初始化后续结点指针 }这时,我们创建一个链表结点对象,构造函数随着对象创建而自动被调用,所以这个对象创建之后idata的值为0,cdata的值为'0',prior和next的值都是NULL:(程序15.2.1)
//main.cpp #include <iostream> #include "node.h" using namespace std; int main() { Node a;//创建一个链表结点对象a,调用构造函数 cout <<a.readi() <<endl; cout <<a.readc() <<endl; return 0; }运行结果:
Node constructor is running... 0 0可是,这样的构造函数还是不太理想。如果每次初始化的值都是固定的,那么有没有构造函数都是一样的。构造函数变成了一种摆设!我们该怎么办?
带参数的构造函数
函数的特征之一就是能够在调用时带上参数。既然构造函数也是函数,那么我们就能给构造函数带上参数,使用重载或默认参数等方法,从而实现更自由地对对象进行初始化操作。以下便是对链表结点类的进一步修改:(程序15.2.2)
//node.h #include <iostream> using namespace std; class Node//定义一个链表结点类 { public: Node();//构造函数0 Node(int i,char c='0');//构造函数重载1,参数c默认为'0' Node(int i,char c,Node *p,Node *n);//构造函数重载2 int readi() const;//读取idata char readc() const;//读取cdata Node * readp() const;//读取上一个结点的位置 Node * readn() const;//读取下一个结点的位置 bool set(int i);//重载,通过该函数修改idata bool set(char c);//重载,通过该函数修改cdata bool setp(Node *p);//通过该函数设置前驱结点 bool setn(Node *n);//通过该函数设置后继结点 private: int idata;//存储数据保密 char cdata;//存储数据保密 Node *prior;//前驱结点的存储位置保密 Node *next;//后继结点的存储位置保密 }; int Node::readi() const//成员函数readi的定义 { return idata; } char Node::readc() const { return cdata; } Node * Node::readp() const { return prior; } Node * Node::readn() const { return next; } bool Node::set(int i)//重载成员函数定义 { idata=i; return true; } bool Node::set(char c) { cdata=c; return true; } bool Node::setp(Node *p) { prior=p; return true; } bool Node::setn(Node *n) { next=n; return true; } Node::Node()//构造函数0的定义 { cout <<"Node constructor is running..." <<endl;//提示构造函数运行 idata=0;//初始化idata cdata='0';//初始化cdata prior=NULL;//初始化前驱结点指针 next=NULL;//初始化后续结点指针 } Node::Node(int i,char c)//构造函数重载1,默认参数只需要在函数原型中出现 { cout <<"Node constructor is running..." <<endl; idata=i; cdata=c; prior=NULL; next=NULL; } Node::Node(int i,char c,Node *p,Node *n)//构造函数重载2 { cout <<"Node constructor is running..." <<endl; idata=i; cdata=c; prior=p; next=n; }
//main.cpp #include <iostream> #include "node.h" using namespace std; int main() { Node a;//创建一个链表结点对象a,调用构造函数0 Node b(8);//创建一个链表结点对象b,调用构造函数重载1,参数c默认为'0' Node c(8,'F',NULL,NULL);//创建一个链表结点对象c,调用构造函数重载2 cout <<a.readi() <<' ' <<a.readc() <<endl; cout <<b.readi() <<' ' <<b.readc() <<endl; cout <<c.readi() <<' ' <<c.readc() <<endl; return 0; }运行结果:
Node constructor is running... Node constructor is running... Node constructor is running... 0 0 8 0 8 F我们看到,在参数和重载的帮助下,我们可以设计出适合各种场合的构造函数。初始化各个对象的成员数据对我们来说已经是小菜一碟了。但是,这时你是否会回想起当初没有编写构造函数时的情形?如果没有编写构造函数,对象的创建是一个怎样的过程呢?
在C++中,每个类都有且必须有构造函数。如果用户没有自行编写构造函数,则C++自动提供一个无参数的构造函数,称为默认构造函数。这个默认构造函数不做任何初始化工作。一旦用户编写了构造函数,则这个无参数的默认构造函数就消失了。如果用户还希望能有一个无参数的构造函数,必须自行编写。
7.拷贝构造函数
我们在程序中常常需要把一些数据复制一份出来备作它用。对于只有基本类型变量的程序来说,这是轻而易举就能做到的——新建一个临时变量,用一句赋值语句就能完成。但如果它是一个有着许许多多成员数据的对象,这就会非常麻烦。最要命的是,那些成员数据还是私有的,根本无法直接访问或修改。那么这时候,我们怎么“克隆”出一个和原来的对象相同的新对象呢?
拷贝构造函数
我们知道,构造函数是可以带上参数的,这些参数可以是整型、字符型等等,那么它可不可以是一个对象类型呢?我们把一个原来的对象丢给构造函数,然后让它给我们造一个相同的对象,是否可以呢?下面我们就来试试看:(程序15.4.1)
//node.h #include <iostream> using namespace std; class Node//定义一个链表结点类 { public: Node();//构造函数的声明 Node(int i,char c='0');//构造函数重载1 Node(int i,char c,Node *p,Node *n);//构造函数重载2 Node(Node &n);//结点拷贝构造函数,&表示引用 int readi() const;//读取idata char readc() const;//读取cdata Node * readp() const;//读取上一个结点的位置 Node * readn() const;//读取下一个结点的位置 bool set(int i);//重载,通过该函数修改idata bool set(char c);//重载,通过该函数修改cdata bool setp(Node *p);//通过该函数设置前驱结点 bool setn(Node *n);//通过该函数设置后继结点 private: int idata;//存储数据保密 char cdata;//存储数据保密 Node *prior;//前驱结点的存储位置保密 Node *next;//后继结点的存储位置保密 }; //未定义的函数与程序15.2.2相同 Node::Node(Node &n) { idata=n.idata;//可以读出同类对象的私有成员数据 cdata=n.cdata; prior=n.prior; next=n.next; }
//linklist.h #include "node.h"//需要使用链表结点类 #include <iostream> using namespace std; class Linklist { public: Linklist(int i,char c);//链表类构造函数 Linklist(Linklist &l);//链表拷贝构造函数,&表示引用 bool Locate(int i);//根据整数查找结点 bool Locate(char c);//根据字符查找结点 bool Insert(int i=0,char c='0');//在当前结点之后插入结点 bool Delete();//删除当前结点 void Show();//显示链表所有数据 void Destroy();//清除整个链表 private: Node head;//头结点 Node * pcurrent;//当前结点指针 }; //未定义的函数与程序15.3相同 Linklist::Linklist(Linklist &l):head(l.head)//调用结点的拷贝构造函数来初始化head { cout<<"Linklist cloner running..." <<endl; pcurrent=l.pcurrent;//指针数据可以直接赋值 }
//main.cpp #include "Linklist.h" #include <iostream> using namespace std; int main() { int tempi; char tempc; cout <<"请输入一个整数和一个字符:" <<endl; cin >>tempi >>tempc; Linklist a(tempi,tempc); a.Locate(tempi); a.Insert(1,'C'); a.Insert(2,'B'); a.Insert(3,'F'); cout <<"After Insert" <<endl; a.Show(); a.Locate('B'); a.Delete(); cout <<"After Delete" <<endl; a.Show(); Linklist b(a);//创建一个链表b,并且将链表a复制到链表b cout <<"This is Linklist b" <<endl; b.Show();//显示b链表中的内容 a.Destroy(); cout <<"After Destroy" <<endl; a.Show(); return 0; }运行结果:
请输入一个整数和一个字符: 4 G Node constructor is running... Linklist constructor is running... Node constructor is running... Node constructor is running... Node constructor is running... After Insert 4 G 3 F 2 B 1 C After Delete 4 G 3 F 1 C Linklist cloner running... This is Linklist b 4 G 3 F 1 C After Destroy 4 G根据程序运行的结果,我们发现输出链表b的内容的确和链表a一样了,并且我们可以得到三个结论:
(1) 拷贝构造函数可以读出相同类对象的私有成员数据;
(2) 拷贝构造函数的实质是把参数的成员数据一一复制到新的对象中;
(3) 拷贝构造函数也是构造函数的一种重载。
默认拷贝构造函数
我们已经知道构造函数有默认构造函数,其实拷贝构造函数也有默认的拷贝构造函数。所谓默认拷贝构造函数是指,用户没有自己定义拷贝构造函数时,系统自动给出的一个拷贝构造函数。默认拷贝构造函数的功能是将对象的成员数据一一赋值给新创建的对象的成员数据,如果某些成员数据本身就是对象,则自动调用它们的拷贝构造函数或默认拷贝构造函数。
拷贝构造函数存在的意义
既然上面说到,默认拷贝构造函数已经能够满足我们大多数的需要,那么自定义的拷贝构造函数是否就可以不用存在了呢?我们修改一下程序15.4.1的main.cpp文件,看看拷贝构造函数到底有什么意义:
#include "Linklist.h" #include <iostream> using namespace std; int main() { int tempi; char tempc; cout <<"请输入一个整数和一个字符:" <<endl; cin >>tempi >>tempc; Linklist a(tempi,tempc); a.Locate(tempi); a.Insert(1,'C'); a.Insert(2,'B'); a.Insert(3,'F'); cout <<"After Insert" <<endl; a.Show(); a.Locate('B'); a.Delete(); cout <<"After Delete" <<endl; a.Show(); Linklist b(a); cout <<"This is Linklist B" <<endl; b.Show(); a.Destroy(); cout <<"After Destroy" <<endl; a.Show(); cout <<"This is Linklist b" <<endl; b.Show();//关键是在这里加了一条语句,让它显示b链表中的内容 return 0; }运行结果:
为什么显示链表b的内容,却导致了严重的错误呢?
这时我们就要来研究一下这个链表的结构了。在这个链表中,成员数据只有头结点head和当前指针pcurrent,所有的结点都是通过new语句动态生成的。而程序15.4.1中的拷贝构造函数仅仅是简单地将头结点head和当前指针pcurrent复制了出来,所以一旦运行a.Destroy()之后,链表a头结点之后的结点已经全部都删除了,而链表b的头结点还傻傻地指向原来a链表的结点。如果这时再访问链表b,肯定就要出问题了。如下所示:
程序15.4.1中的拷贝构造函数仅仅是把成员数据拷贝了过来,却没有把动态申请的资源拷贝过来,我们把这种拷贝称为浅拷贝。相对地,如果拷贝构造函数不仅把成员数据拷贝过来,连动态申请的资源也拷贝过来,我们则称之为深拷贝。
下面我们来看如何实现深拷贝:(程序15.4.2)//node.h同程序15.4.1 //linklist.h #include "node.h"//需要使用链表结点类 #include <iostream> using namespace std; class Linklist { public: Linklist(int i,char c);//链表类构造函数 Linklist(Linklist &l);//链表深拷贝构造函数 bool Locate(int i);//根据整数查找结点 bool Locate(char c);//根据字符查找结点 bool Insert(int i=0,char c='0');//在当前结点之后插入结点 bool Delete();//删除当前结点 void Show();//显示链表所有数据 void Destroy();//清除整个链表 private: Node head;//头结点 Node * pcurrent;//当前结点指针 }; //未定义的函数与程序15.3相同 Linklist::Linklist(Linklist &l):head(l.head) { cout<<"Linklist Deep cloner running..." <<endl; pcurrent=&head; Node * ptemp1=l.head.readn();//该指针用于指向原链表中被复制的结点 while(ptemp1!=NULL) { Node * ptemp2=new Node(ptemp1->readi(),ptemp1->readc(),pcurrent,NULL);//新建结点,并复制idata和cdata,思考为何这里不能直接用Node的拷贝构造函数? pcurrent->setn(ptemp2); pcurrent=pcurrent->readn();//指向表尾结点 ptemp1=ptemp1->readn();//指向下一个被复制结点 } }
//main.cpp #include "Linklist.h" #include <iostream> using namespace std; int main() { int tempi; char tempc; cout <<"请输入一个整数和一个字符:" <<endl; cin >>tempi >>tempc; Linklist a(tempi,tempc); a.Locate(tempi); a.Insert(1,'C'); a.Insert(2,'B'); a.Insert(3,'F'); cout <<"After Insert" <<endl; a.Show(); a.Locate('B'); a.Delete(); cout <<"After Delete" <<endl; a.Show(); Linklist b(a);//创建一个链表b,并且将链表a复制到链表b cout <<"This is Linklist b" <<endl; b.Show(); a.Destroy(); cout <<"After Destroy" <<endl; a.Show(); cout <<"This is Linklist b" <<endl; b.Show();//链表a被Destroy之后察看链表b的内容 return 0; }运行结果:
请输入一个整数和一个字符: 4 G Node constructor is running... Linklist constructor is running... Node constructor is running... Node constructor is running... Node constructor is running... After Insert 4 G 3 F 2 B 1 C After Delete 4 G 3 F 1 C Linklist Deep cloner running... Node constructor is running... Node constructor is running... This is Linklist b 4 G 3 F 1 C After Destroy 4 G This is Linklist b 4 G 3 F 1 C我们看到,现在即使运行a.Destroy()之后,链表b里面的数据仍然能够正常显示。这是因为深拷贝构造函数是真正意义上的复制了链表a,并且使得链表a和链表b各自独立,互不干扰。这才是自定义拷贝构造函数存在的重要意义。
8.析构函数
在学习链表的时候,我们知道结点是动态生成的,如果在程序结束之前不释放内存,就会造成内存泄漏。虽然我们已经编写了成员函数Destroy来删除所有动态生成的结点,但是如果我们不知道这个链表对象何时不再使用,那么调用Destroy的时机对我们来说就是个麻烦了。如果过早地调用,则后面的程序可能会出错。既然有构造函数能随着对象的创建而自动被调用,那么有没有一种函数能随着对象的消亡而自动被调用呢?有!那就是析构函数(Destructor)。
析构函数是一种随着对象消亡而自动被调用的函数,它的主要用途是释放动态申请的资源。它没有返回类型,没有参数,也没有重载。析构函数的函数名也是指定的,是在构造函数名之前加一个“~”符号。
下面我们为程序15.4.2添上析构函数的功能:(程序15.5)//node.h #include <iostream> using namespace std; class Node//定义一个链表结点类 { public: Node();//构造函数的声明 Node(Node &n);//结点拷贝构造函数 Node(int i,char c='0');//构造函数重载1 Node(int i,char c,Node *p,Node *n);//构造函数重载2 ~Node();//结点析构函数 int readi() const;//读取idata char readc() const;//读取cdata Node * readp() const;//读取上一个结点的位置 Node * readn() const;//读取下一个结点的位置 bool set(int i);//重载,通过该函数修改idata bool set(char c);//重载,通过该函数修改cdata bool setp(Node *p);//通过该函数设置前驱结点 bool setn(Node *n);//通过该函数设置后继结点 private: int idata;//存储数据保密 char cdata;//存储数据保密 Node *prior;//前驱结点的存储位置保密 Node *next;//后继结点的存储位置保密 }; //未定义的函数与程序15.4.1相同 Node::~Node() { cout <<"Node destructor is running..." <<endl; } //linklist.h#include "node.h"//需要使用链表结点类 #include <iostream> using namespace std; class Linklist { public: Linklist(int i,char c);//链表类构造函数 Linklist(Linklist &l);//链表深拷贝构造函数 ~Linklist();//链表析构函数 bool Locate(int i);//根据整数查找结点 bool Locate(char c);//根据字符查找结点 bool Insert(int i=0,char c='0');//在当前结点之后插入结点 bool Delete();//删除当前结点 void Show();//显示链表所有数据 void Destroy();//清除整个链表 private: Node head;//头结点 Node * pcurrent;//当前结点指针 }; //未定义的函数与程序15.4.2相同 Linklist::~Linklist() { cout<<"Linklist destructor is running..."<<endl; Destroy();//一个成员函数调用另一个成员函数不需要带上对象名 } //main.cpp同程序15.4.2运行结果:
请输入一个整数和一个字符: 4 G Node constructor is running... Linklist constructor is running... Node constructor is running... Node constructor is running... Node constructor is running... After Insert 4 G 3 F 2 B 1 C Node destructor is running... After Delete 4 G 3 F 1 C Linklist Deep cloner running... Node constructor is running... Node constructor is running... This is Linklist b 4 G 3 F 1 C Node destructor is running... Node destructor is running... After Destroy 4 G This is Linklist b 4 G 3 F 1 C Linklist destructor is running... Node destructor is running... Node destructor is running... Node destructor is running... Linklist destructor is running... Node destructor is running...在After Destroy之前的两条Node destructor运行是因为调用了a.Destroy(),最后的6条destructor是因为程序运行结束使得对象自动消亡。可见析构函数是在使用delete语句删除动态生成的对象或程序结束对象消亡时自动被调用的。
从最后的2条destructor输出我们发现,当一个对象的成员数据还是对象时,析构函数的运行顺序恰好与构造函数的运行顺序相反:一个大对象先调用析构函数,瓦解成若干成员数据,然后各个成员数据再调用各自的析构函数。这体现出构造函数与析构函数的对称性。
9.链表类
链表结点类编写好了,我们可以向链表类进军了。链表是由一个个链表结点组成的,所以我们会在链表类中使用到链表结点类。链表结点类是一个很简单的类,链表类是一个功能更为强大的类。正是将一个个类不断地组合与扩充,使得面向对象的程序功能越来越强大。
让我们感兴趣的是,假设我们编写的链表需要有一个头结点作为成员数据,那么是先有链表呢,还是先有头结点?我们又该如何在给链表作初始化的同时初始化头结点呢?
当一个对象中包含别的对象时,我们可以在它的构造函数定义中用以下格式调用其成员对象的构造函数:
类名::构造函数名(参数表):成员对象名1(参数表)[,……成员对象名n(参数表)]
前一段和普通的构造函数一样,冒号之后则表示该类中的成员对象怎样调用各自的构造函数。
下面我们来看一个简单的面向对象的链表程序:(程序15.3)//node.h同程序15.2.2 //linklist.h #include "node.h"//需要使用链表结点类 #include <iostream> using namespace std; class Linklist { public: Linklist(int i,char c);//链表类构造函数 bool Locate(int i);//根据整数查找结点 bool Locate(char c);//根据字符查找结点 bool Insert(int i=0,char c='0');//在当前结点之后插入结点 bool Delete();//删除当前结点 void Show();//显示链表所有数据 void Destroy();//清除整个链表 private: Node head;//头结点 Node * pcurrent;//当前结点指针 }; Linklist::Linklist(int i,char c):head(i,c)//类名::构造函数名(参数表):成员对象名1(参数表),链表类构造函数,调用head对象的构造函数重载1,详见Node.h文件 { cout<<"Linklist constructor is running..."<<endl; pcurrent=&head; } bool Linklist::Locate(int i) { Node * ptemp=&head; while(ptemp!=NULL) { if(ptemp->readi()==i) { pcurrent=ptemp;//将当前结点指针指向找到的结点 return true; } ptemp=ptemp->readn();//查找下一个结点 } return false; } bool Linklist::Locate(char c) { Node * ptemp=&head; while(ptemp!=NULL) { if(ptemp->readc()==c) { pcurrent=ptemp; return true; } ptemp=ptemp->readn(); } return false; } bool Linklist::Insert(int i,char c) { if(pcurrent!=NULL) { Node * temp=new Node(i,c,pcurrent,pcurrent->readn());//调用Node类构造函数重载2 if (pcurrent->readn()!=NULL) { pcurrent->readn()->setp(temp); } pcurrent->setn(temp); return true; } else { return false; } } bool Linklist::Delete() { if(pcurrent!=NULL && pcurrent!=&head)//head结点不能删除 { Node * temp=pcurrent; if (temp->readn()!=NULL) { temp->readn()->setp(pcurrent->readp()); } temp->readp()->setn(pcurrent->readn());//先连 pcurrent=temp->readp(); delete temp;//后断 return true; } else { return false; } } void Linklist::Show() { Node * ptemp=&head; while (ptemp!=NULL)//链表的遍历 { cout <<ptemp->readi() <<'\t' <<ptemp->readc() <<endl; ptemp=ptemp->readn(); } } void Linklist::Destroy() { Node * ptemp1=head.readn(); while (ptemp1!=NULL) { Node * ptemp2=ptemp1->readn(); delete ptemp1; ptemp1=ptemp2; } head.setn(NULL);//头结点之后没有其他结点 }
//main.cpp #include "Linklist.h" #include <iostream> using namespace std; int main() { int tempi; char tempc; cout <<"请输入一个整数和一个字符:" <<endl; cin >>tempi >>tempc; Linklist a(tempi,tempc);//创建一个链表,头结点数据由tempi和tempc确定 a.Locate(tempi); a.Insert(1,'C'); a.Insert(2,'B'); a.Insert(3,'F'); cout <<"After Insert" <<endl; a.Show(); a.Locate('B'); a.Delete(); cout <<"After Delete" <<endl; a.Show(); a.Destroy(); cout <<"After Destroy" <<endl; a.Show(); return 0; }运行结果:
请输入一个整数和一个字符: 4 G Node constructor is running... Linklist constructor is running... Node constructor is running... Node constructor is running... Node constructor is running... After Insert 4 G 3 F 2 B 1 C After Delete 4 G 3 F 1 C After Destroy 4 G根据程序的运行结果,我们发现头结点的构造函数比链表的构造函数优先运行。这也不难理解:构造函数的目的是要初始化成员数据,初始化成员数据的时候这个成员数据是必须存在的。所以当一个成员数据是一个对象的时候,应当先产生这个成员对象,于是就先调用了成员对象的构造函数。
10.静态成员数据
由于内存的空间有限,我们常常关心已经使用掉了多少内存空间。如果我们修改上一章的链表程序(程序15.5),要能计算出整个程序一共产生了多少链表结点,我们该怎么做呢?
显然,我们需要一个计数器。每产生一个结点,计数器就加一;每消除一个结点,计数器就减一。由于结点的产生和消除只会与链表类或结点类的某些成员函数有关,所以这个计数器只能是一个全局变量了(全局变量的概念见11.1节),否则它将无法被各个成员函数访问和修改。
不过使用全局变量会带来严重的安全性问题。产生了多少个链表结点明明是和结点类有关的,却没有被封装在结点类里面。任何函数都能修改这个全局变量,不得不让我们担忧。
封装在类内部的数据是成员数据。想象一下,如果我们给链表结点类添加一个成员数据count,那么链表结点类的定义就是这样:class Node//定义一个链表结点类 { public: …… //成员函数同程序15.5 private: int idata;//存储数据保密 char cdata;//存储数据保密 Node *prior;//前驱结点的存储位置保密 Node *next;//后继结点的存储位置保密 int count;//新来的成员函数,用于记录产生了多少个结点 };现在计数器是一个成员数据,可以被链表结点类的成员函数访问,也保证它不会被随便修改。但如果我们创建了三个结点对象a、b、c之后,我们发现a.count、b.count和c.count是三个互不相关的变量,也就是说它们的值可能是不一致的。更麻烦的是,我们不知道还会产生多少结点对象,如果新增一个结点对象,那么之前的每一个结点对象的count都要发生变化!
所以,我们需要一种方法,既能把count封装在类的内部,又能使各个对象的count相同。
静态成员数据
我们将产生的结点个数记为count,它不是某一个结点所具有的属性,而应该是整个链表结点类所具有的属性,或者说它是各个结点对象的共有属性。
如果我们把idata和cdata比作每个结点的私有财产,那么count就是所有结点的共有财产。count能被任何一个结点使用,但事实上无论有多少个结点,count只有一个。这样就不会发生a.count、b.count和c.count各不相同的情况了。在C++中,用静态成员数据(Static Data Member)来描述这种共有属性。与一般的成员数据类似,静态成员数据也可以分为公有(Public)的和私有(Private)的。静态成员数据的声明方法为:
static 数据类型成员变量名;
下面我们来看看如何给链表结点类增加一个静态成员数据:class Node//定义一个链表结点类 { public: …… //成员函数同程序15.5 private: int idata;//存储数据保密 char cdata;//存储数据保密 Node *prior;//前驱结点的存储位置保密 Node *next;//后继结点的存储位置保密 static int count;//私有静态成员数据,用于记录产生了多少个结点 };
静态成员数据的初始化
由于静态成员数据不是仅仅属于某一个具体对象的,所以它不能在构造函数中被初始化。(否则岂不是每创建一个对象,静态成员数据都要被初始化一次?)如果类的头文件会被直接或间接地重复包含,则静态成员数据也会被重复初始化。为了避免这个问题,我们可以将类的声明和定义分离,如果忘记了这个问题可参见11.2节。如果类的头文件绝对不会被重复包含,那么把静态成员数据的初始化放在类的头文件中也是可以勉强接受的。
静态成员数据的初始化语句为:
数据类型类名::静态成员数据=初始值;
11.静态成员函数
静态成员数据是某一个类所具有的属性,而不是某一个对象的属性,所以它的存在并不依赖于对象。那么,如果一个类没有任何对象实例时,所有的普通成员函数都无法使用,我们该如何访问私有的静态成员数据呢?
既然成员数据可以属于某一个类而不属于某一个具体的对象,成员函数能否这样呢?答案是肯定的。在C++中,除了有静态成员数据,还有静态成员函数。静态成员函数也是属于某一个类而不属于某一个具体的对象的。静态成员函数的声明方法为:
static 返回值类型函数名(参数表);
不过,在声明静态成员函数时,却不能出现static。
下面我们就来看一下静态成员数据、静态成员函数在程序中如何使用:(程序16.1)//node.h class Node//声明一个链表结点类 { public: Node();//构造函数的声明 Node(Node &n); Node(int i,char c='0'); Node(int i,char c,Node *p,Node *n); ~Node();//析构函数 int readi() const; char readc() const; Node * readp() const; Node * readn() const; bool set(int i); bool set(char c); bool setp(Node *p); bool setn(Node *n); static int allocation();//静态成员函数定义,返回已分配结点数 private: int idata;//存储数据保密 char cdata;//存储数据保密 Node *prior;//前驱结点的存储位置保密 Node *next;//后继结点的存储位置保密 static int count;//静态成员数据,存储分配结点个数 };
//node.cpp,把类声明和定义拆分开了 #include "node.h"//如果没包含头文件连接时会出现错误 #include <iostream> using namespace std; int Node::count=0;//静态成员数据初始化 //未定义的函数与程序15.5相同 Node::Node()//构造函数的定义 { cout <<"Node constructor is running..." <<endl; count++;//分配结点数增加 idata=0; cdata='0'; prior=NULL; next=NULL; } Node::Node(int i,char c)//构造函数重载1 { cout <<"Node constructor is running..." <<endl; count++;//分配结点数增加 idata=i; cdata=c; prior=NULL; next=NULL; } Node::Node(int i,char c,Node *p,Node *n)//构造函数重载2 { cout <<"Node constructor is running..." <<endl; count++;//分配结点数增加 idata=i; cdata=c; prior=p; next=n; } Node::Node(Node &n) { count++;//分配结点数增加 idata=n.idata; cdata=n.cdata; prior=n.prior; next=n.next; } Node::~Node() { count--;//分配结点数减少 cout <<"Node destructor is running..." <<endl; } int Node::allocation()//在定义静态成员函数时不能出现static { return count;//返回已分配结点数 }
//linklist.h同程序15.5 //main.cpp #include "Linklist.h" #include <iostream> using namespace std; int main() { int tempi; char tempc; cout <<"请输入一个整数和一个字符:" <<endl; cin >>tempi >>tempc; Linklist a(tempi,tempc); a.Locate(tempi); a.Insert(1,'C'); a.Insert(2,'B'); cout <<"After Insert" <<endl; a.Show(); cout <<"Node Allocation:" <<Node::allocation() <<endl;//调用静态成员函数 Node b; cout <<"An independent node created" <<endl; cout <<"Node Allocation:" <<b.allocation() <<endl;//调用静态成员函数 return 0; }运行结果:
请输入一个整数和一个字符: 3 F Node constructor is running... Linklist constructor is running... Node constructor is running... Node constructor is running... After Insert 3 F 2 B 1 C Node Allocation:3 Node constructor is running... An independent node created Node Allocation:4 Node destructor is running... Linklist destructor is running... Node destructor is running... Node destructor is running... Node destructor is running...可见,记录结点分配情况的功能已经实现。该程序中出现了两种调用静态成员函数的方法,一种是类名::静态成员函数名(参数表),另一种是对象名.静态成员函数名(参数表),这两种调用方法的效果是相同的。由于静态成员函数是属于类的,不是属于某一个具体对象,所以它分不清到底是访问哪个对象的非静态成员数据,故而不能访问非静态成员数据。
名不符实的static
在第11章中,我们遇到过保留字static,其作用是使局部变量在函数运行结束后继续存在,成为静态变量。(或者说存储空间在编译时静态分配)而本章中static的含义是“每个类中只含有一个”,与第11章中的static毫不相关。所以说这里的static是名不符实的。