14.6 C++类-重载运算符、拷贝赋值运算符与析构函数

14.1 C++类-成员函数、对象复制与私有成员
14.2 C++类-构造函数详解、explicit与初始化列表
14.3 C++类-inline、const、mutable、this与static
14.4 C++类-类内初始化、默认构造函数、“=default;”和“=delete;”
14.5 C++类-拷贝构造函数
14.6 C++类-重载运算符、拷贝赋值运算符与析构函数
14.7 C++类-子类、调用顺序、访问等级与函数遮蔽
14.8 C++类-父类指针、虚/纯虚函数、多态性与析构函数
14.9 C++类-友元函数、友元类与友元成员函数
14.10 C++类-RTTI、dynamic_cast、typeid、type-info与虚函数表
14.11 C++类-基类与派生类关系的详细再探讨
14.12 C++类-左值、右值、左值引用、右值引用与move
14.13 C++类-临时对象深入探讨、解析与提高性能手段
14.14 C++类-对象移动、移动构造函数与移动赋值运算符
14.15 C++类-继承的构造函数、多重继承、类型转换与虚继承
14.16 C++类-类型转换构造函数、运算符与类成员指针

6.重载运算符、拷贝赋值运算符与析构函数

  6.1 重载运算符

    这些运算符都已经很熟悉了,例如如果进行两个整数是否相等的比较,看看如下代码:

int a = 4, b= 5;
if(a == b){...}

    但如果进行两个对象是否相等的比较,看看如下代码:

Time myTime;
Time myTime2;
if(myTime == myTime2)
{
	//...
}

    编译上面的代码发现编译报错。难道真不能比较两个对象是否相等吗?能比!通过重载“==”运算符,就可以让两个对象(如两个Time对象)进行比较。什么叫重载“==”运算符呢?也就是说,这个成员函数里面有一些比较逻辑。就拿Time对象而言,例如如果两个Time对象的成员变量Hour相等,那么就认为这两个对象相等,那么在这个名为“==”的成员函数里只要进行Hour相等的判断,然后返回一个bool值就可以了。当把“==”这个成员函数写出来后,这两个Time对象就可以用“==”进行比较了。重载的“==”运算符大概看起来如下:
    在Time.h中的Time类内可以用public修饰符来声明:

bool operator == (Time& t);

    在Time.cpp中重载的“==”运算符的实现代码大概看起来如下:

//重载=运算符
Time& Time::operator=(const Time& tmpTime)
{
	Hour = tmpTime.Hour;
	Minute = tmpTime.Minute;
	//...可以继续增加代码来完善,把想给值的成员变量全部写进来
	return *this; //返回一个该对象的引用
}

    总结一下:上述的很多运算符如果想要应用于类对象中,就需要对这些运算符进行重载,也就是以这些运算符名为成员函数名来写成员函数,以实现这些运算符应用于类对象时应该具备的功能。
所以,重载运算符本质上是函数,函数的正式名字是:operator关键字后面接这个运算符。既然本质上是函数,那么这个函数当然也就具有返回类型和参数列表。
    有些类的运算符如果不自己重载,某些情况下系统(编译器)会帮助我们重载,如赋值运算符。看看如下代码:

{			
	Time myTime5;
	Time myTime6;		
	myTime6 = myTime5;
}

    从上面代码已经看到,当把myTime5赋值给myTime6的时候,并没有报错,能够成功赋值。有些资料上会解释成系统替我们重载了赋值运算符使得“myTime6=myTime5;”能成功执行,其实,这种说法并不完全正确。
    (1)如果一个类没有重载赋值运算符,编译器可能会重载一个“赋值运算符”,也可能不会重载一个“赋值运算符”。是否重载取决于具体需要。但上面代码的情况下,如果程序员没有重载赋值运算符,编译器并不会重载一个“赋值运算符”,编译器内部只需要简单将对象成员变量的值复制到新对象对应的成员变量中去就可以完成赋值。
    (2)如果Time类中有另外一个类类型(Tmpclass)的成员变量,代码如下:

Tmpclass tmpcls;

而这个Tmpclass类类型内部却重载了赋值运算符:

