C++expected预期结果与错误处理
C++23 的 expected<T, E>:别再用 pair<T, bool> 假装自己在做错误处理了
刚接手一个老项目,翻到一段函数签名:
std::pair<Config, bool> load_config(const std::string& path);
调用时得先检查 .second,再取 .first——像极了当年用 int 返回码还要查文档确认“-1 是文件没找到,-2 是权限不足,-3 是……算了,加个注释吧”。这种写法不是错,只是把错误语义藏在了类型系统之外,靠人肉约定维系。C++23 引入的 std::expected<T, E>,正是为终结这类“心照不宣”而生。
它不是又一个包装器,而是把“成功路径”和“失败原因”同时纳入类型系统的设计。T 是你真正想要的结果,E 是你明确预期的错误类型(比如 std::errc、自定义 ParseError,甚至 std::string)。编译器能帮你守住边界:你无法直接把 expected<int, std::errc> 当作 int 用,也无法忽略错误分支而不付出显式代价。
最实在的好处,是它让“错误传播”变得自然。以前写嵌套调用常这样:
auto a = parse_input(); if (!a.second) return a;
auto b = validate(a.first); if (!b.second) return b;
auto c = process(b.first); if (!c.second) return c;
return c;
三行有效逻辑,六行防御性检查。换成 expected,配合 and_then 或 transform,可以链式展开:
return parse_input()
.and_then(validate)
.and_then(process);
这里没有 if,没有临时变量,每个环节的错误自动短路,成功值自动透传。关键在于:and_then 的参数必须返回 expected,这倒逼你把每一步的错误语义都提前想清楚——不是“出错了”,而是“出错了,因为什么”。
有人担心:expected 会不会让代码变重?其实恰恰相反。它消除了大量重复的错误检查模板代码。更重要的是,它支持移动语义与无异常构造。如果你的 T 或 E 不抛异常(比如 std::string_view 或 ErrorCode),整个 expected 对象就能零开销构造;即使 T 是大对象,expected 内部也只在需要时才就地构造,避免无谓拷贝。
一个容易被忽略但很实用的细节:expected 支持 value_or() 和 error_or()。当错误可降级处理时,比如配置项缺失就用默认值:
auto port = get_port().value_or(8080); // 类型安全,默认值类型必须匹配 T
这比 optional<T> 更进一步——optional 只能表达“有或没有”,而 expected 明确告诉你“为什么没有”,这对调试和日志至关重要。
实际落地时,建议从边界清晰的模块开始替换。比如网络请求封装、配置解析、序列化函数。这些地方错误种类固定(超时、格式错误、IO 失败),E 类型容易定义。别一上来就改整个 std::vector 的 operator[]——expected 不是万能胶布,它适合有明确成功/失败契约的场景,而不是替代断言或运行时崩溃。
还有一点值得提:expected 和异常不是互斥关系。它解决的是预期内、可恢复的错误(如用户输入非法、配置缺失),而异常仍适用于真正意外的情况(如内存耗尽、硬件故障)。二者分工明确:expected 把“可能失败”的操作正经当第一等公民对待,异常则留给“绝不该发生”的兜底。
最后说个真实教训:我们曾把一个返回 bool 的校验函数改成 expected<void, ValidationError>,结果发现调用方全在忽略 .error()——因为 void 成功值太“轻”,让人下意识觉得“反正没东西要拿,错不错都一样”。后来改成 expected<ValidationResult, ValidationError>,哪怕 ValidationResult 是空结构体,调用者也立刻开始检查错误。类型本身就在提醒你:这条路有岔口,别闭眼走。
expected 的价值,不在语法多炫酷,而在于它把长期被弱化的错误语义,重新拉回与业务逻辑同等重要的位置。它不强迫你放弃异常,也不要求你重写整个代码库。它只是安静地站在那里,等你某天写下一个新函数时,顺手把 E 填上——然后突然发现,原来错误处理,也可以有形状、有名字、有尊严。


还没有评论,来说两句吧...