使用错误码对象进行 C++ 错误处理





5.00/5 (8投票s)
如何在 C++ 中使用错误码对象进行错误处理
引言
我使用本文所述的代码和机制已经将近20年了,到目前为止,我还没有找到在大型C++项目中处理错误的更好方法。最初的想法来源于2000年发表在Dr Dobbs Journal上的一篇文章。我在此基础上添加了一些内容,使其在生产环境中更易于使用。
撰写本文的灵感来源于最近Andrzej的C++博客上的一篇文章。正如我们将在本文后面看到的那样,使用错误代码对象可以产生明显更清晰且更易于维护的代码。
背景
每个C++程序员都会学到,有两种传统的处理异常情况的方法:一种是继承自C语言的,即返回一个错误代码,并希望调用者会测试它并采取适当的行动;第二种是抛出异常,并希望周围的代码块提供了该异常的捕获处理程序。C++ FAQ强烈主张第二种方法,认为它能带来更安全的代码。
然而,使用异常也有其自身的缺点。代码往往变得更复杂,用户必须知道所有可能抛出的异常。这就是为什么旧的C++规范在函数声明中添加了“异常规范”。此外,异常往往会使代码效率降低。
错误代码对象(erc
)旨在像传统的C错误代码一样由函数返回。最大的区别在于,如果未被测试,它们会抛出异常。
让我们举一个小的例子,看看不同的实现会是什么样子。首先是使用传统错误代码的“经典C”方法。
int my_sqrt (float& value) {
if (value < 0)
return -1;
value = sqrt(value);
return 0;
}
main () {
double val = -1;
// careful coding verifies result of my_sqrt
if (my_sqrt (val) == -1)
printf ("square root of negative number");
// someone forgot to check the result
my_sqrt (val);
// disaster can strike here because we didn't check the return value
assert (val >= 0);
}
如果结果未被检查,各种糟糕的事情都可能发生,我们必须准备好使用所有传统的调试工具来找出问题所在。
使用“传统”C++异常,相同的代码可能看起来像这样
void my_sqrt (float& value) {
if (value < 0)
throw std::exception ();
value = sqrt(value);
}
main () {
double val = -1;
// careful coding verifies result of my_sqrt
try {
my_sqrt (val);
} catch (std::exception& x) {
printf ("square root of negative number");
}
// someone forgot to check the result
my_sqrt (val);
// program terminates abnormally because there is an uncaught exception
assert (val >= 0);
}
这在这种小例子中非常有效,因为我们可以看到my_sqrt
函数做了什么,并用try
...catch
块来填充代码。
然而,如果函数深埋在库中,您可能不知道它可能抛出什么异常。请注意,my_sqrt
的签名没有提供任何关于它可能抛出任何异常的线索。
现在……隆重登场……erc
对象登场了
erc my_sqrt (float& value) {
if (value < 0)
return -1;
value = sqrt(value);
return 0;
}
main () {
double val = -1;
// careful coding verifies result of my_sqrt
if (my_sqrt (val) == -1) // (1)
printf ("square root of negative number");
// if you are in love with exceptions still use them
try {
my_sqrt (val);
} catch (erc& x) {
printf ("square root of negative number");
}
// someone forgot to check the result
my_sqrt (val); // (2)
// program terminates abnormally because there is an uncaught exception
assert (val >= 0);
}
在深入了解其工作原理的魔力之前,先进行一些观察
- 首先是术语问题:为了区分传统的“C”错误代码和我的错误代码对象,在本文的其余部分,我将称我的错误代码对象为“错误代码”。当我需要引用传统的“C”错误代码时,我将称它们为“C错误代码”。
my_sqrt
的签名清楚地表明它将返回一个错误代码。在C++异常的情况下,没有任何迹象表明可能会抛出异常。曾经,C++98有这些异常规范,但它们在C++11中已被弃用。您可以在Raymond Chen的帖子“C++ throw(…) 异常规范的悲伤历史”中找到关于此的更长讨论。C错误代码解决方案也没有明确指出返回的整数值是错误代码。
初探错误代码对象
为了“大局”呈现,我们将忽略一些细节,但我们稍后会回到这些细节。
当一个erc
对象被创建时,它有一个数值(像任何C错误代码一样)和一个最初设置的*活动标志*。
class erc
{
public:
erc (int val) : value (val), active (true) {};
//...
private:
int value;
bool active;
}
如果对象被销毁且活动标志已设置,则析构函数会抛出异常。
class erc
{
public:
erc (int val) : value (val), active (true) {}
~erc () noexcept(false) {if (active) throw *this;}
//...
private:
int value;
bool active;
}
到目前为止,仍然没有什么特别之处:这是一个抛出异常的对象,尽管它是在析构函数执行期间进行的。如今,这种做法不受欢迎,因此我们必须用noexcept(false)
来修饰析构函数声明。
整数转换运算符返回erc
对象的数值并重置活动标志
class erc
{
public:
erc (int val) : value (val), active (true) {}
~erc () noexcept(false) {if (active) throw *this;}
operator int () {active = false; return value;}
//...
private:
int value;
bool active;
}
由于活动标志已被重置,当对象超出作用域时,析构函数将不再抛出异常。通常,当错误代码与某个值进行测试时,会调用整数转换运算符。
回顾简单使用示例中,在标记为(1)的注释处,函数my_sqrt
返回的erc
对象与一个整数值进行比较,这会调用整数转换运算符。结果,活动标志被重置,析构函数不抛出异常。在标记为(2)的注释处,my_sqrt()
返回后,返回的erc
对象被销毁,并且由于其活动标志已设置,析构函数抛出异常。
遵循成熟的Unix惯例,并且正如亚里士多德所说,成功只有一种方式,值“0
”保留表示成功。值为0
的erc
永远不会抛出异常。任何其他值表示失败并生成异常(如果未测试)。
这便是Dr. Dobbs Journal文章中提出的错误代码对象整个思想的精髓。然而,我无法抗拒将一个简单的想法复杂化的诱惑;请继续阅读!
更多细节
“大局”介绍忽略了一些细节,这些细节对于使错误代码更具功能性并将其集成到大型项目中是必需的。首先,我们需要一个移动构造函数和一个移动赋值运算符,它们从被复制的对象借用活动标志并停用被复制的对象。这确保我们只有一个活动的erc
对象。
我们还需要一种机制,将错误代码类别分组以便于处理。这种机制通过*错误设施*对象(errfac
)实现。除了值和活动标志属性之外,erc
还具有一个设施和一个严重性级别。erc
析构函数不会像我们之前展示的那样直接抛出异常,而是调用相关设施对象的errfac::raise
函数。raise
函数将erc
对象的严重性级别与每个设施关联的*抛出级别*和*日志级别*进行比较。如果错误代码的优先级高于设施的日志级别,errfac::raise()
函数会调用errfac::log()
函数生成错误消息,并且只有在超出预设级别时才抛出异常或记录错误。严重性级别借用自UNIX syslog函数
名称 | 值 | 操作 |
ERROR_PRI_SUCCESS |
0 |
始终不记录,不抛出 |
ERROR_PRI_INFO |
1 |
默认不记录,不抛出 |
ERROR_PRI_NOTICE |
2 |
默认不记录,不抛出 |
ERROR_PRI_WARNING |
3 |
默认记录,不抛出 |
ERROR_PRI_ERROR |
4 |
默认记录,抛出 |
ERROR_PRI_CRITICAL |
5 |
默认记录,抛出 |
ERROR_PRI_ALERT |
6 |
默认记录,抛出 |
ERROR_PRI_EMERG |
7 |
始终记录,抛出 |
默认情况下,错误代码与一个*默认设施*相关联,但可以创建不同的设施来重新组合错误类别。例如,您可以为所有套接字错误创建一个专门的错误设施,该设施知道如何将数字错误代码转换为有意义的消息。
拥有不同的错误级别对于测试或调试目的很有用,因为可以为一类错误更改抛出或日志记录级别。
一个更真实的例子
前面提到的博客文章展示了HTTP客户端程序的基本布局
Status get_data_from_server(HostName host)
{
open_socket();
if (failed)
return failure();
resolve_host();
if (failed)
return failure();
connect();
if (failed)
return failure();
send_data();
if (failed)
return failure();
receive_data();
if (failed)
return failure();
close_socket(); // potential resource leak
return success();
}
这里的问题是,提前返回会导致资源泄漏,因为套接字没有关闭。让我们看看在这种情况下如何使用错误代码。
如果我们想使用异常,代码可能看起来像这样
// functions declarations
erc open_socket ();
erc resolve_host ();
erc connect ();
erc send_data ();
erc receive_data ();
erc close_socket ();
erc get_data_from_server(HostName host)
{
erc result;
try {
//the first operation that fails triggers an exception
open_socket ();
resolve_host ();
connect ();
send_data ();
receive data ();
} catch (erc& x) {
result = x; //return the failure code to our caller
}
close_socket (); //cleanup
return result;
}
没有异常,相同的代码可以写成
// functions declarations
erc open_socket ();
erc resolve_host ();
erc connect ();
erc send_data ();
erc receive_data ();
erc close_socket ();
erc get_data_from_server(HostName host)
{
erc result;
(result = open_socket ())
|| (result = resolve_host ())
|| (result = connect ())
|| (result = send_data ())
|| (result = receive data ());
close_socket (); //cleanup
result.reactivate ();
return result;
}
在上面的代码片段中,result
已被转换为整数,因为它必须参与逻辑或表达式。此转换会重置活动标志,因此我们必须通过调用reactivate()
函数再次显式地将其打开。如果所有函数都已成功,则结果为0
,并且根据约定,它不会抛出异常。
最后润色
附带的源代码质量可靠,且经过合理优化。希望这不会使其使用起来更困难。演示项目是流行SQLITE数据库的C++包装器。它之所以体积更大,仅仅是因为它包含了最新版本(截至本文撰写时)的SQLITE代码。源代码和演示项目都包含Doxygen文档。
历史
- 2019年11月12日:初始版本