C++异常机制

DinS          Written on 2017/9/26

一、为何要使用c++异常处理方式?

软件robust性是优秀的软件应该具有的特性,关于什么是robust性以及设计层面的robust性可参考《软件robust性思考》,本文仅就代码层面的异常处理做分析。

在进入异常处理之前,先要明白什么需要用异常处理。实际上不使用异常处理也可以写出robust的代码,传统上使用返回值来进行错误处理,这也是C风格的写法。
看下面一段代码:

具体的代码可以不必深究,这里重点看错误处理。
这是一个简单的C风格的读取文件的函数,使用了返回值来报告错误信息。打开文件失败返回-1,读内容失败返回-2,正常返回0。于是我们调用这个函数来读文件,为了检查是否正确,我们通过返回值来进行相应处理。

这种思路也可以处理错误,以前用C写的程序都这样处理,但是不够好,不够好在哪里?
第一,函数调用者必须在函数附近进行错误处理,这称为“紧耦合”,这会导致代码变得死板,容易出问题。
第二,多次调用同一个函数,则每次都必须进行返回值判断,增加了代码冗余。
第三,打乱了正常代码阅读思路,在正常逻辑中插入错误处理让代码难以阅读。
第四,错误可以被忽略,判不判断取决于函数调用者,而通常的情况是调用者会懒得处理错误,导致后面bug频发。

总而言之,C风格的错误处理很头疼,所以程序员干脆不进行错误处理了。
c++提供了异常处理来解决这个问题,总体思路就是try-throw-catch。
这个机制能够很好地解决上述四个问题。另外还有一点让我们必须使用c++异常处理方式,那就是抛出异常后析构函数会被自动调用。

二、异常处理的基本流程

到底什么是异常?异常就是个变量/对象,可以是任何类型,比如内置类型或者自定义的类,但通常来说应该为异常创建特定的类,稍后我们会回来讲这一点。
当我们认为某个地方出问题了,可以使用throw来抛出异常。
比如下面这段代码:

这段代码什么都没干,就是抛出一个异常,看看运行的结果

控制台输出这个信息

从说明来看并不奇怪,有一个未经处理的异常。
注意控制台,输出了throw之前的东西,没有输出throw之后的东西。

如何来理解throw?Throw有点像return,也就是说执行到throw后直接从正在调用的函数返回了,并且处于该作用域的局部对象会被自动清理。但是返回到哪里呢?而且目前这么写肯定不行,throw之后相当于程序崩溃了。

一个完整的异常流程是try-throw-catch,throw之后程序流程直接跳到catch处。
看下面的代码:

虽然很简单,却是一个完整的异常处理流程,先看看运行结果

流程是这个样子的:
main() -> SumTwoPositiveNumber() ->throw -> catch -> SumTwoPositiveNumber()返回 -> main() -> return 0
具体而言,try块里面的代码可能抛出异常,如果一旦有异常则用catch进行捕获,捕获成功则进行对应的catch块里的操作。至此程序认为异常已经处理,继续从catch块之后的代码开始执行。

但是你会说:有个卵用?!还不如直接用C风格的呢,处理错误的地方还不是得跟throw的地方在一块?
确实,在这里例子里面没有体现出异常处理的优势,把代码改成如下再看看。

注意现在try-catch不再出现在函数内部,而是直接在main中出现。
运行结果

这里发生了“向上抛”的现象,这正是异常处理吸引人的地方。
一旦throw后在当前语境下找不到catch,程序会自动把这个异常传递到上一层的语境中,再次尝试catch,这个过程会一直持续下去直到main,如果main还没有catch住则程序崩溃,这也就是第一个例子中崩溃的原因。
有了“向上抛”的机制,异常处理算是完美解决了C风格出现的问题。

对于第一个问题,由于可以“向上抛”,catch块可以远离throw的区域,实际上二者可以完全没有任何关系。
对于第二个问题,不管函数调用几次,都可以在同一个catch块得到处理,不必重复编写相同的错误处理代码。
对于第三个问题,catch块与throw分离了自然不需要在正常代码中插入错误处理代码,整体代码清爽很多。只需要判定一下,不对就throw即可。
对于第四个问题,throw不能够被忽略,可以迫使函数调用者来处理。

看到这里,你应该明白异常处理的优势了吧?
以后的代码中应该使用异常替代C风格错误处理。

三、异常对象

throw可以抛出任何类型的对象,但是推荐抛出标准异常对象,或者是从其派生出来的自定义对象。
什么是标准异常?C++提供了一些常见的异常类,并放到了标准库中,称为标准异常。exception类是标准异常基类,从其派生出常用的标准异常类是runtime_error和logic_error,前者代表运行时才能发现的错误,后者代表程序逻辑错误。从这两个派生类中继续派生出具体的异常类,比如out_of_range,这个在用vector越界时就会发生。
可以参考这张图

当我们要throw时,首先考虑抛出标准异常(不要直接抛出exception,这个是基类,最好不要直接用),这些类定义在<stdexcpet>中,看下面的代码。

使用标准异常的一个简单例子,如果index超出合法范围就抛出out_of_range异常,然后catch住直接打印。运行结果。

