如果类没有定义析构函数,那么只有类中含有成员对象(或者本类的基类)拥有析构函数的情况下,编译器才会合成一个出来,否则析构函数被视为不要,也就不需要合成。例如,如下类,虽然Point类拥有虚函数:
之所以有上述机制,当此语句发生时:
必须调用构造函数初始化类对象,没有构造函数,抽象化的使用就会有错误的倾向。当我们delete掉一个类对象时:
不需要在delete之前做类似的操作:
2. 对于多重继承和虚拟继承而言,情况要有所不同,当我们从Point3d和Vertex(虚拟继承自Point)派生出Vertex3d时,如果我们不声明Vertex3d的析构函数,但我们仍然希望Vertex3d对象结束时,调用Vertex的析构函数,那么编译器此时必须合成一个Vertex3d的析构函数,该函数唯一的任务就是调用Vertex的析构函数,如果我们提供一个Vertex3d的析构函数,编译器会扩展之,在我们的代码之后调用Vertex的析构函数,该扩展与构造函数的扩展类似,但是顺序相反:
a. 析构函数本身被执行。
b. 调用成员类对象的析构函数(如果有的话),调用顺序使他们声明的相反顺序。
c. 重置虚表指针。
d. 调用非虚基类的构造函数(如果有的话),调用顺序使他们声明的相反顺序。
e. 调用虚基类的构造函数(如果有的话),如果当前类是最尾端(most-derived)的类,那么虚基类用原来构造顺序的相反顺序析构。
一个类对象生命结束于其析构函数调用开始执行之时,由于每一个基类析构函数都会被调用,所以子类实际上变成了一个完整的对象,例如:对于类d继承自c,c继承自b,b继承自a,当d对象析构时,会依次变成一个c对象,b对象,a对象,当我们在析构函数中调用成员函数时,对象的蜕变会因为虚表指针的重新设定(析构函数中,程序员所供应的代码执行之前)而受到影响,这个会在第六章的内容中整理。
C++的类中有两种函数非常特别,一种是构造函数(constructor),另一种是析构函数(deconstructor)。在上篇文章中已经讲述了构造函数,本文将讨论析构函数。
当我们定义了类的一个对象时,就会隐式的调用构造函数,构造函数执行完成后,对象就有了资源。当我们不需要该对象时,即程序运行到对象作用域之外时,会隐式的调用析构函数,析构函数执行完成后,对象的资源就被释放。
一、析构函数(deconstructor)
构造函数的英文名为constructor,它是起到构造对象的作用的函数。析构函数的英文名为deconstructor,析构函数的作用与构造函数的作用相反,它是用来销毁对象的。(至于它为什么叫析构函数,而不叫销毁函数这样的函数名,我也不知道,自己想吧)
析构函数定义方式为: ~类名(){...}
二、析构函数的特点
析构函数有很多特点,有的和构造函数一样,有的不同,本文将会做一些对比。
(1)析构函数的定义方式。
A、析构函数的函数名和构造函数几乎相同,只是在类名前加了一个波浪号(tilde ~),以示区别。
B、析构函数没有参数。这点和构造函数不同,因为没有参数,所以析构函数也不能像构造函数一样重载。因此一个类中不可能像构造函数那样,有多种析构函数。我们只可为类提供一个析构函数。
C、可以显式的定义析构函数,也可以不定义。这一点和构造函数相同。如果我们显式的定义了析构函数,我们可以在函数体中书写一些语句,用于显示对象在释放前的值等。如果我们没有定义析构函数,编译器会为我们生成一个析构函数。
(2)析构函数的调用。
当类的对象离开其作用域时,析构函数被调用,用于释放对象资源并销毁资源。
值得注意的是,析构函数只会删除真实对象的真实资源。(这句话是我自己想出来的,下面是这句话的注释)
A、只有真实存在的对象离开其作用域时才会调用析构函数,对象的引用,指向对象的指针离开其作用域时,不会调用析构函数。这是为了安全起见,因为很多时候可能对象的引用,指向对象的指针离开作用域时,对象还在其作用域。为了减少程序的bug,建议当对象离开其作用域后,我们让对象的引用,指向对象的指针失效,或者干脆就不再使用它。
B、使用new运算符创建的对象的资源,只有使用delete运算符删除指向它的指针时,才会调用它的析构函数,释放它的资源。这点要特别注意,当我们在类中显式定义析构函数时,函数体中通常就包含delete语句。
C、类中的静态成员属于类,不属于类的对象,它们的资源不会被析构函数释放。
析构函数的调用与构造函数的调用有明显不同:析构函数可以被显式调用,而构造函数不能。显式调用析构函数和调用类的其它成员函数没什么不同。当析构函数被显式调用时,只执行它的函数体,而不删除对象的资源。也就是说,当析构函数被显式调用时,它就是一个普通的成员函数,没有析构功能。
三、析构函数的Rules of Three
通常情况下,我们不需要显式定义析构函数,除非我们需要它完成一些工作。(这一点在下部分讲述)
Rules of Three:如果一个类需要手动定义一个析构函数,那么通常情况下,这个类也需要手动定义复制构造函数和赋值运算符重载函数。
我们在上一篇文章中讲过,复制构造函数用于对象的复制,赋值运算符重载函数的功能和复制构造函数几乎一样。通常,我们将复制构造函数和赋值运算符重载函数绑定,定义了一个,另一个也必须出现。
析构函数、复制构造函数和赋值运算符重载函数,这三个函数是C++类的复制控制(copy-control)成员。复制控制,就是控制类的对象的复制。其中复制构造函数和赋值运算符重载函数是用来复制对象,析构函数是用来删除对象。
通常,使用复制构造函数或者赋值运算符重载函数创建一个对象时,会获得资源,有时必须显式定义析构函数才能释放这样的对象的资源。
四、编译器生成的析构函数
析构函数最特别的一点是,编译器总是为我们生成一个析构函数,不管我们是否显式的定义一个析构函数。这一点和构造函数非常不同。当我们显式的定义了析构函数以后,编译器仍然为我们生成一个析构函数。程序执行过程中,先调用用户显式定义的析构函数,再调用编译器生成的析构函数。
五、显示定义的析构函数不析构
根据上文,可以提出这样两个问题:
1、显式定义的析构函数为什么可以显式调用,而构造函数不能?
2、编译器为什么总是为我们生成一个析构函数?既然这样,用户自定义的析构函数有什么用?
也许很多人都有这样的疑问,我想,弄懂了这两个问题,就算真正明白了析构函数。
第一个问题,只有显示定义了析构函数,我们才能显式调用析构函数。当显式调用析构函数的时候,执行的是析构函数体内的语句,没有执行析构功能。我们可以写如下程序来测试:
上述程序中,我们在类中显式定义了析构函数,在主函数中,我们显式的调用了一次析构函数,之后对象被销毁,又调用了析构函数。可以看到结果出现了两次,而且均相同。
这个结果说明了什么呢?
第一次显式调用析构函数,函数和普通成员函数一样被执行,没有析构。第二次隐式调用析构函数,输出的结果与第一次一样。之后对象释放资源,对象被销毁。那么,我们思考一下,对象是在什么时候释放的资源,是在什么时候被销毁的?
首先,对象肯定不会在析构函数执行之初和执行之时便释放资源,因为之后我们仍然可以输出对象的成员变量的值。当然,也可以认为执行析构函数时创建了一个临时对象,将对象复制后保存在临时对象中,对象此时已被删除。但析构函数执行完成后,临时对象被删除。但是这样一来,无疑加重了内存的负担,假设对象非常之大,临时对象也讲会很大,而且复制的过程也会很长,语言应该不会设计成这样。
其次,对象可能在析构函数体内的语句执行完毕后开始释放资源。这样需要维持一个值,用来表示析构函数是显式调用还是隐式调用,如果是显式调用,那么不能释放资源,如果是隐式调用,必须释放资源。
我们不能排除这种情况,但是于理不可。如果是这样,大可让析构函数和构造函数一样,不能被显式调用,这样多方便。而且,现在我们也没看出来显式调用析构函数有什么用。
最后,对象可能在析构函数执行完成后释放资源。和上种情况一样,我们仍然需要维持一个值,来表示它是显式还是隐式调用。
通过以上分析,我们可以得出这样一个结论:显式定义的析构函数可能根本就不执行析构功能。乍一看,很神奇,显式定义的析构函数不析构,那由谁析构?
我们来看第二个问题。为什么显式定义了析构函数,编译器还要为我们生成一个析构函数?编译器定义的析构函数有什么用?在C++ Primer第四版中,作者提到:当类的对象被销毁时,显式定义的析构函数先运行,当它运行完成后,编译器生成的析构函数开始运行。编译器生成的析构函数会销毁对象的成员变量,对于类类型的成员,如string类的成员,会调用它所属类的析构函数来释放该成员所占用的内存,对于内置类型成员,编译器生成的析构函数什么也不做而销毁它(does nothing to destroy them)。销毁一个内置类型的成员没有任何影响,特别是,编译器生成的析构函数不会删除指针成员指向的对象。
从书中那段话可以看出,显式定义的析构函数可能与对象的销毁无关,析构的过程可能由编译器生成的析构函数完成的。这也就解释了为什么显式定义的析构函数可以显式调用,因为显式定义的析构函数只是虚有其表,名不符实,它和普通的成员函数没什么大的区别,唯一的不同就是在对象的生存周期中,它一定会被调用至少一次(这一次就是在对象销毁时)。
我无法考证这段话的正确性,几乎没有书讲到这一点,也很难用C++程序证明这一点。这个结论是根据书本推论出来的,没有严格的证明,只能算是推论。
六、显式定义析构函数
上述部分告诉我们:如非必要,不要定义析构函数,以免引起不必要的错误。
显式定义析构函数多用于以下两种情况:
1、用于查看对象在销毁的前一刻保存的内容。有时候为了测试程序,会用到。
2、在类中用new运算符动态分配了内存,可以在析构函数中使用delete运算符释放内存。这种情况是最常用的。因为编译器生成的析构函数是不会销毁new出来的动态对象,这一点是因为new出来的对象保存在内存中的堆(heap)区,而编译器生成的析构函数只会释放内存中的栈(stack)区。举个例子:
当我们定义的类中含有指针成员,并在成员函数中使用new运算符动态分配了内存,我们一定要记得使用delete运算符删除之。使用了new运算符,就一定要使用delete运算符,这是一个好的编程习惯。
当然,我们可以在其它的函数中使用delete运算符,甚至我们可以单独定义一个函数如safe_del(){delete []p;p=null;},然后显式的调用它。但是如果我们把它写在析构函数中,那么即使我们忘了删除new出来的对象,程序运行时也不会忘。虽说显式定义的析构函数名不符实,但是我们还是尽量让它实现析构功能吧。
总结:本文讲了析构函数的一些特性,前面的部分很多书都会涉及到,最后的一部分是笔者的学习心得,才是本文的重点。为了文章的完整性,才阐述了前面部分的内容。
1、显式定义的析构函数可以被显式调用,但是显式调用它没有什么意义。
2、显式定义的析构函数的作用不像显式定义的构造函数那么有用,显示定义的析构函数完全可以用别的函数代替,但是,为了使用方便,为了其它编程人员的使用,在需要显示定义析构函数的情况下,还是定义它比较好,这样符合通用编程风格。
3、Rules of Three:如果需要定义析构函数,那么通常也需要定义复制构造函数和赋值运算符重载函数。