发布于 

软件设计:一种C++项目的错误码设计实践

2022.12.11 - V1.0 - 初稿

错误码作为应用软件的基础类,许多软件都实现了适合自身情况的错误码类,比如:leveldb的ErrCode类,TensorFlow Lite的TfLiteStatus 类。

为什么需要错误码类?

错误码是软件可靠性的核心。

以C函数为例,对于一个C函数调用,返回的结果包含了两部分的信息,一是函数执行结果,二是函数执行成功与否,也就是执行状态。假若函数在执行过程中发生了难以继续的错误,就将对应的错误码返回,调用者也可以根据这个错误码,来调整执行流程,比如尝试重试或者记录日志等。

从软件设计的角度看,函数返回错误码和函数执行结果分别表示了控制面和数据面在某一个调用点的状态。

图示:函数调用过程的控制面和数据面

函数调用过程的控制面和数据面

错误码的常见实现

1、单层错误码

enum ErrorCode {
kOk = 0,
kFail = 1,
kAccessError = 2,
kOpenFileError = 3,
...
}

2、不同模块的分段错误码

C风格:

/* module error code */
#define int ERR_MODULE_SOCKET = 1 << 8;
#define int ERR_MODULE_CONFIG = 2 << 8;

/* detail error code of socket module */
#define int ERR_SOCKET_CONNECT = ERR_MODULE_SOCKET + 1
#define int ERR_SOCKET_LISTEN = ERR_MODULE_SOCKET + 2
#define int ERR_SOCKET_BIND = ERR_MODULE_SOCKET + 3
#define int ERR_SOCKET_READ = ERR_MODULE_SOCKET + 4
#define int ERR_SOCKET_WRITE = ERR_MODULE_SOCKET + 5

/* detail error code of configuration file module */
#define int ERR_CONFIG_READ = ERR_MODULE_CONFIG + 1
#define int ERR_CONFIG_PARSE = ERR_MODULE_CONFIG + 2

C++风格:

enum ErrorCode : int {
kSuccess = 0,
kFailed = 1,

kSocketErrorBase = 1000,
kSocketConnectError = 1001,
kSocketListenError = 1002,
kSocketBindError = 1003,
kSocketReadError = 1004,
kSocketWriteError = 1005,

kConfigErrorBase = 2000,
kConfigReadError = 2001,
kConfigParseError = 2002,
}

单纯错误码存在的问题:

1、越细节的错误越灵活,如果每一个具体错误,都需要加一种新的错误码,那不符合开闭原则,ErrorCode公共类收上层业务逻辑影响,导致变化。

2、对于细节的错误,单纯靠错误码难以理解,不利于快速找到错误位置。比如:对于kSocketReadError 这个错误码表示读取socket失败,但出现这个错误码的代码位置有多处,单靠这个错误码无法确定是哪一个执行路径导致的错误。

异常调用栈

异常时打印调用栈是一种清晰易定位的提示错误方式,但古老的语言,比如C自身不支持打印调用栈,除非借助于libwind等三方库,而且release版本一般没有符号表,因此打印的栈信息不完整。

另外,C++的异常机制比较复杂,影响性能的同时,也不能打印调用栈。

错误码+详细错误字符串

另一种思路是:对于大的错误类别,采用错误码,对于具体的错误上下文,由被调用的函数填充具体的错误消息字符串,比如打开某个具体的文件时,权限错误等等。

这种实践上有两个好处:

1、最终出现在日志中的,是带有上下文的错误信息,方便定位,并且决定打印的消息字符串日志级别的是上层调用者,而不是被调用的函数。举个简单的例子,在创建目录时如果发现目录存在就跳过,有一种实现方式是:直接尝试创建目录,如果调用mkdir创建失败,就打印:LOG_ERROR << "mkdir " << dir << " failed";,然后返回kDirExistedError错误码,但是对于上层调用者来说,这不是一个错误,而是一个符合预期的case,这里的LOG_ERROR是不必要的,从这里可以看出,有些场景下,执行失败对于应用来说是否是错误,应该有更高层的调用者来判断,也只有他具有支撑决策的上下文。

2、保留了错误码,可以让调用者高效地判断是否是可接受的异常情况。

总结成一句话:错误码给程序执行用,错误消息给调测人员看。

实现

enum class ErrorCode : int {
kOk = 0,
kError = 1,
kNullPoint = 2,
kBadFuncParm = 3,
kNoPermission = 4,
};

class Error {
public:
Error() {};
Error(ErrorCode code) : error_code_(code) {}
Error(ErrorCode code, const std::string &message): error_code_(code) {
message_ = std::make_shared<std::string>(message);
}
bool &operator==(const Error &) = delete;

bool IsOk() { return error_code_ == ErrorCode::kOk; }
bool IsError() { return error_code_ == ErrorCode::kError; }
bool IsNullPoint() { return error_code_ == ErrorCode::kNullPoint; }
bool EqualCode(ErrorCode code) { return error_code_ == code; }
std::string Message() { return (message_ != nullptr) ? (*message_) : ""; }

private:
ErrorCode error_code_ = ErrorCode::kOk;
std::shared_ptr<std::string> message_ = nullptr;
};