news 2026/10/20 5:18:09

C++23 std::expected:用返回值优雅替代异常和错误码的错误处理新范式

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++23 std::expected:用返回值优雅替代异常和错误码的错误处理新范式

先说结论:如果你还没认真看过std::expected<T, E>,那你在 C++ 错误处理这条路上一定还欠着一笔债。这个从提案走到 C++23 标准库的类型,解决的痛点非常明确——异常机制让你爽,但让你爽的代价是控制流变得隐蔽、性能开销不可控、代码审查和接口设计都会互相拉扯;而传统错误码方案又太原始,写起来又啰嗦又容易漏。expected站在两者之间,用“返回值”的形式承载“异常”的语义,让你把错误当成普通值传递,却又不丢失类型信息。这篇文章我不打算给你抄网上的 API 手册,我想从“为什么需要它”“它到底解决了什么”这两个角度切入,结合我实际从异常和错误码迁移过来的经验,帮你判断它到底适不适合你的项目,以及真正落地时有哪些坑。

适合读者:正在用异常但被性能或心智负担折磨的人,还在用-1和errno硬扛错误码的老派工程师,还有那些写库的人——接口设计得让调用方舒服,这比什么都重要。

1. 先搞清楚一个前提:异常机制的问题到底出在哪

C++ 的异常机制在语言层面几乎是完美的“报告错误”工具:它可以穿透任意层级,不需要每层都手动处理;它携带类型信息;栈展开时还能正确析构局部对象。看起来没什么可挑剔的。但实际用起来,问题集中出现在四个地方。

第一是控制流不可见。你在代码里写一句SaveDocument(),它可能在中途任意一个深层函数里抛异常,也可能正常返回。作为调用方的你,如果不点进去看实现,根本不知道它会丢出什么。这不是态度问题,是接口契约的缺失。函数声明里只写了参数和返回值,异常说明早在 C++17 就被废掉了。结果就是:代码审查的时候,你要么信任每个人都在正确的地方 catch,要么就在脑内模拟整条调用链——这在一万多行的模块里根本不可能。

第二是性能代价不是零。现在的 Itanium ABI 和 MSVC 的异常实现在“无异常抛出”的快乐路径上确实是零开销,这一点先承认。但只要真的抛异常了,代价就不是一般的大。栈展开要做 unwind 表查找,要逐层调用析构,这玩意儿在实时系统、游戏引擎的主循环里基本是碰不得的。很多嵌入式项目直接-fno-exceptions编译,因为异常会让二进制体积膨胀 10%~15%,而且在资源受限的环境里,栈展开的临时内存分配会让你猝不及防。

第三是异常安全和 RAII 纠缠在一起。这听起来像是件好事,但实际写起来,你会发现为了做到“强异常安全保证”,你会不自觉地加很多shared_ptr、拷贝、临时对象。更别提移动构造函数如果抛异常,容器那一堆 reallocation 逻辑直接变成恐怖片现场。我用过一版自定义容器,因为移动构造里有一个assert之后系统调用失败,导致std::vector扩容时直接 terminate,查了一个晚上。

第四是异常不适合作为“分支逻辑”。如果一个函数返回两种常见的失败原因(比如文件不存在、权限不足),你用异常表达,调用方就得写两个 catch 块。catch 本身是个重语法、偏见心的东西,导致很多人干脆 catch(...) 一把梭,然后你就彻底失去了错误类型信息。错误被吞掉的行为,比返回错误码被无视更可怕。

所以异常机制本身没有错,错在它被当成默认的错误处理方案。它应该只用于“如果你不管,程序就活不下去”的致命错误。

2. 从错误码到 expected:一场蓄谋已久的自我救赎

如果你常年写 C 风格代码或者在 Win32/Unix 接口里摸爬滚打,你一定会对以下场景有生理性厌恶:

char* buf = nullptr; int err = GetBuffer(&buf, size); if (err != 0) { LogError(err); goto cleanup; }

