错误处理
概述
现代 C++ 为各类异常的处理提供了一系列工具。错误处理不仅是将错误记录下来,更是融入到接口设计中的设计思想。一个函数的返回值类型中应当体现其运行结果、失败恢复机制、失败处理等元素。
在现代 C++ 中,std::optional 和 std:expected 通过补全类型系统使得错误可以通过调用链进行传递,使得调用方不能在不做判断的情况下取得一个可能不安全的值。他们使得异常链更为清晰。
在现代 C++ 错误处理设计思想中,没有值是一种可以被预见的正常状态,而失败则是一次异常,表示程序没有正确结束。
假设我们要编写一个根据用户名查找头像的接口,那么现在有四种可能的接口类型表达:
| 方式 | 接口类型 | 含义 | 场景 |
|---|---|---|---|
| 异常 | Avatar load(...) | 正常路径直接得到值;无法在当前层合理处理的问题通过栈展开离开 | 构造失败、违反不变量、资源耗尽等异常情况 |
| 错误码 | bool load(..., Avatar&, error_code&) | 调用者必须检查状态,并从额外参数取得结果和原因 | C API、ABI 边界、极低层或既有接口 |
optional | optional<Avatar> find(...) | 可能查不到,但“查不到”本身是正常答案 | 查找、可选配置、可选字段 |
expected | expected<Avatar, error_code> load(...) | 成功有值;失败有可检查、可传播的原因 | I/O、解析、RPC、业务校验等可预期失败 |
接口的选择不是越新越好,尽管 C++23 的 expected 提供了更为现代的处理方式。正确的处理方式是依赖场景的:如果我们认为没有结果也是一次成功的回答,那么 optional<T> 是合适的,因为他相当于为空值外面添加了一层 wrapper,用于保护程序不受非法的值的影响。如果失败原因会影响调用的下一步,则可以考虑利用 expected<T, E> ,或者自定义某种错误码协议,比如 std::error_code。
异常仍有位置。例如 std::vector 分配内存失败会抛出 std::bad_alloc;析构函数也不能可靠地向外返回错误。这种失败通常不是每个调用点都能局部恢复的分支,让它沿调用栈寻找能够处理它的边界往往更合理。
传统 C 风格常把“结果”“是否成功”“失败原因”拆成三个通道:
bool read_file(const std::filesystem::path& path,
std::string& content,
std::error_code& error);它能工作,但成本不低。content 在失败时是否保持原值要靠文档约定;调用者可能忘记检查 bool;error 是输入还是输出也不直观。另外,错误码很容易在多层调用中被遗漏。
对比之下,std::expected<T, E> 把成功值与错误值收进一个对象,形成一个不可变式:对象要么持有 T,要么持有 E,不会同时缺失,也不会同时存在。 这正是错误码方案想表达、却需要额外约定才能维持的状态。
在 expected 之前,C++17 还提供了 std::variant 来提供一对互斥状态的不可变式。它提供了表达 expected 这种互斥状态的基础能力:
using FileResult = std::variant<std::string, std::error_code>;在这样的声明下,FileResult 不是字符串就是错误码。可用 std::holds_alternative 和 std::get 检查:
FileResult result = std::make_error_code(std::errc::no_such_file_or_directory);
if (std::holds_alternative<std::string>(result)) {
const auto& text = std::get<std::string>(result);
// 使用 text
} else {
const auto error = std::get<std::error_code>(result);
// 处理 error
}variant 很适合建模真正的多态状态,例如词法分析器的 token、UI 状态、协议消息,这也是 variant 这个名字的含义。对于正确/错误这样的常见的二选一的问题,更新的 C++ 标准提供了语义更直接的解决方案,即 expected 和 optional。这两个可以理解为 variant 的变体:
template<class T>
using OptionalLike = std::variant<std::monostate, T>;
template<class T, class E>
using ExpectedLike = std::variant<T, E>;Optional
“从文本中解析整数”是典型的 optional 场景。下面的函数把输入是否是一个完整整数作为判断条件,即不是整数时返回空值,这种状态被认为是正常的。
std::optional<int> parse_int(std::string_view text) {
int value{};
const char* first = text.data();
const char* last = first + text.size();
const auto [end, error] = std::from_chars(first, last, value);
if (error != std::errc{} || end != last) {
return std::nullopt;
}
return value;
}调用方必须先处理“有无值”:
if (const auto port = parse_int("8080")) {
start_server(*port);
} else {
show_input_hint();
}这里的 *port 是经过条件检查后的访问。也可以使用下面几个工具,但含义不同:
const int port = parse_int("8080").value_or(3000); // 缺失时使用默认值
const int strict = parse_int("8080").value(); // 空时抛 std::bad_optional_accessvalue_or 适合默认值确实正确的场景,例如可选配置。不要用它来省略必须反馈给用户的无效输入。value() 则适合你已经通过程序逻辑证明必定有值的位置。把它当作普通访问方法,会把本应显式处理的分支重新变成异常。
但是,optional 并不适合用来处理错误。比如下面的接口:
std::optional<std::string> read_text_file(const std::filesystem::path& path);空值可能代表文件不存在、权限不足、路径是目录、编码不支持,或者网络盘临时断开。调用者无法决定该提示“文件不存在”、请求授权,还是稍后重试。只要这些差异有业务价值,就应返回错误原因。这就是 expected 提出的意义。
Expected
std::expected<T, E> 有两种状态:成功时保存 T,失败时保存 E。这里用标准的 std::error_code 作为错误类型,方便与文件系统、网络和操作系统错误互通。下面给出一个例子:
std::expected<std::string, std::error_code>
read_text_file(const std::filesystem::path& path) {
std::ifstream file(path, std::ios::binary);
if (!file) {
return std::unexpected(
std::make_error_code(std::errc::no_such_file_or_directory));
}
std::ostringstream buffer;
buffer << file.rdbuf();
if (!file.eof() && file.fail()) {
return std::unexpected(
std::make_error_code(std::errc::io_error));
}
return buffer.str();
}调用处可以分支处理,也可以先把错误向上传播:
auto text = read_text_file("config.toml");
if (!text) {
report_error(text.error().message());
return;
}
parse_config(*text);text.error() 只应在 !text 的分支中使用;*text 和 text.value() 只应在成功分支中使用。expected 也有 value_or,但它只适合读取失败时使用的内建默认配置确实是正确策略的情况,而不是通用的错误处理方式。
示例为了延时返回类型,把“打不开文件”统一映射为
no_such_file_or_directory。生产代码若要区分权限、路径类型等系统原因,应优先使用std::filesystem的带std::error_code重载,或在平台 API 边界保留原始错误码。
网络请求的失败原因常不属于操作系统错误:HTTP 401、超时、协议格式错误、服务端限流需要不同策略。这时定义面向领域的错误类型更合适:
enum class FetchError {
timeout,
unauthorized,
invalid_response,
};
std::expected<UserProfile, FetchError>
fetch_profile(std::string_view user_id);Rust 的 Option/Result
如果读者学过 Rust 语言,会发现其 Option 和 Result 的处理十分类似:
| C++ | Rust | 语义 |
|---|---|---|
std::optional<T> | Option<T> | 有 T 或没有值 |
std::expected<T, E> | Result<T, E> | 有成功值 T 或错误 E |
std::nullopt | None | 缺失值 |
std::unexpected(e) | Err(e) | 失败值 |
同一个整数解析逻辑在 Rust 中可以写成:
fn parse_int(text: &str) -> Option<i32> {
text.parse::<i32>().ok()
}文件读取则需要保留原因,因此返回 Result:
fn read_text_file(path: &str) -> Result<String, io::Error> {
fs::read_to_string(path)
}Rust 的 ? 运算符能把 Err 自动向上传播;C++23 的 expected 没有等价的语言级 ?。C++ 可以用显式 if (!result) return std::unexpected(result.error());,或在团队统一的辅助函数/库支持下做组合。