Skip to content

错误处理

概述

现代 C++ 为各类异常的处理提供了一系列工具。错误处理不仅是将错误记录下来,更是融入到接口设计中的设计思想。一个函数的返回值类型中应当体现其运行结果、失败恢复机制、失败处理等元素。

在现代 C++ 中,std::optionalstd:expected 通过补全类型系统使得错误可以通过调用链进行传递,使得调用方不能在不做判断的情况下取得一个可能不安全的值。他们使得异常链更为清晰。

在现代 C++ 错误处理设计思想中,没有值是一种可以被预见的正常状态,而失败则是一次异常,表示程序没有正确结束。

假设我们要编写一个根据用户名查找头像的接口,那么现在有四种可能的接口类型表达:

方式接口类型含义场景
异常Avatar load(...)正常路径直接得到值;无法在当前层合理处理的问题通过栈展开离开构造失败、违反不变量、资源耗尽等异常情况
错误码bool load(..., Avatar&, error_code&)调用者必须检查状态,并从额外参数取得结果和原因C API、ABI 边界、极低层或既有接口
optionaloptional<Avatar> find(...)可能查不到,但“查不到”本身是正常答案查找、可选配置、可选字段
expectedexpected<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 风格常把“结果”“是否成功”“失败原因”拆成三个通道:

cpp
bool read_file(const std::filesystem::path& path,
               std::string& content,
               std::error_code& error);

它能工作,但成本不低。content 在失败时是否保持原值要靠文档约定;调用者可能忘记检查 boolerror 是输入还是输出也不直观。另外,错误码很容易在多层调用中被遗漏。

对比之下,std::expected<T, E> 把成功值与错误值收进一个对象,形成一个不可变式:对象要么持有 T,要么持有 E,不会同时缺失,也不会同时存在。 这正是错误码方案想表达、却需要额外约定才能维持的状态。

expected 之前,C++17 还提供了 std::variant 来提供一对互斥状态的不可变式。它提供了表达 expected 这种互斥状态的基础能力:

cpp
using FileResult = std::variant<std::string, std::error_code>;

在这样的声明下,FileResult 不是字符串就是错误码。可用 std::holds_alternativestd::get 检查:

cpp
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++ 标准提供了语义更直接的解决方案,即 expectedoptional。这两个可以理解为 variant 的变体:

cpp
template<class T>
using OptionalLike = std::variant<std::monostate, T>;

template<class T, class E>
using ExpectedLike = std::variant<T, E>;

Optional

“从文本中解析整数”是典型的 optional 场景。下面的函数把输入是否是一个完整整数作为判断条件,即不是整数时返回空值,这种状态被认为是正常的。

cpp
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;
}

调用方必须先处理“有无值”:

cpp
if (const auto port = parse_int("8080")) {
    start_server(*port);
} else {
    show_input_hint();
}

这里的 *port 是经过条件检查后的访问。也可以使用下面几个工具,但含义不同:

cpp
const int port = parse_int("8080").value_or(3000); // 缺失时使用默认值
const int strict = parse_int("8080").value();      // 空时抛 std::bad_optional_access

value_or 适合默认值确实正确的场景,例如可选配置。不要用它来省略必须反馈给用户的无效输入。value() 则适合你已经通过程序逻辑证明必定有值的位置。把它当作普通访问方法,会把本应显式处理的分支重新变成异常。

但是,optional 并不适合用来处理错误。比如下面的接口:

cpp
std::optional<std::string> read_text_file(const std::filesystem::path& path);

空值可能代表文件不存在、权限不足、路径是目录、编码不支持,或者网络盘临时断开。调用者无法决定该提示“文件不存在”、请求授权,还是稍后重试。只要这些差异有业务价值,就应返回错误原因。这就是 expected 提出的意义。

Expected

std::expected<T, E> 有两种状态:成功时保存 T,失败时保存 E。这里用标准的 std::error_code 作为错误类型,方便与文件系统、网络和操作系统错误互通。下面给出一个例子:

cpp
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();
}

调用处可以分支处理,也可以先把错误向上传播:

cpp
auto text = read_text_file("config.toml");
if (!text) {
    report_error(text.error().message());
    return;
}

parse_config(*text);

text.error() 只应在 !text 的分支中使用;*texttext.value() 只应在成功分支中使用。expected 也有 value_or,但它只适合读取失败时使用的内建默认配置确实是正确策略的情况,而不是通用的错误处理方式。

示例为了延时返回类型,把“打不开文件”统一映射为 no_such_file_or_directory。生产代码若要区分权限、路径类型等系统原因,应优先使用 std::filesystem 的带 std::error_code 重载,或在平台 API 边界保留原始错误码。

网络请求的失败原因常不属于操作系统错误:HTTP 401、超时、协议格式错误、服务端限流需要不同策略。这时定义面向领域的错误类型更合适:

cpp
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::nulloptNone缺失值
std::unexpected(e)Err(e)失败值

同一个整数解析逻辑在 Rust 中可以写成:

rust
fn parse_int(text: &str) -> Option<i32> {
    text.parse::<i32>().ok()
}

文件读取则需要保留原因,因此返回 Result

rust
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());,或在团队统一的辅助函数/库支持下做组合。

Released under MIT License