在撰写了二十余篇关于C++底层异常处理的文章之后,是时候回顾并做出有些最终思考。我们学了什么,异常如何抛出,以及如何捕捉它?
抛开读.gcc_except_table令人恶心的细节,它可能是这些文章里最大的部分,我们可以把整个过程总结成这样:
- C++编译器实际上对处理异常贡献甚少,大多数魔术发生在libstdc++里。
- 不过编译器做了一些事。即:
- 它创建了CFI信息来回滚栈。
- 它创建称为.gcc_except_table的事物,带有着陆垫的信息(try/catch块)。类似反射信息。
- 在我们写一条throw语句时,编译器将把它翻译为一对libstdc++函数的调用,它分配异常,然后通过调用libstdc开始栈回滚过程。
- 当在运行时抛出一个异常时,将调用__cxa_throw,它把栈回滚委托给libstdc。
- 随着回滚器通过栈,它将调用libstdc++提供的一个特殊函数(称为personality例程),它检查栈上的每个函数有哪些可以捕捉异常。
- 如果没有找到该异常匹配的catch,调用std::terminate。
- 如果找到一个匹配的catch,回滚器在栈定再次开始。
- 随着回滚器第二次通过栈,它将要求personality例程为这个方法执行清理。
- Personality例程为当前方法检查.gcc_except_table。如果有任何清理活动要执行,它将“跳入”当前栈帧并运行清理代码。这将执行在当前作用域里分配的每个对象的析构函数。
- 一旦回滚器到达可以处理该异常的栈帧,它将跳到正确的catch语句。
- 在完成catch语句的执行后,将调用一个清理函数来释放该异常的内存。
学习了异常如何工作,现在我们可以更好地回答为什么编写异常安全代码是困难的。
虽然概念上清晰,异常相当程度上是“鬼魅般的超视距作用”。抛出与捕捉异常涉及相当程度的反射(就程序必须分析自身而言),这对C++应用程序不常见。
即使我们讨论更高级的语言,抛出一个异常意味着我们不能依赖再依赖于对正常程序执行流应该如何工作的理解:我们习惯于具有一些条件操作符分支或调用其他函数的相当线性的执行流。使用异常,这不再成立:不是我们应用程序代码的实体控制着执行,并且它在程序到处走动,这里执行某些块,那里执行某些块,不遵循如何普通的规则。指令指针被每个着陆垫改变,栈以我们不能控制的方式回滚,最终幕后发生了许多魔术。
进一步总结:异常之所以困难,仅仅是因为它们破坏了我们所理解的程序的自然流程。这不表示它们内在是坏的,因为正确使用异常肯定会得到更干净的代码,但它们总是应该小心使用。