1.5 清理
异常处理的魅力之一在于程序能够从正常的处理流程中跳转到恰当的异常处理器中。如果异常抛出时,程序不做恰当的清理工作,那么异常处理本身并没有什么用处。C++的异常处理必须确保当程序的执行流程离开一个作用域的时候,对于属于这个作用域的所有由构造函数建立起来的对象,它们的析构函数一定会被调用。
这里有一个例子,演示了当构造函数没有正常结束时不会调用相关联的析构函数。这个例子还显示了当在创建对象数组的过程中抛出异常时会发生什么情况:
读者可以通过跟踪程序的执行过程来了解类Trace的对象踪迹。它用一个静态数据成员counter来统计已经创建的对象的个数,而用普通数据成员objid来追踪特定对象的编号。
main函数首先创建一个单独的对象n1(objid 0),然后试图创建一个具有五个Trace对象的数组,但是,在第四个对象(#3)被完整创建之前抛出了一个异常。对象n2根本就没有被创建。在这里可以看到程序的输出结果为:
对象数组的三个元素成功创建了,但是在创建第四个对象数组元素的过程中构造函数抛出了一个异常。在main()函数中,由于第四个构造函数(用于创建array[2]对象)没有完成,所以只有array[1]对象和array[0]对象的析构函数被调用。最后,对象n1销毁。因为对象n2根本就没有创建,所以也没有销毁。
1.5.1 资源管理
当在编写的代码中用到异常时,非常重要的一点是,读者应该问一下,“如果异常发生,程序占用的资源都被正确地清理了吗?”大多数情况下不用担心,但是在构造函数里有一个特殊的问题:如果一个对象的构造函数在执行过程中抛出异常,那么这个对象的析构函数就不会被调用。因此,编写构造函数时,程序员必须特别的仔细。
困难的事情是在构造函数中分配资源。如果在构造函数中发生异常,析构函数将没有机会释放这些资源。这个问题经常伴随着“悬挂”指针(“naked”pointer)出现。例如:
程序的输出为:
程序的执行流程进入了UseResources的构造函数,Cat的构造函数成功地完成了创建对象数组中的三个对象。然而,在Dog:operator new()函数中抛出了一个异常(用于模拟内存不足错误(out-of-memory error))。程序在执行异常处理器之时突然终止,UseResources的析构函数没有被调用。这是正确的,因为UseResources的构造函数没有完成,但是,这也意味着,在堆上成功创建的Cat对象不会被销毁。
1.5.2 使所有事物都成为对象
为了防止资源泄漏,读者必须使用下列两种方式之一来防止“不成熟的”的资源分配方式:·在构造函数中捕获异常,用于释放资源。
·在对象的构造函数中分配资源,并且在对象的析构函数中释放资源。
使用下述方法可以使对象的每一次资源分配都具有原子性,由于资源分配成为局部对象生命周期的一部分,如果某次分配失败了,那么在栈反解的时候,其他已经获得所需资源的对象能够被恰当地清理。这种技术称为资源获得式初始化(Resource Acquisition Is Initialization, RAII),因为它使得对象对资源控制的时间与对象的生命周期相等。为了达到上述目标,利用模板修改前一个例子是一个好方法:
这种使用模板来封装指针的方法与第一种方法的区别在于,这种方法使得每个指针都被嵌入到对象中。在调用UseResources类的构造函数之前这些对象的构造函数首先被调用,并且如果它们之中的任何一个构造函数在抛出异常之前完成,那么这些对象的析构函数也会在栈反解的时候被调用。
PWrap模板是迄今为止读者见到的最典型的使用异常的例子:在operator[]中使用了一个称作RangeError的嵌套类(nested class),如果参数越界,则创建一个RangeError类型的异常对象。因为operator[]的返回值类型是一个引用,所以它不能返回0。(程序中不能有空引用。)这是一个真正的异常情况—在当前语境中,程序不知道该做什么;而且不能返回一个不可能的值。在这个例子中,RangeError[1]是非常简单的,它假设类的名字能够表达所有必需的信息。如果认为出错对象的索引也很重要的话,可以在RangeError类中添加一个数据成员来容纳这个索引值。
这时,程序的输出为:
程序为Dog分配存储空间的时候再一次抛出了异常,但是这一次Cat数组中的对象被恰当的清理了,没有出现内存泄漏。
1.5.3 auto_ptr
由于在一个典型的C++程序中动态分配内存是频繁使用的资源,所以C++标准中提供了一个RAII封装类,用于封装指向分配的堆内存(heap memory)的指针,这就使得程序能够自动释放这些内存。auto_ptr类模板是在头文件<memory>中定义的,它的构造函数接受一个指向类属类型(generic type)的指针(无论在代码中使用什么类)作为参数。auto_ptr类模板还重载了指针运算符*和->,以便对持有的auto_ptr对象的原始指针进行前面介绍的那些运算。这样,读者就可以像使用原始指针一样使用auto_ptr对象。下面的代码演示了如何使用auto_ptr:
TraceHeap类重载了new运算符和delete运算符,这样,就可以准确地看到在程序运行过程中发生了什么事情。注意,像其他类模板一样,main()函数里必须在模板参数中指定所要使用的数据类型。但是这里不能使用TraceHeap*—auto_ptr已经知道了要存储指定类型的指针。main()函数的第二行证实了auto_ptr的operator->()函数间接使用了基本的原始指针。最重要的一点是,尽管程序没有显式地删除该原始指针,但是在栈反解的时候,pMyObject对象的析构函数会删除该原始指针,下面程序的输出证实了这一点:
auto_ptr类模板可以很容易地用于指针数据成员。由于通过值引用的类对象总会被析构,所以当对象被析构时,这个对象的auto_ptr成员总是能释放它所封装的原始指针。[2]
1.5.4 函数级的try块
由于构造函数能够抛出异常,读者可能希望处理在对象的成员或其基类子对象被初始化的时候抛出的异常。为了做到这一点,可以把这些子对象的初始化过程放到函数级try块中。与通常的语法不同,作为构造函数初始化部分的try块是构造函数的函数体,而相关的catch块紧跟着构造函数的函数体,就像下面这个例子中所写的一样:
注意,在Derived类的构造函数中,初始化列表处在关键字try和构造函数的函数体之间。如果在构造函数中发生异常,Derived类所包含的对象也就没有构造完成,因此程序返回到创建该对象代码的地方(构造函数的调用者)是没有意义的。由于这个原因,惟一合理的做法就是在函数级的catch子句中抛出异常。
尽管不是非常有用,C++还是允许在所有函数中使用函数级try块,下面的例子说明了这种用法:
在这种情况下,catch块中的代码可以像函数体中的代码一样正常返回。这种形式的函数级try块与在函数中添加try-catch来环绕所有代码没有什么区别。
[1]注意,在这种情况下最好使用C++标准库中定义的异常类—std:out_of_range。
[2]有关auto_ptr的详细信息,请参考Herb Sutter在1999年10月发表的文章“Using auto_ptr Ef fectively”—《C/C++Users Journal》,第63~67页。