1.7 异常规格说明
有时不要求程序提供资料告诉函数的使用者在函数使用时会抛出什么异常。但是,如果这样做,函数的使用者就无法确定如何编码来捕获所有可能的异常,所以这种做法通常被认为是不友好的。如果函数的使用者可以得到源代码,他们就可以通过查找throw语句来找到函数所抛出的异常,但是,以库的形式提供的函数通常是不包含源代码的。好的文档能够弥补这一缺陷,但是有多少软件项目能够提供编写良好的文档呢?C++提供一种语法来告诉使用者函数所抛出的异常,这样他们就能正确处理这些异常了。这就是可选的异常规格说明(exception specification),它是函数声明的修饰符,写在参数列表的后面。
异常规格说明再次使用了关键字throw,函数可能抛出的所有可能异常的类型应该被写在throw之后的括号中。这里的函数声明如下所示:
在涉及异常的情况下,传统的函数声明:
意味着函数可能抛出任何类型的异常。下面的函数声明
意味着函数不会抛出任何异常(最好确认一下,这个函数所调用的所有函数也不会抛出异常!)。
从好的编码策略、好的文档和便于函数调用这几个方面来说,当读者编写可能抛出异常的函数时,最好考虑使用异常规格说明。(在这一章的后面,将会讨论这一方针的变化。)
1.unexpected()函数
如果函数所抛出的异常没有列在异常规格说明的异常集中,那将会出现什么情况呢?在这种情况下,一个特殊的函数unexpected()将会被调用。默认的unexpected()函数会调用本章前面所讲到的terminate()函数。
2.set_unexpected()函数
像terminate()函数一样,unexpected()可以提供一种机制设置自己的函数来响应意外的异常(unexpected exception)。读者可以调用函数set_unexpected()来完成这件事,类似于set_terminate(),set_unexpected()函数使用一个函数指针作为参数,这个指针所指向的函数没有参数,而且其返回值类型为void。因为set_unexpected()函数返回了unexpected()函数指针先前的值,所以可以保存这个值,并且在以后恢复它。为了要使用set_unexpected()函数,编程人员必须在代码中包含头文件<exception>。下面这个例子用于显示迄今为止这一部分所讨论内容的简单应用:
创建Up类和Fit类作为异常类。虽然异常类通常都很小,但是可以用它们来保存附加信息提供给异常处理器作为参考。
函数f()在其异常规格说明中声明仅会抛出Up和Fit类型的异常,但是从函数的定义来看却不是这样的。函数g()的第1个版本(Version 1)被函数f()调用时不会抛出任何异常。但是如果有人修改了函数g(),使它抛出一个不同类型的异常(就像这个例子中的函数g()的第2个版本(Version 2)抛出一个int型异常),那么函数f()的异常规格说明就违反了规则。
按照自定义unexpected()函数的格式要求,my_unexpected()函数没有参数和返回值。这个函数只是显示一条消息,表明它被调用了,然后退出程序(在这里使用exit(0)),这样编写本书时所使用的make程序就不会失败了)。新的unexpected()函数中不能有return语句。
在main()函数中,try块位于for循环的内部,因此,所有的可能情况都被执行了。使用这种方式,程序可以实现类似异常恢复的功能。把try块嵌套在for、while、do或if块中,并且触发异常来试图解决问题;然后重新测试try块中的代码。
仅Up和Fit异常能够被捕获,因为函数f()的编写者称只有这两种异常会被触发。函数g()的第2个版本使得my_unexpected()被调用,因为f()抛出了一个int型的异常。
在调用set_unexpected()的时候,函数的返回值被忽略了,如果希望在某个时刻恢复先前的unexpected(),读者可以参考本章前面所讲的set_terminate()例子,将set_unexpected()的返回值保存在一个指向函数的指针中。
典型的unexpected处理器会将错误记入日志,然后调用exit()终止程序。它也可以抛出另外一个异常(或重新抛出相同的异常)或调用abort()。如果它抛出的异常类型不再违反触发unexpected的函数的异常规格说明,那么程序将恢复到这个函数被调用的位置重新开始异常匹配。(这是unexpected()函数特有的行为。)
如果unexpected处理器所抛出的异常还是不符合函数的异常规格说明,下列两种情况之一将会发生:
1)如果函数的异常规格说明中包括std:bad_exception(在<exception>中定义),unexpected处理器所抛出的异常会被替换成std:bad_exception对象,然后,程序恢复到这个函数被调用的位置重新开始异常匹配。
2)如果函数的异常规格说明中不包括std:bad_exception,程序会调用terminate()函数。
下面的程序演示了这种行为:
处理器my_uhandler1()抛出一个可以接受的异常(A),所以程序的执行流程成功地恢复到了第1个catch块中。处理器my_uhandler2()抛出的异常(B)不合法,但是g的异常规格说明中包括bad_exception,所以类型为B的异常被替换成类型为bad_exception对象,所以第2个catch也成功了。由于f的异常规格说明中不包括bad_exception,所以程序终止处理器(terminate handler)my_thandler()被调用了。程序的输出为:
1.7.1 更好的异常规格说明
读者可能会觉得现行的异常规格说明规范不太好,而
应该表示函数不会抛出异常。如果程序员想抛出任意类型的异常,他应该写成如下形式:
这确实是一种改进,因为函数的声明会变得更加明确。遗憾的是,通过阅读代码,读者不一定能够准确地知道函数是否会抛出异常—例如,内存分配失败会触发异常。更坏的情况是:在异常处理机制出现之前编写的函数会发觉,由于它们所调用的函数抛出了异常,所以它们也不经意地抛出了异常(它们可能会链接到新的可抛出异常的版本)。因此,这种不明确的描述被保存了下来:
意味着“我可能会抛出异常,也可能不会抛出异常。”为了避免干扰代码的演化,这种不确定性是必需的。如果读者想明确表示函数f不会抛出任何异常,可以使用空的异常类型列表,如下所示:
1.7.2 异常规格说明和继承
类中的每个公有函数本质上来说都是类与用户的一种约定。用户传给函数特定的参数,它执行某种处理并且/或者返回结果。同样的约定必须在派生类中保持有效;否则,派生类和基类之间“是一个(is-a)”的关系就会被违背。由于异常规格说明在逻辑上也是函数声明的一部分,所以在继承层次结构中也必须保持一致。例如,如果基类的一个成员函数声明它只抛出一种类型的异常A,那么派生类中覆盖这个函数的函数不能在异常规格说明列表中添加其他异常。因为如果添加其他异常,就会造成依赖于基类接口的任何程序崩溃。读者可以在派生类函数的异常规格说明中指定较少的异常或指定为不抛出异常,因为这样不需要用户修改任何代码。读者也可以在派生类函数的异常规格说明中指定任何“是一个(is-a)”A来代替A。举例如下:
由于Derived:f()违反了Base:f()的异常规格说明,所以编译器将认为Derived:f()是错误的(或者至少给出一个警告)。Derived:g()的异常规格说明可以被编译器接受,因为DerivedException“是一个(is-a)”BaseException(没有其他可能性)。读者可以认为Base/Derived和BaseException/DerivedException是并行的类层次结构;在派生类中,可以用DerivedException的返回值来代替指向异常规格说明中的BaseException对象的引用。这种行为被称为协变(covariance)(因为两套类同时在各自的继承层次结构上向下变化)。(回顾在第1卷中曾说过:参数类型不能协变—在覆盖虚函数的时候不允许修改函数的签名。)
1.7.3 什么时候不使用异常规格说明
如果阅读标准C++库中定义的函数声明,读者会发现没有一个函数使用了异常规格说明。尽管这看起来很奇怪,但是这种看似奇怪的做法是有原因的:标准C++库主要是由模板组成的,无法知道普通的类或函数会做些什么。例如,读者正在开发一个普通的栈模板,并且在pop函数中使用异常规格说明,如下所示:
由于读者所能预见到的错误只有栈下溢,读者可能认为在异常规格说明中指定一个logic_error或某种恰当的异常类型是安全的。但是类型T的拷贝构造函数可能会抛出异常。那么,unexpected()会被调用,程序终止。应用系统无法提供可支持异常处理的保证。当无法知道会触发什么异常时,不要使用异常规格说明。这就是为什么模板类,也就是标准C++库的主要组成部分,不使用异常规格说明的原因—它们将其所知道的异常写在文档中,把剩下的事情交给用户来做。异常规格说明主要是为非模板类准备的。