如何在C++中设计异常"types"

How to design exception "types" in C++

本文关键字:异常 types C++      更新时间:2023-10-16

在大多数代码库中非常常见的两种反模式是布尔返回值,用于指示成功/失败,以及通用整数返回码,用于指示有关错误消息的更多细节。

这两个都很像C,在我看来不太适合c++。

我的问题是关于在你的代码库中设计异常的最佳实践。换句话说,什么是表示有限的失败可能性的最好方法?例如,前面提到的一个反模式通常有一个巨大的枚举,每个枚举值代表一种特定类型的故障,比如FILE_DOES_NOT_EXISTNO_PERMISSIONS。通常情况下,这些都尽可能保持通用,以便它们可以跨多个不相关的域(例如网络组件和文件I/O组件)使用。

一个类似的设计,一个可能考虑的异常是子类化一个具体的异常类型从std::exception为每一个类型的失败或事情,可能会出错。因此,在我前面的例子中,我们将有以下内容:

namespace exceptions {
class file_does_not_exist : public std::exception {};
class no_permissions : public std::exception {};
}

我认为这更接近于"感觉更好"的东西,但最终这似乎只是一个维护噩梦,特别是如果你有数百个这样的"错误代码"转换到类中。

我看到的另一种方法是简单地使用标准的<stdexcept>类,例如std::runtime_error,并使用带有具体内容的字符串。例如:

throw std::runtime_error( "file does not exist" );
throw std::runtime_error( "no permissions" );

这种设计更易于维护,但是如果这两种异常都可能从同一个核心位置或函数调用抛出,那么有条件地捕获其中任何一种异常就变得困难或不可行。

那么,对于异常类型,什么是好的、可维护的设计呢?我的要求很简单。我想有上下文信息关于发生了什么(我跑完内存吗?我缺少文件系统权限吗?我是否未能满足函数调用的先决条件(例如坏参数)?),我也希望能够相应地对该信息采取行动。也许我对它们都是一样的,也许我对某些失败有特定的catch语句,所以我可以从它们中以不同的方式恢复。

我对这方面的研究只会让我想到这个问题:c++异常类设计

这里的用户问了一个和我类似的问题,他/她在底部的代码样本几乎是可爱的,但是他/她的基异常类不遵循开/闭原则,所以这对我来说并不适用。

c++标准库的异常层次结构是相当随意和无意义的。例如,如果有人开始实际使用例如std::logic_error,而不是在程序明显存在非常严重的bug时终止,则可能会产生问题。如标准所言,

“逻辑错误的显著特征是它们是由程序内部逻辑错误引起的。”

因此,在抛出std::logic_error似乎是合理的时候,程序状态可能会不可预测地混乱,并且继续执行可能会将用户的数据置于危险的境地。

但是,像std::string一样,标准异常类层次结构有一个非常非常重要和实用的特性,即它是正式标准的

因此,任何自定义异常类都应该间接或(尽管我不建议)直接从std::exception派生。

一般来说,当十年前关于自定义异常类的争论激烈时,我推荐仅从std::runtime_error派生的。,我仍然推荐这样做。它是支持自定义消息的标准异常类(其他异常类通常具有最好不要更改的硬编码消息,因为它们具有可识别的价值)。有人可能会争辩说,std::runtime_error是标准异常类,它表示可恢复的故障(与不可恢复的逻辑错误相对,后者不能在运行时修复),或者如标准所述,

“运行时错误是由超出程序范围的事件引起的。它们不容易提前预测。

有时c++异常机制被用于其他事情,只是作为一个低级的动态目标跳转机制。例如,聪明的代码可以使用异常在一系列递归调用中传播成功的结果。但是异常即失败是最常见的用法,这也是c++异常通常被优化的原因,所以使用std::runtime_error作为任何自定义异常类层次结构的根是有意义的。即使这迫使那些想要变得聪明的人抛出一个“failure”指示异常来表示成功& help;

值得注意的是:std::runtime_error有三个标准子类,分别是std::range_errorstd::overflow_errorstd::underflow_error,与它们的名字所表明的相反,后两个不是必须由浮点运算生成的,实际上也不是由浮点运算生成的,而只是由一些&ndash生成的;惊喜!mdash;std::bitset操作。简单地说,在我看来,标准库的异常类层次结构似乎只是为了表面上的原因而被扔在那里,没有任何真正好的理由或现有的实践,甚至没有进行是否有意义的检查。但也许我错过了这一点,如果是这样,那么我仍然有一些新的东西要学习。: -)