class Tmpclass
{
public:
	Tmpclass& operator=(const Tmpclass&)
	{
		return *this;
	}

    这种情况下,当执行类似“myTime6=myTime5;”代码行时,因为Time类中并没有重载赋值运算符,编译器就会在Time类中重载“赋值运算符”并在其中插入代码来调用Tmpclass类中重载的赋值运算符中的代码。
    回过头,假设程序员没有重载“==”运算符,那么如下的代码:

{
	Time myTime;
	Time myTime2;
	if (myTime == myTime2) 
	{ 
		//......
		cout << "相等" << endl;
	}
}

    上面几行代码在编译器遇到“==”时报错,显然,编译器内部没有针对“==”的默认处理动作,也没有重载“==”,这意味着,除非程序员自己重载“==”,否则编译一定会报错。
    这说明编译器对待“=”和“==”有不同的待遇,对待更常用的“=”,显然要比“==”友好得多。接下来看一看针对类Time对象的赋值运算符的重载应该怎样写。

  6.2 拷贝赋值运算符(赋值运算符)

    拷贝构造函数已经很熟悉,请注意,下面的代码行中有两行是调用了拷贝构造函数的:

{
	Time myTime;             //这个会调用默认构造函数(不带参数)
	Time myTime2 = myTime;   //调用了拷贝构造函数	
	Time myTime5 = { myTime }; //调用了拷贝构造函数
}
Time myTime6;
myTime6 = myTime5;

    现在笔者要说的是,如果给对象赋值,那么系统会调用一个拷贝赋值运算符(简称赋值运算符),就是刚刚提到的重载的赋值运算符,也就是一个函数。可以自己进行赋值运算符的重载,如果不自己重载这个运算符,编译器会用默认的对象赋值规则为对象赋值,甚至在必要的情况下帮助我们重载赋值运算符(上面已经说过),看来编译器格外喜欢赋值运算符。当然,编译器重载的赋值运算符功能上可能会比较粗糙,只能完成一些简单的成员变量赋值以及调用类类型成员变量所对应类中提供的拷贝赋值运算符(如上面类Tmpclass中重载的operator==)。
    但为了精确地控制Time类对象的赋值动作,往往会由程序员自己来重载赋值运算符。
    赋值运算符既然是一个函数,就有返回类型和参数列表,这里的参数就表示运算对象,如上面myTime5就是运算对象(因为是myTime5要把值赋给myTime6,所以myTime5就是运算对象)。
    每一个运算符怎样重载,参数、返回类型都是什么,比较固定,读者可以通过搜索引擎来搜集和整理,建议可以适当地记忆,至少在需要的时候要能够随时查阅到。下面是一个赋值运算符重载的声明,可以将其写在Time.h文件的Time类定义中(实现代码后面再谈)并用public修饰符来修饰:

Time& operator = (const Time&);

    那么,在执行诸如“myTime6=myTime5;”这种针对Time对象赋值的语句时,系统就会调用这里的赋值运算符重载的实现代码。
    针对“myTime6=myTime5;”这行代码,对照Time类中针对赋值运算符的重载“Time&operator=(constTime&);”,有几点说明:
    (1)左侧对象myTime6就是这个operator=运算符里的this对象。myTime5就是operator=里面的形参。也就是说,调用的“=”重载运算符实际上是对象myTime6的“=”重载运算符(而不是myTime5的,这一点读者千万不要搞糊涂)。
另外,operator=中的形参写成了const类型,目的是防止误改myTime5里面的值,本来代码是myTime5给myTime6赋值,万一写出来的代码一个不小心把myTime5内成员值给修改了,那就太不科学了,所以形参中加入了const,防止把myTime5值无意中修改掉。
    (2)operator=运算符的返回值通常是一个指向其左侧运算符对象的引用,也就是这个myTime6的引用。读者是否还记得14.3.4节谈到过的return*this?把对象自己返回去。
    (3)如果想禁止Time类型对象之间的赋值,又该怎么做呢?显然在Time.h中声明赋值运算符重载时,用private修饰就行了。例如:

private:
	Time &operator = (const Time&);

