写点什么

C++ 如何写出异常安全的代码

作者:行者孙
  • 2021 年 12 月 14 日
  • 本文字数:2726 字

    阅读完需:约 9 分钟

我在自己的博客《防御式编程、断言和错误处理 - 零壹生万物 (01io.tech)》中提到过,错误处理中有两种重要的方式,错误码异常,这两种方式都是报告错误,让调用端决定错误如何处理。不同的是,错误码报错的方式,通过函数返回的,调用端可能会忽略错误码报错继续执行,带来不可控制的后果;而异常提供了一种无法被忽略的报错(如果异常最终不被 catch,将会导致程序终止)。除此之外,使用异常更容易写出 clean 的代码,具体的优势和劣势的对比会在本系列的第四篇细细道来。

TL,DR

  1. 介绍 C++的异常

  2. 澄清一些 C++异常的误区,例如性能等

  3. 关于异常安全的总结


本篇主要知识总结于《More Effective C++》, 《C++ FAQ (isocpp)》,《C++ Core Guideline》等权威资料,相关资源链接放在文末参考资料中。

异常 exception

C++的异常主要是 try/catchthrow语句。


class MyException{};
void foo(int x){ if(x > 16) throw MyException();
// ...}
void use(){ try{ foo(); }catch(MyException &e){ // handle error }}
复制代码

一些误区

  1. "异常的开销很大",;并不!C++的异常并不会显著降低性能。 现代 C++exception 的实现于相比于没有错误处理只有非常轻微(3%)的性能损失,而使用错误码之类的方式也并不是毫无代价的。一般来说,如果没有异常被抛出,几乎没有性能惩罚。只有当有异常被抛出的时候才有性能代价,但是这种情况应当是低频的,因为错误处理不应该是常态。换句话说,如果没有异常抛出,那么正常的代码可能比检测错误码的代码要快。 参考isocpp:Why use exceptions

  2. "异常可能会导致资源泄漏"。并不!遵守一些简单的原则就可以写出异常安全的代码。 一些糟糕的写法确实导致资源的泄漏,但是使用智能指针等 RAII 的方式已经可以避免这种问题发生了。

  3. "Google 这种巨头的 C++代码规范都不提倡使用异常"。 《Google C++ Style Guide》列出了异常的优点和缺点。但是最主要的不采用异常的原因其实是历史原因: "because most existing C++ code at Google is not prepared to deal with exceptions, it is comparatively difficult to adopt new code that generates exceptions." 因为 Google 大规模的既有的代码没有使用异常,如果新的项目使用异常,那么既有的代码很难使用新轮子。这就是所谓的历史原因。


是否使用异常作为错误处理,完全是看应用场景是否合适(例如在一些 hard-realtime 的系统可能不适合)和收益是否大于付出。在下一篇中我会从各个角度比较异常和错误码两种方式,结论是:对于大多数场景,C++软件开发应该使用异常作为错误处理的方式。

异常安全(exception)

C++的 exception 行为与 Python、Java 这种带垃圾回收机制的语言还不一样,特别要注意所谓异常安全(exception-safety)的问题:


  1. 资源(内存、文件、socket 等)无泄漏

  2. 程序的状态是有效的,保证 invariant 。如果不能保证 invariant,应该终止程序。


invariant:一种程序的状态和约束,必须满足这个状态的程序才是正确的: 例如二叉搜索树的左节点不大于右节点

例 1:异常时的资源泄漏

以下代码演示了在构造函数中发生异常导致的资源泄漏。如果一切正常,构造的时候new申请内存,析构的时候delete释放内存,非常合理。如果发生异常了会怎么样呢?


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;};
复制代码


如果在构造函数中,在(2)处发生了异常(内存分配失败:std::bad_alloc), 那么 A 的析构函数不会被调用,已经被分配的内存mem1将不会被释放,造成内存泄漏!这种情况发生的原因是:如果在类构造函数中异常发生,那么该类的析构函数不会被调用。(如果要让析构函数知道构造函数进行到哪一步了,从而正确的自动清理,需要做很多影响效率的簿记工作,会影响效率;C++在这里选择避免这部分开销,付出的代价就是“不完整的构造不会调用构造函数”)。


如何解决这样的问题呢,一种办法是再 new mem2 的时候 catch 异常,如果发生了异常就 delete mem1; 这种方式可以工作但是我们选择更加符合 Modern C++ 的方法:RAII.


以下代码使用智能指针避免了异常时发生了资源泄漏,具体解释为:如果 mem2 构造失败了,mem1 作为一个类对象成员也可以自己释放资源。


#include <memory>
class A { public: A(unsigned int size_1, unsigned int size_2) : mem1(std::make_unique<int[]>(size_1)), mem2(std::make_unique<int[]>(size_2)) {} ~A() { // nothing to do }
private: std::unique_ptr<int[]> mem1; std::unique_ptr<int[]> mem2;};
复制代码


@Tips: btw, 示例代码仅仅为了演示之用,如果在 C++中需要使用数组,尽可能使用 vector 等容器,而避免自己申请动态数组。

例 2:违反 invariant 的例子

可以参考Bjarne Stroustrup的一个例子

C++中使用异常的最佳实践

1.可以在构造函数中抛异常,但是要保证资源不泄露。参考:ctors can throw在某些情况下,构造失败只能通过抛异常来报错,因为构造函数没有返回值也无法返回错误码。(C++ FAQ 中提供了一种折中的办法,但是并不优雅)。如果类的构造函数中涉及 new 内存、打开文件等资源的情况,强烈推荐使用 RAII 技术来进行资源管理。2. 尽管用好 RAII 可以解决构造函数抛异常的问题,但是使用不当也有可能带来资源泄漏(见GotW #102),记住对于share_ptr使用make_shared这样的工厂方法构造;类似的,unique_ptr使用make_unqiue(也就是说,在 Modern C++中应该尽可能避免直接使用newdelete)。3. 绝对不要让异常抛出析构函数之外。如果发生异常时出现套娃情况,程序将直接终止。参考:dtors shouldn't throw4. 使用引用或者常量引用来 catch 异常。(非常不建议使用传值或者传指针的方式抛出异常,最主要的理由是防止 slicing,也涉及到异常 object 复制几次的问题)参考《More Effective C++》条款 12~13。5. 使用用户定义的异常类型而不是内建类型(例如 int、double 等),建议从std::exception的类体系中派生。(这样可以用 std::exception 作为 general 的方法)。6. 如果对异常无法处理需要原封不动的抛出,使用throw;而不是throw e参考:throw-without-an-object7. 如果要承诺永远不抛异常的函数或者有函数是不被允许抛异常的,使用noexcept。例如swapmove应该声明为noexcept8. 不要使用 exception-specification。这是个糟糕的特性,在 C++11 中已被弃用。


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

参考资料

  1. Scott Meyers,《More Effective C++》,条款 9~15

  2. 《C++ Core Guidelines》

  3. isocpp.org, FAQ: why exceptions

发布于: 30 分钟前阅读数: 5
用户头像

行者孙

关注

Nothing replaces hard work 2018.09.17 加入

充满好奇心,终身学习者。 博客:https://01io.tech

评论

发布
暂无评论
C++如何写出异常安全的代码