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

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

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

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

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

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

demo 用解析 host=...、port=...、ssl=... 的 config.txt 贯穿全文——同一功能,五种写法(C 错误码、异常、error_code、optional、expected),下文前四节对应四种主流策略,error_code 放在第六节单独讲。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
enum class CErrorCode {
SUCCESS = 0,
FILE_NOT_FOUND = -1,
INVALID_FORMAT = -2,
MISSING_KEY = -3
};

int parse_config_c_style(const std::string& filename, Config& config, CErrorCode& error) {
std::ifstream file(filename);
if (!file.is_open()) {
error = CErrorCode::FILE_NOT_FOUND;
return -1;
}
// ... 逐行按 '=' 拆分,缺少 '=' → INVALID_FORMAT,缺 host/port → MISSING_KEY ...
error = CErrorCode::SUCCESS;
return 0;
}
优点 缺点
零异常开销,适合底层 / 热路径 容易忘记检查返回值
与 C API、系统调用一致 返回值被错误码占用,结果要走输出参数
简单直观 错误码含义靠文档,信息量少

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class ConfigException : public std::runtime_error {
public:
explicit ConfigException(const std::string& msg)
: std::runtime_error("ConfigError: " + msg) {}
};

class FileNotFoundException : public ConfigException {
public:
explicit FileNotFoundException(const std::string& filename)
: ConfigException("File not found: " + filename) {}
};

Config parse_config_exception(const std::string& filename) {
std::ifstream file(filename);
if (!file.is_open()) {
throw FileNotFoundException(filename);
}
// ... 格式错误 / 缺键 → throw InvalidFormatException(...) ...
return config;
}

try {
Config config = parse_config_exception("config.txt");
} catch (const FileNotFoundException& e) {
std::cout << "捕获异常: " << e.what() << "\n";
} catch (const ConfigException& e) {
std::cout << "捕获异常: " << e.what() << "\n";
} catch (const std::exception& e) {
std::cout << "捕获标准异常: " << e.what() << "\n";
}

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

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

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

demo 还演示了 noexcept:validate_config(const Config&) noexcept 发现端口越界时只打印警告,不能 throw。

注意:不要用异常做正常控制流(如循环退出);析构函数默认 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。demo 里的 parse_config_optional 为了横向对比,把「文件不存在 / 格式错误 / 缺键」统统返回 std::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
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
struct ConfigError {
std::string message;
ConfigErrc code; // 复用第六节的错误枚举

ConfigError(const std::string& msg, ConfigErrc c)
: message(msg), code(c) {}
};

std::expected<Config, ConfigError> parse_config_expected(const std::string& filename) {
std::ifstream file(filename);
if (!file.is_open()) {
return std::unexpected(ConfigError(
"File not found: " + filename,
ConfigErrc::file_not_found
));
}
// ... 格式错误 → invalid_format,缺键 → missing_key ...
return config;
}

auto result = parse_config_expected("config.txt");
if (result.has_value()) {
Config& config = result.value();
// 使用 config
} else {
ConfigError& error = result.error();
std::cout << "解析失败: " << error.message
<< " (code: " << static_cast<int>(error.code) << ")\n";
}

// C++23 单子操作:and_then 链式校验
result.and_then([](const Config& cfg) -> std::expected<Config, ConfigError> {
if (cfg.port < 1024) {
return std::unexpected(ConfigError("Port too low", ConfigErrc::invalid_format));
}
return cfg;
});
对比 optional expected
语义 可能有值 成功值 或 错误
错误信息 无(只有空) 携带 E 类型
性能 轻量 轻量(无异常展开)

适用:库 API、需要明确错误类型、又不想用异常的场景。编译器需 C++23(__cplusplus >= 202302L);demo 用这个条件定义 HAS_EXPECTED,不满足时 demo_expected() 只打印当前 __cplusplus 值。

六、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、网络底层。

demo 更进一步,为配置解析自定义了错误类别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
enum class ConfigErrc { success = 0, file_not_found, invalid_format, missing_key };

class ConfigErrorCategory : public std::error_category {
public:
const char* name() const noexcept override { return "config"; }
std::string message(int ev) const override {
switch (static_cast<ConfigErrc>(ev)) {
case ConfigErrc::success: return "Success";
case ConfigErrc::file_not_found: return "Configuration file not found";
case ConfigErrc::invalid_format: return "Invalid configuration format";
case ConfigErrc::missing_key: return "Missing required configuration key";
default: return "Unknown error";
}
}
};

std::error_code make_error_code(ConfigErrc e) {
return std::error_code(static_cast<int>(e), config_error_category());
}

namespace std {
template<>
struct is_error_code_enum<ConfigErrc> : public true_type {};
}

// 返回「结果 + 错误码」对,调用方用结构化绑定接收
std::pair<Config, std::error_code> parse_config_error_code(const std::string& filename);
auto [config, ec] = parse_config_error_code("config.txt");

失败时 ec.message() 给出可读信息,ec.category().name() 为 "config";需要时还能 throw std::system_error(ec) 转成异常。

七、选型指南

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

决策顺序:

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

八、noexcept 与性能直觉

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

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

九、demo 导览

ref/cpp_demo/basics/error_handling_demo/ 对同一配置解析场景演示五种方式(含 error_code)。main 先在当前目录写出 config.txt(host=localhost、port=8080、ssl=true),再依次调用 demo_c_style()、demo_exception()、demo_error_code()、demo_optional()、demo_expected(),最后打印一张对比表:

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

demo 的 CMake 是 set(CMAKE_CXX_STANDARD 23),需要 GCC 12+ / Clang 16+ / Apple Clang 15+ 这类带 <expected> 的标准库;默认构建即可看到第 5 段的实际输出:

1
2
解析成功: host=localhost, port=8080, ssl=1
配置验证通过: 端口号 8080 有效

第二行来自 and_then 链:demo 用 auto validated = result.and_then(...) 接住返回值再判断,验证失败时打印「配置验证失败」。如果编译器不支持 std::expected,第 5 段会退化为一句提示,其余 4 段不受影响。

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

十、小结

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

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

系列导航

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