错误处理:异常安全保证,std::expected / std::optional 实战,错误码与错误链
错误处理这件事,说实话,是C++里最容易被低估的环节。我见过太多项目,功能跑得飞起,一遇到异常就崩得稀里哗啦。你想想看,一个系统里90%的代码都在处理正常流程,但真正决定它靠不靠谱的,往往是那10%的错误路径。
今天咱们就聊聊这个话题。我会从异常安全保证讲起,然后深入std::optional和std::expected这两个现代C++的利器,最后聊聊错误码和错误链的设计。嗯,这些都是我在实际项目中踩过坑、填过坑的经验。
异常安全保证:不只是try-catch
很多人以为异常安全就是写几个try-catch块。其实远不止如此。C++标准里定义了四个级别的异常安全保证,我习惯把它们看作一个阶梯:
| 保证级别 | 含义 | 实际场景 |
|---|---|---|
| 无保证 | 异常发生后,资源可能泄漏,数据可能损坏 | 裸指针操作,new了不delete |
| 基本保证 | 异常发生后,资源不泄漏,数据保持有效但可能不一致 | std::vector::push_back(C++11前) |
| 强保证 | 异常发生后,状态回滚到调用前 | copy-and-swap惯用法 |
| 不抛异常 | 操作保证不会抛出异常 | noexcept函数,swap操作 |
我在项目中遇到过最典型的例子:一个交易系统,需要同时更新账户余额和交易记录。如果只更新了余额,记录写入时抛异常,那账就对不上了。这就是基本保证不够用,必须上强保证的场景。
核心原则:写代码时,先想清楚你的函数能提供哪个级别的保证。然后把这个承诺写进注释里,别藏着掖着。
std::optional:可能没有值
std::optional是C++17引入的。说白了,它就是一个「可能为空」的盒子。我特别喜欢用它来替代那些「返回-1表示失败」的旧式API。
// 旧式做法:用特殊值表示失败
int findUserIndex(const std::string& name) {
auto it = std::find(users.begin(), users.end(), name);
return (it != users.end()) ? std::distance(users.begin(), it) : -1;
}
// 现代做法:用optional明确表达「可能没有」
std::optional<size_t> findUserIndex(const std::string& name) {
auto it = std::find(users.begin(), users.end(), name);
if (it != users.end()) {
return std::distance(users.begin(), it);
}
return std::nullopt;
}
你看,用optional之后,调用方一眼就能看出这个函数可能返回空。不用再去猜-1是什么意思。
使用技巧:optional适合「没有值」是正常情况的场景。比如查找、解析、配置读取。但如果「没有值」意味着错误,那应该用std::expected。
std::expected:要么有值,要么有错误
std::expected是C++23的产物,但很多项目已经在用实验版或自实现版了。它比optional多了一个维度:不仅能表示「有值」或「无值」,还能携带错误信息。
我个人习惯把expected看作「带错误码的optional」。它的签名长这样:
// 返回值:要么是User,要么是ErrorCode
std::expected<User, ErrorCode> loadUser(int id) {
if (id <= 0) {
return std::unexpected(ErrorCode::InvalidId);
}
// ... 正常加载逻辑
return User{id, "Alice"};
}
// 调用方
auto result = loadUser(42);
if (result) {
// 使用 result.value()
} else {
// 处理 result.error()
}
这里有个坑,我曾经踩过:不要把expected当作异常来用。expected适合那些「预期内可能失败」的操作,比如网络请求、文件读取。对于真正的异常情况(比如内存耗尽),还是该抛异常就抛异常。
注意:expected的error类型要轻量。别把整个异常对象塞进去,那会破坏性能。用枚举或错误码就够了。
错误码与错误链:让错误会说话
单层错误码好办,但真实场景往往是层层嵌套的。比如:
- 底层:数据库连接超时
- 中间层:用户数据加载失败
- 上层:登录请求处理失败
每一层都只知道自己的错误,但上层需要知道「到底哪里出了问题」。这就是错误链要解决的问题。
我设计过一个简单的错误链系统,核心思路是:每个错误都持有前一个错误的指针。
struct Error {
int code;
std::string message;
std::shared_ptr<Error> cause; // 指向更底层的错误
Error(int c, std::string msg, std::shared_ptr<Error> prev = nullptr)
: code(c), message(std::move(msg)), cause(std::move(prev)) {}
};
// 使用示例
auto dbError = std::make_shared<Error>(1001, "数据库连接超时");
auto loadError = std::make_shared<Error>(2001, "用户数据加载失败", dbError);
auto loginError = std::make_shared<Error>(3001, "登录请求处理失败", loadError);
// 打印错误链
void printErrorChain(const Error& err) {
std::cerr << "[" << err.code << "] " << err.message << "\n";
if (err.cause) {
printErrorChain(*err.cause);
}
}
这样设计的好处是:上层可以只关心自己的错误码,但调试时又能追溯到根因。说白了,就是「各层各管各的,但链条不断」。
避坑指南:我曾经在错误链里用了unique_ptr,结果发现共享错误对象时特别麻烦。后来改成shared_ptr,世界清净了。记住:错误链天然是共享的,别跟所有权过不去。
实战:一个完整的错误处理框架
下面是我在一个项目中实际用过的错误处理框架骨架。它结合了expected和错误链:
// 错误码枚举
enum class AppError {
None,
NotFound,
Timeout,
PermissionDenied,
InternalError
};
// 带错误链的expected
template<typename T>
using Result = std::expected<T, std::shared_ptr<Error>>;
// 便捷宏:快速创建带上下文的错误
#define MAKE_ERROR(code, msg) \
std::make_shared<Error>(static_cast<int>(code), msg)
#define MAKE_ERROR_WITH_CAUSE(code, msg, cause) \
std::make_shared<Error>(static_cast<int>(code), msg, cause)
// 使用示例
Result<User> loadUserFromDb(int id) {
auto conn = tryConnect();
if (!conn) {
return std::unexpected(
MAKE_ERROR(AppError::Timeout, "数据库连接超时")
);
}
// ... 正常逻辑
return User{id, "Bob"};
}
Result<User> loadUser(int id) {
auto result = loadUserFromDb(id);
if (!result) {
return std::unexpected(
MAKE_ERROR_WITH_CAUSE(AppError::InternalError,
"用户加载失败",
result.error())
);
}
return result;
}
这个框架的好处是:
- 每个函数都明确声明了「可能失败」
- 错误信息层层叠加,调试时一目了然
- 没有异常开销,适合性能敏感场景
知识体系总览
下面这张图总结了本章的核心逻辑。你可以看到,错误处理不是孤立的,它从异常安全保证出发,经过optional和expected两个工具,最终落到错误链的设计上。
你看,整个体系是层层递进的。先确定你的函数能提供什么级别的保证,然后选择合适的工具(optional还是expected),最后用错误链把上下文串起来。这三步走完,你的错误处理基本就稳了。
我的建议:别想着一步到位。先从std::optional开始,把那些「可能为空」的场景清理干净。然后引入expected处理「可能失败」的场景。最后,等你的项目大到需要跨模块追踪错误时,再上错误链。循序渐进,别过度设计。
好了,关于错误处理,咱们就聊这么多。记住一句话:错误处理不是事后补救,而是设计的一部分。你越早把它纳入架构设计,后面就越省心。
公众号:蓝海资料掘金营,微信deep3321