C++23 预期类型(std::expected):在 C++ 底层链路开发中利用代数数据类型优雅地处理非异常错误流

C++23 预期类型(std::expected):在 C++ 底层链路开发中利用代数数据类型优雅地处理非异常错误流

讲座题目: 别再和错误码谈恋爱了,拥抱 std::expected 吧!
主讲人: 一名在底层泥潭里摸爬滚打多年的资深 C++ 程序员
时长: 理论上需要 4 小时,这里浓缩成 5000 字的精华


各位好,欢迎来到今天的讲座。

如果你们中有人刚从嵌入式、网络底层或者系统编程的工位上站起来,我猜你们现在的脸上大概挂着一种混合了“疲惫”和“无奈”的表情。为什么?因为你们刚刚处理完一个极其复杂的函数调用链,而在这个过程中,你不得不在一个又一个的 if (error != 0) 中打转,像一只无头苍蝇一样检查每一个返回值。

今天,我们要聊的,就是如何终结这种“地狱模式”。

C++ 23 引入了一个新成员:std::expected<T, E>。听名字你可能觉得它很普通,像个快递员。但实际上,它是一个来自数学界的“幽灵”,一个代数数据类型(ADT)的化身。它将彻底改变你处理错误的方式——特别是在我们这种底层链路开发中,那种不允许抛出异常、不允许内存泄漏、必须与硬件对话的硬核场景。

准备好了吗?让我们把 C++ 的错误处理方式,从“过家家”进化到“精密仪器”。


第一章:C++ 的情感创伤史

在深入 std::expected 之前,我们必须先聊聊 C++ 在处理错误时到底经历了什么。这就像在听一个老男人的忏悔录。

1.1 异常:那个说走就走的“渣男”

在 C++ 中,异常曾经是处理错误的王者。try, throw, catch。听起来很美好,对吧?你调用一个函数,如果出错了,函数直接把你“扔”出来。

但是,兄弟们,在底层开发中,这简直是灾难。
为什么?因为异常是有成本的。这种成本不仅仅是 CPU 周期,更是栈展开。当一个异常被抛出时,C++ 运行时需要沿着调用栈回溯,寻找匹配的 catch 块。如果在这个过程中,你的对象析构函数抛出了异常(虽然标准禁止,但在复杂逻辑中很难保证),或者你的代码在栈上分配了大量内存,这简直就是一场性能灾难。

更重要的是,底层链路开发(比如驱动开发、网络协议栈)通常不允许抛出异常。为什么?因为异常可能被一个底层的系统调用抛出,然后一路传导到你的 UI 层,导致你的整个应用程序崩溃。硬件不会在乎你的 try-catch,硬件只会返回一个错误码。用异常处理硬件错误?这就好比你想用“愤怒”来修复一个坏掉的硬盘。

1.2 返回码:那个总是让你加班的“老实人”

既然不能抛异常,那我们就用返回码吧。这很安全,很高效。
但这也太繁琐了。看看这段代码:

// 模拟一段糟糕的底层代码
int connect_to_device(const char* ip) {
    if (init_serial_port() != 0) return ERR_INIT_FAILED;
    if (handshake() != 0) return ERR_HANDSHAKE_FAILED;
    if (send_config() != 0) return ERR_CONFIG_FAILED;
    return SUCCESS;
}

// 调用者
int main() {
    int ret = connect_to_device("192.168.1.1");
    if (ret == ERR_INIT_FAILED) {
        // 处理初始化失败
    } else if (ret == ERR_HANDSHAKE_FAILED) {
        // 处理握手失败
    } else if (ret == ERR_CONFIG_FAILED) {
        // 处理配置失败
    } else if (ret == SUCCESS) {
        // 干活
    }
    return 0;
}

你看,这就是所谓的“错误流处理”。你的主逻辑被 if-else 阻塞了。如果你想调用 10 个函数,你的代码里就会嵌套 10 层 if。这就是所谓的“面条代码”。

1.3 std::optional:一个没心没肺的“花花公子”

C++11 引入了 std::optional,试图解决这个问题。它说:“嘿,我不返回错误码,也不抛异常,我返回一个包裹着结果的盒子。”

std::optional<Data> read_data() {
    if (error) return std::nullopt;
    return data;
}

这确实好了一点点,我们不需要检查 if (error) 了,只需要检查 if (value.has_value())。但是,optional 有一个致命的缺陷:它只关心“成功”,而完全忽略了“为什么失败”

当你拿到一个 std::nullopt 时,你只知道“出事了”,但你不知道具体是什么事。对于底层开发,不知道错误原因就像盲人摸象。你想知道是串口断开了?是协议不匹配?还是内存溢出?optional 告诉你:“我也不知道,反正没了。”


