用了一年多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"的最小核心,不做多功能瑞士军刀。这个取舍很重要,因为一旦API提供太多槽位,使用者就会争论到底该把错误放哪个槽,反而增加心智负担。
E放在最后一个模板参数也不是随手定的。一方面是为了和std::optional
2.3 从Rust Result借鉴了什么,又没借鉴什么
Rust的Result<T, E>给C++提供了一个很好的范本。它证明了"把错误类型写进签名,用组合子管理失败分支"这套思路在工程上是可行的,而且能让代码的错误处理路径变得非常显式。C++23的expected里,transform对应Rust的map,and_then对应Rust的and_then,or_else对应Rust的or_else,连语义都几乎一一对应。
但有一个重要区别:Rust有match模式和?操作符,可以非常方便地提前返回错误,C++标准库里没有等价的语法糖。这意味着在C++里你要么用一堆if来传播错误,要么在调用链上写很多.and_then,代码量比Rust明显更大。社区里有人用宏模拟?操作符,比如在lambda里early return,但这不是标准方案,用起来总有点别扭。
另外一个Rust给我们的启示是:错误类型本身也需要精心设计。在Rust里,错误类型通常实现了Display和Error trait;在C++里,你可以给自定义错误枚举重载operator<<,或者写一个error()方法来转成可读字符串。如果不做这一步,expected的错误信息在日志里就是一堆 magic number,到时候排查问题照样难受。
3. 核心API速成:从构造、读取到组合子的完整用法
3.1 构造一个expected:值、错误、in_place三种形态
先看最基础的用法。假设你要写一个整数解析函数,用expected来表示"可能失败且失败原因可枚举":
cpp复制#include <expected>
#include <string>
#include <string_view>
enum class ParseError {
InvalidInput,
OutOfRange,
EmptyInput,
};
std::expected<int, ParseError> parse_int(std::string_view s) {
if (s.empty()) {
return std::unexpected(ParseError::EmptyInput);
}
int value = 0;
for (char c : s) {
if (c < '0' || c > '9') {
return std::unexpected(ParseError::InvalidInput);
}
value = value * 10 + (c - '0');
if (value > 1000000) {
return std::unexpected(ParseError::OutOfRange);
}
}
return value;
}
成功时直接返回T类型的值,编译器会用隐式构造把它包成expected<int, ParseError>;失败时用std::unexpected(错误值)构造一个错误分支。std::unexpected是一个标签类型,它的作用就是告诉编译器"我要构造的是错误分支",这个包装在调用方看起来稍微有点啰嗦,但能精确表达意图。
还有一种场景是T无法通过return直接构造,或者你不想引入额外的移动构造。这时可以用std::in_place显式构造:
cpp复制std::expected<std::string, ParseError> make_config() {
return std::expected<std::string, ParseError>(
std::in_place, "default config");
}
expected本身也有emplace方法,可以在已有对象上重新构造值,类似optional的emplace。
3.2 读取结果的姿势:operator*、value()与value_or()
拿到expected之后,怎么读值?
cpp复制std::expected<int, ParseError> r = parse_int("123");
// 方式1:先检查再解引用(推荐)
if (r.has_value()) {
int v = *r; // operator*,性能最好,但必须在has_value()为true时才能用
}
// 方式2:operator bool + operator*
if (r) {
int v = *r;
}
// 方式3:value(),内部会检查,失败时抛异常
try {
int v = r.value();
} catch (const std::bad_expected_access<ParseError>& e) {
// e.error() 返回 ParseError
std::cout << "parse error: " << static_cast<int>(e.error()) << '\n';
}
// 方式4:value_or(),失败给默认值,适合回退场景
int safe_v = r.value_or(0);
这里有个非常关键的语义差异:operator不检查,如果当前状态是错误分支,直接解引用是未定义行为,程序可能发生各种奇怪问题;value()则会检查,如果是错误分支,抛出std::bad_expected_access
value_or()适合做默认值兜底,比如端口号没配置就返回8080,但这个API相比optional的value_or多了一个特点:expected的value_or默认值只处理T分支,不会告诉你错误是什么,所以如果调用方需要知道"为什么失败",还是要走if分支。
3.3 组合子:and_then和transform,让调用链不再堆if
如果业务逻辑是一串"可能失败"的操作串联,比如解析参数、构造地址、建立连接,用if逐个判断error会让代码层层嵌套。组合子就是为这个场景准备的。
先看transform,它把成功值映射成另一个普通值,错误分支直接透传:
cpp复制std::expected<double, ParseError> half = parse_int("100")
.transform([](int v) { return v / 2.0; });
// half 是 expected<double, ParseError>,解析失败时保留错误
and_then稍微复杂一点,它的转化函数本身也返回一个expected,适合串联可能失败的下一段逻辑:
cpp复制std::expected<std::string, ParseError> build_endpoint(int port) {
if (port < 1 || port > 65535) {
return std::unexpected(ParseError::OutOfRange);
}
return "127.0.0.1:" + std::to_string(port);
}
std::expected<std::string, ParseError> endpoint =
parse_int("8080").and_then(build_endpoint);
如果parse_int失败,整个链返回原有的解析错误;如果成功,才走到build_endpoint。用组合子最大的好处是:错误分支的处理是隐式且一致的,不会因为中间某个人忘了检查而导致错误被吞掉。
3.4 错误处理侧的组合:or_else与transform_error
组合子不仅可以用在成功侧,也可以用在错误侧。or_else提供错误恢复的机会:
cpp复制std::expected<int, ParseError> fallback = parse_int("abc")
.or_else([](ParseError e) -> std::expected<int, ParseError> {
// 可以在这里写日志,或者恢复默认值
return 42;
});
transform_error则把错误类型转换成另一种类型,适合在库边界把内部错误转成对外API的错误码或者字符串消息:
cpp复制std::expected<int, std::string> result = parse_int("abc")
.transform_error([](ParseError e) -> std::string {
switch (e) {
case ParseError::EmptyInput: return "empty input";
case ParseError::InvalidInput: return "invalid characters";
case ParseError::OutOfRange: return "out of range";
}
return "unknown error";
});
另外,expected<void, E>也有自己的特化形态,常用于校验类函数,不返回值但可能失败:
cpp复制std::expected<void, ParseError> validate(const std::string& s) {
if (s.empty()) {
return std::unexpected(ParseError::EmptyInput);
}
return {};
}
void特化没有operator->,解引用语义也不同,主要用途就是has_value()和error()检查。
4. 异常机制的隐性成本,被大多数项目低估了
4.1 异常在正确路径上是"零成本",但在错误路径上不是
很多人对"异常零成本"这句话有误解。在Itanium ABI的模型下,异常正常路径确实几乎没有额外开销,编译器不需要在函数入口注册任何错误处理代码,函数正常返回和普通函数一样快。但这句话有个没说出口的前提:前提是你从来不throw。
一旦执行throw,运行时就要进行栈展开(stack unwinding),逐层销毁已经构造出来的局部对象,通过LSDA和catch表找到匹配的处理器。这个过程牵扯到很多内存访问和函数调用,开销通常是几百到几千个时钟周期。如果"失败"是低频事件,比如真的发生了不可恢复的硬件故障,那这个成本无所谓;但如果"失败"是每个请求都可能会触发的业务校验,比如用户提交了不合法表单,那异常的错误路径成本就非常扎眼。
用expected替代之后,错误分支只是一次普通的分支判断加一个赋值,编译器甚至可以把错误对象直接放在寄存器里,不碰内存。在高频失败路径上,这差距是实打实的性能优化。
4.2 代码体积与内联的连锁反应
异常还会影响代码体积。启用异常后,编译器会为每个可能抛异常的函数生成额外的冷路径(cold path),同时生成异常处理表。这些数据虽然不在热路径上,但会显著增加二进制体积。在嵌入式、移动端这种对包体积敏感的场景,异常带来的体积膨胀是一个真实问题。
更隐蔽的影响是内联。当一个函数无法证明自己不会抛出异常时,编译器在做内联决策时会更加保守。尤其是一些小函数,如果它调用了可能抛异常的函数,编译器可能为了保留异常传播路径而放弃内联,哪怕函数体本身非常短。这会让热路径上的指令缓存更差,性能进一步受损。
4.3 禁异常环境:游戏引擎、嵌入式与Android NDK的现实
游戏引擎基本都不开异常。Unreal Engine默认-fno-exceptions,很多嵌入式项目的构建系统也直接关掉异常支持。原因倒不完全是不信任异常这个机制,而是异常在这些环境里会引入不可控的运行时开销,而且在资源紧张的设备上,栈展开本身也是一个风险点。
如果你的代码库重度依赖异常做错误处理,遇到这种禁异常平台就得动大手术。更麻烦的是第三方库的错误处理风格各不相同:有的抛异常,有的返回错误码,有的用expected。在同一个项目里混合这些风格,调用边界上就得不断做转换,非常折磨人。
4.4 并发与协程场景的坑
异常和并发的配合也很别扭。C++17的并行STL算法,比如std::for_each(std::execution::par, ...),如果元素处理函数抛异常,标准规定行为是调用std::terminate,程序直接终止。这意味着并行算法里不能依靠异常传播错误,你必须用其他方式收集错误。expected这种值语义的错误处理在并行环境下就自然得多,每个子任务把错误存进自己的expected,最后汇总。
C++20协程也是如此。异常在协程里会强制销毁协程帧,然后异常向上传播。如果协程被用在高频异步流水线中,一个业务校验失败就要走一遍协程帧销毁重建流程,开销非常大。expected配合协程则更可控,虽然标准库还没提供原生的"co_await一个expected并提前返回错误"的操作符,但思路是对的。
5. 面对面对比:同一业务用异常和expected分别怎么写
5.1 业务场景设定
为了不流于空谈,我们找一个具体的例子:读取一个INI配置文件,里面包含端口号和超时时间,校验参数之后绑定socket,启动服务器。失败时,调用方需要知道到底是文件缺失、字段非法、端口越界还是socket绑定失败,并做相应的日志和回退处理。
5.2 异常版本实现
cpp复制struct Config {
int port;
int timeout_ms;
};
Config load_config(const std::string& path) {
auto lines = read_lines(path); // 文件不存在时抛 std::runtime_error
Config cfg{};
for (const auto& line : lines) {
if (line.starts_with("port=")) {
cfg.port = std::stoi(line.substr(5)); // 解析失败抛 exception
} else if (line.starts_with("timeout=")) {
cfg.timeout_ms = std::stoi(line.substr(8));
}
}
if (cfg.port < 1 || cfg.port > 65535) {
throw std::runtime_error("invalid port");
}
return cfg;
}
void run_server() {
try {
Config cfg = load_config("server.ini");
bind_socket(cfg.port);
start_loop(cfg.timeout_ms);
} catch (const std::exception& e) {
log_error("failed: {}", e.what());
exit(1);
}
}
异常版本看起来很简洁,main流程干净。但问题在于:catch拿到的是一个what字符串,如果你要针对不同错误做不同处理,比如"文件缺失"提示用户重新安装,"端口非法"提示修改配置,就必须解析字符串或者定义不同的异常类型,然后写多个catch块。一旦异常类型多了,catch块会变得非常长,而且编译器不会提醒你"这个函数可能抛出哪些异常"。
5.3 expected版本实现
cpp复制enum class ServerError {
FileNotFound,
BadLine,
InvalidPort,
InvalidTimeout,
BindFailed,
};
std::expected<Config, ServerError> load_config(const std::string& path) {
auto lines = read_lines(path);
if (!lines) {
return std::unexpected(ServerError::FileNotFound);
}
Config cfg{};
for (const auto& line : *lines) {
if (line.starts_with("port=")) {
auto port = parse_int(line.substr(5));
if (!port) return std::unexpected(ServerError::InvalidPort);
if (*port < 1 || *port > 65535) {
return std::unexpected(ServerError::InvalidPort);
}
cfg.port = *port;
} else if (line.starts_with("timeout=")) {
auto timeout = parse_int(line.substr(8));
if (!timeout) return std::unexpected(ServerError::InvalidTimeout);
cfg.timeout_ms = *timeout;
}
}
return cfg;
}
std::expected<void, ServerError> bind_socket(int port) {
if (port <= 0) return std::unexpected(ServerError::InvalidPort);
// 实际调用系统 bind
return {};
}
std::expected<void, ServerError> start_server(const std::string& path) {
auto cfg = load_config(path);
if (!cfg) {
return std::unexpected(cfg.error());
}
auto binding = bind_socket(cfg->port);
if (!binding) {
return std::unexpected(binding.error());
}
return {};
}
调用侧:
cpp复制auto server = start_server("server.ini");
if (!server) {
switch (server.error()) {
case ServerError::FileNotFound:
log_error("配置文件不存在");
break;
case ServerError::InvalidPort:
log_error("端口非法,请检查配置");
break;
case ServerError::BindFailed:
log_error("端口被占用");
break;
default:
log_error("未知配置错误");
break;
}
}
5.4 对比总结:这段对比的价值在哪里
| 维度 | 异常机制 | std::expected |
|---|---|---|
| 失败是否体现在签名 | 否,需要靠文档 | 是,类型系统可见 |
| 调用方是否被强制处理 | 否,不catch就往上抛 | 是,不检查就相当于主动忽略 |
| 错误类型信息 | 字符串或自定义异常类型 | 编译期强类型枚举 |
| 错误路径开销 | 栈展开,开销大 | 普通分支判断 |
| 主流程可读性 | 好,无错误噪音 | 差一些,中间层也要传递 |
| 适合场景 | 深层兜底、不可恢复错误 | 可预期失败、库边界、禁异常环境 |
最关键的差异是"显式性"。异常版本里,load_config会不会抛异常、抛什么异常,调用方必须读文档或看实现;expected版本里,函数签名直接写着std::expected<Config, ServerError>,调用方一眼就知道要处理ServerError。这在大型项目里价值巨大,因为代码重构的时候,如果错误类型变了,编译器会帮你把每个遗漏的调用点都标出来。
6. 我的选型策略与迁移实践
6.1 边界划分:库内部与库边界
我个人在项目里的原则很简单:库边界和外层接口,优先用expected;模块内部,逻辑错误继续用assert或者异常兜底。
库边界用expected,是因为库的调用方往往不在你的控制范围内。你抛一个异常出去,调用方如果没接住,整个任务就炸了;你返回expected,调用方至少能在编译期看到这个类型,被迫做一个决定。尤其是被多人维护的中间层服务,把错误类型显式化之后,团队成员之间的沟通成本明显下降。
模块内部使用expected就要克制一点。如果一个函数只在内部被调,而且它的调用点已经被检查了,再层层返回expected只会增加噪音。内部逻辑的不可恢复错误,比如"这里不可能为null但是变成了null",直接assert或者抛异常更合理。expected的价值在里体现不出来,反而让代码难读。
6.2 迁移的平滑路径
从异常迁移到expected,不建议一步到位。我的习惯是分四步走。
第一步,定义统一的错误类型。先把你领域里"可枚举、可恢复"的错误整理成enum class或者轻量struct,确认哪些错误会让调用方做不同的处理。这一步决定后续迁移是否有意义。
第二步,挑一个边界清晰的模块做试点。把一个"经常被调用、错误类型清晰"的函数从"抛异常"改成"返回expected",然后修所有调用点。这个过程能帮你暴露很多问题,比如错误类型设计不合理、模块间依赖不清晰、有些调用点在偷偷忽略错误。
第三步,在库入口保留一层try-catch兜底。因为依赖的底层函数可能还有第三方库在抛异常,入口处统一catch并转成expected,防止异常泄漏到边界之外。
第四步,跑全量错误路径测试。expected的好处是错误成了一个普通值,你可以在单元测试里非常方便地断言错误类型,而不是去捕获一个异常再检查what字符串。测试覆盖一下子容易了很多。
6.3 一个通用的异常转expected工具函数
如果项目里有大量第三方函数仍然抛异常,可以写一个小的工具函数来转换:
cpp复制template <typename F>
auto try_as_expected(F&& f)
-> std::expected<std::invoke_result_t<F&&>, std::exception_ptr> {
using R = std::invoke_result_t<F&&>;
try {
return static_cast<R>(std::forward<F>(f)());
} catch (...) {
return std::unexpected(std::current_exception());
}
}
用法:
cpp复制auto result = try_as_expected([] {
return std::stoi("123abc");
});
if (!result) {
// result.error() 是 std::exception_ptr,可以用 std::rethrow_exception 重新抛出
}
这个工具函数在适配旧代码时很有用,但它只是一个过渡手段。长期来看,更推荐让底层函数直接返回expected,因为std::exception_ptr虽然能保留原始异常,但它把错误类型重新藏回了运行时,调用方又回到了"我不知道具体是什么错误"的状态。
7. 九个容易踩的坑,和它们的解药
7.1 错误类型不能是void、引用,T不能是引用
expected<T, E>的第一个模板参数T不能是引用类型,第二个模板参数E不能是void、引用、数组和不完整类型。如果你真的想在expected里存引用,比如需要返回"某个全局对象的引用或者错误",你得用std::reference_wrapper
另一种常见错误是错误类型E自己不能递归包含expected,比如struct Error { std::expected<int, Error> next; },这种自引用会导致编译器报错。遇到这种需求,考虑用指针或者std::unique_ptr来打破循环。
7.2 operator*不检查,value()会抛异常
这个前面已经强调过,但值得再说一遍。operator在错误分支上是未定义行为,编译器不会给你任何警告,运行时代码可能会继续执行一个随机值,小项目里可能在很远的地方才暴露问题。value()则会通过std::bad_expected_access抛异常,你在调试器里能清楚地看到错误来源。我的建议是默认都用value(),只有在用profiler证明某个解引用是性能瓶颈时才换成operator,并且用注释讲清楚这里的前提。
7.3 注意T和E的构造和析构约束
expected本身要求T和E都是完整类型且可析构。如果你用默认构造expected<T, E>,T必须可默认构造;如果你直接返回T值,T必须可移动构造。E必须可移动构造,因为std::unexpected包装错误时会发生移动。这些约束在编译期就会暴露出来,但报错信息往往很长,容易让人摸不着头脑。所以设计错误类型时,尽量用简单的enum或者轻量struct,并确保它们满足可移动构造、可析构这些基本要求。
7.4 expected<void, E>的特化细节
expected<void, E>
