C++异常处理解析: 异常的引发(throw), 捕获(try catch)、异常安全
前言:
C++的异常处理机制是用于将运行时错误检测和错误处理功能分离的一 种机制(符合高内聚低耦合的软件工程设计要求), 这里主要总结一下C++异常处理的基础知识, 包括基本的如何引发异常(使用throw)和捕获异常(try catch)相关使用注意点, 以及C++标准库提供的一套标准异常类和这些异常类的继承层级结构以及相关使用方法和常用习惯.
C++异常的引发(throw):
引发C++异常的语法就是使用throw语句: throw object; 注意这里throw抛出的是一个对象,也就是说是一个实例. 一旦抛出, 发生两件事情: 第一, C++异常机制开始寻找try catch模块, 寻找和抛出的对象的类型相匹配的catch子句找到处理代码进行异常的处理, 这个过程是一个栈展开的 过程,也就是说C++讲先从当前的函数体里面寻找try catch模块, 如果没有, 则在调用当前函数(比如我们叫当前函数A)的函数(我们叫调用A的函数B)寻找处理代码(在B里面寻找), 一直寻找直到找到匹配的catch子句, 然后运行catch里面的代码, 运行完毕以后, 从这个匹配的catch后面的代码继续运行. 第二件事情是, 栈展开前面的所有函数作用域都失效(比如, A调用B, B调用C, C调用D, D调用E, E抛出异常同时在C找到了处理异常的catch子句, 那么D, E作用域失效, 等效于D, E运行到了函数结尾), 局部对象(自动释放内存的对象, 而不是那些动态分配内存的对象, 这一点和异常安全有关我们后面会提到)都将调用析构函数进行销毁.
注意点:
1. throw抛出的对象一定要是可以复制的(C++ Primer中的原话是: 异常对象是通过复制被抛出表达式的结果创建, 该结果必须是可以复制的类型)
2. 不要抛出(throw)一个数组或者函数, 原因是, 和函数参数一样, 数组和函数类型实参, 该实参自动转换为一个指针.
3. C++异常说明: void func(int) throw(exception type list), 表明函数func会且仅会抛出list中列举的异常对象类型, throw()表示不会抛出任何异常(空异常类型列表)
C++异常的捕获(try catch):
如果要试图捕获C++异常, 那么将可能抛出(throw)异常的代码块放到try{}里面, 在try{} 后面跟上catch(exception e) {}, 这里的e是一般的异常对象, C++异常处理通过抛出对象的类型来判断决定激活哪个catch处理代码. 具体语法可以参见任何一本C++的书籍. 这里主要提几点注意点:
1. 讲throw的时候也提到了, catch是一层一层catch(栈展开), 当寻找到main里面也没有catch捕获的时候, C++机制一般将调用terminate终止进程(abort)
2. catch子句列表中, 最特殊的catch必须最先出现, 不然永远都不可能执行到
3. catch(…) 这个语法表示catch捕获所有异常
4. 在catch里面使用throw ;这条语句将重新抛出异常对象, 改异常对象是和捕获的一场对象同一个对象(catch中可以修改这个对象)
C++标准异常介绍(继承层次结构等):
C++标准库提供了以下的标准异常类, 他们的继承层次结构如下(参考: Chapter 17: Advanced C++ Topics III). 比较好的写异常的做法是继承这些C++标准的异常类, 然后定义一组适合自己应用的异常处理对象集合.
C++的异常处理机制主要用于将错误检测和错误处理功能分离, 从而达到低耦合的要求, 这篇文章主要总结了一下C++异常处理的基础知识, 从如何使用throw引发异常, 使用try catch等捕获异常到C++标准库提供的一套标准异常类和这些异常类的继承层级结构, 主要给出了相关使用方法和注意点以及一些程序设计的良好习惯. 文章全凭本人自己的理解原创行文, 如有不当之处, 在所难免, 还请不吝指正.
异常安全(内存泄露, 空指针等问题)
前言:
C++异常安全是针对C++异常处理带来的可能的隐患(内存泄露, 空指针等)而言的, 我们知道异常一旦发生, 程序就会转移控制权, 如果在转移控制权的之前, 没有妥善处理, 比如忘记释放内存, 空指针等, 会造成严重的未定义行为或者资源泄露(内存泄露, 空指针等). 所谓异常安全, 就是为了保证即使是发生了异常, 这些类似的未定义(内存泄露, 空指针等)行为也不会发生.
C++异常安全概念:
我们写程序的时候往往习惯按照假设程序正常运行的行为写代码, 管理资源等. 有时候也会写错误检测和处理的代码, 但是在这两个地方重叠时候, 也就是错误发生的时候的资源管理往往是容易被忽视的(下面马上会给出两个例子, 内存泄露问题和空指针未定义行为问题).
异常安全是这么一个概念: 这个是指, 即使发生异常, 程序也能正确操作(异常发生以后要杜绝一切未定义的行为, 包括空指针, 内存泄露等, 即使异常发生, 那么相关实例还是应该保持有效的状态).
C++异常安全要求:
C++异常安全一般有四个等级的要求(异常安全等级由低到高): 1. 没有任何异常安全保证, 也就是异常一旦发生, 可能造成程序行为的未定义; 2. 基本保证, 也就是异常发生的时候, 程序的行为还是合法的, 状态也都是有效的, 行为是有定义的, 但是程序实例的状态有可能改变(仍旧合法) 3. 强保证(回滚保证), 这个等级就要求异常一旦发生然后进行处理了以后, 要么一次性全部成功, 要么就回滚到异常钱的原始状态(程序状态和异常发生以前一模一样). 4. 保证不会有任何一方的发生.
这里面1是最不安全的, 不可取. 4基本上等级最强, 但是一般情况下不可能满足. 所以异常安全往往在2和3这两个等级间取舍. 等级3有可能会有额外的负担, 资源消耗等. 具体情况根据程序逻辑和实际情况判断取舍.
C++异常安全举例, 避免内存泄露:
C++异常安全其中一条重要的惯例, 是需要保证 如果发生异常, 被分配到的任何资源都适当地得到释放. 这个情况一般发生在动态分配内存的时候, 比如我程序里面有一段代码, 在第20行的时候首先动态分配了内存给一个指针p, 正常运行的话, 中间有一些处理代码, 然后到第40行delete [] p 释放内存, 程序正常运行的话没有问题, 但是要是在第20行到40行之间的代码出现了异常, 程序控制权转移给上级调用程序的时候, 这样的代码就有问题了, 此时, 作用域等效于已经到达了当前函数的结束, 所有局部变量或者实力都会调用自身的析构函数进行释放资源, 但是对动态分配内存的实例来讲, 因为是直接异常跳转, 虽然作用域结束, 但是没有执行到delete进行手动释放, 这块动态内存将造成内存泄露.
那么比较好的保证这一类内存资源不泄露的异常安全的技术成为“资源分配即初始化”(参考RAII). 对于这句话“资源分配即初始化”我自己是这么理解的, 我们要进行资源分配, 保证异常安全的做法不是普通的动态分配一块内存, 而是等效的初始化一个资源管理类的实例. 这就是所谓的“资源分配即初始化”, 也就是把资源分配等效的用初始化资源管理类来替代. 那么这里又提到了资源管理类, 我们解释一下资源管理类以及“资源分配即初始化”到底好处在哪里. 基本上这点要求我们设计一个资源管理类统一的管理资源的分配和释放, 更具体的, 利用构造函数分配资源, 利用析构行数释放资源. 这样做的好处呢, 是资源管理类本身是一个自动的局部对象, 不管是因为异常发生还是正常的程序运行到了改局部对象的作用域的结束的时候, 这个类的析构函数都会被调用从而保证了资源的释放, 避免了内存泄露问题. C++里面提供了RAII的auto_ptr类, 就是一个资源管理类, 行为雷系指针. 我们这里就不深入研究它了.
C++异常安全举例, 避免空指针:
C++异常安全的另一个常见的管理就是需要避免空指针. 这个情况的发生往往是我们在动态分配内存的时候发生了异常. 比如我们要分配p = new int[100], 这个时候要是内存不够, 那么就发生bad_alloc异常, p指针是空的NULL. 这个时候如果后面的代码依赖于p的未定义行为, 这样很容易导致程序的崩溃. 一个有效的避免空指针的做法就是, 在赋值之前就知道内存的分配是成功还是失败, 同样可以利用我们的资源管理类. 管理动态分配的内存, 如果分配成功, 那么将内存块的指针赋值给p, 如果失败, 那么抛出异常, 程序在p赋值前转移了控制权,此时p的值是不会改变的. 这样做就使得程序更加鲁棒(异常发生的时候, p的状态没有改变, 也没有产生未定义行为).
错误处理(返回值, 错误标志变量, 异常)
前言:
程序设计里面至关重要的一块就是错误处理, C++异常处理是一种面向对象的机制, 期望将错误处理和错误检测分离. 这里我们结合其他两种错误处理方式(返回值, 错误标志变量)来分析一下不同的错误处理(包括返回值判断, 错误标志变量, 异常处理机制)各有什么优缺点以及各自的适用环境.
函数返回值判断错误处理:
这种错误处理和判断的方法基本上是使用一组错误处理的常量, 然后通过函数返回值, 把错误信息返回给函数调用者. 比如如下简单的代码:
const int invalidPara = -2;
const int outOfRange = -3;
const int other = -4;
int func(int para)
{
if(invalid parameter)
return invalidPara;
do something here;
if(out of range)
return outOfRange;
if(other error)
return other;
}
这样的返回值判断的好处在于和系统API统一, 我们知道WinAPI以及Linux下面的系统函数都是以返回0(零)表示程序正常运行, 返回非零值表示不同的错误. 所以如果我们也采用这样的返回值判断的话可以和系统调用统一起来.
但是返回值判断错误的限制以及缺点也是很明显的(个人不是很推崇用返回值, 但是也还是要看具体情况). 首先呢, 返回值判断错误会破坏正常的返回值的作用, 使得函数调用不能被充分利用, 函数返回值不能作为其他表达式的组成部分, 因为这个返回值已经用来指示错误了而不是用来返回其他正常的计算结果, 即使可以既用于正常值计算又用于返回错误, 比如正常值都是正数, 错误值都是负数, 那这个结果还是不能直接被用作任何计算, 首先还是要判断这个是正常计算结果呢还是一个错误信息, 这就造成了计算的不方便.
其次很多时候其实是没办法使用返回值来判断错误信息的. 比如 1) 当func()返回类型是int的时候, 而且正常的结果的返回就是所有int型的值都有可能, 这个时候我们其实没法找到一个很好的int value 作为indicatro来指示这是个错误返回还不是一个正常的结果. 2) 编写范型的时候比如return T, 那怎么利用返回值来判断? 这个时候因为我们不明确T的类型, 所以也没有很好的办法利用一个明确的返回值来判断或者给出错误信息. 在这些情况下, 异常处理应该是更为合理的错误处理的方式. 我们后面第三条会再讲到。接下来可以看看第二种错误处理机制.
错误标志变量判断:
这个类型的错误判断基本上可以用下面的这段程序表示. 也就是设置一个错误标志变量, 然后通过引用或者指针的形式传递给被调用的函数, 函数一旦发现错误就设置这个标志, 上层调用者通过检查这个标志变量来判断是否有错误发生.
int funcCallee(int para, int &errorFlag) { if(invalid parameter) set errorFlag and return; do something here; if(out of range) set errorFlag and return; if(other error) set errorFlag and return; } int funcCaller(int para) { int errorFlag = 0; int ret = funcCallee(1, errorFlag); check errorFlag; }
这个方法的好处在于现在我们的返回值值表示正常计算结果, 可以被方便的利用起来, 比起第一种利用返回值判断的话是一个比较明显的优势, 而且前面提到的两种不能使用返回值判断错误的情况(泛型, 正常结果返回涵盖所有整型), 我们也可以使用标志位. 因为表示为总是可以保证是int型的, 而且是不受函数的代码逻辑影响的, 基本上是一个独立的错误标志. 在我看来这种方法似乎并没有明显的缺陷. 我个人比较推重.
C++异常处理机制:
其实我觉得C++的异常处理就是我们这里说的第二种利用标志变量的面向对象版本的错误处理机制, 本质上似乎没有太大区别. 当然异常处理还有复杂的多精细的多. 两者都是统一的独立于程序业务逻辑的错误处理机制. 比如不管程序干什么(泛型也好, 其他什么也好), 我们遇到错误总是能够抛出一个异常, 终止当前函数, 把控制权转移给上层调用函数进行处理. 对应到我们的第二种错误标志变量的话, 就是检测到异常或者错误的时候, 正确设置标识变量, 然后return, 控制权也转移给上层调用函数, 上层调用函数通过判断标志变量的值来进行处理. 从这个角度来讲, 似乎两者也没有太大区别.
另一方面呢, 异常机制作为C++的一种语言级别的机制, 其实会有比较大的开销, 包括控制权的转移等等, 他的好处在于错误处理和错误逻辑分离的很清楚, 而且强制使用者一定要处理异常, 否则程序将最终终止. 但是方法二呢, 要是我忘记去检查那个错误标志变量了怎么办? 回答是不怎么办. 因为这个仅仅是代码级别的判断, 没有任何强制措施去要求一定要处理。 这个就是很危险的了. 所以异常机制(语言级别的判断)从这个角度来讲也是比较好的一种错误处理的选择.
结束语:
这篇文章我们还是解析C++的异常处理机制, 这里我们结合其他两种错误处理方式(返回值, 错误标志变量)分析了这些不同的错误处理, 即返回值判断, 错误标志变量, 异常处理机制各有什么优缺点以及各自的适用环境.