ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

std::expected vs std::variant:C++错误处理选型指南

std::expected vs std::variant:C++错误处理选型指南 写 C 的年头一多很多人会慢慢纠结同一个问题函数到底该用什么姿势告诉调用方“我没拿到你要的结果”。早年间清一色返回码后来又流行异常再后来std::optional能表达“有值/没值”现在 C23 又把std::expectedT, E正式端上了桌。而在这个演进过程里std::variantT, E也一直被人拿来干几乎同样的事把“正常值 T”和“错误值 E”塞进同一个类型里返回给调用方自己拆。同一个目标两种类型背后的设计哲学却差得很远甚至可以说是在回答两个不同的问题。这篇文章想聊的就是这两者之间的区别std::expected和std::variant在错误处理里各自的定位是什么接口为什么长成这样实际项目里到底怎么选。我会把二者的设计思路、核心接口、内存开销、避坑经验全部摊开来讲顺带聊几个和错误码、异常机制互相转换的实操技巧。无论你是刚接触 C17/23 的新手还是准备在代码评审里跟同事辩一辩“为什么用 expected 而不用 variant”这篇应该都能给你一点底气。1. 先捋清楚expected 和 variant 到底差在哪1.1 错误处理的老三样返回码、异常、optional在进入expected和variant之前先把 C 里已有的错误处理方式过一遍。返回码是最古老的做法函数返回一个整数0 表示成功非 0 表示某种错误。优点是零开销、可预测缺点是太容易漏检查而且“正常值”和“错误码”往往要分开传要么用输出参数要么返回bool再用引用拿结果。异常则是另一条路线它把错误处理和正常流程彻底分离抛出后由上层统一捕获。优点是代码看着干净缺点也不小异常的安全性问题让很多人头疼而且异常路径的执行成本很高在嵌入式、实时系统、游戏引擎里往往被直接禁用。std::optionalT解决了一部分问题它能明确表达“要么有值要么没有”。但它表达不了“为什么没有”空指针、文件不存在、网络超时到调用方这里全是std::nullopt完全丢失了原因信息。于是很多人开始自己拼组合std::variantT, ErrorCode或者更简陋的struct { bool ok; T value; E error; }。1.2 expected 和 variant 都是一箱双格但语义不同从内存布局上看std::expectedT, E和std::variantT, E很像都是“同一时刻只存储 T 或 E 其中一个”底层基本都靠联合体实现。但它们的设计出发点完全不同std::variantT, E是一个“多类型安全容器”T 和 E 是平等的两个备选类型。它不知道“错误”是个什么概念只是把两种可能的类型放在一起具体哪个是错误、哪个是正常值全靠使用者自己约定。std::expectedT, E则天生是为“结果”设计的。它把 T 定义为预期值expected value把 E 定义为错误error接口层面直接提供了has_value()、value()、error()这些语义明确的方法并且在约束上偏心预期值类型不该是voidC23 有expectedvoid, E的讨论但常规情况下我们说的是T非空错误类型也只允许一个。更直白一点说variant告诉你“这里可能是 A也可能是 B你自己判断”expected告诉你“这里要么是你要的结果要么是我没办成事的原因你按这个流程处理就行”。同样是“二选一”前者模拟的是可能性后者模拟的是结果与失败。2. 设计哲学单向门与岔路口2.1 expected 的主路径与侧信道std::expected的接口设计处处透着一个理念正常路径应该是“默认的、顺畅的”错误路径是“侧信道”。什么叫侧信道就是说主流程该拿值就拿值错误信息不占主角位置但在你需要时随时能拿到。看它的核心接口就能体会出来std::expectedint, std::error_code parse_int(std::string_view s) { int val 0; if (std::from_chars(s.data(), s.data() s.size(), val).ec std::errc{}) return val; // 直接返回 T隐式构造 expected return std::unexpected(std::make_error_code(std::errc::invalid_argument)); // 或者 return std::unexpectedstd::error_code(...); }调用方拿到这个expected后的第一反应通常是if (auto res parse_int(123); res) { int v *res; // 解引用取正常值 } else { auto ec res.error(); // 取错误 }注意这个operator bool和has_value()它把“有没有值”变成了一个显式的布尔判断编译期语法上默认要求你先检查再取值。虽然value()实际上还是有未定义行为但设计层面的暗示是“请用安全的方式访问”。更关键的是expected提供了value_or()、transform()、and_then()这些链式操作可以把“如果成功就继续处理”写成一长串流程错误则自动短路。这个设计哲学其实是把 Rust 的ResultT, E和函数式编程里的Either/Result概念搬进了标准库。错误不再通过“跳到别的地方”来传递异常也不再是每个函数都要手动检查的累赘返回码而是变成了一种可以被组合、被转换、被延迟处理的值。2.2 variant 把 T 和 E 放在同一水平线上std::variantT, E的设计哲学要中立得多。它的本质是“一个有标签的联合体”语言层面只知道“当前存的是哪个类型”不知道“哪个类型代表成功”。所以你用variant做错误处理时会写成这样std::variantint, std::error_code parse_int(std::string_view s) { int val 0; if (std::from_chars(...).ec std::errc{}) return val; return std::error_code{/* ... */}; }调用方可能是auto res parse_int(123); if (std::holds_alternativeint(res)) { int v std::getint(res); } else { auto ec std::getstd::error_code(res); }两种写法看着差别不大但语义氛围完全不同variant的检查是“它到底装的是哪一类”而不是“成没成功”。虽然这也能实现错误处理但它把“成功”和“失败”放在了完全对等的位置上。这会导致几个实际影响代码阅读者必须从命名或注释里去推断哪个类型是错误。std::getT在类型不匹配时抛std::bad_variant_access异常机制在这个“错误处理方案”里又借尸还魂了。没有任何强制力让你在取值前检查类型std::get_if忘了检查照样是空指针问题。最典型的问题是当T和E是同一类型时std::variantint, int直接编译不过因为变体不允许有两个相同类型就算允许也没法区分谁是正常值谁是错误。而std::expectedint, int就没这个问题它从语义上就分得清清楚楚。这一点在实际代码里很常见——比如你的正常值就是int错误码也是int。2.3 为什么这个区别决定了 API 行为很多初学者会问既然variant能做错误处理标准库为什么还要搞一个expected答案就是“语义决定 APIAPI 决定正确性”。std::expected明确把 T 叫做“期望值”把 E 叫做“错误”于是它可以提供value_or(T fallback)这种接口成功就返回 T失败就返回 fallback。它可以提供.and_then([](T) - std::expectedU, E)让连续处理步骤在遇到错误时直接跳过。这些操作放在variant上也能实现但你得自己写辅助函数而且写出来的辅助函数在语义上必须多做一层解释我是在“把 variant 当成 expected 用”。std::variant则是一把更通用的瑞士军刀。它可以装 3 个、5 个、10 个不同类型可以实现状态机、多态返回值、Type-erased 容器错误处理只是它众多应用场景里的一种。换句话说用variant做错误处理不是不行但它就像“用一把菜刀去拧螺丝”——能拧但顺手程度和专业性都差一截。3. 接口细看与实测同样的 T/E不同的性格3.1 expected 的核心接口与约束std::expected在 C23 里正式入标准头文件expected。最常用的接口我整理了一下方法行为has_value()/operator bool是否持有 Tvalue()返回 T 的引用无值时未定义行为error()返回 E 的引用有值时未定义行为value_or(U)有值返回 T无值返回参数里的默认值operator*/operator-访问底层 T不做检查emplace(Args...)原地构造 T并标记为有值transform(Fn)对 T 做映射返回expectedU, Eand_then(Fn)对 T 做返回expectedU, E的映射自动展平or_else(Fn)对 E 做处理返回expectedT, Eerror()在无值时返回 E语法上“错误侧信道”几个容易踩的坑value()在无值状态下调用是未定义行为不是抛异常。这和std::variant::get抛bad_variant_access完全不同。正确做法是先判has_value()或者用value_or()。error()在有值状态下调用也是未定义行为。std::unexpected是一个包装器用来构造“错误状态”的 expected用法是std::unexpectedE(e)C23 里也有std::unexpected(e)的推导指引。expectedT, E的operator bool不是显式的但建议显式使用if (res)而不是if (res true)避免和某些 operator 纠缠。再看看带链式操作的例子std::expectedint, std::string parse_int(std::string_view s) { if (s.size() 3) return std::unexpected(too long); int val 0; for (char c : s) { if (c 0 || c 9) return std::unexpected(bad char); val val * 10 (c - 0); } return val; } // 链式调用 auto result parse_int(42) .and_then([](int v) - std::expectedint, std::string { if (v % 2 0) return v * 2; return std::unexpected(odd); }) .transform([](int v) { return v 1; }) .or_else([](std::string e) { std::cerr error: e \n; return std::unexpected(e); });这里and_then之后的 lambda 返回expectedint, stringtransform之后的 lambda 返回普通int自动包成expectedor_else接收错误并仍然转发错误。这一长串流程看起来就像异常处理“没有异常时一路执行下去”但完全没有异常机制参与性能是可控的错误类型也显式暴露在签名里。3.2 variant 做错误处理时的取舍如果用std::variantT, E实现同样功能代码会稍微“啰嗦”一点using ParseResult std::variantint, std::string; ParseResult parse_int(std::string_view s) { if (s.size() 3) return too long; int val 0; for (char c : s) { if (c 0 || c 9) return bad char; val val * 10 (c - 0); } return val; } // 使用 ParseResult r parse_int(42); if (auto v std::get_ifint(r)) { std::cout value: *v \n; } else if (auto e std::get_ifstd::string(r)) { std::cout error: *e \n; }这里有几个关键差异std::get_ifT(r)返回T*如果 variant 当前不是 T返回空指针。这种方式是避免抛异常的主流做法但空指针又引入“忘了判断空指针”的可能。std::getT(r)在类型不匹配时会抛std::bad_variant_access。这和在错误处理代码里使用异常有点自相矛盾——你为了不用异常才用 variant结果取值时又可能抛异常。variant的“空状态”是一个隐藏角落valueless_by_exception()。比如赋值时内部对象的构造抛了异常variant 可能进入一个既不是 T 也不是 E 的状态。虽然实际触发条件比较苛刻但的确存在。variant在错误处理场景下的优势在于它可以一次性表达多于两种状态。比如std::variantint, std::error_code, std::string可以同时包含“失败但带 error_code”和“失败但带描述性字符串”。这在一些多级错误建模里有价值但代价是调用方要处理的状态更多分支也更复杂。3.3 性能与内存开销对比很多人关心expected和variant在内存和性能上的差异。理论上它们都应该优化成“只占用 T 和 E 大小中的较大者 一个 tag”但因为标准库实现细节不同实际结果会有出入。我写了个简单的测试来观察大小#include expected #include variant #include string #include system_error #include iostream struct Big { char data[128]; }; int main() { std::cout sizeof(expectedint, int) sizeof(std::expectedint, int) \n; std::cout sizeof(variantint, int) error \n; // variantint,int 编译不过 std::cout sizeof(variantint, std::string) sizeof(std::variantint, std::string) \n; std::cout sizeof(expectedint, std::string) sizeof(std::expectedint, std::string) \n; std::cout sizeof(variantBig, int) sizeof(std::variantBig, int) \n; std::cout sizeof(expectedBig, int) sizeof(std::expectedBig, int) \n; }在我本机 libstdc 实测的结果大概是这样不同编译器和版本会有差异类型sizeofexpectedint, int8variantint, std::string40取决于 string 的 SSO 与对齐expectedint, std::string40expectedBig, int136variantBig, int136expectedvoid, int4如果实现支持可以看出在“两个备选类型相同布局”的模型下expected和variant的内存差别基本可以忽略。真正拉开差距的是构造开销expected成功状态直接构造 T失败状态构造 E二者都不会额外构造没有用到的类型。variant也一样但variant在赋值替换类型时会额外多做一些析构/构造工作。访问开销expected::value()在 release 模式下很多时候可以直接翻译成对联合体成员的访问连分支都不需要因为你保证“已经检查过了”。variant::get_if需要检查当前索引并进行分支开销略高。编译期复杂度expected的链式调用transform/and_then会实例化不少模板代码编译时间比variant略长但这是可接受范围内的。所以说性能不是选择这两者的主要依据设计语义才是。我在实际项目里测下来的结论是如果两个类型大小、对齐接近那二者的代码生成差异非常小如果 T 和 E 大小差距很大那无论如何都要按大的来存储这个没得跑。4. 选型决策与踩坑实录4.1 什么时候用 expected什么时候用 variant聊了这么多理论和实测最后落到实操项目里到底怎么选我自己的经验是分场景判断看这个函数的“返回值”在语义上究竟是一个结果还是一组可能性。建议优先使用std::expected的场景函数语义是“我要做一件有成功/失败之分的事”例如解析、读取配置、执行某个可能失败的计算。不希望用异常但又不想丢失错误原因。需要链式处理多个可能失败的操作希望错误自动短路。你的项目能接受 C23或者你用tl::expected、Boost.Outcome 做平替。适合继续使用std::variant的场景类型集合大于 2比如“网络请求结果”可能是Success、Timeout、Cancelled、Retryable此时 variant 天然比 expected 更顺。你只是想表达“运行时类型在几个候选里切换”不强调错不错误。你的代码库已经大量使用variantvisit的模式再引入 expected 会多一套心智负担。一个折中建议如果项目停留在 C17又不想引入第三方库用variant做错误处理是能接受的但一定要在命名上强制约定“第一个类型是正常值第二个是错误”并且写一个if_ok之类的辅助模板来统一检查逻辑。不要让每个调用方自己写holds_alternative否则代码风格会迅速分裂。以下是决策速查表场景推荐原因单个成功值 单个错误类型expected语义清晰接口完备多个状态非单纯对错variant多类型一次容纳错误链式短路expectedand_then/transform现成与异常体系共存都行取决于团队约定编译器只支持 C17variant 或第三方 expected标准库没有 expected4.2 踩坑实录TE、无值调用与 bad_variant_access错误处理代码最容易出的问题往往不是业务逻辑错而是“值到底在不在”这个问题上踩雷。我把常见情况列成一张问题速查表问题现象原因对策std::expectedbool, bool语义混乱看不出正常值是 true 还是错误是 true两种类型相同语义只能靠约定用强枚举包装错误别让 E 和 T 相同expected::value()崩溃release 模式下可能直接读到垃圾值无值时 value() 是 UB先判断has_value()或使用value_orexpected::error()崩溃有值时取 error() 同样是 UB用户没按契约使用检查后再访问或在类型上做包装std::getint(variant)抛异常抛std::bad_variant_access当前 variant 存的是另一个类型优先用get_if且判断空指针variant 换成常见错误码显式检查代码里到处是 if/else没有封装统一的访问模式写一个if_ok辅助函数或visit分发再提醒一个容易被忽略的点std::expectedT, E要求 T 和 E 必须都是完整类型而且 E 不能是voidC23 讨论过expectedvoid, E但常规使用不要依赖它。如果错误类型需要“无错误”这种状态可以用std::monostate作为 E 的替代或者把“无错误”建模成expectedT, std::error_code里的默认error_code{}。4.3 与老代码共存error_code 转换与 return 风格统一在真实项目里你很难做到“整个代码库都从 C17 蹦到 C23”。所以expected要融入大项目通常要处理和老式错误码、异常体系的互相转换。最简单的转换方式是用std::error_code作为 E这样既能和系统错误码对接也能在必要时转成异常#include expected #include system_error #include stdexcept #include iostream std::error_code to_error_code(int err) { return {err, std::generic_category()}; } std::expectedint, std::error_code try_get_value(bool ok) { if (!ok) return std::unexpected(to_error_code(EINVAL)); return 42; } // 转异常因为 E 是 error_code可以快速装成 system_error int get_value_or_throw(bool ok) { auto res try_get_value(ok); if (res) return *res; throw std::system_error(res.error()); } int main() { try { auto v get_value_or_throw(false); std::cout v \n; } catch (const std::system_error e) { std::cout caught: e.code().message() \n; } }这种“用 expected 做内部数据流在边界处转异常”的模式我个人的体会是把异常限制在系统边界上核心逻辑用值类型传递错误既保住了可测试性也便于将来替换底层实现。如果你在维护一个老项目老函数还在用bool 输出参数也完全可以封装一层std::expectedint, std::error_code wrap_old_api(int input) { int result 0; if (!old_function(input, result)) { return std::unexpected(std::error_code(errno, std::generic_category())); } return result; }封装之后调用方就能享受expected的链式语法老代码也不用推倒重写。5. 个人实操心得你不需要在两者之间纠结太久说了这么多原理和接口最后分享一点我在实际项目里的体会。我的习惯是凡是我要“返回一个结果并附带错误信息”的地方一律用expected凡是我在“多个运行状态之间切换”的地方才考虑variant。这个标准听起来很虚但其实就是问自己一句话如果我把这个方法给一个完全不认识的同事看他第一眼能不能看出哪个类型代表成功expected能让答案一目了然variant做不到。如果在 C17 环境里没有std::expected可用与其自己用variant硬拼我更推荐引入tl::expected这类库或者考虑 Boost.Outcome。因为它们在设计上已经把“成功路径”和“错误路径”的语义固化下来了团队里的新人不容易用歪。还有一个容易被忽略的小技巧在你第一次使用expected的模块里尽量在代码评审时约定“所有返回expected的函数都不允许在内部抛出业务异常”。这条约束保证了expected的链式短路逻辑不会被意外打断。我自己踩过几次“函数内部某个地方还是会 throw结果链式调用实际执行顺序跟预期完全不一样”的坑把这个约定写进团队规范之后问题就再没出现过。最后再谈一点和这俩都无关但又和错误处理强相关的事不管是expected还是variant它们都解决不了“所有人都忘记检查返回值”的问题。expected的operator bool不是[[nodiscard]]的强制约束你照样可以写parse_int(abc);然后无视结果。真正让错误处理跑起来的关键是用命名规范、review 流程和[[nodiscard]]这些外部手段把“必须处理错误”这件事变成团队习惯。类型系统能帮你的是“如果愿意检查它就在那儿不检查也能活得很好”。这句话听起来像是废话但就是很多项目从“能用”到“好用”之间那道最容易被忽略的坎。
返回列表