C++如何写出异常安全的代码
我在自己的博客《》中提到过,错误处理中有两种重要的方式,错误码和异常,这两种方式都是报告错误,让调用端决定错误如何处理。不同的是,错误码报错的方式,通过函数返回的,调用端可能会忽略错误码报错继续执行,带来不可控制的后果;而异常提供了一种无法被忽略的报错(如果异常最终不被catch,将会导致程序终止)。除此之外,使用异常更容易写出clean的代码,具体的优势和劣势的对比会在本系列的第四篇细细道来。
TL,DR
本篇主要知识总结于《More Effective C++》, 《C++ FAQ (isocpp)》,《C++ Core Guideline》等权威资料,相关资源链接放在文末参考资料中。
异常exception
C++的异常主要是 和语句。
class MyException{};
void foo(int x){
if(x > 16)
throw MyException();
// ...
}
void use(){
try{
foo();
}catch(MyException &e){
// handle error
}
}
一些误区
是否使用异常作为错误处理,完全是看应用场景是否合适(例如在一些hard-realtime的系统可能不适合)和收益是否大于付出。在下一篇中我会从各个角度比较异常和错误码两种方式,结论是:对于大多数场景,C++软件开发应该使用异常作为错误处理的方式。
异常安全(exception)
C++的exception行为与Python、Java这种带垃圾回收机制的语言还不一样,特别要注意所谓异常安全(exception-safety)的问题:
invariant:一种程序的状态和约束,必须满足这个状态的程序才是正确的: 例如二叉搜索树的左节点不大于右节点
例1:异常时的资源泄漏
以下代码演示了在构造函数中发生异常导致的资源泄漏。如果一切正常,构造的时候申请内存,析构的时候释放内存,非常合理。如果发生异常了会怎么样呢?
class A {
public:
A(unsigned int size_1, unsigned int size_2) {
mem1 = new int[size_1]; // (1)
mem2 = new int[size_2]; // (2) if bad_alloc here,mem1 will leak
}
~A() {
delete mem1;
delete mem2;
}
private:
int *mem1;
int *mem2;
};
如果在构造函数中,在处发生了异常(内存分配失败:), 那么A的析构函数不会被调用,已经被分配的内存将不会被释放,造成内存泄漏!这种情况发生的原因是:如果在类构造函数中异常发生,那么该类的析构函数不会被调用。(如果要让析构函数知道构造函数进行到哪一步了,从而正确的自动清理,需要做很多影响效率的簿记工作,会影响效率;C++在这里选择避免这部分开销,付出的代价就是“不完整的构造不会调用构造函数”)。
如何解决这样的问题呢,一种办法是再new mem2的时候catch异常,如果发生了异常就delete mem1; 这种方式可以工作但是我们选择更加符合Modern C++ 的方法:RAII.
以下代码使用智能指针避免了异常时发生了资源泄漏,具体解释为:如果mem2构造失败了,mem1作为一个类对象成员也可以自己释放资源。
#include
class A {
public:
A(unsigned int size_1, unsigned int size_2)
: mem1(std::make_unique(size_1)),
mem2(std::make_unique(size_2)) {}
~A() {
// nothing to do
}
private:
std::unique_ptr mem1;
std::unique_ptr mem2;
};
@Tips: btw, 示例代码仅仅为了演示之用,如果在C++中需要使用数组,尽可能使用vector等容器,而避免自己申请动态数组。
例2:违反invariant的例子
C++中使用异常的最佳实践
1.可以在构造函数中抛异常,但是要保证资源不泄露。在某些情况下,构造失败只能通过抛异常来报错,因为构造函数没有返回值也无法返回错误码。(C++ FAQ中提供了一种折中的办法,但是并不优雅)。如果类的构造函数中涉及new 内存、打开文件等资源的情况,强烈推荐使用RAII技术来进行资源管理。2. 尽管用好 RAII 可以解决构造函数抛异常的问题,但是使用不当也有可能带来资源泄漏(见),记住对于使用这样的工厂方法构造;类似的,使用(也就是说,在Modern C++中应该尽可能避免直接使用和)。3. 绝对不要让异常抛出析构函数之外。如果发生异常时出现套娃情况,程序将直接终止。4. 使用引用或者常量引用来catch异常。(非常不建议使用传值或者传指针的方式抛出异常,最主要的理由是防止slicing,也涉及到异常object复制几次的问题)参考《More Effective C++》条款12~13。5. 使用用户定义的异常类型而不是内建类型(例如 int、double等),建议从的类体系中派生。(这样可以用std::exception作为general的方法)。6. 如果对异常无法处理需要原封不动的抛出,使用而不是。7. 如果要承诺永远不抛异常的函数或者有函数是不被允许抛异常的,使用。例如和应该声明为8. 不要使用 exception-specification。这是个糟糕的特性,在C++11中已被弃用。
关于异常,还有一些语言无关的使用建议,将在本系列的第四篇中讨论。