第二章:代数数据类型(ADT)的浪漫邂逅

好了,铺垫了这么多,我们要见主角了。std::expected<T, E>

它不仅仅是一个模板类,它是一种哲学。在计算机科学中,我们有一种东西叫代数数据类型(ADT)。简单来说,它把两种可能的状态结合在了一起。

想象一下一枚硬币。

  1. 正面:代表成功(Value T)。
  2. 反面:代表错误(Error E)。

这枚硬币只有两种状态,没有第三种状态。它不像 std::optional,可能里面什么都没有(nullopt)。std::expected 要么有值,要么有错误。这就是数学上的“和”类型(Sum Type)。

它的定义是这样的:

template<class T, class E>
class expected;
  • T: 成功时持有的数据类型。
  • E: 失败时持有的错误类型。

这就像是一个保险箱:

  • 如果运气好,里面放着你的宝藏(T)。
  • 如果运气不好,里面放着一张写着原因的纸条(E)。
  • 绝不会是空的。

在底层链路开发中,这种特性简直是神来之笔。我们处理的数据要么是有效的(比如一个完整的 TCP 包头),要么是无效的(比如校验和不匹配)。std::expected 完美映射了这种二元逻辑。


第三章:实战演练 – 重新设计“底层链路”

让我们回到现实。假设我们在写一个网络协议栈的底层读取函数。通常我们会这样写:

// 传统写法
bool read_packet(Packet& out_packet) {
    // 1. 读取头部
    Header header;
    if (read_header(header) != 0) return false;

    // 2. 读取负载
    Payload payload;
    if (read_payload(payload) != 0) return false;

    // 3. 校验
    if (!validate(header, payload)) return false;

    out_packet = {header, payload};
    return true;
}

这种写法充满了“意大利面条”式的嵌套。如果中间哪一步失败了,后面的步骤根本不会执行,导致资源泄漏(比如打开的文件句柄没关)。

现在,让我们用 std::expected 重写它:

#include <expected>
#include <string>

// 定义错误类型
enum class NetworkError {
    ConnectionLost,
    Timeout,
    InvalidHeader,
    CorruptData
};

// 定义成功返回的数据结构
struct Packet {
    Header header;
    Payload payload;
};

// 1. 读取头部
std::expected<Header, NetworkError> read_header() {
    if (/* 硬件读取失败 */) {
        return std::unexpected(NetworkError::ConnectionLost);
    }
    return Header{...};
}

// 2. 读取负载
std::expected<Payload, NetworkError> read_payload() {
    if (/* 读取超时 */) {
        return std::unexpected(NetworkError::Timeout);
    }
    return Payload{...};
}

// 3. 校验
std::expected<Packet, NetworkError> validate(const Header& h, const Payload& p) {
    if (!h.is_valid()) {
        return std::unexpected(NetworkError::InvalidHeader);
    }
    return Packet{h, p};
}

// 主函数:扁平化处理
std::expected<Packet, NetworkError> read_packet() {
    // 使用 and_then 链式调用,如果失败直接返回,不会执行后续代码
    auto header = read_header();
    if (!header) return header.error(); // 或者直接 return header; (C++23特性)

    auto payload = read_payload();
    if (!payload) return payload.error();

    return validate(*header, *payload);
}

看这段代码!没有任何 if 嵌套! 代码流是直的。这就是 std::expected 的魅力。它把“错误”和“成功”像对待正常数据一样处理,唯一的区别是我们在最后检查它是否持有值。


第四章:链式调用的艺术(The Magic Methods)

如果你觉得上面的例子还不够惊艳,那是因为你还没用过它的“魔法咒语”。

C++23 给 std::expected 提供了一整套类似于 std::optional 和函数式编程风格的 API。这对于底层开发来说,意味着我们可以像流水线一样处理数据流。

4.1 and_then: 深入挖掘

and_then 的作用是:如果我有值,我就继续处理;如果我没值,我就直接结束,把错误传下去。

std::expected<int, std::string> divide(int a, int b) {
    if (b == 0) return std::unexpected(std::string("Div by zero"));
    return a / b;
}

std::expected<double, std::string> process(int a, int b) {
    return divide(a, b)
        .and_then([](int result) { // 只有当 divide 返回 int 时才会执行这里
            return std::expected<double, std::string>(result * 1.5);
        })
        .and_then([](double val) { // 只有当上一步返回 double 时才会执行
            if (val > 100) return std::expected<double, std::string>("Too big");
            return val;
        });
}

