异常机制简单探讨
扫描二维码
随时随地手机看文章
引 言
我们在编写软件时不但要追求代码的正确性,更要关注程序的容错能力,在环境不正确或操作不当时不能死机,更不能造成灾难性后果。程序运行时有些错误是不可避免的,如内存不足、文件打开失败、数组下标溢出等,这时要力争做到排除错误,继续运行。
传统做法是返回一个错误代码,调用者通过if等语句测试返回值来判断是否成功。这样做有几个缺点:首先,增加的条件语句可能会带来更多的错误;其次,条件语句是分支点,会增加测试难度;另外,构造函数没有返回值,返回错误代码是不可能的。
C++的异常机制为我们提供了更好的解决方法。异常处理的基本思想是:当出现错误时抛出一个异常,希望它的调用者能捕获并处理这个异常。如果调用者也不能处理这个异常,那么异常会传递给上级调用,直到被捕获处理为止。如果程序始终没有处理这个异常,最终它会被传到C++运行环境,运行环境捕获后通常只是简单地终止这个程序。异常机制使得正常代码和错误处理代码清晰地划分开来,程序变得非常干净并且容易维护。
但是如何合理地使用异常机制来达到预期的效果呢?MISRA C++给出了一些推荐的规则,帮助程序员更加合理、可靠地实现异常机制。下面将结合这些规则对异常机制进行简单的探讨。
1 在恰当的场合使用恰当的特性
MISRA C++对异常的第1条规则就是:
规则15-0-1(不容讨论):异常机制只能用来处理错误。
异常处理的本质是控制流程的转移,但异常机制是针对错误处理的,仅在代码可能出现异常的情况下使用,不能用来实现普通的流程转移。
例如:
语法不会阻止你这样做,但杀鸡焉用牛刀。这样不但会降低程序的可读性,也会带来更大的开销。实际上,用一个简单的if语句就可以实现上述逻辑。同样,出于程序流程的清晰性考虑的还有:
规则15-0-3(强制):不允许通过goto或者switch语句跳转到try或catch语句块内。
2 正确地抛出异常
什么时候,什么地方,抛出什么样的异常,都是需要仔细考虑的。MISRA C++对此也作了相关规定。首先,来看一下抛出异常对象的类型中有哪些需要注意的地方。规则15-0-2(推荐):抛出的异常对象不应该是指针类型。
如果抛出的异常对象是个指针类型,指向的是动态创建的对象,那么这个对象应该由哪个函数来负责销毁,什么时候销毁,都很不清楚。比如说,如果是在堆中建立的对象,那通常必须删除,否则会造成资源泄漏;如果不是在堆中建立的对象,通常不能删除,否则程序的行为将不可预测。
规则15-1-2(强制):不能显式地把NULL作为异常对象抛出。
因为throw(NULL)=tbrow(0),因此NULL会被当作整型捕获,而不是空指针常量,这可能与程序员的预期不一致。
通常,很多函数都是基于function-try-block结构的,即函数体整个包含在一个函数try块中。而函数能抛出什么类型的异常对象,有以下规定:
规则15-5-2(强制):如果一个函数声明时指定了具体的异常类型,那么它只能抛出指定类型的异常。
规则15-4-1(强制):如果一个函数声明时指定了异常的类型,那么在其他编译单元里该函数的声明必须有同样的指定。
函数的代码结构如下:返回值类型函数名(形参表)throw(类型名表){函数体}
如果函数在声明时没有异常规范,那么它可以抛出任意类型的异常对象;如果异常类型为空,则表示不抛出任何类型异常。注意这两者之间的区别,前者指没有throw(类型名表)语句,而后者有throw(类型名表),只是类型名表为空。但如果声明时指定了异常的类型,那么它只能抛出指定类型的异常。
另外,函数原型中的异常声明要与实现中的异常声明一致,否则会引起异常冲突。由于异常机制是在运行出现异常时才发挥作用的,因此如果函数的实现中抛出了没有在其异常声明列表中列出的异常,编译器也许不能检查出来。当抛出一个未在其异常声明列表里的异常类型时,unexpected()函数会被调用,默认会导致std::bad_exception类型的异常被抛出。如果std::bad_exception不在异常声明列表里,又会导致terminate()被调用,从而导致程序结束。
对于什么时候能抛出异常,则有以下规定:
规则15-3-1(强制):异常只能在初始化之后而且程序结束之前抛出。
在执行main函数体之前,是初始化阶段,构造和初始化静态对象;在main函数返回后,是终止阶段,静态对象被销毁。在这两个阶段中如果抛出异常,会导致程序以不定的方式终止(这依赖于具体的编译器)。例如:
在这个例子中,catch块只能捕获上面try块中的异常。如果在对象c的构造函数或析构函数中抛出异常,并不能被main里的catch块捕获,而且会导致程序终止。
除了上述规则,还有以下两个规则需要注意:
规则15-1-1(强制):throw语句中的表达式本身不能引发新的异常。
如果在构造异常对象,或者计算赋值表达式时引发新的异常,那么新的异常会在本来要抛出的异常之前被抛出,这与程序员的预期不一致。
规则15-1-3(强制):空的throw语句只能出现在catch语句块中。
空的throw用来将捕获的异常再抛出,可以实现多个处理程序问异常的传递。然而,如果在catch语句外用,由于没有捕获到异常,也就没有东西可以再抛出,这样会导致程序以不定的方式终止(这依赖具体的编译器)。
3 合理地处理异常
由于后面的讨论多处涉及到“栈展开”这个概念,这里先解释一下。“栈展开”是异常机制中一个重要的过程:在逐层查找用来处理异常的catch子句时,因为异常而退出复合语句和函数定义,这个过程被称作“栈展开”。随着栈的展开,在退出的复合语句和函数定义中声明的局部变量的生命期也结束,而且这些局部类对象的析构函数也会被调用,这样能保证内存空间得到合理回收。栈展开的概念对于理解后面的内容很重要,我们通过一个具体例子进一步阐述。
当异常发生时,在函数调用链中逐层查找该异常的catch子句。在栈展开过程中函数foo()首先被检查到,因为产生异常的语句没有被放在try块中,所以不会在:foo()中查找针对该异常的catch子句。栈展开过程继续向上遍历函数调用链到达调用foo()的函数。然而在foo()带着这个未处理的异常退出之前,栈展开过程会销毁foo()中所有在异常产生之前被创建的局部类对象。结果就是:o1、o2的析构函数被调用,o3已经“死亡”,而o4还没“出生”。
顾名思义,“异常”就是程序运行出现了非预期的情况,或者说错误。因此,出现异常必须有针对地处理。对此,MISRA C++首先有如下规定:
规则15-3-4(强制):所有可能的流程中显式抛出来的异常都应该有一个类型兼容的处理程序。
规则15-3-2(推荐):至少要有一个处理程序来处理所有其他未针对处理的异常。
如果程序抛出一个没有被处理的异常,程序会终止,而终止前调用栈有没有被“展开”,动态对象能不能被析构,这些都依赖于编译器。上面两条规则规定了:不但预期抛出的异常要进行处理,其他可能被抛出的异常也要有相应的处理措施。请注意规则15-3-4中“类型兼容”的字眼,C++有非常灵活的类型兼容规则,尤其对于类。例如当异常对象是派生类时,“兼容类型”可以是派生类,也可以是基类。后面我们还会具体讨论这个问题。
一个try块后可以有多个catch块来捕获不同的异常。当出现异常时,catch处理程序按照其在try块后出现的顺序被逐个检查,只要找到一个匹配的异常类型,后面的异常处理都被忽略。因此,catch处理程序出现的顺序很重要。
规则15-3-6(强制):若一个try-catch语句块有多个处理程序,或者一个派生类和其部分或全部基类的function-try-block块有多个处理程序,处理程序的顺序应该是先派生类后基类。
规则15-3-7(强制):若一个try-catch语句块或者function-try-block块有多个处理程序时,catch(…)处理程序(捕获所有异常)应该放在最后。
这是因为根据类型兼容规则,异常对象为派生类时可以被针对基类的处理程序所捕获。如果针对基类的处理程序放在前面,后面针对派生类的处理程序就不会被执行到。同理,catch(…)处理程序能捕获所有类型的异常,在其后面所有的异常处理程序都不会被执行到。[!--empirenews.page--]根据上述规则,典型的try-catch的结构示例如下:
细心的读者也许会发现,上面例子中是通过引用来捕获类的对象。当异常对象类型为某个类时,有3种方式传递到catch子句里:指针、传值和引用。也许大家首先想到的是指针,指针的确是效率很高的工具,而且不涉及到对象拷贝。但不要忘了,前面的规则15-0-2中明确指出,抛出的异常对象不应该是指针类型。而对于传值和引用,在MISRA C++中给出的规定是:通过引用捕获异常。
规则15-3-5(强制):若异常对象为类的对象时,应该通过引用来捕获。
通过值传递,不但会增加拷贝对象的开销,而且还会出现“退化”问题。所谓“退化”是指:如果异常对象是一个派生类对象,但被作为基类捕获,那么只有基类的函数(包括虚函数)能被调用,派生类中增加的数据成员都不能被访问。通过引用捕获则没有这个问题。下面的例子具体地说明了“退化”问题:
鉴于类的构造函数和析构函数的特殊性,还有两点需要注意。
规则15-3-3(强制):如果类的构造函数和析构函数是function-try-block结构的,在catch处理程序中不能引用该类或其基类的非静态成员。
这种行为的后果是不定的。比如说,当构造对象分配内存时抛出了异常,这时该对象本身还不存在,访问其成员也就出错。相反,在析构函数里,可能在异常处理程序执行前该对象已被成功销毁了,也就无从访问其成员了。而类的静态成员则没有上述问题。
规则15-5-1(强制)。类的析构函数退出后不能还有未处理的异常。
当异常抛出时,会进行栈展开。如果在某个析构过程中引发没有被处理的异常,程序将会以不定的方式终止。析构函数抛出异常的问题在很多C++的书中都有讨论,概括来说:析构函数应尽可能地避免抛出异常,如果的确无法避免,则析构函数自己应该包含处理所有可能抛出的异常的代码。
4 小 结
异常机制是C++崭新而高级的特性之一。与其他C++特性一样,C++标准并没有规定应该如何来实现异常机制,这依赖于具体的编译器。异常机制是有代价的,它会增加代码大小和运行开销。以VC++为例,异常处理是通过在函数调用栈里增加许多相关的数据结构来实现的,感兴趣的读者可以查看相关资料,这里不再进一步讨论;而且异常处理是在操作系统的协助下,由C++编译器和运行时异常处理库共同完成的。如何合理地使用异常机制来提高程序的健壮性,MISRA C++给出了一些规范,但具体还需要程序员反复斟酌,甚至需要多次实验。至此,关于MISRA-C++:2008的学习暂告一段落。
在这4期的讲座中,我们主要讨论了C++对于C新增的特性,列举和解释了其中有代表性的规则,且尽量使每篇文章都能涵盖C++的一个重要特性。有些例子是在我们理解的基础上加的,可能存在着错误或偏差,欢迎大家和我们共同讨论。通过这4期介绍,希望大家能够意识到:C++对于C并不是简单的语言的改进,C++面向对象的思想从根本上影响了软件的架构。
可以预见,随着嵌入式产业的飞速发展,在嵌入式领域C++将会有辉煌的前景。对C++进行改造,使其适用于嵌入式环境,提高其可靠性,对于推动C++在嵌入式领域的应用是很重要的。MISRA-C已经在嵌入式C语言上取得了很大的成功,成为行业普遍认同和遵循的规范。我们希望MISRA-C++也能和MISRA-C一样,推动C++在嵌入式领域的规范化。