跳到主要内容

C++中的错误处理

阅读量: 101阅读人次: 102

异常

异常(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 的那一套,那么这就类似 NSErrordomain 属性。

此外,虽然没有明说 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>::valuetrue 时才会有效。所以你自定义错误码枚举时,需要在 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_conditionstd::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.asiostd::filesystem等库中,都提供了接口抛异常以及额外传入一个 error_code 就不抛异常的实现。

个人在实际使用中,还是倾向于后者 额外传入一个 error_code 就不抛异常 的实现。使用异常,大部分情况,我们都会在 main 函数最后的位置实现 catch 语句来捕获一些开发过程中,为捕获到的异常。但是在开发后期,确实会遇到一个异常在 main() 才被捕获到,且不知道在哪throw的,这样就很让人头疼。