C++ 没有统一的「错误处理方式」——return -1throwstd::optionalstd::expected 各有战场。选错方式要么性能吃亏,要么错误被静默忽略,要么 API 语义含糊。

这一篇用同一个「解析配置文件」场景,对比 四种主流错误处理策略,并给出选型指南。对应 demo:ref/cpp_demo/basics/error_handling_demo/

这是「现代 C++ 实战」系列的第 8 篇。建议先读 第 07 篇:C++17 工具箱

一、为什么错误处理值得单独讲?

现代 C++ 项目里,错误处理往往比算法更影响可维护性

  • 调用方会不会忘记检查返回值?
  • 失败时资源会不会泄漏(文件句柄、锁、内存)?
  • API 语义是否清晰:「找不到」和「出错了」是一回事吗

demo 用解析 host=...port=... 的配置文件贯穿全文——同一功能,四种写法。

二、C 风格错误码:return -1 + 输出参数

1
2
3
4
5
6
7
8
enum class CErrorCode { SUCCESS = 0, FILE_NOT_FOUND = -1, INVALID_FORMAT = -2 };

int parse_config(const std::string& path, Config& out, CErrorCode& err) {
std::ifstream file(path);
if (!file.is_open()) { err = CErrorCode::FILE_NOT_FOUND; return -1; }
// ... 解析 ...
return 0;
}
优点 缺点
零异常开销,适合底层 / 热路径 容易忘记检查返回值
与 C API、系统调用一致 返回值被错误码占用,结果要走输出参数
简单直观 错误码含义靠文档,信息量少

适用:系统调用封装、嵌入式、性能极度敏感且错误路径极少的代码。

三、C++ 异常:throw + try/catch + RAII

1
2
3
4
5
6
7
8
9
10
11
12
13
Config parse_config(const std::string& path) {
std::ifstream file(path);
if (!file.is_open())
throw std::runtime_error("file not found: " + path);
// ... 解析失败 throw ...
return config;
}

try {
auto cfg = parse_config("app.conf");
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
}

RAII 是异常安全的基石ifstream、智能指针、lock_guard 在栈上析构时自动释放——即使中途 throw 也不会泄漏。

异常安全等级 含义
基本保证 不泄漏,对象处于有效状态
强保证 失败时状态不变(如 copy-and-swap)
nothrow 绝不抛异常

适用:业务逻辑、构造函数失败、错误路径复杂且需要丰富错误信息的场景。

注意:不要用异常做正常控制流(如循环退出);析构函数默认 noexcept,析构里抛异常会 std::terminate

四、std::optional:「可能没有值」,但不是错误

1
2
3
4
5
6
7
8
9
10
11
std::optional<Config> find_config(const std::string& name) {
if (name == "default")
return Config{"localhost", 8080, false};
return std::nullopt; // 找不到——不一定是「出错」
}

if (auto cfg = find_config("prod")) {
use(*cfg);
} else {
use_defaults();
}
场景 用 optional
map 查找、缓存命中 ✅ 「没有」是正常结果
解析失败、文件不存在 ❌ 应使用 expected 或异常

optional 表达的是可空性,不是错误语义——别把「磁盘坏了」和「键不存在」混成一个 nullopt

五、std::expected<T, E>(C++23):值或错误,类型安全

Rust 的 Result 在 C++23 里的对应物:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
enum class ParseError { FileNotFound, InvalidFormat, MissingKey };

std::expected<Config, ParseError> parse_config(const std::string& path) {
std::ifstream file(path);
if (!file.is_open())
return std::unexpected(ParseError::FileNotFound);
// ...
return config;
}

auto result = parse_config("app.conf");
if (result) {
use(*result);
} else {
switch (result.error()) {
case ParseError::FileNotFound: /* ... */ break;
// ...
}
}
对比 optional expected
语义 可能有值 成功值 错误
错误信息 无(只有空) 携带 E 类型
性能 轻量 轻量(无异常展开)

适用:库 API、需要明确错误类型、又不想用异常的场景。编译器需 C++23(__cplusplus >= 202302L)。

六、std::error_code:与系统错误对接

操作系统和 Boost.Asio 等库常用 error_code 而非异常:

1
2
3
4
5
std::error_code ec;
fs::copy_file(src, dst, ec);
if (ec) {
std::cerr << ec.message() << '\n'; // 人类可读
}

error_code + error_category平台相关错误码统一成可比较、可传递的对象——适合 I/O、网络底层。

七、选型指南

方式 何时用
错误码 C 互操作、系统调用、热路径、简单二元成败
异常 构造函数失败、深层调用栈、错误信息丰富、业务层
optional 「找不到 / 没有」是正常结果,不是 failure
expected 明确的成功/失败联合类型,库 API,无异常策略
error_code 文件/网络等系统级错误,与 POSIX/Win32 对接

决策顺序

  1. 「没有值」算正常吗?→ optional
  2. 失败需要携带类型化错误且不想抛异常?→ expected
  3. 底层 / C API / 性能关键?→ 错误码error_code
  4. 其余业务逻辑 → 异常 + RAII

八、noexcept 与性能直觉

  • 异常正常路径几乎零开销(零成本抽象的「零」指成功路径)
  • 异常抛出路径昂贵(栈展开)——适合「真异常」、低频失败
  • 标记 noexcept 帮助编译器优化移动操作;违反 noexceptterminate

不要为了「性能」把所有函数改成错误码——在错误极少发生时,异常往往更清晰;在错误是常态时(如解析器逐 token),expected 或错误码更合适。

九、demo 导览

ref/cpp_demo/basics/error_handling_demo/ 对同一配置解析场景演示五种方式(含 error_code):

1
2
cd ref/cpp_demo/basics/error_handling_demo
./build.sh --run

建议对照输出,看每种方式调用方必须写多少检查代码

十、小结

概念 要点
错误码 简单、快,易忽略检查
异常 RAII 保安全,适合罕见失败
optional 可空 ≠ 出错
expected C++23,值 | 错误,类型明确
error_code 系统 / I/O 层标准做法

现代 C++ 实战系列第 8 篇完。下一篇 C++20 格式化与编译期计算——std::formatconstexpr 进阶。

系列导航

篇号 标题 状态
07 C++17 工具箱
08 错误处理策略(本篇)
09 C++20 格式化与编译期计算 下一篇

完整大纲见工作区 docs/CPP_SERIES_OUTLINE.md