错误处理:异常安全保证,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类型要轻量。别把整个异常对象塞进去,那会破坏性能。用枚举或错误码就够了。

错误码与错误链:让错误会说话

单层错误码好办,但真实场景往往是层层嵌套的。比如:

  1. 底层:数据库连接超时
  2. 中间层:用户数据加载失败
  3. 上层:登录请求处理失败

每一层都只知道自己的错误,但上层需要知道「到底哪里出了问题」。这就是错误链要解决的问题。

我设计过一个简单的错误链系统,核心思路是:每个错误都持有前一个错误的指针。

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两个工具,最终落到错误链的设计上。

错误处理知识体系 异常安全保证 无保证 基本保证 强保证 不抛异常 错误处理工具 std::optional std::expected 错误链设计 从保证级别 → 工具选择 → 链式追踪,层层递进

你看,整个体系是层层递进的。先确定你的函数能提供什么级别的保证,然后选择合适的工具(optional还是expected),最后用错误链把上下文串起来。这三步走完,你的错误处理基本就稳了。

我的建议:别想着一步到位。先从std::optional开始,把那些「可能为空」的场景清理干净。然后引入expected处理「可能失败」的场景。最后,等你的项目大到需要跨模块追踪错误时,再上错误链。循序渐进,别过度设计。

好了,关于错误处理,咱们就聊这么多。记住一句话:错误处理不是事后补救,而是设计的一部分。你越早把它纳入架构设计,后面就越省心。


公众号:蓝海资料掘金营,微信deep3321