    当然,这里并不需要禁止Time类型对象之间的赋值。所以,显然上面这行赋值运算符重载的声明的代码应该用public来修饰。
    下面把这个赋值运算符重载的函数体写到Time.cpp文件中:

//重载=运算符
Time& Time::operator=(const Time& tmpTime)
{
	Hour = tmpTime.Hour;
	Minute = tmpTime.Minute;
	//...可以继续增加代码来完善,把想给值的成员变量全部写进来
	return *this; //返回一个该对象的引用
}

    此时,可以在代码行myTime6=myTime5;设置断点,并开始跟踪,当断点停止到这行的时候,按快捷键F11(对应“调试”→“逐语句”命令)跟踪进去会发现,程序会自动调用上述的operator=,请读者在跟踪过程中注意观察myTime6对象的地址(&myTime6)和myTime5对象的地址,再继续观察operator=中的形参tmpTime的地址,从而确认调用的是myTime6的operator=函数以及确定传递进来的形参是myTime5的引用。
    这样就重载了Time类的赋值运算符,下次再给一个Time对象赋值的时候,系统就会自动调用这里书写的赋值运算符重载(实际就是operator=函数)。

  6.3 析构函数(释放函数)

    析构函数与构造函数正好是相对的,或者说是相反的,定义对象的时候,系统会调用构造函数,对象销毁的时候,系统会调用析构函数。析构函数也没有返回值。
    如果不写自己的析构函数,编译器可能会生成一个“默认析构函数”,也可能不会生成一个“默认析构函数”,是否生成取决于具体需要。例如,如果上面的Tmpclass类中有析构函数如下:

~Tmpclass()	//Tmpclass类的析构函数,已“~”开头并跟着类名,没有返回值
{}

并在Time类定义中有如下代码表明tmpcls是类类型成员变量:

Tmpclass tmpcls;

    那么,编译器会生成一个Time类的“默认析构函数”,并在该“默认析构函数”中插入代码来调用Tmpclass类的析构函数。这样,当销毁(也叫“释放”或者“析构”)一个Time类对象的时候,也同时能够顺利地销毁Time类中的类类型成员变量tmpcls。
    那么,在什么情况下有必要书写自己的析构函数呢?
    例如,在构造函数里如果new了一段内存,那么,一般来讲,就应该写自己的析构函数。在析构函数里,要把这段new出来的内存释放(delete)掉。请注意,即便编译器会生成“默认析构函数”,也绝不会在这个“默认析构函数”里释放程序员自己new出来的内存,所以,如果不自己写析构函数释放new出来的内存,那就会造成内存泄漏。久而久之,程序将会因内存耗尽而运行崩溃。
    析构函数也是类中的一个成员函数,它的名字是由波浪线连接类名构成,没有返回值,不接受任何参数,不能被重载,所以一个给定的类,只有唯一一个析构函数。当一个对象在离开其作用域被销毁的时候,那么该对象的析构函数就会被系统调用。
    再提一下函数重载这个概念。系统允许函数名字相同,但是这些同名函数带的参数个数或者参数类型不同,系统允许这些同名函数同时存在,但调用这些函数的时候,系统根据调用这个函数时提供的实参个数或实参类型,就能区别出来到底想调用哪个函数。
    但在这里,因为析构函数压根就不允许有参数存在,所以也就不存在针对析构函数的重载。
现在来为Time类写一个析构函数。
    在Time.h中的Time类内部,声明public修饰的析构函数(注意用public修饰否则编译会报错):

public:
	~Time();	//声明Time类的析构函数

    在Time.cpp中书写类Time的析构函数实现代码:

Time::~Time()
{
	//这里随便写一点代码
	int abc;
	abc = 0;
}