在这个例子中,如果 divide 返回错误,process 会直接返回那个错误,后面的 lambda 函数根本不会被执行。这保证了性能(没有无用的计算)和安全性。

4.2 or_else: 错误的“急救室”

当发生错误时,我们有时想做一些补救措施,而不是直接返回。比如,连接超时了,我们重试一次;或者错误码需要转换。

or_else 允许你在一个 std::unexpected 上执行回调函数来修复它。

std::expected<int, std::string> get_value() {
    return std::unexpected(std::string("Not found"));
}

std::expected<int, std::string> smart_fetch() {
    return get_value()
        .or_else([](const std::string& err) -> std::expected<int, std::string> {
            if (err == "Not found") {
                // 假设我们有一个后备数据库
                return std::expected<int, std::string>(42); 
            }
            return std::unexpected(err); // 其他错误无法修复,继续抛出
        });
}

这就像是一个错误处理的中转站。你不必在每个地方都写 if (error) return error,你可以把这些逻辑封装在 or_else 里。

4.3 transform: 改变数据的“整容术”

如果你拿到了一个值,想把它转换成另一种类型,同时保留成功/失败的状态,用 transform

std::expected<std::string, int> parse_int(const char* str) {
    // 假设解析逻辑
    if (str == nullptr) return std::unexpected(1);
    return std::string(str);
}

std::expected<int, int> to_int(const std::string& s) {
    try {
        return std::stoi(s);
    } catch (...) {
        return std::unexpected(2);
    }
}

// 链式调用
std::expected<int, int> final_result = parse_int("123")
    .transform(to_int); // 如果 "123" 解析成功,转成 int;如果失败,错误保持为 1

第五章:为什么底层开发必须用 std::expected

我知道,有些老派的工程师会嘀咕:“我写了 20 年 C 语言,用 int 返回错误码,也没见出什么大事。为什么现在非要搞个这么复杂的东西?”

好问题。让我们来一场关于“底层尊严”的辩论。

5.1 类型安全的错误传播

在底层开发中,我们经常写库。你的库调用别人的库,别人的库调用操作系统。
如果大家都用 int 错误码,很容易出错。

  • 你的函数返回 -1 表示错误。
  • 调用者的代码里正好有个 -1 的常量,结果被误判为成功。

或者,函数 A 返回错误码 10,函数 B 期望错误码 10,但函数 C 期望错误码 20。一旦在函数链中传递,类型信息就丢失了。std::expected 把错误类型(E)保存在栈上。编译器会强制检查类型是否匹配。编译器是这世界上最好的守门员,它绝不会漏掉一个类型错误。

5.2 避免资源泄漏

还记得我说的“面条代码”吗?在 if (error) return; 里面,你经常忘记释放锁,忘记关闭文件,忘记释放内存。

std::expected,你的代码是线性的。所有的清理工作都在函数结束时自动完成。你不需要在每个 return 之前写 cleanup();。这不仅减少了代码量,更重要的是,它消除了人为疏忽导致的内存泄漏。

5.3 性能依然是顶级的

有些人担心 std::expected 会像 std::variant 一样占用大量内存。
实际上,std::expected 的实现非常聪明。它通常只存储成功时的值 T。当发生错误时,它才存储 E。在某些实现中(如 MSVC 或 GCC 的新实现),它甚至可能使用“未定义值”的技巧来避免内存分配。

对于底层开发来说,std::expected 的内存布局和 std::optional 类似,甚至更紧凑。它的性能开销几乎为零。它不会比直接返回错误码慢。

5.4 与 C API 的互操作

底层开发往往需要调用 C 语言写的硬件库。

// C API
int read_sensor(int* value);

你可以轻松地包装它:

std::expected<int, int> read_sensor_c_api() {
    int val = 0;
    int ret = read_sensor(&val);
    if (ret != 0) return std::unexpected(ret);
    return val;
}

现在,你的整个 C++ 项目都拥有了类型安全的错误处理,而那个 C 库依然可以保留它那“古老而实用”的 int 错误码。


第六章:std::unexpected 的哲学

std::expected 的核心在于 std::unexpected<E>

当你想表示“我失败了”时,你通常需要提供一个具体的错误值。在 C++ 中,E 可以是 int,可以是 std::string,甚至可以是一个复杂的结构体,里面包含错误码、文件名、行号、堆栈跟踪等。

这是底层开发的福音。
想象一下,当底层链路发生错误时,你不仅仅想知道“连接失败了”,你还想知道“为什么失败?是网卡断了吗?是 CRC 校验错了吗?错误偏移量是多少?”

你可以定义一个超级详细的错误类型:

struct DetailedError {
    ErrorCode code;
    std::string context;
    uint32_t offset;
};

