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。用 *resultresult.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 是错误,就直接跳过,把错误原样传递下去。

这就像函数式编程里的 flatMapbind。你想想看,整个调用链上只要有一个环节失败,后面的就不会执行,错误会一路传播到最后。是不是很像异常?但这里没有异常的开销,也没有隐藏的控制流。

transform:只转换值,不改变错误

有时候你只是想对成功值做点变换,不引入新的失败可能:

auto result = parse_int("42")
    .transform([](int x) { return x * 2; });
// result 是 expected<int, ParseError>,值为 84

transformand_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::expectedE 类型可以是任何东西,但建议用 std::error_codestd::string 或者枚举。别用裸指针或复杂对象,否则拷贝开销会让你头疼。

我的习惯: 在项目里我会定义一个统一的错误类型,比如 using AppError = std::pair<int, std::string>;,然后所有函数都返回 std::expected<T, AppError>。这样整个项目的错误处理风格就统一了。

知识体系图

下面这张图总结了 std::expected 的核心概念和操作:

std::expected<T, E> 核心概念 std::expected<T, E> 持有 T(正常值) 持有 E(错误值) and_then:串联可能失败的操作 transform:转换成功值 or_else:处理或恢复错误 value_or:提供默认值 函数式风格:错误自动传播,无需手动检查每个步骤

什么时候用 std::expected?

我个人的经验是:

  • 适合的场景: 函数可能失败,但失败是“预期内”的(比如解析、验证、网络请求)。你想让调用者显式处理错误,但又不想用异常。
  • 不适合的场景: 构造函数、运算符重载、或者性能极其敏感的热路径(虽然 expected 比异常快,但比裸返回值还是多了一点开销)。
  • 和异常的关系: 不是替代关系。异常适合“真的意外”的情况(比如内存耗尽),expected 适合“业务上可预见的失败”。

一句话总结: std::expected 让你用函数式的链式调用,写出既安全又优雅的错误处理代码。它把“成功”和“失败”都变成了类型系统的一部分,编译器帮你检查,你只管写逻辑。

好了,关于 std::expected 的核心用法就聊到这里。记住,工具再好,也要用在合适的地方。下次你写一个可能失败的函数时,不妨试试它——说不定你会爱上这种风格。


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