1. 为什么突然想聊std::expected
最近群里又有人因为"异常到底该不该用"吵起来了,起因是有人在代码评审里用了std::expected,另一个同事说不认识这玩意,觉得不如继续抛异常。这场面其实这两年很常见——C++23正式把std::expected收进标准库之后,很多团队的代码规范里多了一条"新代码优先用expected,异常只留给真正的异常情况"。
先说清楚它是什么。std::expected在C++23里落地,它更像是对C++17那个std::optional的扩展,只不过optional只表示"有值还是没值",而expected把这个概念推进一步:它要么存一个"预期中的正常值",要么存一个"错误详情"。没有异常抛出、没有栈展开、没有运行时开销的惩罚,错误作为值老老实实地在类型系统里流动。
那问题来了,C++不是早就有异常机制吗?try/catch用了这么多年,RAII配栈展开也不是闹着玩的,为什么还要再搞一个错误处理方案出来?这个问题的答案恰恰涉及到异常机制本身的两个软肋:性能和"异常不适用于所有场景"这件事。
这篇文章不站队,我会把std::expected和异常机制从头到尾做一次对比,从异常的性能成本、类型安全、可组合性,到实际工程里怎么混用两者、怎么避坑,尽量讲得直白一点。适合正在为团队制定错误处理规范的开发者,也适合反复被问"expected和异常到底选谁"的面试候选人。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. std::expected是什么
2.1 一段代码快速认识expected
看代码比看标准库文档直观得多。假设我们要从一个配置文件里读端口号,传统的错误处理写法是函数返回bool,再通过输出参数带回数值:
cpp复制bool read_port_from_config(const std::string& path, int& port);
这种写法的问题很经典:调用方很容易忘记检查返回值,port可能根本没被初始化,而且错误细节完全丢失,只知道"失败了",不知道"为什么失败"。于是很多人退而求其次,选择抛异常:
cpp复制int read_port_from_config(const std::string& path) {
if (!file_exists(path)) {
throw std::runtime_error("config file not found");
}
// ...
return port;
}
而std::expected的版本长这样:
cpp复制std::expected<int, std::string> read_port_from_config(const std::string& path) {
if (!file_exists(path)) {
return std::unexpected("config file not found: " + path);
}
return port;
}
调用的时候,代码可以这样写:
cpp复制auto port = read_port_from_config("app.conf");
if (!port) {
std::cerr << "load failed: " << port.error() << std::endl;
return;
}
start_server(*port);
它与optional最显著的区别就在这里:不满足条件时返回的unexpected可以携带一个我们自定义的错误类型。std::string只是最简单的一例,工程上完全可以用enum、错误码结构体,甚至一个带上下文信息的Error对象。这就把"没有值"升级成了"带着为什么没有值"。
2.2 expected不是新发明,它是函数式错误处理的延续
如果熟悉Rust的Result类型或者Haskell的Either类型,看std::expected会毫无障碍。它就是C++对这套"错误即值"理念的实现。标准委员会其实很早就铺路了——std::optional先用起来,让大家习惯"某个函数可能拿不到结果"的思维,再推出expected,让大家把"拿不到结果的原因"也装进类型里。
还有一个必须提的东西叫std::error_code。C++11其实就有了这个玩意,从POSIX错误码抽象出来的错误传播载体,配合std::system_error可以做到不抛异常也携带错误码。但问题在于,error_code的设计偏底层,适合系统调用、网络库这类场景,对应用层开发者来说不够直观。std::expected厉害的地方在于它的错误类型是模板参数,留给使用方极大的自由度。
这里插一句:expected在标准库里的别名和关系要理清,它和std::optional同属于工具组件,但语义不同。optional表示"可能没有"是正常的业务状态,比如查找一个不存在的键;expected表示"这事儿可能失败"且失败需要被看到、被处理或者被传播。工程上的语义差别很大,别混着用。
2.3 关键API与惯用法速览
先用最快的方式过一下必备API,方便后面展开对比时读者不至于迷路:
- 构造:正常返回时直接
return value;,错误返回时return std::unexpected(error); - 观察:
has_value()判断是否有正常值,operator bool也可用,*解引用拿值,error()拿错误 - 转换:
value_or(default)拿值或给默认值,transform对正常值做映射,and_then链式调用下一个返回expected的函数 - 错误分支处理:
or_else在错误时执行一段逻辑
一个最常用的链式写法示例:
cpp复制std::expected<Config, std::string> load_config() {
auto port = read_port_from_config("app.conf")
.and_then(validate_port_range) // 校验端口合法性,返回 std::expected<int, std::string>
.transform(Config::from_port); // 把int转成Config对象
return port;
}
这里值得展开讲讲and_then和transform的区别。transform是对"正常值"做映射,如果当前是错误就直接透传错误不执行函数;and_then是把"正常值"喂给下一个同样返回expected的函数,把两个可能失败的步骤串成一条链。如果中途任何一步出错,后面的步骤全部跳过,错误作为最终结果返回。这套组合子设计让错误传播变得极其简洁,从Fortran时代流传下来的"错误码逐层if判断"在它面前完全过时了。
3. 异常机制的底层真相:它到底贵在哪
3.1 零成本异常的谎言与真相
C++社区流传着一句话叫"零成