    可以在析构函数中设置断点,发现main函数执行完成后,释放局部的myTime5和myTime6对象时系统都会调用自己所写的析构函数。
    注意,在断点落到Time析构函数中的时候,可以通过“调用堆栈”窗口并单击该窗口的第2行,就能够看到谁调用的Time析构函数,如图14.8所示。

在这里插入图片描述
    如果没有看到“调用堆栈”窗口,请在程序执行停在某个断点处时使用“调试”→“窗口”→“调用堆栈”命令打开“调用堆栈”窗口观察函数关系,双击该窗口中的某一行,可以随时查看某个函数的调用关系和调用时的上下文状态。请读者自己尝试和熟悉。
    “调用堆栈”窗口也是调试程序时常用的一个窗口,要求熟练掌握。

  6.4 几个话题

(1)构造函数的成员初始化

    对于构造函数的实现,例如如下这个带两个形参的构造函数实现代码:

Time::Time(int tmphour, int tmpmin, int tmpsec)
	:Hour(tmphour), Minute(tmpmin)//初始化列表
{
	//这里是函数体
}

    这个构造函数做的事其实可以看成两部分:函数体之前和函数体之中。
    根据上面的代码,成员Hour、Minute的初始化是在函数体开始执行之前进行的(初始化列表先执行),如上面的Hour(tmphour)、Minute(tmpmin)。但要注意这两个成员变量的初始化顺序——按照它们在Time类中定义的顺序来初始化而不是按照这里的书写顺序来初始化。
    然后再执行函数体(也就是{}包着的部分),如果在函数体中给成员变量值,如形如Hour=tmphour;,那就成了赋值而不是成员变量初始化了。
    在14.2.6节中已经提到过,对于一个基本数据类型的成员变量,如上面的Hour,无论是通过初始化列表的方式给值,还是通过赋值的方式给值,对于系统来讲,所执行的代码几乎没差别。
    但是,对于类类型的成员变量,使用初始化列表的方式初始化比使用赋值语句初始化效率更高。为什么?这是一个让很多读者朋友都很费解的问题,笔者这里试图用最简单的举例来说明。
    (1)当用诸如“TimemyTime3c(10,20,30);”这行代码生成一个Time类型对象的时候,所有人都会注意到这会导致Time类带三个参数的构造函数的执行,但是请往下看。
    (2)因为Time类中有一个类类型成员变量tmpcls(Tmpclasstmpcls;)的存在,细心的读者一定能够观察到,在执行Time类的带三个参数的构造函数的初始化列表的那个时刻,tmpcls所属的类Tmpclass的构造函数(Tmpclass())被执行了一次。这表明,在执行Time类的带三个参数的构造函数的初始化列表的那个时刻,系统会给一次构造类类型对象tmpcls的机会。
    (3)现在为了演示得更丰满,笔者修改Tmpclass类的构造函数,为其新增一个int类型的形参并给一个默认值(带默认参数)。

Tmpclass(int itmpvalue = 0)
{
	std::cout << "调用了Tmpclass::Tmpclass(int itmpvalue)构造函数" << std::endl;
}

    之所以要给Tmpclass这个构造函数形参一个默认值,是因为这样可以保证构造Tmpclass类对象时无论是否带实参都能够成功。例如,代码“Tmpclassa;”或者“Tmpclassa(100);”都能够成功构造出类Tmpclass对象a。
    (4)读者可以试想一下,在执行“TimemyTime3c(10,20,30);”这行代码的时候,如果想用带一个参数的Tmpclass构造函数来初始化Time的成员变量tmpcls,那就绝对不应该放过“执行Time类的带三个参数的构造函数的初始化列表的那个时刻系统给的那次构造类类型对象tmpcls的机会”,那Time的带三个参数的构造函数就应该这样写:

Time::Time(int tmphour, int tmpmin, int tmpsec)
	:Hour(tmphour), Minute(tmpmin), tmpcls(100)
{
	//这里是函数体
}

    上面的tmpcls(100)会导致Tmpclass(int itmpvalue)构造函数被执行一次。但是就算没有tmpcls(100),Tmpclass(intitmpvalue)构造函数也会被执行一次,因为这次是系统自动给的,不要也不行。
(5)但是如果像下面这样写代码:

Time::Time(int tmphour, int tmpmin, int tmpsec)
		:Hour(tmphour),Minute(tmpmin)
{
	//这里是函数体
	tmpcls = 100;
}