然后注意catch里的参数是const &,一般而言推荐用这个,避免复制开销,而且对于多态有重要意义。当然如果要改变异常类的状态,则把const去掉,但是通常来说我们不会在catch中改变异常类。

标准异常虽然涵盖了常见问题,但是我们有理由定义自己的异常类,这样会携带更多信息,更加符合我们的具体应用。
自定义异常类其实很简单,看如下代码:

自定义异常类推荐从标准异常派生,这样更方便。
构造函数中只要把基类异常初始化,然后初始化新添加的信息即可。不需要多复杂,毕竟只是报告问题,使用起来跟其他类型的异常一样。运行结果。

能够派生自己的异常类,就可以处理所有的场景了。

四、异常匹配与多态

抛出单一异常往往不够用,有可能抛出各种类型的异常。对了应对这个问题,catch可以并列多个,分别捕获不同的异常。这个称为异常匹配,从第一个catch开始依次尝试,一旦有一个符合的就进入该catch块,剩下的catch块忽略掉。
看下面一个例子。

不同输入抛出不同类型异常,输出不同内容。运行结果。

根据异常对象类型匹配不同catch,这个不难理解,但是让异常匹配变得复杂的是基类与派生类。注意到传给异常的通常是引用,那么这里会发生类型装换的问题吗?让我们来做个试验

跟上面的代码相比,这里增加了一个catch块,捕获的对象是exception。
注意到exception是out_of_range和invalid_argument的基类,代码中没有直接抛出excpetion类型的对象。
会发生什么呢?试验一下

结果是不管抛出哪个,都进入了catch(exception)块,也就是说被基类捕获了。不过输出的内容倒是不同的,这个不奇怪。

这告诉了我们一个道理:如果catch并列了派生类和基类,那么把基类放到最后一个catch块,避免派生类被架空。另外由于推荐异常类都从exception派生出来,所以可以在catch的最后放上exception,这样保证可以捕获异常。

五、异常安全

之前一直都在说如何捕获异常,但是你可能会问:捕获异常之后干什么呢?
这是一个好问题。
当程序发生错误了,通常有两种处理方式:一种是清理资源,告知出问题的地方和原因,然后结束程序;另一种是尝试修复,然后重新回到出错之前的状态,再次启动。
理想情况是第二种方式,但是这个要求太高了,绝大多数程序都做不到,于是只得转而求其次,如果能做到第一种方式,已经很不错了。做到了这一点就称为异常安全。

显然做到异常安全要程序做到三点:
退出。这个不需要程序员考虑。
告知问题原因。只要捕获了异常这个不成问题,调用what()打印到输出日志即可,当然这要求what的描述要有条理。
清理资源。这里面有些坑,对于内置类型系统自己会清理,对于自定义类型由于throw后先执行析构函数再返回,所以也可以清理掉。但是这里提出了一个重要的前提条件:析构函数不能抛出异常。
貌似很合理,不需要程序员做额外的工作,但是坑就坑在如果构造函数抛出了异常该如何处理。

让我们做个试验看看,先来看看正常情况下,即构造函数不抛出异常的处理。

我们建立了一个BadClass的对象,然后抛出异常。BadClass本身不抛出异常。
运行结果

可见throw之后先调用析构函数,然后catch,这个是正常的。
然后我们把构造函数中的throw bad_alloc()注释去掉,即在构造函数中抛出异常,看看会发生什么

注意,析构函数并没有被调用。为什么呢?因为构造函数没有完成,等于说对象并没有建立起来,既然对象没有建立自然不会调用析构函数。
这就给我们提出了严峻的问题。构造函数当然有可能失败,这一点都不奇怪,如果构造函数中new了,然后抛出异常,本来用于清理的析构函数没被调用,这就内存泄露了。为之奈何?

有几种处理方法:一是在构造函数语境下catch然后释放资源。二是不要手动new,能使用stl提供的容器就用容器,因为throw后已经构造的对象会调用析构函数,所以如果用容器则没问题。如果实在需要new,那么使用shared_ptr,这个会自动释放内存,即使在构造函数中throw了也会正确释放掉。

总而言之,不要new而是用shared_ptr,这样一来内存释放问题就不需要程序员去考虑了。

六、使用异常的套路

通常而言try-catch不会与throw出现在同一语境下,如果这样就变成在出错地方的附近解决问题,又回到C风格了。也就是说,try块里看不到throw,throw周围看不到try-catch。这样的话我们就需要设计在哪里捕获异常。最好是设计出几级的异常捕获网,逐层捕获。但是这个层级不需要跟函数调用层级吻合,只要逻辑上合理即可。
具体而言,main()里肯定得有一个try-catch。因为是用c++,所以main()里的代码不会多,一般也就是创建个对象,然后调用个方法。用try块包围起来。继续深入的话无定法,在关键地方用try-catch进行异常处理。仁者见仁,智者见智了。

然后说一些推荐做法:
从标准异常派生自定义异常类。
main()的catch的最后一句是catch(const exception &)这样保证可以捕获任何异常。
异常的描述要清楚,方便查找原因。
析构函数不要抛出异常。
构造函数中避免new,用shared_ptr。
代码层级的异常处理只是robust的一部分,要领悟结构设计的重要性。