错误和异常处理
参考
以下论文是对编写健壮的泛型组件的一些问题的良好介绍
D. Abrahams: ``泛型组件中的异常安全性'', 最初发表于 M. Jazayeri, R. Loos, D. Musser (编辑): 泛型编程, Dagstuhl 研讨会会议记录, 计算机科学讲义. 卷. 1766
指南
我应该在什么时候使用异常?
简单的答案是:“只要异常的语义和性能特征是合适的。”
一个经常被引用的指南是问自己一个问题:“这是一个异常(或意外)情况吗?” 这个指南听起来很有吸引力,但通常是一个错误。 问题在于,一个人的“异常”是另一个人的“预期”:当您仔细查看这些术语时,这种区别就消失了,您就没有任何指导了。 毕竟,如果您检查错误条件,那么在某种意义上您希望它发生,否则检查就是浪费代码。
更合适的问题是:“我们在这里想要堆栈展开吗?” 因为实际处理异常可能比执行主线代码慢得多,所以您还应该问:“我能负担得起这里的堆栈展开吗?” 例如,执行长时间计算的桌面应用程序可能会定期检查用户是否按下了取消按钮。 抛出异常可以允许操作优雅地取消。 另一方面,在计算的内部循环中抛出和处理异常可能是不合适的,因为这可能会对性能产生重大影响。 上述指南确实包含一些真理:在时间紧迫的代码中,抛出异常应该是例外,而不是规则。
我应该如何设计我的异常类?
- 从
std::exception
派生您的异常类。 除非在 *非常* 罕见的情况下,您无法承担虚拟表的成本,否则std::exception
是一个合理的异常基类,并且当普遍使用时,允许程序员捕获“所有内容”而无需诉诸catch(...)
。 有关catch(...)
的更多信息,请参见下文。 -
使用虚拟继承。 这种见解归功于 Andrew Koenig。 从异常的基类(或多个基类)使用虚拟继承可以防止在捕获站点出现歧义问题,以防有人抛出从具有共同基类的多个基类派生的异常
#include <iostream> struct my_exc1 : std::exception { char const* what() const throw(); }; struct my_exc2 : std::exception { char const* what() const throw(); }; struct your_exc3 : my_exc1, my_exc2 {}; int main() { try { throw your_exc3(); } catch(std::exception const& e) {} catch(...) { std::cout << "whoops!" << std::endl; } }
上面的程序打印"whoops"
,因为 C++ 运行时无法解析在第一个 catch 子句中匹配哪个exception
实例。 -
不要嵌入 std::string 对象或任何其他数据成员或基类,其复制构造函数可能会抛出异常。 这可能会直接导致在抛出点 std::terminate()。 同样,使用普通构造函数可能会抛出的基类或成员也是一个坏主意,因为虽然不一定会对您的程序造成致命错误,但您可能会报告与throw-expression 不同的预期异常,其中包含诸如
throw some_exception();
有多种方法可以避免在复制异常时复制字符串对象,包括在异常对象中嵌入固定长度的缓冲区,或通过引用计数管理字符串。 但是,在采用这些方法之前,请考虑下一点。
- 按需格式化
what()
消息,如果您认为您真的必须格式化消息。 格式化异常错误消息通常是内存密集型操作,可能会抛出异常。 最好将此操作延迟到堆栈展开发生之后,并且大概已释放了一些资源。 在这种情况下,最好使用catch(...)
块保护您的what()
函数,以便在格式化代码抛出异常时您有一个后备方案 - 不要太担心
what()
消息。 拥有程序员有机会弄清楚的消息很好,但是您不太可能在抛出异常时编写相关的且用户可理解的错误消息。 当然,国际化超出了异常类作者的范围。 Peter Dimov 提出了一个极好的论点,即正确使用what()
字符串是作为错误消息格式化程序表中的键。 现在,如果我们只能为标准库抛出的异常获得标准化的what()
字符串... - 在您的异常类的公共接口中公开有关错误原因的相关信息。 对
what()
消息的执着很可能意味着您忽略了公开某人可能需要的信息,以便为用户提供连贯的消息。 例如,如果您的异常报告数值范围错误,则重要的是在异常类的公共接口中以数字形式提供实际涉及的数字,以便错误报告代码可以对它们进行一些智能处理。 如果您仅在what()
字符串中公开这些数字的文本表示形式,那么对于需要对它们执行比简单输出更多操作(例如减法)的程序员来说,您将使生活变得非常困难。 - 如果可能,使您的异常类对双重销毁免疫。 不幸的是,一些流行的编译器偶尔会导致异常对象被销毁两次。 如果您可以安排使其无害(例如,通过将已删除的指针归零),您的代码将更健壮。
程序员错误呢?
作为开发人员,如果我违反了我正在使用的库的先决条件,我不想要堆栈展开。 我想要的是核心转储或等效物 - 一种在检测到问题的确切点检查程序状态的方法。 这通常意味着 assert()
或类似的东西。
有时,有必要拥有可以承受几乎任何类型的客户端滥用的弹性 API,但是这种方法通常会付出巨大的代价。 例如,它通常要求跟踪客户端使用的每个对象,以便可以检查其有效性。 如果您需要这种保护,通常可以在更简单的 API 之上提供一层。 但是,请注意半途而废的措施。 承诺针对某些但并非所有滥用行为的 API 是灾难的邀请。 客户端将开始依赖保护,并且他们的期望将增长到涵盖接口的未受保护部分。
Windows 开发人员注意:不幸的是,大多数 Windows 编译器使用的本机异常处理实际上在您使用 assert()
时抛出异常。 实际上,对于其他程序员错误(例如段错误和除零错误)也是如此。 其中一个问题是,如果您使用 JIT(即时)调试,则在调试器出现之前会有附带的异常展开,因为 catch(...)
将捕获这些并非真正的 C++ 异常。 幸运的是,有一个简单但鲜为人知的解决方法,即使用以下咒语
extern "C" void straight_to_debugger(unsigned int, EXCEPTION_POINTERS*) { throw; } extern "C" void (*old_translator)(unsigned, EXCEPTION_POINTERS*) = _set_se_translator(straight_to_debugger);
如果 SEH 是从 catch 块(或从 catch 块调用的函数)内引发的,则此技术不起作用,但它仍然消除了绝大多数 JIT 掩蔽问题。
我应该如何处理异常?
通常,处理异常的最佳方法是根本不处理它们。 如果您可以让它们通过您的代码并允许析构函数处理清理,您的代码将会更简洁。
尽可能避免 catch(...)
不幸的是,除 Windows 之外的操作系统也将非 C++ “异常”(例如线程取消)卷入 C++ EH 机制中,有时没有对应于上述 _set_se_translator
hack 的解决方法。 结果是 catch(...)
可能会在恢复不可能的点上产生一些意外的系统通知,看起来就像是从合理位置抛出的 C++ 异常一样,从而使通常安全的假设失效,即析构函数和 catch 块已采取有效步骤来确保展开期间的程序不变量。在新闻组中经过多次长时间的辩论之后,我勉强将这一点让步给 Hillel Y. Sims:在所有操作系统都“修复”之前,如果每个异常都从 std::exception
派生,并且每个人都用 catch(std::exception&)
代替 catch(...)
,世界将会变得更美好。
有时,尽管与操作系统/平台设计选择存在不良交互,但 catch(...)
仍然是最合适的模式。 如果您不知道可能会抛出哪种异常,并且您真的必须停止展开它,那么它可能仍然是您的最佳选择。 发生这种情况的一个明显位置是在语言边界。