std::expected:C++23 的“预期值”与错误传播
聊到错误处理,C++ 的老玩家们肯定都跟异常、错误码、甚至 std::optional 打过不少交道。我个人觉得,这些方案各有各的痛点:异常有性能开销和“看不见的控制流”;错误码容易漏检查;std::optional 又没法告诉你“为什么没值”。
C++23 引入的 std::expected,说白了就是来填补这个空白的。它既能携带“预期的正常值”,也能携带“非预期的错误信息”。而且它的设计思路非常函数式——你不需要到处写 if-else 去拆包,而是用一套链式调用的风格把逻辑串起来。
嗯,我在一个网络库的重构项目中用过它。当时要把一堆可能失败的解析步骤串起来,用异常太慢,用错误码又得写一堆 switch。换成 std::expected 之后,代码干净了不少。今天我们就把它掰开揉碎讲清楚。
std::expected 是什么?
先看它的基本形态:
#include <expected>
#include <string>
#include <system_error>
// 定义一个错误类型
enum class ParseError {
InvalidInput,
Overflow,
Unterminated
};
// 返回 expected<int, ParseError>
// 正常时返回 int,错误时返回 ParseError
std::expected<int, ParseError> parse_int(const std::string& s) {
// ... 解析逻辑
if (s.empty()) {
return std::unexpected(ParseError::InvalidInput);
}
// 假设解析成功
return 42;
}
你看,std::expected<T, E> 有两个模板参数:T 是正常值的类型,E 是错误值的类型。它要么持有 T,要么持有 E,但不会同时持有两者。
返回错误时,用 std::unexpected(err) 包装一下。这个设计很巧妙——它让错误路径在语法上显式可见,不像异常那样“偷偷”跳走。
怎么用?拆包与检查
最基础的用法当然是检查再取值:
auto result = parse_int("123");
if (result) {
// result 有值,可以安全访问
int val = *result;
// 或者用 result.value()
std::cout << "Parsed: " << val << "\n";
} else {
// result 有错误
ParseError err = result.error();
std::cout << "Error code: " << static_cast<int>(err) << "\n";
}
这里 result 可以隐式转换为 bool,有值时是 true。用 *result 或 result.value() 取正常值,用 result.error() 取错误值。
result 处于错误状态,你却调用了 result.value(),它会抛 std::bad_expected_access<E> 异常。所以要么先检查,要么用 value_or() 给个默认值。
函数式风格:and_then / or_else / transform
这才是 std::expected 真正发光的地方。它提供了几个成员函数,让你可以像流水线一样组合多个可能失败的操作。
and_then:串联“成功路径”
假设我们有三个步骤,每一步都可能失败:
std::expected<int, ParseError> step1(const std::string& s);
std::expected<double, ParseError> step2(int x);
std::expected<std::string, ParseError> step3(double y);
// 传统写法:嵌套 if
auto r1 = step1("42");
if (r1) {
auto r2 = step2(*r1);
if (r2) {
auto r3 = step3(*r2);
// ...
}
}
// 函数式写法:链式调用
auto result = step1("42")
.and_then(step2) // 如果 step1 成功,把值传给 step2
.and_then(step3); // 如果 step2 成功,把值传给 step3
// result 要么是最终的 std::string,要么是第一个错误
and_then 的意思是:如果当前 expected 有值,就把这个值传给一个函数,该函数返回一个新的 expected。如果当前 expected 是错误,就直接跳过,把错误原样传递下去。
这就像函数式编程里的 flatMap 或 bind。你想想看,整个调用链上只要有一个环节失败,后面的就不会执行,错误会一路传播到最后。是不是很像异常?但这里没有异常的开销,也没有隐藏的控制流。
transform:只转换值,不改变错误
有时候你只是想对成功值做点变换,不引入新的失败可能:
auto result = parse_int("42")
.transform([](int x) { return x * 2; });
// result 是 expected<int, ParseError>,值为 84
transform 和 and_then 的区别在于:transform 的 lambda 返回普通值(不是 expected),而 and_then 的 lambda 返回 expected。前者用于“纯转换”,后者用于“可能失败的操作”。
or_else:处理错误路径
如果你想在错误发生时做点什么,比如记日志或提供备选方案:
auto result = parse_int("abc")
.or_else([](ParseError err) {
std::cerr << "Parse failed with error: " << static_cast<int>(err) << "\n";
// 可以返回一个备选值,或者返回一个新的 expected
return std::expected<int, ParseError>(0); // 用 0 兜底
});
// result 现在是 expected<int, ParseError>,值为 0
or_else 只在错误状态下被调用。你可以用它来“恢复”一个错误,或者纯粹做副作用(比如日志)。
实战:一个配置文件解析的例子
我在一个项目里写过这样的代码。当时要从一个字符串里解析出“键=值”对,每个步骤都可能失败:
using ParseResult = std::expected<std::pair<std::string, std::string>, std::string>;
ParseResult parse_key_value(const std::string& line) {
auto pos = line.find('=');
if (pos == std::string::npos) {
return std::unexpected("Missing '=' in line: " + line);
}
auto key = line.substr(0, pos);
auto value = line.substr(pos + 1);
if (key.empty() || value.empty()) {
return std::unexpected("Empty key or value in line: " + line);
}
return std::pair{key, value};
}
// 然后可以链式处理多行
std::vector<std::string> lines = {"name=hello", "version=2", "badline"};
for (const auto& line : lines) {
auto result = parse_key_value(line)
.transform([](const auto& pair) {
return pair.first + " -> " + pair.second;
})
.or_else([](const std::string& err) {
return ParseResult(std::unexpected("ERROR: " + err));
});
if (result) {
std::cout << *result << "\n";
} else {
std::cerr << result.error() << "\n";
}
}
你看,每个解析步骤只关心自己的逻辑,错误传播和组合完全由 expected 的链式调用搞定。代码读起来像在描述“做什么”,而不是“怎么处理错误”。
避坑指南
我曾经在一个代码评审里看到有人这么写:
auto r = some_function();
if (r.has_value()) {
// 用 r.value()
} else {
// 用 r.error()
}
嗯,这样写当然没错,但完全没发挥出 expected 的优势。如果你只是把它当 optional 用,那还不如直接用 optional 加一个错误码全局变量呢。
还有一点要注意:std::expected 的 E 类型可以是任何东西,但建议用 std::error_code、std::string 或者枚举。别用裸指针或复杂对象,否则拷贝开销会让你头疼。
using AppError = std::pair<int, std::string>;,然后所有函数都返回 std::expected<T, AppError>。这样整个项目的错误处理风格就统一了。
知识体系图
下面这张图总结了 std::expected 的核心概念和操作:
什么时候用 std::expected?
我个人的经验是:
- 适合的场景: 函数可能失败,但失败是“预期内”的(比如解析、验证、网络请求)。你想让调用者显式处理错误,但又不想用异常。
- 不适合的场景: 构造函数、运算符重载、或者性能极其敏感的热路径(虽然
expected比异常快,但比裸返回值还是多了一点开销)。 - 和异常的关系: 不是替代关系。异常适合“真的意外”的情况(比如内存耗尽),
expected适合“业务上可预见的失败”。
一句话总结: std::expected 让你用函数式的链式调用,写出既安全又优雅的错误处理代码。它把“成功”和“失败”都变成了类型系统的一部分,编译器帮你检查,你只管写逻辑。
好了,关于 std::expected 的核心用法就聊到这里。记住,工具再好,也要用在合适的地方。下次你写一个可能失败的函数时,不妨试试它——说不定你会爱上这种风格。
公众号:蓝海资料掘金营,微信 deep3321