一个函数,输出参数、返回值、全局状态各负责一部分信息,调用方要自己维护错误状态。这还能忍。最让人崩溃的是,错误码通常是裸int或者enum,它没有携带被调方内部的上下文。文件打开失败,错误码是2,但为什么失败?是路径不存在、被占用、还是权限?全靠调用方自己猜。

我在一个旧的数据同步项目里就干过这种事。底层返回int错误码,中层把错误码写进一个全局last_error,上层根据last_error映射出用户可读的文案。结果是:只要两个线程同时调用底层函数,全局错误码就被互相覆盖,线上隔三差五冒出一个连接被重置,查日志的时候错误码和上下文对不上。那次事故之后,我就发狠要找替代方案。

std::expected<T, E>的核心思想很简单:一个函数既返回正常值,也返回错误值,二者只居其一。它把原先“返回值 + 错误码 + 日志 + 全局变量”的散装组合,压缩成一个类型安全的联合体。

从类型设计的角度,它等价于一个“受限制的 variant”:要么包含一个T(正常值),要么包含一个E(错误对象)。但它和std::variant最大的区别是提供了“预期”的语义——它默认认为正常值是主路径,错误值是例外。你在读代码的时候,safe_div(a, b)的返回类型expected<double, error_code>直接告诉你:这不是一个总是成功的函数,你要检查它。同理,如果看到返回double,你反推就应该知道,这个函数等价于“要么不失败,要么抛异常”,失败不在返回值里表达。

这种表述比裸错误码强在三点:

  1. 类型强制:编译期就知道可能失败,不检查就取.value(),那是你自找的。
  2. 错误是对象:可以是enum、error_code、字符串,甚至一个带调用栈的结构体,想丰富就能丰富。
  3. 组合方便:和std::optional一样,支持链式调用,处理分支不再嵌套十层 if。

我当年第一次用第三方提供的tl::expected时,第一反应是“这玩意不就是Result<T, E>吗?”。对,Rust 生态就是这么叫的。C++ 落后了吗?也没完全落后,至少我们现在把它纳入了标准库,编译器一升级就能用。

3. 深度拆解:std::expected<T,E> 提案的核心设计

该特性来源于标准提案库,最终成型于 C++23。它在标准库里长这样:

template<class T, class E> class expected;

两个模板参数,T 是成功时包含的值的类型,E 是失败时包含的错误类型。这在语义上就把“成功”和“失败”显式地编码进类型系统了。

3.1 基本操作:构造、访问与判断

先看最常见的使用方式:

#include <expected> #include <string> #include <iostream> enum class ParseError { InvalidChar, OutOfRange, EmptyInput }; std::expected<int, ParseError> ParseNumber(std::string_view s) { if (s.empty()) { return std::unexpected(ParseError::EmptyInput); } // 假设这里真的做了解析… int value = 0; for (char c : s) { if (!std::isdigit(static_cast<unsigned char>(c))) { return std::unexpected(ParseError::InvalidChar); } value = value * 10 + (c - '0'); // 简单演示,不考虑溢出 } return value; }

这里有三个值得注意的细节:

如何表示失败?用std::unexpected(E)包装后返回。std::unexpected是一个指示器类型,用来在构造函数重载决议中区分“存一个 T”还是“存一个 E”。你不需要手动构造std::expected<T, E>,直接返回T或者std::unexpected(E)都可以隐式转换,这是提案里特意设计的。

如何访问值?expected具有bool类型转换能力,可以判断是否包含有效值。value()返回 T&,如果当前处于错误状态,行为是 UB;error()返回 E&,只有在错误状态时调用才有意义。注意这个不对称——value()和error()都不是安全的。你最好先判断再访问,或者用下面说的 monadic 操作。

和 optional 的区别?std::optional<T>只有“值/无值”两种状态,没有错误信息。expected<T,E>的 E 正是为了补救这种“无值但是不知道为什么”的尴尬而生的。你可以认为optional<T>是expected<T, std::monostate>的退化的特例,但标准库没有这么做。

看下面的实操安全模式:

