C++中的错误处理
异常
异常(exception)是 C++ 中处理错误的一种方式。
C++内置了异常处理的语法,try
语句处理正常代码逻辑,catch
语句处理异常情况。C++ 通过 throw
语句抛出异常信息。例如:
#include <iostream>
double divide(double a, double b) {
constexpr double delta = 0.00000000000000000001;
double ret = 0;
if ((b < -delta) || b > delta) {
ret = a / b;
} else {
throw 0; // 产生除0异常
}
return ret;
}
int main() {
try {
double r = divide(1, 0);
} catch (...) {
std::cout << "Divide by zero ..." << std::endl;
}
return 0;
}
throw
抛出的异常必须被 catch
处理。当前函数能够处理异常,程序继续往下执行;当前函数无法处理异常,未被处理的异常会顺着函数调用栈向上传播,直到被处理为止。C++ 实现中,提供了一个默认的全局异常处理函数 std::unexpected_handler()
,如果一个异常没有被我们应用代码的 catch
语句捕获,那么最终它会被 std::unexpected_handler()
所捕获,默认的 std::unexpected_handler()
中会调用 std::terminate()
结束程序。当然我们也可以使用 std::set_unexpected()
将 std::unexpected_handler()
替换成我们自己的实现。
栈展开(Stack Unwinding)
当一个异常被抛出,运行时机制首先在当前的作用域寻找合适的 handler
(即 catch
语句)。如果不存在这样一个 handler
(即当前作用域没有 catch
语句或者 catch
语言为成功捕获到当期 throw
的异常类型),那么将会离开当前的作用域,进入更外围 的一层继续寻找。这个过程不断的进行下去直到合适 的 handler
被找到为止。表现在计算机执行层面,就是函数不断退栈的过程。
与正常执行流不同的是,函数将会从 throw
处进行栈展开,那么后续 try
语句的代码将无法被执行到,这里很容易发生系统资源未被释放的问题。一个比较合理的处理方式就是多使用 RAII
。
另外,谨慎在 C++ 的类构造函数中抛出异常,这样会使得构造函数未正确执行完毕,导致析构函数也不会被执行,同样也会导致上述问题。当然可以小心的实现代码使得构造函数被执行,以达到可以在构造函数抛异常的目的,但是工程应用中实属不推荐,所以这里也不去了解了。
错误码
STL 中早期类似的错误码实现需求来自文件系统(C++17 才正式进入标准的)API,当时的实现认为错误分类应在 errno
和操作系统原生错误码上二选一。但由于 STL 中网络、各种奇形怪状的 Boost 库的陆续加入,大家急需一个可扩展的错误码表示方案,才演变成现在的 std::error_code
。
std::error_code
顾名思义表示错误码,是由一个 int
型的 value
和一个 std::error_category *
型的 category
组成的值类型类。
之所以在 value
外还需要保存一个 category
,是因为即使是同样的错误码,在不同的库或者场景下表示的意义可能不同。同样是 42,在一个库的错误码中可能表示「文件不存在」,在另一个库中可能就表示「DNS 解析失败」。如 果你熟悉 Cocoa 的那一套,那么这就类似 NSError
的 domain
属性。
此外,虽然没有明说 value
在等于 0 时表示无错误,但整个系统就是建立在这样的假设上的,例如:
operator bool
是根据value
是否非 0 来的clear()
函数会将value
设为 0
构造 std::error_code
std::error_code
的构造函数共有三种重载:
error_code() noexcept
构造一个类似clear()
后的对象error_code( int ec, const error_category& ecat ) noexcept
用参数填充对应的字段template< class ErrorCodeEnum > error_code( ErrorCodeEnum e ) noexcept
调用make_error_code(enum_value)
工厂函数
前两种没啥好说的,第三种却值得推敲,ErrorCodeEnum
只是名字上说是枚举,但其实只要是用户定义类型就行(比如 enum class
/ enum
/ class
),所以理论上可以从异常直接构造 std::error_code
。
通常,如果构造函数只接受一个参数,那么我们推荐将它标记为 explicit 以免发生不必要的隐式转换。但这个函数不然,它要的就是让原始的 ErrorCodeEnum
可以隐式转换为 std::error_code
,从而实现这两者的直接比较。
catch (std::system_error const& e) {
if (e.code() == std::errc::invalid_argument) {
// blah blah blah...
}
}
当然,为了避免从任意类型的值都能搞个 std::error_code
出来,此重载只有当 std::is_error_code_enum<ErrorCodeEnum>::value
是 true
时才会有效。所以你自定义错误码枚举时,需要在 std
命名空间中特化此模版。
#include <iostream>
#include <system_error>
enum class YourErrorCode {
Success = 0, // 别忘了 0 应该表示无错误
NetworkError,
BadRequest,
ServerError,
};
// 特化模版,启用对应的重载
namespace std {
template<>
struct is_error_code_enum<YourErrorCode>: true_type {};
}
// 提供工厂函数,工厂函数不必要写在 std 中
std::error_code make_error_code(YourErrorCode code) {
return {
static_cast<int>(code),
std::generic_category(), // 这里暂时用自带的 category
};
}
int main() {
std::error_code e = YourErrorCode::BadRequest;
std::cout << e << '\n'; // 自带一个输出流的重载
}
std::error_category
前面说到 std::error_code
里保存的其实是指向 std::error_category
的指针,而非对象。这是因为这个类就是应该被当作单例来用的,实际也只能这么用才对,因为它的 operator==()
是通过直接比较 this
指针来实现的。
此外,std::error_category
还是个纯虚类,你必须实现的纯虚函数有:
virtual const char* name() const noexcept = 0;
返回这类错误的名字virtual std::string message( int condition ) const = 0;
为给出的「错误码」返回对应的文字描述
STL 自带 std::error_category
的几个子类,很好地示范了「如何正确使用纯虚类」:不暴露具体的子类,而是暴露工厂函数,只通过接口(纯虚类)访问子类。
// 得到的都是 const std::error_category&
auto const& gec = std::generic_category();
auto const& sec = std::system_category();
std::error_condition
为了区分「系统相关的错误」和「平台无关的错误」,std::error_condition
诞生了。
因此你可以看到,std::error_condition
是一个与 std::error_code
除了语义几乎没有差别的东西。从库作者的角度,你可以理解为封装底层细节时用 std::error_code,而对外暴露接口时推荐使用 std::error_condition。
std::error_condition
与 std::error_code
虽然是两个独立的类,但它们可以通过 std::error_category
连接在一起。
这两个类的对象互相比较时,STL 提供了对应的 operator==()
等函数的重载,通过调用双方的 category().equivalent(other)
来比较。只要任何一方的 category
认为对方与自己等价,两者就会被判为相等。
enum class MyErrorCondition {
Chenggong,
WangluoCuowu,
QingqiuCuowu,
EFuwuqiCuowu,
};
class MyErrorCategory: public std::error_category {
public:
static const MyErrorCategory& instance() {
static MyErrorCategory instance;
return instance;
}
char const *name() const noexcept override {
return "MyErrorCategory";
}
std::string message(int code) const override {
return "Message"; // 偷个懒
}
bool equivalent(std::error_code const& code, int condition) const noexcept override {
// 理论上你用不着在这里处理 code.category() == this->instance 的情况
// 因为是个单例,所以某些情况下不得不用这么绕的办法来拿
auto const& yourErrorCodeCategory = std::error_code(YourErrorCode{}).category();
if (code.category() == yourErrorCodeCategory) {
switch (static_cast<MyErrorCondition>(condition)) {
case MyErrorCondition::Chenggong:
return code == YourErrorCode::Success;
case MyErrorCondition::WangluoCuowu:
return code == YourErrorCode::NetworkError;
case MyErrorCondition::QingqiuCuowu:
return code == YourErrorCode::BadRequest;
case MyErrorCondition::FuwuqiCuowu:
return code == YourErrorCode::ServerError;
}
}
return false;
}
};
// error_condition 同样需要特化模版启动重载
namespace std {
template<>
struct is_error_condition_enum<MyErrorCondition>: true_type {};
}
// error_condition 同样可以通过工厂函数构造
std::error_condition make_error_condition(MyErrorCondition code) {
return {static_cast<int>(code), MyErrorCategory::instance()};
}
int main() {
std::error_code code = YourErrorCode::NetworkError;
std::error_condition condition = MyErrorCondition::WangluoCuowu;
std::cout << (code == condition) << '\n';
}
与 std::system_error 结合使用
std::system_error
是一个继承自 std::runtime_error
的异常类,用以表示与 OS 交互时得到的以错误码形式返回的错误。因此除了提供标准的 virtual const char* what() const noexcept;
函数外,它还额外暴露了一个 const std::error_code& code() const noexcept;
函数。同时,因为必须提供错误码,它既不提供空构造、也不提供类似 runtime_error( const std::string& what_arg );
仅接受字符串做为参数的构造函数。
STL 乃至 C++ 中推荐的错误汇报方法还是异常。由于存在与操作系统等底层 API 的交互,才引入了「从 API 返回的错误码构建异常」以及「将异常转换为错误码传给 API」这样的需求。
当然,为了方便统一范式,便于不支持异常的系统使用文件系统 API。C++17 才正式进入标准的文件系统 API 也添加了「默认抛异常,但额外传一个 std::error_code
就不抛异常」的机制。
总结
在开发中,很好见着有同事使用异常,大部分的同事还是倾向于使用 返回负数
的形式来表示一个函数运行遇到了什么错误。在这种情况下,一旦这个函数几经不同的人维护,很容易返回值就失去了维护的意义,后面的人都不知道返回的-1、-2、-3、...
是什么意思,为了一时偷懒,后续全返回 -1
。这个时候,维护一个 error_category
,在函数接口使用类似于void foo(int parameter, std::error_code &error)
是一个不错的选择。在 boost.asio
、std::filesystem
等库中,都提供了接口抛异常
以及额外传入一个 error_code 就不抛异常
的实现。
个人在实际使用中,还是倾向于后者 额外传入一个 error_code 就不抛异常
的实现。使用异常,大部分情况,我们都会在 main
函数最后的位置实现 catch
语句来捕获一些开发过程中,为捕获到的异常。但是在开发后期,确实会遇到一个异常在 main()
才被捕获到,且不知道在哪throw的,这样就很让人头疼。