一个函数失败时怎么把「出错」这件事告诉调用方?C++ 里没有标准答案,只有三种流派:抛异常(exception)、返回错误码(error code)、返回值里塞一个「值或错误」的 std::expected(C++23)。三者不是谁取代谁,而是各有主场。这篇用同一个除法场景把它们并排跑一遍,帮你按场景选型。
1. 引子:三种写法,同一个函数
同样是「整数除法,除数为零算失败」,三种风格长这样:
// 错误码风格:返回值是错误码,结果通过 out 参数带出
enum class DivError { Ok, DivideByZero };
DivError div_err(int a, int b, int& out) {
if (b == 0) return DivError::DivideByZero;
out = a / b;
return DivError::Ok;
}
// 异常风格:失败就 throw,成功正常返回
int div_ex(int a, int b) {
if (b == 0) throw std::runtime_error("div by zero");
return a / b;
}
// expected 风格:返回值里要么是结果、要么是错误(需要 C++23)
std::expected<int, std::string> div_exp(int a, int b) {
if (b == 0) return std::unexpected(std::string("div by zero"));
return a / b;
}
三种风格的核心区别一句话:异常把错误「炸」出来、不可忽略但代价高;错误码把错误「摆」在明面、显式但容易漏;expected 把错误「装」进返回值、类型安全但需要 C++23。 下面逐个拆。
2. 错误码:显式,但最容易漏
错误码是最古老的做法:函数返回一个枚举/整数表示成败,真正的结果通过引用或指针参数带出。
#include <cstdio>
#include <string>
enum class DivError { Ok, DivideByZero };
DivError div_err(int a, int b, int& out) {
if (b == 0) return DivError::DivideByZero;
out = a / b;
return DivError::Ok;
}
int main() {
int result = 0;
DivError e1 = div_err(10, 2, result);
if (e1 == DivError::Ok) {
printf("10/2 = %d\n", result);
} else {
printf("10/2 出错\n");
}
DivError e2 = div_err(10, 0, result);
if (e2 == DivError::Ok) {
printf("10/0 = %d\n", result);
} else {
printf("10/0 出错(除数为零)\n");
}
}
10/2 = 5
10/0 出错(除数为零)
错误码最大的坑是「忘检查」:编译器不会因为你忽略返回值而报错。下面这行就能静默地埋下 bug——result 里是上一次的残留值,调用方却以为除法成功了:
#include <cstdio>
enum class DivError { Ok, DivideByZero };
DivError div_err(int a, int b, int& out) {
if (b == 0) return DivError::DivideByZero;
out = a / b;
return DivError::Ok;
}
int main() {
int result = 999; // 残留值
div_err(10, 0, result); // 反例:忘了检查错误码
printf("result = %d(错误码被忽略,残留值当成了结果)\n", result);
}
result = 999(错误码被忽略,残留值当成了结果)
官方文档:C++ Core Guidelines E.28 —— 错误码风格的典型问题:状态容易丢失、被忽略
3. 异常:不可忽略,但有代价
异常把错误从正常路径里彻底剥离:失败直接 throw,调用方要么 catch、要么让异常一路向上传播直到 terminate。错误不可被静默忽略,这是它最大的优点。
#include <cstdio>
#include <stdexcept>
int div_ex(int a, int b) {
if (b == 0) throw std::runtime_error("div by zero");
return a / b;
}
int main() {
try {
int r = div_ex(10, 2);
printf("10/2 = %d\n", r);
} catch (const std::exception& e) {
printf("出错: %s\n", e.what());
}
try {
int r = div_ex(10, 0);
printf("10/0 = %d\n", r);
} catch (const std::exception& e) {
printf("出错: %s\n", e.what());
}
}
10/2 = 5
出错: div by zero
代价在哪?现代编译器实现了零成本异常(zero-cost exception):不抛异常的「快乐路径」几乎不额外开销;但一旦真的抛出,就要做栈展开(stack unwinding):逐帧回溯找 catch、析构沿途对象,这条路径很贵。所以异常的定位是「异常情况」:真正罕见、不该频繁发生的事,而不是可预期的常规分支(比如「用户输入了非法字符」这种高频可预期情况,就不该用异常驱动流程)。
4. std::expected:值或错误,装进类型(C++23)
std::expected<T, E> 的返回值要么是 T,要么是 E,两者互斥、且都带类型。它解决了错误码「易漏」和「污染返回值」两个问题,又没有异常的栈展开代价。
// verify: std=c++23
#include <cstdio>
#include <expected>
#include <string>
std::expected<int, std::string> div_exp(int a, int b) {
if (b == 0) return std::unexpected(std::string("div by zero"));
return a / b;
}
int main() {
auto r1 = div_exp(10, 2);
if (r1.has_value()) {
printf("10/2 = %d\n", r1.value());
} else {
printf("10/2 出错: %s\n", r1.error().c_str());
}
auto r2 = div_exp(10, 0);
if (r2) {
printf("10/0 = %d\n", *r2);
} else {
printf("10/0 出错: %s\n", r2.error().c_str());
}
}
10/2 = 5
10/0 出错: div by zero
注意:expected 也不强制检查。你完全可以不判断 has_value() 就 value(),那样会抛 std::bad_expected_access。但它把「错误」变成了返回值的一部分,类型系统帮你把错误信息和成功值捆在一起,调用方一看签名就知道「这个函数可能失败,失败原因长什么样」。这段代码需要 C++23(gcc 14+ / clang 17+ 才带 <expected>)。
optional 和 expected 常被混淆,区别在于「错误带不带信息」:
std::optional<T> | std::expected<T, E> | |
|---|---|---|
| 语义 | 有值 / 无值 | 有值 / 错误 |
| 无值时有错误信息吗 | 没有,只知道「空」 | 有,E 可以携带原因 |
| 典型场景 | 「可能没有结果」(查找) | 「可能失败」(解析、IO) |
| 引入版本 | C++17 | C++23 |
5. 三大策略完整对比
| 维度 | 异常 | 错误码 | std::expected |
|---|---|---|---|
| 错误能否被忽略 | 不能(不 catch 就 terminate) | 能(忘检查就静默) | 能(不检查就 value() 会抛) |
| 失败频率假设 | 罕见(抛出路径贵) | 高频可预期 | 高频可预期 |
| 返回通道 | 与正常返回分离 | 占用返回值,结果走 out 参数 | 返回值装值或错误 |
| 性能 | 快乐路径零代价,抛出贵 | 恒为廉价分支 | 多一次分支 + 可能的存储开销 |
| 类型安全 | 强(异常有类型) | 弱(错误码是裸枚举) | 强(E 带类型) |
| 跨库/跨 ABI 边界 | 危险(不能跨 C 边界) | 安全 | 安全 |
| 版本要求 | C++98 起 | C++98 起 | C++23 |
错误传递路径的差别,一张图看明白:
异常:错误从正常路径「跳」出去,沿调用栈向上找 catch
main ──► parse ──► div_ex
│ throw
▼
栈展开,逐帧回溯 catch ──► 代价高
错误码:错误和结果一起「挤」在返回值/参数里,一步步手动传
main ◄── err ◄── parse ◄── err ◄── div_err
(每一层都要写 if 检查,漏一层就丢)
expected:错误「装」在返回值对象里,随值一路返回,类型可见
main ◄── expected<int,string> ◄── parse ◄── div_exp
(错误信息和成功值绑定,一眼看出「可能失败」)
6. 真实项目的混合策略
成熟的 C++ 项目很少只用一种,而是分层:
- 模块内部:用异常表达「逻辑错误 / 不该发生的 bug」,用
expected或错误码表达「可预期的失败」。 - 库/插件边界:一律用错误码或
expected,绝不让异常跨过 C ABI 或动态库边界(跨边界的异常对象布局不统一,是未定义行为区)。 - 对外 API:把内部的异常在边界兜住,转成错误码返回。
下面是一个完整的最小「除法服务」,内部分层,边界转错误码:
#include <cstdio>
#include <stdexcept>
#include <string>
// ---- 内部层:用异常表达「不该发生的错误」----
int div_internal(int a, int b) {
if (b == 0) throw std::invalid_argument("div by zero");
return a / b;
}
// ---- 边界层:兜住异常,转成错误码返回 ----
enum class Error { Ok, Invalid };
Error div_api(int a, int b, int& out) {
try {
out = div_internal(a, b);
return Error::Ok;
} catch (const std::exception&) {
return Error::Invalid;
}
}
int main() {
int r = 0;
Error e1 = div_api(20, 4, r);
printf("20/4 : %s", e1 == Error::Ok ? "ok" : "invalid");
if (e1 == Error::Ok) printf(", 结果 %d", r);
printf("\n");
int r2 = 0;
Error e2 = div_api(20, 0, r2);
printf("20/0 : %s\n", e2 == Error::Ok ? "ok" : "invalid");
}
20/4 : ok, 结果 5
20/0 : invalid
官方文档:C++ Core Guidelines E.15 —— 异常按引用捕获,边界处兜住转错误码的推荐做法
7. 延伸阅读
- std::expected — cppreference:C++23 的「值或错误」,
value()/error()/and_then等全部 API - std::optional — cppreference:C++17 的「可能没有值」,与
expected的选型对照 - C++ 异常机制 — cppreference:throw/try/catch 的语义与栈展开
- C++ Core Guidelines 错误处理章节:E 系列规则,异常与错误码的官方取舍建议
本知识库内的相关篇目:
- 《C ABI 与插件系统:dlopen 导出稳定接口》 —— 为什么插件系统都要用 C ABI?这篇从名字修饰讲起
- 《C++ 序列化与版本兼容:没有反射,手写字段也能持久化》 —— C++ 没有内置反射,序列化只能靠显式声明字段。
- 《C++ 跨平台工程:特性检测与 feature-test 宏实战》 —— 一份代码要在 Windows/Linux/macOS 上都能编,关键是不写死平台和标准。
8. 一句话总结
三种策略各有主场:异常留给「罕见且不可忽略」的逻辑错误(零成本快乐路径、抛出代价高),错误码留给「高频可预期」的常规失败(显式但易漏),C++23 的 std::expected 在两者之间——把错误装进类型、跨库边界安全,是现代库接口的首选。真实项目要分层混用,边界处把异常兜住转错误码,绝不让异常跨过 C ABI。
352



被折叠的 条评论
为什么被折叠?



