1.9 在编程中使用异常
对大多数程序员,尤其是C程序员来说,他们目前使用的程序设计语言不支持异常,所以需要做一些调整。下面是一些在程序设计中使用异常的指导原则。
1.9.1 什么时候避免异常
异常并不能解决所有问题;过度使用会造成麻烦。本文下面的部分指出了在哪种情况下不应该使用异常。有关何时应该使用异常的最好建议是:只有当函数不符合它的规格说明时才抛出异常。
1.不要在异步事件中使用异常
标准C的signal()系统及类似系统负责处理异步事件:这些事件是发生在程序流程之外的,而且这些事件的发生是程序无法预料的。由于异常和它的处理器必须处在相同的函数调用栈上,所以无法使用C++中的异常机制来处理异步事件。也就是说,异常依赖于程序运行栈上的动态函数调用链(他们有“动态作用域(dynamic scope)”),然而异步事件必须由完全独立的代码来处理,这些代码不是正常程序流程的一部分(典型的例子是:中断服务例程和事件循环)。不要在中断处理程序中抛出异常。
这并不是说异步事件不能与异常发生联系。但是,中断处理程序应该尽快完成工作并返回。处理这种情况的典型方式是,中断处理程序设置一个标记,程序的主干代码同步地检查这个标记。
2.不要在处理简单错误的时候使用异常
如果能得到足够的信息来处理错误,那么就不要使用异常。程序员应该在当前语境中处理这个错误,而不是将一个异常抛出到更大的(上一层)语境中。
此外,C++在遇到机器层事件如除零错误[1]时不会抛出异常。读者可以认为其他一些机制,如操作系统或硬件会处理这种事件。这样,C++的异常机制可以相当有效,它们被隔离起来只用于处理程序级的异常状况。
3.不要将异常用于程序的流程控制
异常看起来有点像函数返回机制的代替品,也有点像switch语句,因此,读者可能觉得用异常来代替这些普通的语言机制很有吸引力。这是一种错误的想法,部分原因是异常处理系统的效率比普通的程序流控制差很多。异常仅仅是一个非常事件,使用异常要付出一定的代价。同样,如果把异常用于处理错误之外的其他地方,也会令类或函数的使用者带来混乱。
4.不要强迫自己使用异常
某些程序是相当简单的(例如一些小型的实用程序)。程序中可能只需要接收输入数据,进行某些处理。在这些程序中,可能在分配内存时失败,打开文件时失败等。遇到这类情况,显示一个消息然后退出程序就可以了,最好把清理工作交给操作系统来处理,而不必费劲地捕获所有异常并释放资源。简单地说,如果读者不需要异常,就不要强迫自己使用它们。
5.新异常,老代码
另一个问题出现在需要对现有的没有使用异常的程序进行修改的情况下。在程序设计中,可能引入了一个使用异常机制的库,并且想知道是否应该修改程序中所有的代码。假设在程序中已经拥有了一个令人满意的错误处理模式,最直接的方法是把使用新库的覆盖范围最大的代码段(可能是main()函数中的所有语句)放到try块中,追加一个catch(……),然后是基本的错误信息。可以进一步精练它们,根据需要的程度,添加更明确的异常处理器来改进这种做法,但是无论如何,新添加的代码应该尽可能的少。更好的方法是把产生异常的代码隔离在try块中,并且编写异常处理器把异常转换成与现有错误处理模式兼容的形式。
当一个编程人员正在编写一个供其他人使用的库时,特别是当无法知道他们如何响应致命性错误条件的时候,慎重地考虑异常非常重要(回忆一下之前对异常安全的讨论,为什么标准C++库中没有使用异常规格说明)。
1.9.2 异常的典型应用
在下列情况下请使用异常:
·修正错误并且重新调试产生异常的函数。
·在重新调试中的函数外面补偿一些行为以便使程序得以继续执行。
·在当前语境中做尽可能多的事情,并把同样类型的异常重新抛出到更高层的语境中。
·在当前语境中做尽可能多的事情,并将一个不同类型的异常抛出到更高层的语境中。
·终止程序。
·将使用普通错误处理模式的函数(尤其是C库函数)封装起来,以便用异常来代替原有的错误处理模式。
·简化。如果建立的错误处理模式使事情变得更复杂并且难以使用,那么异常可以使错误处理更加简单有效得多。
·使建立的库和程序更安全。使用异常既是一种短期投资(为了调试方便)也是一种长期投资(为了应用系统的健壮性)。
1.什么时候使用异常规格说明
异常规格说明就像函数原型:它提醒使用者来编写异常处理代码以及处理什么异常。它提醒编译器这个函数可能抛出异常,让编译器能够在运行时检测违反该异常规格说明的情况。
一个程序设计人员不能总是通过检查代码来预测某个特定的函数会抛出什么异常。有时候函数会产生无法预料的异常,有时候一个不抛出异常的旧函数会被一个抛出异常的新函数替换掉,并且迫使程序调用unexpected()。任何时候如果要使用异常规格说明,或调用使用异常规格说明的函数,最好编写自己的unexpected()函数,在这个unexpected()函数中将消息记入日志,然后抛出异常或终止程序。
如前所述,应该避免在模板类中使用异常规格说明,因为无法预料模板参数类(template parameter classes)所抛出的异常的类型。
2.从标准异常开始
在编写自己的异常类之前检查标准C++库中所定义的异常类。如果标准异常类符合系统设计的要求,则可能会使用户更容易理解和处理。
如果标准库中没有定义用户所需的异常类,应尽量从现有的标准异常类中继承出一个。如果用户能够使用exception()类中定义的接口函数what(),那么用户的异常类将显得非常友好。
3.嵌套用户自己的异常
如果为用户自己的特定类创建异常类,最好在这个特定类中或包含这个特定类的名字空间中嵌套异常类,这就为读者提供了一个明确的信息—这个异常类仅在用户自己的特定类中使用。另外,这也避免了污染全局名字空间。
即使用户自己的异常类是从C++标准异常类中派生的,用户也可以嵌套它们。
4.使用异常层次结构
异常层次结构为用户的类或库可能遇到的不同类型的重要错误提供了一个有价值的分类方法。这种方法给用户提供了有价值的信息,帮助他们组织代码,使他们能够有选择地忽略所有异常的附加的类型而仅仅捕获基类类型。另外,后来添加到异常类层次结构中的从相同基类继承的任何异常不会迫使用户重写现有的代码—针对基类的异常处理器将会捕获到这个新异常。
标准C++异常类是异常层次结构的一个好的范例。如果可以的话,应基于这些异常类来创建用户自己的异常类。
5.多重继承(MI)
读者在研读第9章的时候就会发现,惟一必须用到多重继承的情况是:当需要将一个对象指针向上类型转换成两个不同的基类类型时—也就是说,读者同时需要这两个基类的多态行为。异常层次结构在这种情况下也是有用的,因为多重继承异常类的任何一个基类的异常处理器都能够处理这个异常。
6.通过引用而不是通过值来捕获异常
正如读者在“异常匹配”那节内容所见到的,应该通过引用来捕获异常,这么做有两个原因:
·当异常对象被传递到异常处理器中的时候,避免进行不必要的对象拷贝。
·当派生类对象被当做基类对象捕获时,避免对象切割。
尽管可以抛出并且捕获指针类型的异常,但是如果这么做的话,将会在代码中引入紧耦合—抛出异常的代码和捕获异常的代码,必须就如何为异常对象分配内存和如何清理异常对象达成一致。由于在堆耗尽的时候也可能会触发异常,所以这也造成了一个问题。如果程序抛出异常对象,异常处理系统负责处理所有与存储有关的问题。
7.在构造函数中抛出异常
由于构造函数没有返回值,有两种方法来报告在构造对象期间发生的错误。
·设置一个非局部的标记,并且希望用户检查它。
·返回一个未完成的创建对象,并且希望用户检查它。
这个问题至关重要,因为C程序员希望所有的对象创建工作总是成功的,这一点在C语言中并不是不合理的,因为C语言中的类型非常简单。但是在C++程序中,不理会构造函数中出现的故障而继续运行,肯定会导致灾难性的后果,所以构造函数是抛出异常最重要的位置之一—现在用户有了一种安全有效的方式来处理构造函数异常。然而,当构造函数抛出异常时,用户必须注意对象内部的指针和它的清理方式。
8.不要在析构函数内部触发异常
因为析构函数会在抛出其他异常的过程中被调用,所以绝不要在析构函数中抛出异常或在析构函数中执行其他可能触发抛出异常的操作。如果在析构函数中抛出异常,这个新的异常可能会在现存的异常(其他异常)到达catch子句之前被抛出,这会导致程序调用terminate()函数。
如果在析构函数中调用的函数可能会抛出异常,应该在这个析构函数中编写一个try块,并把这些函数调用放到try块中,析构函数必须自己处理所有这些异常。绝对不能有任何一个异常从析构函数中抛出。
9.避免悬挂指针
请看这一章前面的Wrapped.cpp程序。如果需要给指针分配资源,那么悬挂指针通常意味着构造函数的弱点。如果在构造函数中抛出异常,因为指针没有析构函数,那么这些资源将无法释放。请使用auto_ptr或其他智能指针(smart pointer)类型[2]来处理指向堆内存的指针。
[1]某些编译器在这种情况下会抛出异常,但是它们通常提供编译器选项来禁止这种(不常见的)行为。
[2]在网址http://www.boost.org/libs/smart_ptr/index.htm可以找到增强的智能指针类型。下一版的标准C++正在考虑包含这些智能指针类型中的一部分。