auto result = ParseNumber(text); if (!result) { // 错误处理 std::cerr << "parse failed, code = " << static_cast<int>(result.error()) << "\n"; return; } int n = *result; // 等价于 result.value(),用 operator* 是当前状态合法的 std::cout << "parsed: " << n << "\n";

偏好用if (!result)而不是if (result.has_value() == false),因为这是唯一一种让你“错误优先”的写法,把主干逻辑往右推,对不对?不过这些都只是风格问题。

3.2 monadic 操作:真正的杀手锏

如果你只是把expected当成“带错误信息的 optional”,那它只比传统错误码进步 30%。真正拉开差距的是这组 monadic 接口:

  • and_then(F): 如果当前有值,把 F 应用到值上并返回 F 的结果(也必须是 expected);否则原样返回错误。
  • transform(F): 如果当前有值,把 F 应用到值上并返回“普通值包装成 expected”;否则原样返回错误。
  • or_else(F): 如果当前处于错误状态,调用 F,否则返回自身。
  • transform_error(F): 对错误对象做映射,常用于把底层错误翻译成高层错误。

这组操作连起来用,代码会非常舒服。我拿一个配置文件解析的伪代码举例子:

std::expected<Config, AppError> LoadConfig(std::string_view path); std::expected<Server, AppError> InitServerFromConfig(std::string_view path) { return LoadConfig(path) .and_then([](const Config& cfg) { return CreateServer(cfg); }) // 失败就中断 .transform([&](Server s) { s.SetRetryCount(3); return s; }); }

注意and_then和transform的区别:前者要求传入的可调用对象也返回expected,适合“管道式的下一步操作”;后者只要求返回普通值,适合“对成功值做加工”。如果弄混了,编译错误会教你做人。GCC 12 以后的报错信息还算友好,如果你还在用老编译器,遇到模板爆出来的几百行错误也别慌,直接告诉你:闭眼换and_then或transform试试,基本都能解决。

这种链式写法的价值,不是语法糖那种表面的甜腻,而是它强制你考虑分支情况。and_then的回调函数只处理“有值”的情况,“无值”会自动短路。你不需要在每个步骤后面写if (!res) return res;这种样板代码,折磨人的“错误传播”被结构性地消灭了。

3.3 原位构造与性能

std::expected设计时非常看重性能。它内部是带对齐的联合体存储,既不会像std::variant那样需要额外的判别式存储空间(其实 variant 也做得很紧凑,这里是指概念上),也不会把 T 和 E 同时构造出来浪费资源。

关键操作是emplace:

std::expected<std::string, int> e; e.emplace("hello"); // 构造一个 string,不动 int 的部分

如果你有一个对象既昂贵又不可默认构造,放进expected完全没问题,因为expected不会要求 T 默认构造。但是注意,它要求 T 和 E 满足可析构等基本条件。这个细节重不重要?重要。我遇到过一个因为 T 不可拷贝导致expected无法编译的案例(具体说,代码在移动后仍然被复制),标准库实现要求T和E都是可移动构造的,如果你的类型删掉了移动构造,那expected也没法用。

注意:std::expected并不是免费的。它比裸返回值的体积通常大一些,因为需要额外的bool判别标志以及对齐空间。在极端内存受限的场景,如果你每个对象都塞一个std::string作为 E,内存膨胀是实打实的。但这也是设计取舍——你要的是安全性和可组合性,row 数字节的代价,值。

4. 异同对比:异常、错误码、expected 三足鼎立

纸上谈兵没意思,直接把三种方案放在一个具体场景里对比:解析一个配置文件,函数签名分别是ParseConfig,中间可能因为文件不存在、格式错误、值越界失败,失败时要记录上下文。我们看看三种方案的接口和调用长什么样。

维度异常机制错误码机制std::expected
接口表达Config ParseConfig(path)int ParseConfig(path, Config& out)std::expected<Config, ParseError> ParseConfig(path)
成功与否可见性不可见,要看实现必须检查返回值类型直接可见
失败信息丰富度可携带任意类型,栈回溯极简,通常只有枚举一个对象,任你发挥
调用方忽略错误的代价无,异常会一路冒泡静默错误取.value()可能 UB
错误传播成本零快乐路径 / 抛出时昂贵每次逐层手动返回移动一个值,低成本
嵌套/管线处理try-catch 嵌套多级 if-else 地狱and_then 链式,清爽
适合场景致命错误、上层统一处理系统调用薄封装日常业务逻辑、库接口

是不是看出来了?expected跟异常并不是零和竞争,它其实正在抢的是错误码的地盘。至少在我的项目中,它首先淘汰掉的是那些“返回值+输出参数+错误码”的三件套,而不是异常。

很多人问“那我是不是应该把所有异常都改成 expected?”我建议是:先别。异常在你无法就地处理、必须跨越很多层、且上下文无关的时候(比如线程池里的任务执行器统一 catch),是最高效的方案。expected最好的应用场景是那种“错误需要被当前调用方理解,并且可以基于错误做分支决策”的场合。

举个反面教材:如果你在一个跨模块的公共边界上把所有这些错误都变成 expected 类型,中间每一层都得搬运、转换错误类型,你会陷入一个比异常更啰嗦的泥潭。所以我的经验法则是:

  1. 模块内部:优先用expected,让失败分支在类型层面可见,调用链清晰。
  2. 模块边界:保留异常,或者定义统一的错误对象并配合一个极端简单的异常类型抛出。
  3. 底层系统调用封装:永远用expected或错误码,不要用异常包住errno,因为系统调用的失败是正常业务逻辑,不是意外事故。

说到底,错误处理方案的拉锯战,本质是“显式与隐式”的辩证。你要显式,就拥抱 expected/错误码,你愿意隐式,就选异常。C++23 给出 expected,不是告诉你“异常死了”,而是告诉你“第三个选项已经就位”。

5. 从工作代码看迁移:一次不算成功的重构,和我学到的教训

我之前在一个网络库的内部状态机里做了一次从“异常+错误码混合”到expected的迁移。模块大概 3000 行,负责握手、超时、重试的状态流转。旧的代码长这样:

bool TryConnect(Connection& conn) { auto* req = BuildRequest(); // 返回 nullptr 表示失败 if (req == nullptr) { Log("build request failed"); return false; } int rc = SendRequest(conn, req, 5000); if (rc < 0) { if (rc == TIMEOUT) { // 走重试逻辑 } Log("send failed, rc =", rc); return false; } if (!WaitForAck(conn)) { // 内部可以抛异常 // 异常在更上层被 catch 掉,但这一层根本不知道发生了什么 return false; } return true; }

发现问题了吗?这个函数永远不会告诉你失败的具体原因。它返回 bool,内部把错误分成日志输出和异常扔走。上层拿到 false 只能猜。我重构后的版本长这样:

std::expected<ConnectionState, ConnectError> TryConnect(Connection& conn) { auto req = BuildRequest(); if (!req) { return std::unexpected(ConnectError::BuildRequestFailed); } if (auto sendRes = SendRequest(conn, *req, 5000); !sendRes) { if (sendRes.error() == ErrorCode::Timeout) { return TransitionToRetry(); // 这是有意义的 state } return std::unexpected(ConnectError{ErrorType::SendFailed, sendRes.error(), GetTimestamp()}); } auto ackRes = WaitForAck(conn); if (!ackRes) { return std::unexpected(ackRes.error()); // 直接向上透传 } return ConnectionState{Ready, ackRes->GetPeerInfo()}; }

重构之后,可读性确实显著提升,因为每个失败分支都是有值、有类型的对象,不是裸false。但是踩坑也来了。

坑一:错误类型一复杂,链式调用就没那么丝滑了。一开始我用ConnectError承载所有失败原因,包括“连接被拒”“DNS 解析失败”“超时”“握手返回非法协议”。后来发现 retry 逻辑需要区分“可重试错误”和“不可重试错误”,于是要写一堆分支去检查ConnectError的 subcode。最后我在错误类型里加了一个bool is_retryable字段才解决。教训是:错误类型应该按场景设计,而不是做成一个大杂烩结构体。底层错误码、业务错误、用户可见错误应该分层表达。

坑二:性能真的不是完全没有代价。我在传输热点路径上放了些expected<std::vector<byte>, Error>,结果内存分配比原来多了一倍。不是 expected 本身的额外消耗,而是我每次失败时构造的 Error 里有个std::string消息字段,这玩意儿一拷贝就是一次堆分配。后来把 Error 里的字符串换成了std::string_view指向静态字符串,或者干脆用一个错误枚举+一个格式化函数,内存就下来了。细节魔鬼,真不是说说的。

坑三:std::expected<void, E>的语义有点别扭。标准库提供的是expected<void, E>特化(之前讨论过expected<T,E>,但在 void 时它保持类似 expected 的接口,只是不存储 T)。代码里写return {}表示成功,写return std::unexpected(e)表示失败。判断用if (res)。这个体验能接受,但和bool相比并没有质变,不如Status类直观。如果你大量使用无返回值操作,我建议用枚举错误码直接写在expected<void, ErrorCode>里,别把期望值设得太高。

我最终的结论是:规模在 1000 行以上的新代码,我默认优先上 expected;老代码不会大规模重写,只会随着重构逐步替换错误码路径,异常机制则留给真正的非预期错误。

6. 常见问题与排查技巧实录

问题 1:expected的operator*和value()在错误状态下有什么区别?为什么标准不统一?

答:operator*不检查状态,前提是“假设你有值”,类似 optional 的operator*;value()在错误状态下会抛出std::bad_expected_access<E>异常(C++23 是这样设计的)。两者的语义边界是:operator*是为“我已经判断过状态”且不想再付一次检查成本准备的;value()是为“我赌它一定有值”但愿意在极端情况下付出异常代价准备的。我强烈建议生产代码只用value()或在if判断后用operator*,别学那些裸调value()的演示代码,那是 UB 陷阱。

问题 2:为什么我的expected<T, E>编译报错说 E 不能是void?

答:E 不能为void,但 T 可以为 void。如果你确实没有错误信息可传,用std::monostate占位。如果你想要一个不携带任何数据的失败表示,std::unexpected(std::monostate{})就能绕过去。

问题 3:and_then里的回调如果返回void能编过吗?

答:不能。and_then要求回调返回expected<U, E>(U 可以跟原 T 不一样),如果你只是想对当前值做副作用,用链式但忽略返回值的方式。你可以写transform然后void一下,更直接的办法是:

auto lambda = [](T val) -> void { side_effect(val); }; if (res) lambda(*res);

别硬凹 monadic 风格,这里不是每个问题都是钉子。

问题 4:E 能不能放std::error_code或std::exception_ptr?

答:都可以。前者适合和系统错误配合,后者适合“把异常转成语义值”。我个人偏好 E 用强枚举或小型 POD。它保持预期值小的体积、容易深拷贝、方便打印和嵌入日志。如果你想跨模块传递异常堆栈,那就别折腾expected了,直接在边界 catch,再存exception_ptr,因为要让 E 包含完整的 throwable 上下文会带来体积和拷贝的双重灾难。

问题 5:到底要不要给expected<T, E>加noexcept?

答:要加,而且必须加在那些“不抛出任何异常”的路径上。expected本身旨在避免异常,如果你的接口签名写的是expected<T,E> f()但内部居然可能 throw,调用方必须 catch 住才能充分利用 expected,否则就是混合语义,比纯异常还难维护。写库的人尤其注意:expected应该和noexcept手拉手出现。

7. 一点个人建议:选型不要看热度,要看错误生命周期

老实说,我也想过“既然 C++23 都支持 expected 了,是不是应该全面转过去”。后来在几个不同项目里比较后,我对选型有了更冷静的判断。

异常依然适合的场合:线程边界、task 调度层、事件循环、插件系统里的初始化失败——这些地方你希望把所有不可恢复的错误统一上抛,集中记录,并有统一的兜底策略。异常的这种特性,让“错误生命周期”非常长,跨层传播是它最大的价值。

错误码/expected 适合的场合:业务逻辑接口、解析器、校验器、状态机、RPC 处理函数。这些函数通常错误发生在“用户输入不合法”或“资源暂时不可用”,调用方有能力并且有义务对具体错误做差异化处理,错误的生命周期短,不应该飘到顶层去。

很多团队在用expected但 E 类型设计得很糟糕,我见过直接用std::string的,也见过用同一个枚举但语义模糊的,这些都会让你在后续维护中付出比异常更大的代价。E 类型最好是一个“值域清晰、可以被格式化成日志、可以映射到用户可见错误码“的类型。

我在最近一个 JSON 解析器项目里,直接把 E 定义成:

struct ParseIssue { int line; int column; enum class Kind { UnexpectedToken, UnterminatedString, InvalidNumber } kind; std::string_view message; // 指向源文本,不拥有数据 };

这个结构体 POD、可复制、体积小、容易断言,而且把定位信息放进了类型。测试和调试的幸福感直接上升一个台阶。

最后说一个小技巧:如果你在写一个返回expected<T, E>的函数,建议把return std::unexpected的写法包装成宏或者工厂函数,比如return Fail(ErrorKind::X),因为手写std::unexpected<ErrorType>(...)这个类型名在回报错的时候会让代码长长长。尤其是 E 类型嵌套在其他命名空间里时。不过,宏要克制,否则下一个接手代码的人会在代码审查里骂你。使用 inline 辅助函数而不是宏,是更好的选择,如果你担心类型推导,auto Fail(Error e) { return std::expected<T, Error>{std::unexpected(e)}; }这种模板化辅助就挺好用。

选择错误处理方案,与其追逐标准库新特性,不如先数一数你的错误需要跨越几层、被谁消费、携带什么上下文。std::expected是一个好工具,但真正的工程价值,永远在你如何设计那个 E 类型里。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 5:17:59

转行面试PPT怎么做?用AI高效制作面试PPT的完整实操指南

1. 转行面试为什么要死磕一份PPT转行这件事&#xff0c;最难的从来不是学新技能&#xff0c;而是怎么把过去那些看起来"不相关"的经历&#xff0c;翻译成目标岗位能听懂的语言。我见过太多人简历写得还行&#xff0c;一到面试就卡壳&#xff0c;尤其是被要求"做…

作者头像 李华
网站建设 2026/10/20 5:16:59

GPT Image 2.5 vs Midjourney:图像生成工具选型指南与场景对比

1. 选型这件事&#xff0c;先别急着比参数每次有人问我“GPT Image 2.5 和 Midjourney 到底选哪个”&#xff0c;我都不太愿意直接甩结论。因为这个问题本身就问得有点偏——它默认了两个工具是同一类东西&#xff0c;默认了“好”有一个统一标准。但实际用下来你会发现&#x…

作者头像 李华
网站建设 2026/10/20 5:15:31

基于YOLOv11的水下鱼类检测系统实战:从数据集到界面部署

水下鱼类检测这几年在养殖管理、海洋生态调查、渔业资源评估里用得越来越广。去年底YOLOv11发布后&#xff0c;很多之前基于v5、v8做的水下目标检测项目都开始往新版本迁移。我自己也把一个长期维护的水下鱼类检测系统整体重构了一遍&#xff1a;模型换成YOLOv11&#xff0c;数…

作者头像 李华
网站建设 2026/10/20 5:14:22

AA-Scan:可编程扫描仪行为模拟与缺陷图像生成工具

简介&#xff1a;AA-Scan是一款基于Arduino与Android平台的开源全自动3D扫描仪软硬件方案&#xff0c;面向嵌入式开发初学者、创客及三维建模爱好者&#xff0c;解决低成本、易组装、可复现的简易3D扫描系统搭建问题。资源包共5个文件&#xff0c;含2个核心Python脚本&#xff…

作者头像 李华