用了一年多std::expected之后,我越来越觉得C++社区关于"异常 vs 错误码"的争论,本质上是两种世界观在打架。你以为你在选技术方案,实际上你在选这个项目将来会变成什么样——谁负责发现错误、错误发现之后怎么处理、调用方有没有义务处理失败,这些问题在写第一行代码之前就已经被你的选择决定了。
std::expected不是第一个试图终结这场争论的替代品,但它是第一个进入C++标准、并且真正被大型项目接住的方案。它跟异常机制的关系,不是简单的二选一,而是给错误处理这条线补上了一块长期缺失的拼图:把"这个函数可能失败"直接写进函数签名里。这篇文章不会劝你全面弃用异常,也不会把expected吹成银弹。我会从提案的设计动机出发,把它的API掰开揉碎讲一遍,再用同一段业务逻辑分别用异常和expected实现一遍,最后分享一下我在真实项目里的选型原则和踩过的坑。
1. 错误处理的三角困局:为什么我们总在错误码和异常之间摇摆
1.1 错误码时代的血泪
聊std::expected之前,得先回头看看C++之前是怎么处理错误的。C语言时代基本是错误码的天下:open返回-1,fread返回读到的项数,malloc返回nullptr,具体错误详情则塞进errno这个全局变量。这套模式沿用了几十年,但每个写过C的开发者应该都体会过它的几个硬伤。
首当其冲的是"返回值既要表达数据又要表达错误"。比如一个解析整数函数,你返回-1到底是"解析失败"还是"解析出了负一"?只能靠注释和函数名来约定。其次,errno是线程相关的,但很多人写代码时根本没有这个意识,一个线程里读到errno,另一个线程同时在改,错误信息就串了。更麻烦的是中间层如果不处理错误,把错误码原样向上抛,上一层的调用方往往只能拿到一个孤零零的"失败",具体为什么失败,信息在传递链条上早就丢了。
写网络协议栈的时候这种痛苦最明显。recv返回-1,你得先判断errno是EAGAIN还是EINTR还是ECONNRESET,每种情况处理方式都不一样。如果你在中间层直接return -1,那么上游根本不知道是重试还是关闭连接,排查问题纯粹靠运气。错误码模式的核心矛盾在于:它把错误处理的责任完全交给了每个调用点的自觉性,而人恰恰是最不靠谱的环节。
1.2 异常机制带来的新问题
异常的出现,某种程度上就是为了解决错误码"会被无意识地忽略"这个问题。它的设计哲学很清晰:你不处理,它就一路向上传播,直到遇到能处理的地方。这个机制在跨越多层调用栈的场景下非常有用,比如深层库函数发现资源不足,一路传到顶层UI层弹个提示,中间层完全不用关心。
但异常在C++里也带来了一堆新问题,而且这些问题相当隐蔽。
函数签名里看不到异常。一个函数会不会抛异常、抛什么异常,全靠文档约定。你重构代码的时候,很难静态地追踪一条异常传播路径。更糟糕的是,很多项目把异常用成了流程控制:解析业务数据失败,抛一个ValidationError,调用方在catch里接住然后跳转逻辑。这其实就是拿异常当goto用,控制流在throw和catch之间来回横跳,代码根本没法读。
异常的性能模型也常被误解。所谓"零成本异常",指的是正常路径上不需要为异常做额外工作,但一旦真的throw,运行时需要展开栈、沿途逐个析构局部对象、找到匹配的catch块。这个成本通常比一次普通分支高两到三个数量级。如果你的代码里"失败"不是小概率事件,而是每次请求都可能发生的业务校验失败,异常的错误路径成本就会被放大得非常可观。
还有一个绕不开的现实:很多平台默认不启用异常。游戏引擎基本都禁止异常,嵌入式MCU环境经常用-fno-exceptions,Android NDK历史上也默认关掉。你写了一个依赖异常做错误处理的库,到了这些环境就得整个重写。
1.3 社区对第三条道路的探索
正因为错误码和异常各有各的毛病,C++社区这些年一直在尝试找第三条路。std::optional是个过渡产物,它解决了"可能没有值"的问题,但带不了错误详情。你只知道结果可能为空,不知道为什么会空,所以很多人拿optional当错误码的壳,在外面再包一层枚举,比如optional
Boost.Outcome和tl::expected是更接近最终答案的探索。Boost.Outcome功能很全,可以同时携带成功值、错误码、异常指针,但正因为太全,用起来也重。tl::expected是一个轻量实现,把"成功值或错误值"这个思想做得很干净,很多项目在std::expected落地之前就直接用了它,相当于做了一轮大规模的真实项目验证。
Rust的Result<T, E>则给了大家一个更清晰的参照:错误类型写进函数签名,所有可能失败的函数都返回Result,调用方用组合子和?操作符处理错误。C++把Rust的这套思路搬过来,结合自己已有的optional API设计习惯,最终就是std::expected。它的定位非常明确:处理那些"预期内的、可枚举的、调用方可以恢复"的错误;而那些"预料之外的、无法恢复的"故障,仍然留给异常或者terminate。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 提案背后的博弈:std::expected从P0323到C++23的演进细节
2.1 提案十年磨一剑
std::expected的标准化过程相当漫长。提案编号是P0323,最早由Vicente Botet Escribá等人提出,前后大改了好几轮。最初的设计里,错误类型E的约束跟现在不太一样,组合子API也有争议。委员会对几个关键问题反复拉锯:错误类型要不要默认可构造?expected<void, E>应该长什么样?monadic操作到底该不该进标准?
这期间tl::expected和Boost.Outcome积累了大量实际反馈,委员会也趁机吸收了不少经验。最终P0323在C++23正式进入标准库,包含std::expected<T, E>、std::unexpected
2.2 设计权衡:为什么是这个形态
std::expected<T, E>本质上是一个和类型:要么持有T,要么持有E,内部用一个布尔标志来区分当前活跃的是哪个分支。这个设计的核心是值语义。成功值和错误值都直接存在栈上,不涉及堆分配,不需要像异常那样在运行时去查表展开栈。错误类型E通常是一个枚举或者一个轻量struct,拷贝成本很低,所以把它放在模板参数里,让编译器在编译期就把错误分支的类型和布局定死,是这门语言能做到的最优解。
对比Boost.Outcome,expected刻意保持了最小化。Outcome同时支持"成功值 + 错误码 + 异常指针"多路状态,适用于需要保留整个异常栈信息的复杂场景。但标准库expected只做一个"T或者E"的最小核心,不做多功能瑞士军刀。这个取舍
