错误处理策略:选择正确的方法
比较异常、错误码和 std::expected──学习每种方法的适用场景以及如何在构造函数、析构函数和多线程代码中处理错误。
没有万能的解决方案
C++ 提供多种错误处理机制,每种都有不同的权衡。选择正确的方式取决于你的领域:
异常最适合罕见且无法在本地处理的错误。它们在成功路径上零开销,但在抛出时代价高昂(数千纳秒)。它们自动跨调用帧传播。
错误码(返回值、errno、std::error_code)最适合预期的且频繁的错误。它们在每次调用时有小的恒定开销(调用者必须检查返回值)。它们是 C API 和内核级代码的标准。
std::expected(C++23)结合了两者的优点:它强制调用者确认错误(像错误码一样),但携带丰富的错误类型(像异常一样),并支持单子链式操作。它正迅速成为新 C++ 代码中的首选方法。
关键洞察:这些不是相互竞争的哲学──它们是针对不同情况的工具,生产代码通常会同时使用这三种。
比较:异常 vs 错误码 vs std::expected
| 标准 | 异常 | 错误码 | std::expected |
|---|---|---|---|
| 成功时的开销 | 零(基于表) | 分支 + 检查 | 最小(存储值) |
| 错误时的开销 | 高(展开) | 与成功时相同 | 与成功时相同 |
| 可以被忽略? | 否(程序终止) | 是(静默错误) | 更难(必须访问 .value()) |
| 构造函数 | 唯一选择 | 无法返回值 | 无法返回值 |
| 析构函数 | 不能抛出 | 可以 | 可以 |
| 可组合性 | 有限(try/catch) | 手动 if 链 | 单子式(and_then、transform) |
| 二进制大小 | 较大(展开表) | 最小 | 最小 |
| 实时安全? | 否(无界时间) | 是 | 是 |
构造函数中的错误处理
构造函数无法返回错误码──它们没有返回值。异常是表示构造失败的唯一干净机制。抛出的异常意味着对象从未完全构造,因此其析构函数永远不会被调用(但已构造的子对象和基类的析构函数会被调用)。
#include <fstream>
#include <string>
#include <stdexcept>
#include <iostream>
class ConfigFile {
public:
explicit ConfigFile(const std::string& path)
: path_(path), stream_(path) {
if (!stream_.is_open()) {
throw std::runtime_error(
"Cannot open config file: " + path);
}
// Parse the file — if this throws, stream_ is
// already constructed and its destructor will
// close the file automatically (RAII)
parse();
}
std::string get(const std::string& key) const {
auto it = data_.find(key);
if (it == data_.end()) {
throw std::out_of_range("Key not found: " + key);
}
return it->second;
}
private:
void parse() {
std::string line;
while (std::getline(stream_, line)) {
auto pos = line.find('=');
if (pos != std::string::npos) {
data_[line.substr(0, pos)] = line.substr(pos + 1);
}
}
}
std::string path_;
std::ifstream stream_; // RAII: closed by destructor
std::map<std::string, std::string> data_;
};
// Usage: the object is fully valid or does not exist
int main() {
try {
ConfigFile cfg("/etc/myapp.conf");
std::cout << cfg.get("port") << '\n';
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
}
}用 std::error_code 处理系统级错误
std::error_code 为系统级错误提供轻量级、非抛出的机制。 库是一个很好的示例──大多数函数都有一个接受 std::error_code& 参数而非抛出异常的重载。
#include <filesystem>
#include <iostream>
#include <system_error>
namespace fs = std::filesystem;
// Throwing version — suitable for unexpected errors
void copy_file_throwing(const fs::path& src, const fs::path& dst) {
fs::copy_file(src, dst); // throws fs::filesystem_error on failure
}
// Non-throwing version — suitable for expected failures
bool copy_file_safe(const fs::path& src, const fs::path& dst) {
std::error_code ec;
fs::copy_file(src, dst, ec); // sets ec instead of throwing
if (ec) {
std::cerr << "Copy failed: " << ec.message()
<< " (" << ec.category().name()
<< ':' << ec.value() << ")\n";
return false;
}
return true;
}
int main() {
// The error_code version is preferable in loops where
// failure is expected (e.g., batch file processing)
for (const auto& entry : fs::directory_iterator(".")) {
std::error_code ec;
auto sz = fs::file_size(entry.path(), ec);
if (!ec) {
std::cout << entry.path().filename()
<< ": " << sz << " bytes\n";
}
}
}C++11 及以后版本中的析构函数隐式地是 noexcept。如果析构函数抛出异常,std::terminate() 会被调用。即使你显式地将析构函数标记为 noexcept(false),在栈展开期间(当另一个异常已经处于活动状态时)抛出异常也会调用 std::terminate()。
- 记录并抑制:如果清理操作失败,记录错误并继续
- 提供单独的
close()方法:让调用者在销毁前显式处理清理错误 - 设计资源使清理不会失败:例如,关闭文件描述符在操作系统层面总是成功的
- 永远不要从析构函数中调用可能抛出异常的函数,除非将其包裹在 try/catch 中
多线程代码中的错误处理
异常不会跨线程边界传播。如果线程抛出异常且该异常未在该线程内被捕获,std::terminate() 会被调用。要在线程之间传播异常:
1. 使用 std::future 和 std::async:异步任务中抛出的异常会被捕获,并在你对 future 调用 .get() 时重新抛出。
2. 使用 std::exception_ptr 配合 std::current_exception() 和 std::rethrow_exception() 来手动捕获和传输异常。
3. 使用 std::promise::set_exception() 将异常从生产者线程发送到消费者线程。
对于高吞吐量的并发代码,错误码或 std::expected 通常更受欢迎,因为它们避免了每个线程中异常基础设施的开销,并且更容易跨线程边界进行推理。
将错误处理机制与具体情况相匹配。
- 使用异常处理罕见的、无法在本地处理并应沿栈向上传播的错误(例如,文件未找到、数据库宕机)
- 使用错误码处理失败是正常且预期的高频操作(例如,缓存查找未命中、非阻塞 I/O 暂时不可用)
- 使用
std::expected编写新代码,当你需要显式错误处理、丰富的错误类型和单子式组合时 - 在构造函数中使用异常──它们是在对象存在之前表示失败的唯一方式
- 永远不要在析构函数中抛出异常──记录错误并抑制它
- 异常、错误码和
std::expected是互补的工具──不是相互竞争的哲学 - 构造函数必须使用异常来表示失败;错误码不是一种选择
- 析构函数决不能抛出异常──自 C++11 起它们隐式地是
noexcept - 异常不会跨线程边界传播──使用
std::future或std::exception_ptr来传输它们 std::error_code非常适合系统级错误,当你需要非抛出的 API 时
测验 — 检验你的知识
(15 经验值)1. 为什么在构造函数中异常是首选的错误处理机制?
2. 如果在另一个异常的栈展开过程中,析构函数抛出了异常,会发生什么?
3. 如何在线程之间传输异常?