错误处理与 `expected`、异常设计
错误处理与 expected、异常设计
时间:2026/04/09
关键词:异常、
noexcept、std::expected、optional、错误传播、恢复性错误、编程错误
核心目标:建立一套工程上可执行的判断标准,知道什么时候该抛异常,什么时候该返回错误值或expected。
1. 错误处理不是“选一个 API”那么简单
错误处理真正要先回答的是:
- 这是不是预期内会发生的失败
- 调用方是否应该恢复
- 失败信息需要多详细
- 代码库是否接受异常
所以讨论异常和 expected 时,重点不是站队,而是:
- 哪种语义更适合这一层接口
2. 先把失败分类型
最有用的分类通常是:
2.1 编程错误 / 违反前置条件
例如:
- 越界
- 非法状态
- 不满足接口约束
这类错误通常不属于“正常业务失败”。
2.2 可恢复的业务失败
例如:
- 解析失败
- 文件不存在
- 权限不足
- 网络超时
调用方通常有机会决定下一步怎么做。
2.3 致命错误
例如:
- 系统资源耗尽
- 程序已进入不一致状态
3. 异常适合什么场景
异常最适合:
- 错误很少发生
- 一旦发生,需要沿调用栈自动展开
- 局部函数不适合层层手动返回错误码
典型例子:
- 构造函数失败
- 资源获取失败
- 深层调用链中的异常退出
4. expected 适合什么场景
std::expected<T, E> 更适合:
- 失败是正常、可预期分支
- 调用方需要显式处理失败
- 你希望错误成为接口类型的一部分
例如:
- 配置解析
- 用户输入校验
- 业务规则检查
- 网络协议解析
5. optional 和 expected 的区别
optional<T> 表示:
- 可能有值,也可能没值
但它不告诉你:
- 为什么没值
expected<T, E> 表示:
- 要么有
T - 要么有错误
E
所以经验上:
- 只有“有没有结果”时,用
optional - 需要表达“为什么失败”时,用
expected
6. 一个最直接的例子
1 | std::optional<User> find_user(int id); |
前者表达“可能没有”,后者表达“失败且要知道原因”。
7. 什么时候不适合抛异常
7.1 失败是高频正常分支
7.2 热路径里非常在意开销和可预测性
7.3 跨 ABI / 跨模块边界不方便统一异常策略
7.4 团队整体约束就是禁用异常
这时更适合:
expected- error code
- status object
8. 什么时候异常特别合理
8.1 构造函数失败
8.2 无法在每层都写样板检查
8.3 资源清理由 RAII 承担
异常和 RAII 配合得最好:
- 抛出异常
- 栈展开
- 局部资源自动释放
9. 一个 expected 风格的例子
1 |
|
10. 异常风格的例子
1 | int parse_int_or_throw(std::string_view s) { |
11. 一层系统里最好统一风格
最容易出问题的是混乱:
- 一半函数抛异常
- 一半函数返回错误码
- 一半函数返回空值
更稳妥的做法是:
- 每一层接口尽量有统一错误处理约定
12. noexcept 的意义
noexcept 表示:
- 这个函数承诺不抛异常
它既是语义约束,也会影响某些容器对移动操作的选择。
13. 析构函数为什么通常不能抛
析构阶段如果异常继续外逃,尤其在栈展开过程中,会很危险。
工程上通常遵循:
- 析构函数不要让异常逃出
14. 一个很实用的决策表
14.1 用异常
当失败:
- 不常发生
- 不适合做常规分支
- 需要栈展开自动清理
14.2 用 expected
当失败:
- 很常见
- 需要显式处理
- 希望错误成为接口类型的一部分
14.3 用 optional
当失败:
- 只是“没有结果”
- 不需要详细错误原因
15. 常见坑
15.1 用异常做普通循环分支
15.2 用 optional 隐藏真实错误信息
15.3 一个模块里混用三四种错误风格
15.4 给所有函数乱加 noexcept
16. 一页总结
错误处理最重要的不是“异常 vs expected 谁更先进”,而是:
- 先判断失败是不是正常可恢复分支
- 再决定错误是否应该进入类型系统
- 再决定是否需要异常自动展开调用栈
可以直接记这条经验:
- 不常发生、跨层传播、依赖 RAII 清理:优先考虑异常
- 常见失败、需要显式处理、想把错误写进接口:优先考虑
expected
如果只记一句:
错误处理风格最怕的不是选错,而是同一层接口没有一致性。