Skip to content

Expected 与单子式错误处理——告别混乱的异常和错误码

概念介绍

错误处理是编程世界里一个永恒的难题,每个方案都有自己的痛点。我们先来回顾一下 C++ 里常见的错误处理方式,看看它们各自有什么问题,然后再引入我们要介绍的主角——expected<T, E>

最古老的方式是返回错误码。函数返回一个 int 或者 enum 来表示成功还是失败,调用者需要手动检查返回值。这种方案的问题显而易见:你太容易忘了检查返回值了,而且编译器不会帮你盯着。更惨的是,如果一个调用链上有五六层函数,每一层都要把错误码一层层往上传,你的代码很快就变成一堆 if (err != OK) return err; 的面条。在嵌入式环境下,大家经常用这种方案,但你去看实际项目的代码,十个调用里至少有两三个没检查返回值的,这就是定时炸弹。

C++ 引入了异常机制来改善这个问题,异常的好处是错误处理和正常逻辑可以分离——你不需要在每个函数调用后都写错误检查,异常会自动沿着调用栈向上传播直到被 catch 住。但异常也有它自己的问题:控制流是隐式的,你看到一个函数调用,你不知道它会不会抛异常、会抛什么异常,这让代码审查变得困难。更致命的是,在嵌入式系统和实时系统中,异常经常是不可用的——它有运行时开销,需要 RTTI 支持,而且在某些平台上根本不支持。另外异常还可能导致内存泄漏,因为构造函数里抛异常时析构函数不会被调用。

那么有没有一种方案,既能像错误码一样显式(你必须处理错误),又能像异常一样不会让正常逻辑被错误处理代码淹没?这就是 sum type(和类型)的思路:让返回值本身就能表达"要么成功要么失败"两种状态。Rust 的 Result<T, E> 就是这个理念的代表作,而在 C++ 里,std::expected<T, E> 在 C++23 才进入标准,但我们可以自己实现一个 C++17 兼容的版本——这正是 edgecv 所做的事情。

expected<T, E> 是一个模板类,它要么持有一个类型为 T 的正常值,要么持有一个类型为 E 的错误值,两者互斥。你可以把它理解为一个更强大的 optional<T>——optional 只能告诉你"有值还是没值",而 expected 还能告诉你"为什么没值"。配合 unexpected<E> 包装器,我们可以明确地构造错误状态,让代码的意图非常清晰。

动机

让我们用一个简单的除法函数来对比几种错误处理方式,从中理解为什么 expected 是更好的选择。

首先是错误码的方式:

cpp
enum class DivError { DivisionByZero };

int safe_div(int a, int b, DivError& err) {
    if (b == 0) {
        err = DivError::DivisionByZero;
        return 0;  // 调用者可能不检查 err,直接用这个 0
    }
    return a / b;
}

这里用一个输出参数来返回错误,调用者很容易忘了检查。而且如果这个函数被嵌套在别的函数里调用,错误传递的代码会非常啰嗦。

接下来是异常的方式:

cpp
int safe_div(int a, int b) {
    if (b == 0) throw std::runtime_error("division by zero");
    return a / b;
}
// 调用者可能完全不知道这里会抛异常

异常让错误处理变得隐式了,而且如果你在嵌入式环境下工作,异常可能根本用不了。

现在看 expected 的方式:

cpp
expected<int, std::string> safe_div(int a, int b) {
    if (b == 0) {
        return unexpected(std::string("division by zero"));
    }
    return a / b;  // 隐式构造 expected 的值状态
}

返回类型 expected<int, std::string> 直接告诉调用者:这个函数可能返回一个 int,也可能返回一个 string 类型的错误。调用者必须处理两种情况,因为不检查 has_value() 就直接用 * 解引用是未定义行为(虽然不会编译报错,但这是一种明确的编程约定)。更重要的是,错误不会丢失——你没法像忽略错误码那样忽略它,因为你想拿到值就必须先确认它是成功的。

最小示例:一个完整的 expected 使用场景

让我们构建一个稍完整的示例,展示 expected 在实际使用中的样子,特别是它的单子式操作(monadic operations)。

cpp
#include <string>
#include <iostream>

// 假设我们有 expected 的实现(或者用 std::expected in C++23)
using cvw::expected;
using cvw::unexpected;

expected<int, std::string> safe_div(int a, int b) {
    if (b == 0) {
        return unexpected(std::string("division by zero"));
    }
    return a / b;
}

expected<int, std::string> safe_add(int a, int b) {
    // 模拟一个可能失败的加法(比如溢出检查)
    long long result = static_cast<long long>(a) + b;
    if (result < INT_MIN || result > INT_MAX) {
        return unexpected(std::string("integer overflow"));
    }
    return static_cast<int>(result);
}

