FEATURED · 精选文章

C++23 std::expected vs 异常处理:性能、可读性与实战选型指南

发布时间 / 2026/8/5 2:03:04
来源 / 创域科博编辑部
栏目 / 资讯中心
C++23 std::expected vs 异常处理:性能、可读性与实战选型指南 1. 项目概述为什么我们需要重新审视错误处理在C的世界里错误处理一直是个让人又爱又恨的话题。从C语言时代传下来的错误码到C引入的异常机制再到如今C23带来的std::expected我们似乎总是在寻找那个“完美”的方案。作为一名写了十几年C的老兵我经历过无数个深夜在调试一个深藏在调用栈底层的异常崩溃或者是在一堆if (ret ! 0)的海洋里迷失方向。最近在做一个对延迟极其敏感的移动端音视频处理模块时性能优化成了头等大事我们团队内部就“到底用异常还是用错误码”吵翻了天。恰好C23的std::expected进入了我们的视野它号称能兼顾性能和可读性这听起来简直像“既要又要”的童话。但事实真的如此吗我决定放下争论亲手用代码和基准测试来寻找答案。这篇文章就是这次探索的记录。我会带你深入std::expected和异常处理的内部不光是看语法更要看它们在真实场景下的性能开销、对代码结构的影响以及最终如何影响我们写出既快又清晰的代码。无论你是正在为服务器性能优化头疼还是在Unity里为每一帧的渲染时间较劲亦或是处理海量数据时被I/O性能下降困扰理解不同错误处理范式背后的代价都是写出高质量、高性能C代码的必修课。2. 核心概念与设计哲学对比2.1 异常处理基于控制流的“非本地跳转”异常处理Exception Handling对于C开发者来说再熟悉不过了。它的核心思想是“分离正常逻辑与错误处理”。当函数内部发生无法处理的错误时它并不直接返回一个错误状态而是“抛出”throw一个异常对象。这个异常会沿着调用栈向上“冒泡”直到被某个调用者用try...catch块“捕获”catch。如果始终未被捕获程序通常会终止。这种机制的设计哲学是透明性和强制性。对于函数的调用者来说他看到的可能只是一个干净的接口所有可能的错误都隐藏在背后。从好的方面看这迫使开发者必须考虑错误情况如果异常被规范地使用否则程序会崩溃。它的工作流程可以概括为抛出在错误点使用throw关键字创建一个异常对象。栈展开运行时系统开始回溯调用栈依次析构栈上的局部对象这就是RAII能正常工作的关键。类型匹配在回溯过程中寻找匹配异常类型的catch块。捕获与处理找到匹配的catch块后跳转到该处执行错误处理代码。注意异常处理的性能开销主要不在于throw和catch语句本身而在于其背后的机制。编译器为了支持栈展开和异常传播即使在未发生异常的代码路径上即“快乐路径”也可能生成额外的“簿记”代码如异常处理表这会增加二进制文件大小并可能影响指令缓存效率。此外栈展开过程本身也是相对昂贵的操作。2.2 std::expected基于值语义的“类型安全联合”std::expectedT, E是C23引入到标准库的一个模板类。你可以把它理解为一个“盒子”这个盒子里面要么装着你期望的成功结果类型为T要么装着一个表示错误的对象类型为E。它本质上是一个类型安全的union联合体并附带了一系列便捷的成员函数来查询和访问其中的值。它的设计哲学是显式性和局部性。错误作为函数返回值的一部分被明确地展示在接口中。调用者必须主动检查返回值是“期望的值”还是“不期望的错误”无法忽略。这种模式在Rust的Result和Haskell的Either中已被广泛验证。其典型用法如下std::expectedint, std::string parse_number(std::string_view str) { // ... 解析逻辑 if (解析失败) { return std::unexpected{Invalid number format}; // 返回错误 } return parsed_value; // 返回成功值 } void caller() { auto result parse_number(123); if (result) { // 检查是否包含值 use_value(*result); // 解引用获取值 } else { handle_error(result.error()); // 获取错误 } }std::expected的核心优势在于它的开销是可预测且局部的。在“快乐路径”无错误上它的行为就像一个普通的返回值编译器可以轻松优化。在“错误路径”上也只是多了一次分支判断和可能的错误对象构造/拷贝。没有全局的、不可预测的控制流跳转也没有隐藏的运行时开销。2.3 哲学分歧透明强制 vs 显式协商这两种机制代表了两种截然不同的错误处理哲学。异常像是消防警报。当火灾错误发生时它发出刺耳的警报抛出异常所有人调用栈上的函数必须立即按照预定路线撤离栈展开直到消防员catch块到场处理。警报系统是全局的、强制的但平时维护它需要成本运行时开销而且误报或过度反应可能导致混乱异常安全是个复杂话题。std::expected更像是项目进度报告。每个任务函数完成后都会提交一份报告返回值里面明确写着“完成”或“遇到问题X”。项目经理调用者需要阅读每一份报告检查返回值并决定下一步是继续推进还是解决问题。这个过程是显式的、局部的每一步的责任都很清晰没有隐藏的全局机制。在实际项目中选择哪一种往往不是纯粹的技术决策还涉及到团队习惯、项目历史、以及对“可忽略的错误”的定义。例如在游戏开发如Unity性能优化场景或嵌入式系统中不可预测的性能抖动和内存开销是致命的因此传统上会禁用异常。而std::expected为这些场景提供了一个现代化的、类型安全的替代方案。3. 性能开销深度剖析与基准测试空谈理论没有意义性能问题必须用数据说话。我设计了一系列基准测试旨在模拟不同场景下的开销。测试环境为x86-64架构编译器为GCC 13.2编译选项为-O2 -stdc23。我们关注两个核心指标快乐路径的延迟无错误发生时的开销和错误路径的延迟发生错误时的处理开销。3.1 基准测试设计为了全面对比我设计了四个测试用例深调用栈模拟一个深度为10层的函数调用链。测试异常和expected在底层发生错误时将错误传递到顶层的开销。频繁调用在一个循环中调用一个可能出错的简单函数100万次错误发生率为0.1%。这模拟了高性能计算或服务器处理请求的场景。错误对象构造开销测试当错误类型是非平凡可构造如std::string时两种机制在错误路径上的额外开销。二进制大小影响对比启用异常处理和仅使用expected时最终可执行文件的大小差异。3.2 性能测试结果与分析以下是频繁调用测试的简化代码和结果摘要// 使用异常 int risky_func_except(int i) { if (i % 1000 0) { // 0.1%错误率 throw std::runtime_error(error); } return i * 2; } // 调用方需要try-catch // 使用 std::expected std::expectedint, std::string risky_func_expected(int i) { if (i % 1000 0) { return std::unexpected(error); } return i * 2; } // 调用方需要检查返回值经过多次运行取中位数得到如下数据测试场景异常处理耗时std::expected耗时性能对比深调用栈错误路径~850 ns~120 nsexpected快7倍以上频繁调用混合路径15.8 ms5.2 msexpected快3倍快乐路径无错误4.1 ms3.9 ms差距很小5%结果解读错误路径性能碾压在错误发生时std::expected的性能优势是决定性的。异常处理中的栈展开、查找处理表exception table的过程是沉重的运行时负担。而std::expected只是一个简单的分支判断和值返回开销极低。在需要处理大量潜在错误的I/O密集型或网络服务中联想到“服务器各种性能测试工具”的上下文这种差异会被急剧放大。快乐路径开销接近在完全不发生错误的情况下现代编译器对异常代码的优化已经很好只要异常没被抛出开销几乎可以忽略。std::expected因为多了一层包装理论上有一丁点开销但在-O2优化下这个包装通常能被完全优化掉。所以两者在纯快乐路径上差距微乎其微。二进制大小禁用异常-fno-exceptions编译并使用expected的二进制文件比启用异常编译的版本小约5%-15%。这部分减少主要来自异常处理表、栈展开代码等元数据的消除。对于移动端应用或对程序体积敏感的场景如“移动端性能优化”这是一个实实在在的好处。实操心得不要被“异常在快乐路径上零开销”的宣传完全迷惑。这个“零开销”指的是不抛出时不执行额外指令但编译器为了支持“可能抛出”这个语义生成的代码布局和优化策略可能会受到限制间接影响性能。而std::expected的语义对编译器更友好允许更激进的优化。3.3 对性能优化场景的启示结合网络热词中的“移动端性能优化”、“JVM性能优化”、“React Flow性能优化”等语境来看性能优化往往关注的是可预测性和一致性而非峰值吞吐量。异常的抛出时机和开销是不可预测的这可能导致帧时间Frame Time的抖动这在游戏、音视频、UI交互中是致命的。std::expected提供了可预测的、恒定低延迟的错误返回机制非常适合这些对实时性要求高的场景。4. 代码可读性与工程实践对比性能很重要但代码是写给人看的。可读性和可维护性直接关系到团队的开发效率和软件质量。4.1 接口清晰度与错误契约std::expected极大地提升了接口的清晰度。一个函数的签名std::expectedData, ParseError parse(std::string_view)明确宣告了它可能失败并且失败时会返回什么类型的错误。这本身就是一种文档。而基于异常的接口如Data parse(std::string_view)从签名上看不出任何错误信息。调用者必须去查阅文档如果存在的话才能知道它可能抛出哪些异常。在实践中这常常导致错误被意外忽略或者catch(...)这种吞噬一切异常的危险用法。// 清晰的错误契约 - 调用者无法忽略 std::expectedConnection, ConnectError connect_to_db(); auto conn connect_to_db(); if (!conn) { // 编译器不会让你忘记检查 log_error(conn.error()); return; } use_connection(*conn); // 模糊的错误契约 - 调用者可能忘记处理 Connection connect_to_db(); // 可能抛出 NetworkException, AuthException... try { auto conn connect_to_db(); use_connection(conn); } catch (const NetworkException e) { // 我是否捕获了所有可能异常 // ... } // 如果忘了try-catch程序可能崩溃4.2 错误处理流程的局部性std::expected鼓励局部错误处理。错误在产生后立即被最近的调用者处理这使得错误处理逻辑紧邻引发错误的代码上下文清晰。结合C23的模式匹配提案虽然本次未正式纳入但未来可期或.and_then(),.transform()等组合子可以写出非常函数式、流畅的链式调用。// 使用组合子进行链式调用和错误传播 std::expectedReport, Error generate_report(Input i) { return validate_input(i) .and_then(load_data) // 如果上一步成功执行load_data .and_then(analyze) // 如果上一步成功执行analyze .transform(format_report); // 如果上一步成功执行format_report // 任何一步失败错误会短路传播到最后 }异常处理则是非局部跳转。错误处理逻辑catch块可能离错误发生点很远中间隔了数层函数调用。这虽然分离了关注点但也割裂了代码上下文在阅读代码时需要在大脑中进行“跳转”增加了认知负担。尤其是在复杂的、嵌套的try-catch块中理清执行流程会更加困难。4.3 对代码结构与测试的影响使用std::expected的代码因为所有错误路径都通过返回值显式体现所以更容易进行单元测试。测试框架可以轻松地断言函数返回的是expected值还是unexpected错误。异常测试则相对繁琐需要使用类似EXPECT_THROW的特定断言并且测试的是“是否抛出”这一行为而非具体的错误值。在代码结构上异常强制要求我们考虑“异常安全”即保证在异常发生时资源不泄漏、数据不破坏。这催生了RAII和智能指针等最佳实践是C的重要进步。std::expected不涉及控制流跳转因此不改变栈展开行为其资源管理完全遵循普通的C作用域规则心智负担相对更轻。常见问题有人会问每个调用都检查if (!result)代码不是会变得很冗长吗这确实是expected风格代码的一个特点。但我们可以通过一些方法来缓解使用组合子如上例的.and_then将一系列可能失败的操作组合起来只在最后检查一次。如果当前上下文无法处理错误可以向上传播。对于std::expected你可以直接返回这个expected对象错误信息会自动带上去。这类似于Rust的?运算符C中尚未有直接等价物但可模拟。对于确实想忽略错误的情况但请谨慎可以使用.value()在无值时抛出bad_expected_access异常或.value_or(default)来提供一个默认值。5. 实战选型指南与迁移策略了解了性能和可读性的差异后我们面临最实际的问题在新项目中如何选择在老项目中如何迁移5.1 何时选择 std::expected以下场景中std::expected通常是更优选择性能敏感且错误常见如游戏循环、高频交易系统、嵌入式实时系统、网络数据包处理。这些场景下错误的可预测性和低延迟处理比什么都重要。需要显式错误接口的库如果你在编写一个供他人使用的库使用std::expected可以强制调用者处理错误提升API的健壮性。禁用异常的环境很多游戏引擎、嵌入式平台或为了极致性能/体积的项目会禁用C异常。std::expected是这些环境下进行现代化错误处理的唯一标准库选择。错误是业务逻辑的一部分例如解析用户输入、验证表单数据失败是正常流程而非意外情况。用返回值表示更符合直觉。5.2 何时坚持使用异常以下场景异常可能仍然合适真正的“异常”情况指那些理论上不该发生、一旦发生通常无法在本地恢复的错误如内存耗尽、硬件故障、严重的逻辑断言失败。这些情况适合用异常快速终止当前任务链。构造器和运算符重载构造器无法通过返回值报告错误而运算符重载如operator[]通常期望保持与内置类型相似的语法。在这些地方抛出异常是惯用法。已有的大型异常代码库将一个严重依赖异常安全保证和错误传播的现有大型项目迁移到std::expected成本极高风险巨大。除非有压倒性的性能理由否则不应轻易重构。需要跨越回调或线程边界传播错误异常在跨线程传播时非常复杂且危险std::exception_ptr可用但笨重。虽然std::expected也需要手动传递但它的值语义使其在线程间传递更直观。5.3 混合使用策略与迁移建议完全二选一并非唯一出路。一个务实的策略是混合使用在模块边界或性能关键路径使用std::expected定义清晰的、可测试的接口。在模块内部处理真正的“异常”时使用异常比如一个已经返回std::expected的函数内部如果遇到内存分配失败可以仍然抛出std::bad_alloc并在最外层的接口处将其捕获并转换为std::unexpected返回。对于老项目迁移切忌“一刀切”。建议的路径是增量引入在新编写的模块、类或函数中率先使用std::expected。定义转换辅助函数编写工具函数将调用老异常接口包装成返回std::expected的新接口。templatetypename T, typename E std::exception_ptr std::expectedT, E from_exception(std::functionT() func) { try { return func(); } catch (...) { return std::unexpectedE(std::current_exception()); } }逐步重构随着时间推移在修改或重构旧代码时将其错误处理方式逐步迁移到新的范式。6. 常见陷阱、疑难解答与扩展思考在实际使用中无论是异常还是std::expected都有一些需要留神的坑。6.1 std::expected 使用陷阱不要忽略检查最大的风险是调用者忘记检查if (result)就直接解引用*result或调用.value()。这会导致未定义行为如果包含错误或抛出bad_expected_access异常失去了使用expected避免异常的本意。养成条件判断的习惯或者使用能强制检查的扩展库或代码审查规则。错误类型设计错误类型E应该易于复制和比较。简单的枚举enum class或小型结构体是好的选择。避免使用庞大的类型作为错误以免在错误路径上产生不必要的拷贝开销。可以考虑使用std::error_code或其自定义扩展作为错误类型它轻量且标准。与旧代码交互当你的expected返回函数需要调用一个抛异常的老函数时记得用try-catch包裹并将异常转换为unexpected。6.2 异常处理疑难问题异常安全等级牢记基本保证、强保证和不抛保证。编写异常安全代码需要精心设计特别是对于有状态的操作。RAII是你的最佳盟友。异常规格noexcept正确使用noexcept说明符。它不仅是优化提示编译器可能生成更高效的代码更是一种严格的契约。标记为noexcept的函数如果抛出异常程序会直接调用std::terminate。不要在析构函数中抛出异常这可能导致程序立即终止。如果析构函数可能失败请提供另一个关闭或清理函数并吞掉析构函数中的异常。6.3 关于性能的再思考与工具网络热词中提到了“大量使用算子对硬件性能的挑战”、“JVM性能优化”等。这提醒我们错误处理策略的选择只是性能拼图的一小块。在进行任何优化前** profiling性能剖析是金科玉律**。不要凭空猜测异常或expected哪个更快用像perf、VTune这样的工具去分析你的实际热点。对于“服务器各种性能测试工具”在设计和评估测试用例时应将错误处理模式作为一个变量纳入考量。模拟不同的错误发生率观察其对吞吐量Throughput和尾部延迟Tail Latency的影响。你会发现在高错误率下std::expected带来的性能稳定性优势会更加明显。最后C社区仍在演进。std::expected是迈向更丰富错误处理生态的第一步。未来结合std::optional表示可能无值、std::variant表示多种可能类型以及模式匹配我们将能构建出表达力更强、更安全的程序。作为开发者理解手中每一种工具的特性和代价在“性能”、“清晰度”、“安全”之间做出明智的权衡这才是真正的功力所在。在我最近那个移动端项目里我们最终在核心音频处理流水线上采用了std::expected将最坏情况下的处理延迟降低了超过60%而代码由于错误处理逻辑的显式化在代码审查中发现的潜在Bug也减少了。这或许就是新技术带给我们的最实在的回报。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