std::expected<Data, DetailedError> process_stream() {
    // ... 处理逻辑 ...
    if (fail) {
        return std::unexpected(DetailedError{
            .code = ErrorCode::DATA_CORRUPTION,
            .context = "Reading Sector 4092",
            .offset = 0x1A2B
        });
    }
    return data;
}

有了 std::expected,你不再需要把错误信息塞进全局变量或者 errno 里。每一个 std::expected 实例都携带了自己的错误信息。这极大地提高了代码的可调试性和可测试性。


第七章:与 std::variant 的爱恨情仇

很多同学会问:“既然 std::expectedT | E,那它和 std::variant<T, E> 有什么区别?”

这是一个非常好的问题。区别在于语义用法

  • std::variant<T, E>:它是一个容器。它说“这里面装着 T,或者装着 E”。它不知道哪个是成功,哪个是失败。它只是数据的混合体。你通常需要手动检查 std::holds_alternative<T>

  • std::expected<T, E>:它是一个承诺。它说“我承诺要么给你 T,要么给你 E”。它强制要求你处理这两种情况。它有专门的 has_value() 方法,有 value() 方法,有 error() 方法。

在底层开发中,我们处理的数据往往有明确的“有效/无效”语义。std::expected 比起 std::variant 更符合人类的直觉。它就像一个“期望”:“我期望这个函数返回数据,如果不行,给我看错误。”

而且,std::expected 提供了 value_or,这在底层开发中非常实用:

int fallback_value = 0;
int result = read_value().value_or(fallback_value);

这比 variant 方便太多了。


第八章:C++23 的完整图景

std::expected 的引入不仅仅是增加了一个类。它填补了 C++ 标准库的一个巨大空白。

在 C++23 之前,如果你想要 std::expected,你只能用第三方库,比如 tl::expected(这是 Ben Klemens 写的,非常优秀)。现在,它是标准的一部分了。

这意味着什么?

  1. 标准化:各大编译器厂商(MSVC, GCC, Clang)都会全力支持它。性能会越来越好,bug 会越来越少。
  2. 兼容性:你写的代码在未来会更容易迁移到其他编译器。
  3. 工具支持:IDE 的智能提示、调试器、静态分析工具都能更好地理解它。

第九章:实战中的“陷阱”与“避坑指南”

虽然 std::expected 很棒,但在底层开发中,我们依然要小心。

9.1 移动语义的迷雾

std::expected 通常支持移动语义。但在底层开发中,T 往往是 POD(纯旧数据)类型,甚至是 std::arraystd::string。这些类型的移动开销很小。但如果你在 std::expected 里放了一个巨大的、深拷贝的 std::vector,并且频繁地在函数间传递,你可能会遇到性能瓶颈。

建议:在底层开发中,尽量让 T 是轻量级的。如果 T 很大,考虑传递指针或引用,或者使用 std::expected<std::unique_ptr<Data>, E>

9.2 嵌套的复杂性

std::expected 的链式调用非常爽,但不要滥用。如果你的链子太长,会降低代码的可读性。
建议:把复杂的链式调用拆分成小的、有意义的辅助函数。

9.3 与异常的混合使用

虽然我们说底层开发不用异常,但在某些极少数情况下,你可能需要把 std::expected 转换为异常(比如在跨模块边界时)。
建议:尽量保持模块内部使用 std::expected,只在入口/出口处进行转换。不要在底层链路的核心循环里混合使用 try-catchstd::expected,这会让 CPU 的分支预测器崩溃。


第十章:结语

各位,std::expected 的出现,标志着 C++ 在“类型安全”与“零开销抽象”之间的平衡达到了一个新的高度。

它没有引入新的运行时开销,它没有破坏现有的 ABI,它只是给我们提供了一把更锋利的手术刀,让我们能够更清晰地解剖那些复杂的错误流。

在底层链路开发中,我们面对的是冰冷、坚硬、不可预测的硬件。我们不需要那些花里胡哨、不仅昂贵而且不可控的异常机制。我们需要的是确定性。我们需要的是类型安全

std::expected 就像是一个沉默的守护者。它静静地躺在你的栈上,当你成功时,它微笑着递给你数据;当你失败时,它冷静地递给你错误。它不抱怨,不咆哮,不跳转。

从今天开始,试着在你的下一个硬件驱动、网络协议栈或者内存管理器中使用 std::expected。你会发现,处理错误不再是噩梦,而变成了一种优雅的舞蹈。

不要让错误码统治你的代码库。拥抱 std::expected,拥抱代数数据类型,拥抱一个更干净、更安全的 C++ 世界。

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注