最基础的使用方式是检查 has_value() 然后分别处理:

cpp
auto result = safe_div(10, 3);
if (result.has_value()) {
    std::cout << "结果是: " << result.value() << '\n';
} else {
    std::cout << "出错了: " << result.error() << '\n';
}

但这还是有点啰嗦。expected 真正强大的地方在于它提供了单子式操作,可以像铁路轨道一样把操作串联起来。想象两条轨道——上面是成功轨道,下面是错误轨道。正常情况下数据在上面走,一旦出错就掉到下面的轨道,后面的操作全部跳过,直接把错误传到终点。

and_then 是最核心的操作,它的语义是"如果当前是成功的值,就用这个值调用给定的函数;如果是错误,就直接传递错误,不调用函数":

cpp
// 铁路式编程:成功走上面,错误自动掉下去
auto final_result = safe_div(10, 2)
    .and_then([](int v) {
        return safe_add(v, 5);  // v = 5, 5 + 5 = 10
    })
    .and_then([](int v) {
        return safe_div(v, 0);  // 这里会失败!
    });

// final_result 包含错误 "division by zero"
// 中间的 and_then 自动跳过

⚠️ 这里有一个非常重要的特性叫短路行为(short-circuit):一旦某一步返回了错误,后续所有的 and_then 都不会再执行,错误会像管道一样自动传递到最终结果。这就像电路里的保险丝——一旦断了,后面的电器都不会通电。这正是"铁路轨道"比喻的由来。

transform 操作用于在成功时对值做变换,它的返回类型是 expected<U, E>,也就是把值的类型从 T 变成 U:

cpp
auto result = safe_div(10, 3)
    .transform([](int v) {
        return v * 2;  // 把 int 映射成新的 int
    });
// result 的值是 6

or_else 是错误轨道的处理器,它只在出错时被调用:

cpp
auto result = safe_div(10, 0)
    .or_else([](const std::string& err) {
        std::cerr << "错误: " << err << '\n';
        return expected<int, std::string>(0);  // 提供默认值恢复
    });
// result 的值是 0(从错误中恢复了)

还有一种特殊形式 expected<void, E>,它用于那些不需要返回值但可能失败的操作。比如文件保存、资源释放等——成功了就是成功了(没有值),失败了带一个错误。这种情况下 and_then 接受的函数不需要参数:

cpp
expected<void, std::string> save_to_disk(const std::string& path) {
    // 保存操作... 失败了返回 unexpected,成功了返回 {}
    if (/* something wrong */ false) {
        return unexpected(std::string("disk full"));
    }
    return {};  // 成功,无值
}

与 edgecv 的关联

edgecv 库中的每一个算法函数都返回 expected,错误类型是 AlgorithmError。这意味着调用任何算法都不会抛异常,你必须显式处理可能出现的错误。我们来看一个典型的 edgecv 算法函数签名:

cpp
[[nodiscard]] expected<Image<Gray>, AlgorithmError>
to_gray(Image<BGR> img);

这个函数接受一个 BGR 图像,尝试转换为灰度图。成功时返回 Image<Gray>,失败时返回 AlgorithmError[[nodiscard]] 属性确保你不会忽略返回值——如果你写了 to_gray(my_img); 而不接收返回值,编译器会发出警告。

edgecv 还提供了 Pipeline 机制,它利用 and_then 把多个算法串联成一条处理流水线。Pipeline 的核心实现正是基于 expected 的单子式操作:

cpp
// make_pipeline 的实现(简化版)
template <typename First, typename... Rest>
auto make_pipeline_impl(First&& first, Rest&&... rest) {
    auto tail = make_pipeline_impl(std::forward<Rest>(rest)...);
    return [first = std::forward<First>(first),
            tail = std::move(tail)](auto input) mutable {
        return first(std::move(input)).and_then([&tail](auto next) {
            return tail(std::move(next));
        });
    };
}

可以看到,每一个步骤通过 and_then 串联——如果前一步成功了,就把结果传给下一步;如果前一步失败了,后面所有的步骤自动跳过,错误直接传递到最终结果。这正是铁路轨道模式的完美体现。在实际使用中,你可以这样构建一个图像处理流水线:

cpp
using namespace cvw::steps;
using namespace cvw::pipe_ops;

auto result = Image<BGR>(640, 480)
    | to_gray()
    | gaussian_blur(5)
    | canny(50, 150)
    | save("edges.png");

这行代码里,如果 to_gray 失败了,后面三步全部自动跳过,result 直接包含错误信息,不需要一层层写 if 检查。这种风格让图像处理代码变得非常干净,同时又不牺牲错误处理的完备性。这种设计在嵌入式环境下特别有价值——没有异常,没有隐藏的控制流跳转,所有的错误路径都是显式可追踪的。

Built with VitePress