    那就不但浪费了系统给的那次构造类类型对象tmpcls的机会,而且还会因为代码行Tmpcls=100;的存在导致Tmpclass类的构造函数、operator=、析构函数分别被执行了一次(读者可以设置断点细致地跟踪观察),这是完全没必要的,并且浪费了效率。
    所以,读者一定可以看出,对于类类型成员变量的初始化,能放在构造函数的初始化列表里进行的,千万不要放在构造函数的函数体里来进行,这样可以节省很多次不必要的成员函数调用,从而提高不少程序的执行效率。

(2)析构函数的成员销毁

    这里谈如下几个问题:
    (1)析构函数做的事情,其实也可以看成两部分:函数体之中和函数体之后。
当释放一个对象的时候,首先执行该对象所属类的析构函数的函数体,执行完毕后,该对象就被销毁,此时对象中的各种成员变量也会被销毁。
所以,在理解的时候千万不要认为对象中的成员变量是在析构函数的函数体里面销毁的,而是函数体执行完成后由系统隐含销毁的。
    (2)另外一个问题是成员变量的初始化和销毁顺序问题。
成员变量初始化的时候是在类中先定义的成员变量先进行初始化,销毁的时候是先定义的成员变量后销毁。
    (3)如果是用malloc/new等分配的内存,则一般都需要自己释放(通过在析构函数中使用free/delete来释放内存)。例如,如果有一个Time类的成员变量如下:

char * mp;

在Time的构造函数中有如下代码:

m_p = new char[100];	//分配内存

那么,在Time的析构函数中,就应该有如下代码:

delete[]mp;				//释放内存

    不要指望系统帮助释放m_p指针成员变量所指向的内存,系统不会做这件事。
    (4)对象销毁这种操作一般不需要人为去干预,如果是一个类类型成员变量,那么对象销毁的时候,系统还会去调用这个成员变量所属类的析构函数。所以说,成员销毁时发生的事情是依赖于成员变量类型的,如果成员变量是int这种系统内置类型,那销毁的时候也不需要干什么额外的事情,系统可以自行处理,因为这些内置类型也没有析构函数,所以系统就会直接把它们销毁掉。

(3)new对象和delete对象

现在展示的范例都是生成一个临时对象,例如:

Time myTime5;

    可以看到,当执行这行代码时,系统调用Time类的构造函数,当整个main主函数执行完时,也就是myTime5所在的函数执行完毕,那么myTime5这个变量的作用域也就到此结束,这时系统调用Time的析构函数。析构函数执行完毕后,系统会把这个myTime5对象销毁掉。
    这里介绍另外一种生成对象的方法,就是使用new。new在13.4.2节中讲解过。
    可以注意到一个事实,用new创建一个对象的时候,系统调用了该Time类的构造函数。
    但是必须要注意,自己new出来的对象,自己必须想着释放,否则会造成内存泄漏,所以,在程序停止运行之前的某个时刻,一定要用如下代码:

{
	Time* pmytime5 = new Time;   //调用不带参数的构造函数
	Time* pmytime6 = new Time(); //调用不带参数的构造函数,带括号的new Time和不带括号的new Time有点小区别,有兴趣可以通过搜索引擎了解一下,暂时建议可以先认为这两者没什么区别
	delete pmytime5; //释放千万不能忘记
	delete pmytime6; //释放千万不能忘记 
}

    把这两个new出来的对象释放掉。注意,什么时候delete了new出来的对象,系统就会在什么时候去调用Time类的析构函数。
    所以,这种new和delete创建和销毁对象的方式,必须配对使用(有new必然要有delete),不要想当然地认为pmytime5是一个局部指针,一旦离开作用域了它指向的内存会被自动释放。手工new出来的对象(占用内存)必须手工调用delete去释放(释放内存)。也只有手工delete的时候,Time类的析构函数才会被系统调用,一旦new一个对象,用完后忘记了delete,就会造成内存泄漏,如果泄漏的内存多了,后果严重,整个程序可能会因为内存资源耗尽而最终崩溃。为什么许多人写C++程序不稳定?忘记释放内存导致内存不断泄漏,也是一个很大的诱因。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值