C++ 如何写出异常安全的代码
我在自己的博客《防御式编程、断言和错误处理 - 零壹生万物 (01io.tech)》中提到过,错误处理中有两种重要的方式,错误码和异常,这两种方式都是报告错误,让调用端决定错误如何处理。不同的是,错误码报错的方式,通过函数返回的,调用端可能会忽略错误码报错继续执行,带来不可控制的后果;而异常提供了一种无法被忽略的报错(如果异常最终不被 catch,将会导致程序终止)。除此之外,使用异常更容易写出 clean 的代码,具体的优势和劣势的对比会在本系列的第四篇细细道来。
TL,DR
介绍 C++的异常
澄清一些 C++异常的误区,例如性能等
关于异常安全的总结
本篇主要知识总结于《More Effective C++》, 《C++ FAQ (isocpp)》,《C++ Core Guideline》等权威资料,相关资源链接放在文末参考资料中。
异常 exception
C++的异常主要是 try/catch
和throw
语句。
一些误区
"异常的开销很大",;并不!C++的异常并不会显著降低性能。 现代 C++exception 的实现于相比于没有错误处理只有非常轻微(3%)的性能损失,而使用错误码之类的方式也并不是毫无代价的。一般来说,如果没有异常被抛出,几乎没有性能惩罚。只有当有异常被抛出的时候才有性能代价,但是这种情况应当是低频的,因为错误处理不应该是常态。换句话说,如果没有异常抛出,那么正常的代码可能比检测错误码的代码要快。 参考isocpp:Why use exceptions
"异常可能会导致资源泄漏"。并不!遵守一些简单的原则就可以写出异常安全的代码。 一些糟糕的写法确实导致资源的泄漏,但是使用智能指针等 RAII 的方式已经可以避免这种问题发生了。
"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)的问题:
资源(内存、文件、socket 等)无泄漏
程序的状态是有效的,保证 invariant 。如果不能保证 invariant,应该终止程序。
invariant:一种程序的状态和约束,必须满足这个状态的程序才是正确的: 例如二叉搜索树的左节点不大于右节点
例 1:异常时的资源泄漏
以下代码演示了在构造函数中发生异常导致的资源泄漏。如果一切正常,构造的时候new
申请内存,析构的时候delete
释放内存,非常合理。如果发生异常了会怎么样呢?
如果在构造函数中,在(2)
处发生了异常(内存分配失败:std::bad_alloc
), 那么 A 的析构函数不会被调用,已经被分配的内存mem1
将不会被释放,造成内存泄漏!这种情况发生的原因是:如果在类构造函数中异常发生,那么该类的析构函数不会被调用。(如果要让析构函数知道构造函数进行到哪一步了,从而正确的自动清理,需要做很多影响效率的簿记工作,会影响效率;C++在这里选择避免这部分开销,付出的代价就是“不完整的构造不会调用构造函数”)。
如何解决这样的问题呢,一种办法是再 new mem2 的时候 catch 异常,如果发生了异常就 delete mem1; 这种方式可以工作但是我们选择更加符合 Modern C++ 的方法:RAII.
以下代码使用智能指针避免了异常时发生了资源泄漏,具体解释为:如果 mem2 构造失败了,mem1 作为一个类对象成员也可以自己释放资源。
@Tips: btw, 示例代码仅仅为了演示之用,如果在 C++中需要使用数组,尽可能使用 vector 等容器,而避免自己申请动态数组。
例 2:违反 invariant 的例子
C++中使用异常的最佳实践
1.可以在构造函数中抛异常,但是要保证资源不泄露。参考:ctors can throw在某些情况下,构造失败只能通过抛异常来报错,因为构造函数没有返回值也无法返回错误码。(C++ FAQ 中提供了一种折中的办法,但是并不优雅)。如果类的构造函数中涉及 new 内存、打开文件等资源的情况,强烈推荐使用 RAII 技术来进行资源管理。2. 尽管用好 RAII 可以解决构造函数抛异常的问题,但是使用不当也有可能带来资源泄漏(见GotW #102),记住对于share_ptr
使用make_shared
这样的工厂方法构造;类似的,unique_ptr
使用make_unqiue
(也就是说,在 Modern C++中应该尽可能避免直接使用new
和delete
)。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
。例如swap
和move
应该声明为noexcept
8. 不要使用 exception-specification。这是个糟糕的特性,在 C++11 中已被弃用。
关于异常,还有一些语言无关的使用建议,将在本系列的第四篇中讨论。
参考资料
Scott Meyers,《More Effective C++》,条款 9~15
版权声明: 本文为 InfoQ 作者【行者孙】的原创文章。
原文链接:【http://xie.infoq.cn/article/8cfee58f1e151437d068dca59】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论