尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++23 std::expected:现代C++错误处理的新范式

C++23 std::expected:现代C++错误处理的新范式 先说一个我自己做项目时经常问自己的问题一个函数到底该怎么告诉调用方“我做失败了”早年写业务代码我条件反射就是丢异常try/catch 用得飞起后来做游戏服务器、做嵌入式边缘计算发现异常这东西在不少场景里根本不能用要么编译器关了-fno-exceptions要么线上出现诡异崩溃时你根本没法从堆栈里看出哪里漏了 catch。这种真实的拧巴感就是 C23 引入std::expected的原始动机。std::expected不是要“取代”异常它其实是把 C 语言那一套“返回错误码”的做法用现代 C 的类型系统重新武装了一遍让函数同时具备“返回正常值”和“返回错误详情”的能力并且所有错误都老老实实地写在函数签名里。提案编号是 P0323从 2016 年一直磨到 2022 年才进了 C23。很多项目现在已经在用boost::expected或各种早期实现标准库落地之后这东西很快会变成 C 工程里绕不开的话题。这篇文章我想认认真真把std::expected设计思路、与异常机制在性能和控制流上的差异、以及实际工程里怎么选型掰开揉碎讲一遍。1. 为什么非要在异常之外再找一种错误处理方案先别急着讨论 API先聊痛点。C 的异常机制从 C98 开始就是语言的一部分RAII 配合栈展开这个设计在理论上非常优雅函数内部创建的局部对象在异常传播过程中会被自动析构资源不会泄漏调用方只需要在合适的地方 catch 就行。教科书里管这个叫“异常安全”乍一看确实没毛病。但真到生产环境问题就出来了。第一是性能折损并不总是零。异常在“未抛出”的正常路径上确实惊人地高效因为编译器会修改二进制的异常处理表正常指令流里不加额外的判断分支这就是所谓 Zero-Cost Exceptions。可一旦真正抛出一个异常代价就完全不一样了需要构造异常对象、查表定位 handler、逐层展开栈并调用沿途所有局部对象的析构函数这个开销不是几个周期而是几十微秒甚至更多。如果是在网络请求的关键路径上或者实时性要求高的控制逻辑里这种不确定性的延迟抖动会很难受。第二是错误处理被边缘化了。异常最被诟病的一点调用一个函数你怎么知道它会不会抛异常函数签名里没有这个信息。你当然可以看文档、看注释但项目一大、团队一多没人能保证每个函数都把异常规范写在头文件里。结果就是要么层层 try/catch 把代码包成洋葱要么干脆不 catch让异常一路飘到最外层程序直接崩溃。这玩意的可维护性说实话还不如 C 语言里返回错误码来得直观。第三是全球化开发环境里“禁用异常”越来越普遍。游戏引擎、嵌入式系统、部分实时计算框架编译期干脆-fno-exceptions整个标准库都得换实现。在这些场景下你写了再漂亮的异常类、再严谨的异常安全代码全都没用唯一的出路就是返回错误码。回到工程常识一个函数的错误处理方式本质上分为两类。一类是“发不发生都不意外”的平凡错误比如文件不存在、网络超时、用户输入格式不对这类错误应该是函数返回值的自然组成部分另一类是“理论上不该出现”的致命错误比如内存耗尽、逻辑不变量被破坏这类错误更适合用异常快速失败。std::expected出来之后C 终于有了一个标准答案去处理第一类错误而不必再靠std::optional加一个error_code参数这种别扭的打法。2. std::expected 提案的设计骨架它到底是个什么东西std::expected的核心思想一句话就能说清楚一个对象要么持有你期望的正常值要么持有一个错误对象。它有点像std::variantT, E的简化版但语义上更明确——它不是“二选一的通用值”而是“成功了放 T失败了放 E”。头文件是expected基本声明长这样templateclass T, class E class expected;其中T是正常返回值E是错误类型。如果你只关心执行失败与否不关心具体错误细节可以写成expectedT, std::error_code或expectedT, std::string。而如果你确实“什么都不想返回”还有个特化版本expectedvoid, E相当于“无返回值但可能失败”的函数语义。构造方式也很直白。成功时直接返回T失败时用std::unexpected包一层。#include expected #include string #include charconv enum class parse_error { invalid_input, out_of_range }; std::expectedint, parse_error parse_int(std::string_view s) { int value 0; auto [ptr, ec] std::from_chars(s.data(), s.data() s.size(), value); if (ec std::errc::invalid_argument) { return std::unexpected(parse_error::invalid_input); } if (ec std::errc::result_out_of_range) { return std::unexpected(parse_error::out_of_range); } if (ptr ! s.data() s.size()) { return std::unexpected(parse_error::invalid_input); } return value; }调用侧的基本判断方式auto result parse_int(123); if (result) { // 成功分支*result 或 result.value() 拿值 std::cout *result \n; } else { // 失败分支result.error() 拿错误 std::cout parse failed: static_castint(result.error()) \n; }这里有几个容易忽略的细节第一operator bool或has_value()用于判断是否成功。但这只是一个“你是否愿意检查”的软约束编译器不会强制你。如果result处于错误状态而你直接调*result这是未定义行为调value()则会抛出std::bad_expected_accessE。所以习惯上你不用 value() 就得先判 has_value()这个心理压制比异常机制强得多。第二错误包装std::unexpected是 C23 的一个标签类型作用就是把普通值“标记”成错误状态。不能直接return error_value必须写明return std::unexpected(error_value)。好处是构造expected对象时错误和正常值永远不会因为类型相同而混淆。第三expectedT, E要求T和E都是完整类型并且E必须可以作为对象存储。如果E是void有专门的特化版本如果T是引用类型也有对应的偏特化版本不过大部分场景用普通值类型就够了。再说一个我实际使用时的体会把函数返回类型从int改成std::expectedint, parse_error之后调用方拿到的不仅是“对或错”而是三个明确信息这个函数会失败、失败时可能有哪些错误类型、成功时返回什么。这种自文档化的能力是异常机制给不了的。3. 逐项硬碰expected 与异常机制的性能与控制流差异这一节是很多同学最关心的部分到底用expected能省多少开销公平对比是什么3.1 正常路径上的开销对比先说结论异常在正常路径上几乎零开销expected 则因为返回值的拷贝/移动以及分支判断总是有固定成本。但这个固定成本非常小通常可以被内联优化掉。看一个最简单的函数// 异常版本 int divide_exc(int a, int b) { if (b 0) { throw std::runtime_error(divide by zero); } return a / b; } // expected版本 std::expectedint, std::errc divide_exp(int a, int b) { if (b 0) { return std::unexpected(std::errc::invalid_argument); } return a / b; }编译器开启 O2 之后divide_exp的返回值大概率会被优化成一个寄存器里的整数加一个“是否有效”的标志位两三个指令就能完成。divide_exc在正常路径上指令数更少——因为编译器只需在 b0 时跳到错误分支正常分支就是一个除法。但代价是整个函数的指令缓存被异常处理的元数据占用二进制体积会变大。有人可能会说“那 expected 不是更亏吗正常路径多了一条判断”。其实在绝大多数真实业务里expected的判断分支恰恰是需要写的——你本来就要判断结果是否有效异常版本里那个判断只是被编译器藏在了 catch table 里。从 CPU 分支预测的角度看二者差异几乎可以忽略。3.2 错误路径上的开销对比这才是真正的分水岭。异常版本在抛出时要经过构造异常对象 - 分配某些实现还要在线程局部做一次堆内存分配 - 调用__cxa_throw进入 unwind 流程 - 逐层找 handler - 逐层调用析构函数。整个过程不仅要访问大量元数据还经常导致指令缓存和 TLB 缓存刷新。我见过一个真实案例某个后台服务开启异常后在出现大量异常的网络场景下吞吐量掉了 40%。而用expected之后错误路径就是一个普通的分支返回开销和正常返回几乎一致完全可预测。对实时系统、高频交易、游戏逻辑这种“不允许延迟毛刺”的场景这个优势是决定性的。对比维度异常机制std::expected正常路径开销几乎为零但有代码体积成本固定的小开销可内联错误路径开销重栈展开动态分配轻等同于普通返回是否需要动态内存异常对象可能分配堆不需要错误信息在签名中不可见可见用户是否被强制处理否可能漏 catch显式判断代码作者必须面对RTTI/异常表需求需要不需要RAII 栈展开自动执行是否需要手动控制生命周期3.3 控制流上的差异显式与隐式异常是隐式控制流。从函数 A 调用函数 BB 内部抛出来的异常会穿越 A甚至穿越 A 的调用方直到有人 catch。好处是中间层不需要写任何错误传递代码坏处是——你也看不到错误在哪一层被吞了。调试的时候最麻烦的不是“函数返回了一个错误码”而是“异常被某一个泛型 catch 吞掉日志里啥都没有”。expected是显式控制流。每一次函数返回调用方都必须像拆盲盒一样打开expected看一眼。没打开那是你的逻辑 bug按未定义行为处理。虽然这种显式处理让代码写起来多几行但在架构层面错误路径变得和正常路径一样可追踪单步调试、日志、静态分析工具都更容易。举一个真实业务例子。解析配置文件的逻辑// 异常版本如果某个中间层忘记catch错误会一路冒泡 void load_config(const std::string path) { nlohmann::json j parse_json_file(path); // 可能抛 parser_error auto timeout j.at(timeout).getint(); // 可能抛 out_of_range auto host j.at(host).getstd::string(); // ... }这段代码看起来干净但要回答一个问题timeout 字段缺失时到底是谁的职责去处理如果全项目都没有 catch程序直接崩如果外层有个catch (const std::exception)错误信息就是一句笼统的 “json exception”根本定位不到是哪个字段。用expected重写之后错误的边界就清楚了std::expectedConfig, config_error load_config(const std::string path) { auto j parse_json_file(path); // expectednlohmann::json, parser_err if (!j) return std::unexpected(config_error{config_stage::parse, j.error()}); auto timeout j-at(timeout); // 或者继续返回expected if (!timeout) return std::unexpected(config_error{config_stage::missing_field, timeout}); // ... }你会主动思考“这个错误应该让谁看见”而不是把一堆异常类扔出去就完事。4. 泛型组合与工程落地expected 在真实项目中的定位std::expected不只是“能返回错误”的 optional它自带一套泛型组合工具用起来有点像函数式编程里的Result。4.1 transform、and_then、or_else 怎么用三个最常用的成员函数transform(Func)当expected持有正常值时用Func变换值如果已经是错误状态直接透传错误。and_then(Func)和transform类似但Func必须返回一个新的expected适合串联多个可能失败的步骤。or_else(Func)当expected处于错误状态时调用Func处理错误否则原样返回。看一个实际例子读取配置并解析端口号std::expectedstd::string, std::error_code read_env(const char* name); std::expectedint, parse_error parse_port(const std::string s); std::expectedint, std::error_code check_range(int port); std::expectedint, std::error_code get_port_from_env() { return read_env(PORT) .and_then([](std::string s) { auto r parse_port(s); if (!r) return std::expectedint, std::error_code( std::unexpected(std::make_error_code(std::errc::invalid_argument))); return std::expectedint, std::error_code(*r); }) .and_then([](int port) { return check_range(port); }); }这种链式写法最大的好处是每一步的失败都自动短路后面的逻辑根本不会执行。你不用写一长串 if-else 嵌套也不会漏掉某个中间错误。坏处也很明显——错误类型之间的转换很啰嗦如果每一步错误类型不一样你需要手动包装。所以在实践里我倾向于在模块边界使用统一的错误类型比如项目里定义一个AppError所有函数都返回expectedT, AppError组合起来才顺畅。4.2 在禁用异常的项目里怎么用如果你的项目编译参数是-fno-exceptionsstd::expected几乎成了主流方案。不过有个重要的坑std::expected的value()在错误状态下会抛异常。在禁用异常的环境里这个行为由标准库实现接管但大概率会触发std::terminate。所以我在无异常项目里有一条纪律永远不要调value()只使用*需要先检查或value_or()。宁可多写两行显式判断也不要给自己留一个隐藏崩溃点。另一个实践技巧是配合std::error_code使用。C 的system_error里已经有一大堆标准错误码可以直接作为E类型这样expected返回的错误可以很好地和现有 C API、系统调用错误码衔接。你自己定义的错误枚举也可以包装成std::error_code实现make_error_code和std::is_error_code_enum特化即可。这样项目里所有错误类型统一调用方拿到的都是同一个类型体系。4.3 基于expected设计类库 API给类库设计 API 时expected的定位要比异常更讲究。我建议的粗粒度原则普通函数、算法、I/O 操作用expectedT, E返回可预见的业务错误。构造函数没办法返回expected失败只能抛异常或采用“构造后检查有效位”的惯用法。析构函数永远不要抛异常这个几乎没有争议。运算符重载比如operator/如果你觉得除零是一个可预期的错误返回expected会让表达式变得很奇怪所以更倾向于抛异常或保持 UB如果运算符本身就是一个会失败的复杂操作考虑提供命名函数而非重载。个人经验库作者如果一开始就定好“这个 API 的错误是正常流程的一部分还是编程失误”接口设计会清晰很多。前者放expected后者放异常。5. 异常安全的死角RAII 与 expected 的相互作用异常机制一个巨大的优势是 RAII 的自动栈展开对象析构函数在异常传播过程中一定会执行锁会被释放文件会被关闭内存会被回收。这是 C 资源管理的地基。换到expected之后这一切都没有了。错误只是一个返回值没有人帮你“展开栈”。如果某个中间对象持有锁、打开着文件在错误路径上你必须手动释放。举个例子模拟一个数据库事务std::expectedvoid, db_error update_balance(Database db, int account_id, int delta) { auto tx db.begin_transaction(); // 持有事务锁 auto record db.find(account_id); // 可能失败 if (!record) { db.rollback(tx); // 必须手动清理 return std::unexpected(db_error::not_found); } auto ok db.update(account_id, record-balance delta); if (!ok) { db.rollback(tx); return std::unexpected(ok.error()); } db.commit(tx); return {}; }每个错误分支都要手动回滚代码量确实上去了。那么有没有办法恢复 RAII 的便利有但需要自己封装一个作用域守卫templateclass F struct scope_exit { F f; ~scope_exit() { f(); } };在进入函数时声明scope_exit rollback_guard{[] { db.rollback(tx); }};然后在成功返回之前调用release()取消回滚。这种思路本质上就是用 RAII 守卫来弥补expected在栈展开上的缺失。我在工程里就是这么做的既能享受expected的显式控制流又不至于在每个错误分支都复制一遍清理代码。这里也引出一个深层思考expected适合的是“错误发生后我已经知道怎么处理”的场景而异常适合“错误发生后我需要中间层对象自动做资源清理”的场景。两者的基本假设不同不存在谁全面优于谁。6. 混合策略与现状C23 之后怎么选型最后聊一聊具体项目里怎么落地。我的建议从来不是“全面用 expected 取代异常”而是按层级和错误预期做分区。6.1 错误分类先行拿到一个函数先问三个问题这个函数在正常业务逻辑里“会失败”吗如果会比如网络请求、文件读写、用户输入优先expected。失败之后需要清理局部资源吗如果需要配合 scope guard 使用或者考虑异常机制依靠 RAII 自动清理。调用方必须在当前上下文处理吗如果必须expected能强制他写处理代码如果允许一路冒泡到顶层异常更省事。用这三个问题过一遍大部分项目的 90% 业务逻辑都可以直接切到expected而把异常留作“程序员错误”或“不可恢复的系统级错误”。6.2 编译器支持情况截至 2024 年主流编译器的支持状态编译器最低版本备注GCCGCC 12支持expectedC23 模式ClangClang 16支持expectedC23 模式MSVCVS 2022 17.5基本支持如果你还在写 C17可以使用boost::expected或者抄一个简化版。不过既然 C23 已经落地新项目我建议直接按 C23 编译早点摸熟expected的生态。6.3 一套可落地的错误处理四层模型我最近在团队里推广的是这样一套分层策略第一层普通业务函数内部。能用expected返回错误码/错误对象就尽量用不在函数内部捕获“可以预知但不想处理”的错误。第二层模块/组件边界。提供一个统一错误类型内部细节错误转换成这个统一错误用expectedT, ModuleError对外暴露。第三层进程入口/线程入口。这是唯一允许异常冒泡到的地方用一个catch (...)兜底记录日志。–第四层绝对不能失败的地方。比如析构函数、内存分配失败处理按特定策略处理不用expected也不用异常。这套模型既保留了expected的显式性与性能也没有放弃异常在极端场景下的兜底能力。最理想的状态是你写代码的时候百分之九十的错误都从函数返回值里显式处理而不是靠猜测“这个函数会不会抛”。7. 踩坑记录和一个小建议最后分享几个我实际用过std::expected之后踩过的坑。第一个坑是错误类型过大导致的体积膨胀。expectedT, E的大小约等于T和E的联合体加上必要的标志位。如果你错误类型写了一个巨大的结构体哪怕正常值只有一个 int整个expected也会变得很大进而导致缓存行消耗和拷贝开销。解决办法错误类型尽量用std::error_code、std::string或枚举这种轻量对象不要把整个上下文都塞进E。第二个坑是误用operator*导致未定义行为。在一个大系统里团队里不是每个人时时刻记得“要先判断再解引用”。我的建议是库接口里宁可把value()留着也不要太鼓励*。虽然value()在错误时会抛异常但至少它是一个受控的失败行为不会静默出错。第三个坑是链式调用中的错误类型不统一。前面说了and_then很爽但每步的错误类型一不一样包装代码就很烦。所以项目最好一开始就定义好统一的错误类型建立make_error_code的工厂函数体系别让每个人各搞一套枚举。说实话std::expected并不会让你的代码一夜之间变得完美。它只是把一个本来就存在的选择摆到了台面你真的打算把错误当作一等公民来设计了吗如果你愿意函数签名会成为你的文档错误路径会变成显式的分支如果你不愿意它无非就是一个复杂版的 optional。对我来说用了一段时间expected之后回头看异常代码反而有种“这函数到底能不能抛”的不确定感这大概就是回不去的特征吧。
返回列表