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