那么,它就是std::runtime_error了。

在自定义异常类的层次结构的顶部,在c++ 03中添加c++ 03标准异常中缺少的重要内容是有用的:

  • 虚拟clone方法(对于通过C代码传递异常尤其重要)。

  • 虚拟throwSelf方法(与克隆相同的主要原因)。

  • 支持链式异常消息(标准化格式)。

  • 支持携带失败原因代码(例如Windows或Posix错误代码)。

  • 支持从携带的故障原因代码中获取标准消息。

c++ 11增加了对其中大部分内容的支持,但除了尝试对失败原因代码和消息的新支持,并注意到不幸的是它非常特定于unix而不太适合Windows,我还没有使用过它。无论如何,为了完整:c++ 11标准没有添加克隆和虚拟重扔(这是普通应用程序程序员在自定义异常类层次结构中所能做的最好的事情,因为作为应用程序程序员,您不能将当前异常对象从实现的异常传播使用的存储中提升出来),而是添加了自由函数std::current_exception()std::rethrow_exception(),它增加了一个mixin类std::nested_exception和自由函数std::rethrow_nestedstd::rethrow_if_nested,而不是支持链式异常消息。

考虑到c++ 11对上述要点的部分支持,一个新的和现代的自定义异常类层次结构应该更好地与c++ 11的支持集成,而不是解决c++ 03的缺点。好吧,除了c++ 11的失败代码,这似乎非常不适合Windows编程。因此,在自定义层次结构的顶部,就在std::runtime_error下面,理想情况下至少会有一个通用异常类,并从该异常类派生出一个支持故障代码传播的异常类。

现在,最后,到问题的要点:现在是否应该最好地为每个可能的失败原因派生一个唯一的异常类,或者至少为主要的失败原因派生一个唯一的异常类?

我说不:不要增加不必要的复杂性.

如果或者在什么地方可以帮助调用者区分一个特定的失败原因,一个不同的异常类是非常有用的。但是在大多数情况下,调用者唯一感兴趣的信息是发生了异常这一单一事实。不同的失败原因导致不同的修复尝试,这是非常罕见的。

但是失败原因代码呢?

好吧,当这是底层API给你的东西时,它只是添加了创建相应异常类的工作。但另一方面,当您在调用链中通信失败时,调用者可能需要知道确切的原因,那么使用代码意味着调用者将不得不在catch中使用一些嵌套的检查和调度。所以这些是不同的情况:(A)你的代码是失败指示的原始来源,而(B)你的代码使用了一个失败的Windows或Posix API函数,并且通过失败原因代码指示失败原因。

我已经使用boost::exception有一段时间了,我真的很喜欢在异常中插入任意数据。除了特定的异常类型之外,我还这样做,例如

#define MY_THROW(x) 
BOOST_THROW_EXCEPTION(x << errinfo_thread_id(boost::this_thread::get_id()))
class DatabaseException : public std::exception, public boost::exception { ... };
typedef boost::error_info< struct errinfo_message_, std::string > errinfo_message;
MY_THROW(DatabaseException(databaseHandle)
<< boost::errinfo_api_function("somefunction")
<< errinfo_message("somefunction failed terribly.")
);

通过这种方式,你可以捕获特定的异常,同时还可以提供来自throw站点的大量细节(例如,文件名,行号,线程id,…)。

它还提供了一些异常消息及其详细信息的漂亮打印。大多数情况下,我将这些信息写入日志并根据异常情况终止程序。

编辑:正如你引用的线程中所指出的,使用浅层次结构。我使用了3-4个异常类,它们直接继承了std::exception和boost::exception。我还在异常中添加了许多细节(例如,线程id)。

如果错误条件是你的库的调用者可以通过改变他们的代码逻辑来阻止的,那么从logic_error派生你的异常。通常,如果抛出logic_error,调用者将无法进行简单的重试。例如,有人调用你的代码,它会导致除以0,那么你可以创建自定义异常,

class divide_by_zero : public logic_error {
public:
divide_by_zero(const string& message)
: logic_error(message) { 
}
}

如果错误条件是调用者无法阻止的,则派生自runtime_error。其中一些错误可能是可恢复的(即调用者可以捕获异常,重试或忽略)。

class network_down : public runtime_error {
public:
network_down(const string& message)
: runtime_error(message) { 
}
}

这也是在标准库中设计异常的一般原则。你可以在这里查看GCC异常代码。