Bootstrap

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中已被弃用。

关于异常,还有一些语言无关的使用建议,将在本系列的第四篇中讨论。

参考资料