Skip to content

从零手搓 Expected<T, E>——不用异常也能优雅地处理错误

在嵌入式视觉开发的场景里,错误处理一直是个让人头疼的问题。OpenCV 的 API 动不动就 throw 一个 cv::Exception,看起来很优雅,但到了资源受限的嵌入式平台上,事情就变得复杂了——很多项目编译时直接带上 -fno-exceptions,异常机制整个被干掉,那些 throw 语句要么编译不过,要么直接 std::terminate。退一步说,就算平台支持异常,在实时性要求高的视觉 pipeline 里,异常带来的运行时开销和不可预测的控制流也是不太能接受的。

用错误码?倒也不是不行,但 int foo() 返回 -1 代表失败这种事情,实在太容易忘了。你见过多少人在调用函数之后老老实实检查返回值的?更别提错误码本身的语义也不清晰,-1 到底是"参数非法"还是"内存不足"?不用翻文档你根本不知道。

所以我们需要一种机制,它能强制你处理错误,同时在编译期就能推断出类型,不依赖运行时异常——这就是 expected<T, E>。如不了解 Expected 的设计思路,请先阅读 primer/expected.md,那里讲了它解决什么问题、和 optional / std::variant 有什么区别。另外如不熟悉 RAII 和移动语义,请参阅 primer/raii-and-move-semantics.md,这一章会大量涉及手动管理对象生命周期的代码;如不熟悉模板元编程中的 SFINAE 和 type traits,请参阅 primer/template-metaprogramming.md,我们的构造函数上有不少 requires 约束。

准备好了的话,我们就从最小的零件开始,一步步把 expected 搭出来。

第一个零件:bad_expected_access<E>

expected<T, E> 里面存了一个值或者一个错误,那如果你在它存着错误的情况下强行调用 value() 怎么办?我们需要一个异常类型来表示"你访问了一个不该访问的值"。这个类型就是 bad_expected_access

先看最简单的 void 特化版本:

cpp
template <> class bad_expected_access<void> : public std::exception {
  public:
    [[nodiscard]] const char* what() const noexcept override {
        return "bad_expected_access";
    }
};

这个 void 版本是所有 bad_expected_access<E> 的基类。它做的事情非常简单:继承 std::exception,覆写 what() 返回一个固定的字符串。为什么需要这个 void 版本?因为有时候我们只想 catch 任意类型的 bad_expected_access,不需要知道具体的错误类型,这时候 catch bad_expected_access<void>& 就够了。

接下来是带错误类型的模板版本:

cpp
template <typename E> class bad_expected_access
    : public bad_expected_access<void> {
  public:
    explicit bad_expected_access(E e) : error_(std::move(e)) {}

    const E& error() const& noexcept { return error_; }
    E& error() & noexcept { return error_; }
    const E&& error() const&& noexcept { return std::move(error_); }
    E&& error() && noexcept { return std::move(error_); }

  private:
    E error_;
};

你会发现 error() 有四个重载,分别对应 const lvalue、lvalue、const rvalue、rvalue 四种值类别。这看起来有点啰嗦,但这是 C++ 值类别完备性的标准写法——调用方无论是 const expected& 还是 expected&&,都能拿到正确类型的引用。构造函数里用 std::move(e) 把错误值 move 进来,explicit 防止隐式转换。整个类的开销非常小,就是多存了一个 E。

第二个零件:unexpected<E>

expected<T, E> 需要一种方式来区分"我是带着一个值构造的"还是"我是带着一个错误构造的"。我们用 unexpected<E> 这个包装类型来做这个区分——它就是一个只装着错误值的壳,但类型不同,所以可以用来触发不同的构造函数重载。

cpp
template <typename E> class unexpected {
    static_assert(!std::is_void_v<E>, "E must not be void");
    static_assert(!std::is_reference_v<E>, "E must not be a reference");

开头两个 static_assert 把不合法的类型直接拍死在编译期。E 不能是 void(void 错误没有意义),也不能是引用(引用不是对象类型,在 union 里存不住)。

构造函数方面,最常用的是这个:

cpp
    template <typename Err = E> constexpr explicit unexpected(Err&& e)
        requires(!std::is_same_v<std::decay_t<Err>, unexpected> &&
                 std::is_constructible_v<E, Err>)
        : error_(std::forward<Err>(e)) {}

requires 约束做两件事:第一,防止 unexpected(unexpected{...}) 这种自己构造自己的情况被这个模板抢走(应该走拷贝/移动构造);第二,确保 E 确实能用 Err 来构造。explicit 关键字意味着你不能写 unexpected<E> u = some_error,必须老老实实 unexpected<E> u(some_error)

我们还提供了 in-place 构造能力:

cpp
    template <typename... Args>
    constexpr explicit unexpected(std::in_place_t, Args&&... args)
        requires(std::is_constructible_v<E, Args...>)
        : error_(std::forward<Args>(args)...) {}

    template <typename U, typename... Args>
    constexpr explicit unexpected(std::in_place_t,
                                  std::initializer_list<U> il, Args&&... args)
        requires(std::is_constructible_v<E, std::initializer_list<U>&, Args...>)
        : error_(il, std::forward<Args>(args)...) {}

这样你就可以 unexpected<std::string>(std::in_place, 5, 'x') 一步到位构造出 "xxxxx",而不需要先构造一个临时 std::string 再 move 进去。initializer_list 版本则服务于那些接受 initializer_list 的容器类型。

error() 的访问接口和 bad_expected_access 一样是四重重载,这里不再赘述。比较运算符直接比较内部的 error 值:

cpp
    template <typename E2> friend constexpr bool
    operator==(const unexpected& a, const unexpected<E2>& b) {
        return a.error_ == b.error();
    }

注意这里是 friend 模板函数,支持跨类型比较(比如 unexpected<int>unexpected<short>),只要两者的 error() 能用 == 比较就行。

最后还有一个非常关键的推导指引(deduction guide):

cpp
template <typename E> unexpected(E) -> unexpected<E>;

有了它,你就能写 unexpected(42) 而不是 unexpected<int>(42),编译器会自动从构造参数推导出 E 的类型。这在日常使用中极大减少了模板参数的书写量。

很好,零件准备齐了,接下来我们组装主体。

核心:expected<T, E> 的存储设计

expected<T, E> 的核心挑战在于:同一块内存里要么放 T,要么放 E,但不是两个都放。C++ 的 union 天然支持这种"多选一"的语义,但代价是你必须手动管理对象的生命周期。

cpp
template <typename T, typename E> class expected {
    static_assert(!std::is_void_v<E>, "E must not be void");
    static_assert(!std::is_reference_v<T>,
                  "T must not be a reference (use T* or std::reference_wrapper)");
    static_assert(!std::is_reference_v<E>, "E must not be a reference");

同样开头先检查类型约束。T 也不能是引用——在 union 里面存引用是没有意义的。

接下来是内部的 Storage union:

cpp
    union Storage {
        T val;
        E err;
        Storage() {}
        ~Storage() {}
    };

    Storage storage_;
    bool has_val_;

Storage 是一个 tagged union。valerr 共享同一块内存(union 的基本特性)。构造函数和析构函数都是空的——这是因为 union 不会自动构造或析构它的成员,这个责任完全落在了我们肩上。has_val_ 是我们的判别式(discriminant),true 表示 val 有效,false 表示 err 有效。

⚠️ 这里的核心陷阱:placement new 创建了对象之后,你必须手动调用析构函数来销毁它,否则资源泄漏。这就是 destroy() 辅助函数的作用:

cpp
    void destroy() noexcept {
        if (has_val_) {
            storage_.val.~T();
        } else {
            storage_.err.~E();
        }
    }

根据 has_val_ 的状态,调用对应成员的析构函数。注意这里用的是 storage_.val.~T() 这种"显式析构函数调用"语法,它不会释放内存(因为内存是 union 自己的),只会执行 T 的析构逻辑。

构造函数:在 union 里播种

默认构造函数让 expected 进入"有值"的状态:

cpp
    constexpr expected() noexcept(std::is_nothrow_default_constructible_v<T>)
        : has_val_(true) {
        ::new (&storage_.val) T();
    }

has_val_ 设为 true,然后用 placement new 在 storage_.val 的地址上构造一个默认的 T。noexcept 规范直接委托给 T 的默认构造是否 noexcept。

值构造函数用 requires 做了精心的约束:

cpp
    template <typename U = T> constexpr expected(U&& v)
        requires(!std::is_same_v<std::decay_t<Err>, expected> &&
                 !std::is_same_v<std::decay_t<U>, std::in_place_t> &&
                 !std::is_same_v<std::decay_t<U>, unexpect_t> &&
                 std::is_constructible_v<T, U>)
        : has_val_(true) {
        ::new (&storage_.val) T(std::forward<U>(v));
    }

四个约束条件各有分工:不能从 expected 自身构造(那应该走拷贝/移动构造),不能从 in_place_t 构造(那是 in-place 构造的领地),不能从 unexpect_t 构造(那是错误状态的领地),最后 U 必须能构造 T。这些约束确保了构造函数重载之间不会打架。

unexpected 的构造函数类似,只不过在 error 那边做 placement new:

cpp
    template <typename G = E> constexpr expected(const unexpected<G>& u)
        requires(std::is_constructible_v<E, const G&>)
        : has_val_(false) {
        ::new (&storage_.err) E(u.error());
    }

事情到这里还没完,我们还提供了 in-place 构造和 unexpect 构造:

cpp
    template <typename... Args>
    constexpr explicit expected(std::in_place_t, Args&&... args)
        requires(std::is_constructible_v<T, Args...>)
        : has_val_(true) {
        ::new (&storage_.val) T(std::forward<Args>(args)...);
    }

    template <typename... Args>
    constexpr explicit expected(unexpect_t, Args&&... args)
        requires(std::is_constructible_v<E, Args...>)
        : has_val_(false) {
        ::new (&storage_.err) E(std::forward<Args>(args)...);
    }

in_place_t 版本直接在值区域构造 T,unexpect_t 版本直接在错误区域构造 E。这两个构造函数对于非移动类型(比如 std::mutex)非常重要——你没法先构造一个临时对象再 move 进去,必须原地构造。

析构函数就一行:

cpp
    ~expected() { destroy(); }

根据当前状态销毁对应的成员。简单,但别忘了一件事——如果你在写赋值运算符时没有先 destroy() 就直接 placement new,那之前那个活着的对象就泄漏了。真正的坑在后面。

最难的部分:拷贝与移动

说真的,如果你觉得前面的构造函数已经很复杂了,那赋值运算符才是真正的 Boss 战。原因在于赋值时 this 已经持有一个对象了——你得先处理旧对象,再构造新对象,而且两者的状态组合有四种可能。

先看拷贝赋值:

cpp
    expected& operator=(const expected& o) {
        if (this == &o) {
            return *this;
        }
        if (has_val_ && o.has_val_) {
            storage_.val = o.storage_.val;
        } else if (!has_val_ && !o.has_val_) {
            storage_.err = o.storage_.err;
        } else {
            destroy();
            has_val_ = o.has_val_;
            if (has_val_) {
                ::new (&storage_.val) T(o.storage_.val);
            } else {
                ::new (&storage_.err) E(o.storage_.err);
            }
        }
        return *this;
    }

我们一个一个分支来拆解。首先自赋值检查 this == &o 直接返回,这没什么好说的。接下来是四种状态组合:

  • val -> val(我也有值,对方也有值):最简单,直接赋值 storage_.val = o.storage_.val,不需要析构再构造,因为两边都是同类型的活对象。
  • err -> err(我也没值,对方也没值):同理,直接赋值 error。
  • val -> err 或者 err -> val(状态不同):这才是真正麻烦的地方。我们必须先 destroy() 销毁当前持有的对象,然后根据对方的状态用 placement new 构造新的对象,最后更新 has_val_

⚠️ 注意这里 destroy 和 placement new 的顺序。你必须先 destroy 再 new,因为 union 的同一块内存不能同时持有两个活对象。如果你先 new 再 destroy,那 destroy 会把刚刚 new 出来的对象给毁掉——顺序反了就全完了。

移动赋值的结构和拷贝赋值完全一样,只不过把拷贝换成 std::move

cpp
    expected& operator=(expected&& o) noexcept(...) {
        if (this == &o) return *this;
        if (has_val_ && o.has_val_) {
            storage_.val = std::move(o.storage_.val);
        } else if (!has_val_ && !o.has_val_) {
            storage_.err = std::move(o.storage_.err);
        } else {
            destroy();
            has_val_ = o.has_val_;
            if (has_val_) {
                ::new (&storage_.val) T(std::move(o.storage_.val));
            } else {
                ::new (&storage_.err) E(std::move(o.storage_.err));
            }
        }
        return *this;
    }

除此之外还有从裸值和裸 unexpected 赋值的运算符,逻辑类似但稍微简化——比如从值赋值时,如果当前持有值就直接赋,如果当前持有错误就先 destroy 再 placement new:

cpp
    template <typename U> expected& operator=(U&& v)
        requires(!std::is_same_v<std::decay_t<U>, expected> &&
                 std::is_constructible_v<T, U> && std::is_assignable_v<T&, U>)
    {
        if (has_val_) {
            storage_.val = std::forward<U>(v);
        } else {
            destroy();
            ::new (&storage_.val) T(std::forward<U>(v));
            has_val_ = true;
        }
        return *this;
    }

requires 约束确保 U 不是 expected 自身(避免抢夺 expected 的赋值运算符),而且 T 确实能从 U 构造和赋值。

观察者接口:检查和访问

构造和赋值搞定了,现在来看看怎么从 expected 里把东西取出来。最基础的观察者是 has_value()operator bool

cpp
    [[nodiscard]] constexpr bool has_value() const noexcept { return has_val_; }
    constexpr explicit operator bool() const noexcept { return has_val_; }

explicit 防止你写 int x = e; 这种隐式转换,但 if (e)!e 这些上下文里可以自然使用。

直接访问用 operator*operator->,它们是 unchecked 的——如果你在错误状态下调用了它们,行为是未定义的:

cpp
    constexpr T& operator*() & noexcept { return storage_.val; }
    constexpr const T& operator*() const& noexcept { return storage_.val; }
    constexpr T&& operator*() && noexcept { return std::move(storage_.val); }
    constexpr const T&& operator*() const&& noexcept { return std::move(storage_.val); }

    constexpr T* operator->() noexcept { return &storage_.val; }
    constexpr const T* operator->() const noexcept { return &storage_.val; }

operator* 同样有四个值类别的重载。这是为了保证 *std::move(e) 返回 T&& 而不是 T&,让移动语义能正确传递。

然后是 checked 版本的 value()——如果你在错误状态下调用它,它会抛出 bad_expected_access<E>

cpp
    constexpr T& value() & {
        if (!has_val_) {
            throw bad_expected_access<E>(storage_.err);
        }
        return storage_.val;
    }

这里的关键是抛异常时把内部存储的错误值 move 进了 bad_expected_access,这样 catch 的一方还能拿到具体的错误信息。rvalue 版本会把整个 expected 的错误 move 出去,调用之后原来的 expected 里的 error 处于 moved-from 状态。

error() 的接口和 value() 类似但不抛异常——它假设你已经确认了 expected 处于错误状态:

cpp
    constexpr E& error() & noexcept { return storage_.err; }
    constexpr const E& error() const& noexcept { return storage_.err; }
    constexpr E&& error() && noexcept { return std::move(storage_.err); }
    constexpr const E&& error() const&& noexcept { return std::move(storage_.err); }

value_or 和 error_or:优雅的降级方案

有时候我们不在乎具体是什么错误,只想要一个兜底值。value_or 就是干这个的:

cpp
    template <typename U> constexpr T value_or(U&& default_val) const& {
        return has_val_ ? storage_.val
                        : static_cast<T>(std::forward<U>(default_val));
    }

    template <typename U> constexpr T value_or(U&& default_val) && {
        return has_val_ ? std::move(storage_.val)
                        : static_cast<T>(std::forward<U>(default_val));
    }

const lvalue 版本返回值的拷贝,rvalue 版本把值 move 出来。三元运算符让代码很紧凑。static_cast<T> 确保默认值能正确转换成 T 类型。

error_or 是镜像操作——如果有错误就返回错误,没有错误就返回默认值:

cpp
    template <typename U> constexpr E error_or(U&& default_err) const& {
        return !has_val_ ? storage_.err
                         : static_cast<E>(std::forward<U>(default_err));
    }

注意这里的条件是 !has_val_——和 value_or 刚好相反。

重头戏:单子操作(Monadic Operations)

如果你用过 Rust 的 Result 或者 Haskell 的 Monad,那对下面这套 API 应该不会陌生。如果你没接触过这些概念也没关系——我们可以用"铁路模型"来理解。

想象一条铁路有两条轨道:上面是"成功轨道"(值),下面是"失败轨道"(错误)。and_then 就是一个道岔:如果火车在成功轨道上,就把值交给你的函数处理,函数可以继续返回一个 expected 让火车留在铁路上;如果火车已经在失败轨道上了,那就直接跳过你的函数,错误一路往下传。这就是所谓的"铁路导向编程"(Railway-Oriented Programming)。

and_then:成功时继续

cpp
    template <typename F> auto and_then(F&& f) & {
        using Ret = std::invoke_result_t<F, T&>;
        if (has_val_) {
            return std::forward<F>(f)(storage_.val);
        }
        return Ret(unexpect, storage_.err);
    }

Ret 是用 std::invoke_result_t 推导出的返回类型——你的 callable 必须返回一个 expected<某类型, 某类型>。如果当前有值,就调用 f(val) 并返回结果;如果没有值,就构造一个 Ret(unexpect, error) 把错误原封不动地传播下去。

关键点在于返回类型推导:我们通过 Ret(unexpect, storage_.err) 这一行来构造错误状态的返回值,这要求 Ret 必须能从 unexpect_t 和 error 值构造——而所有 expected 的特化都提供了这个构造函数,所以这总是成立的。

const lvalue 和 rvalue 的重载只是把 T& 换成 const T&T&&

cpp
    template <typename F> auto and_then(F&& f) const& {
        using Ret = std::invoke_result_t<F, const T&>;
        // ...
    }

    template <typename F> auto and_then(F&& f) && {
        using Ret = std::invoke_result_t<F, T&&>;
        if (has_val_) {
            return std::forward<F>(f)(std::move(storage_.val));
        }
        return Ret(unexpect, std::move(storage_.err));
    }

rvalue 版本中值和错误都被 move 出来,这对于 expected<MoveOnly, int> 这种只移动类型至关重要。

or_else:失败时处理

or_elseand_then 的镜像——它在错误轨道上放道岔:

cpp
    template <typename F> auto or_else(F&& f) & {
        using Ret = std::invoke_result_t<F, E&>;
        if (!has_val_) {
            return std::forward<F>(f)(storage_.err);
        }
        return Ret(storage_.val);
    }

如果有错误就交给你的函数处理,函数可以选择"恢复"(返回一个有值的 expected)或者返回新的错误;如果有值就直接传播值。这个操作在"错误恢复"的场景里非常好用——比如某个错误你可以用默认值兜底,那就在 or_else 里返回 expected<T,E>(default_value) 就行了。

transform:变换值

transformand_then 的区别在于:and_then 的 callable 返回 expected<U, E>,而 transform 的 callable 返回一个裸值 U,transform 会自动把它包进 expected<U, E> 里:

cpp
    template <typename F> auto transform(F&& f) & {
        using U = std::invoke_result_t<F, T&>;
        if (has_val_) {
            return expected<U, E>(std::forward<F>(f)(storage_.val));
        }
        return expected<U, E>(unexpect, storage_.err);
    }

这个操作让你不用每次都手动包装返回值——如果你的函数不会失败(比如一个纯计算函数),用 transformand_then 简洁得多。而且 transform 能改变值的类型:expected<int, E> 经过 [](int v) -> string { ... } 之后就变成了 expected<string, E>,但错误类型 E 保持不变。

transform_error:变换错误

transform_errortransform 的错误版本——它变换错误而不是值:

cpp
    template <typename F> auto transform_error(F&& f) & {
        using G = std::invoke_result_t<F, E&>;
        if (!has_val_) {
            return expected<T, G>(unexpect, std::forward<F>(f)(storage_.err));
        }
        return expected<T, G>(storage_.val);
    }

如果当前有错误,就用函数变换它;如果有值就原封不动传播值。注意返回类型的值类型 T 不变,但错误类型变成了 G。这在统一不同来源的错误类型时非常有用——比如你有一个 pipeline 的多个步骤各自使用不同的错误枚举,最终可以用 transform_error 把它们统一成一种错误类型。

链式调用

这四个操作真正的威力在于链式组合:

cpp
auto result = expected<int, TestErr>(10)
    .and_then([](int v) -> expected<int, TestErr> { return v + 5; })
    .transform([](int v) { return v * 2; })
    .transform([](int v) -> std::string { return std::to_string(v); });
// result 是 expected<string, TestErr>,值为 "30"

任何一步返回了错误,后续的 and_thentransform 都会自动跳过——错误沿着失败轨道一路滑到底。你不需要写一堆嵌套的 if-else,整条 pipeline 的错误传播是自动的。

如果中间某个环节失败了,但你想要恢复呢?用 or_else

cpp
auto result = expected<int, TestErr>(unexpected(TestErr::B))
    .or_else([](TestErr e) -> expected<int, TestErr> {
        if (e == TestErr::B) return 100;  // 恢复!
        return unexpected(e);
    })
    .transform([](int v) { return v + 1; });
// result 值为 101

比较运算符

比较运算符让 expected 可以和 expected、裸值、unexpected 三种对象做相等比较:

cpp
    template <typename T2, typename E2> friend constexpr bool
    operator==(const expected& a, const expected<T2, E2>& b) {
        if (a.has_val_ != b.has_value()) return false;
        if (a.has_val_) return *a == *b;
        return a.error() == b.error();
    }

两个 expected 相等意味着它们状态相同(都有值或都有错误),而且对应的内容也相等。状态不同直接返回 false。

和裸值的比较:

cpp
    template <typename T2>
    friend constexpr bool operator==(const expected& a, const T2& v) {
        return a.has_val_ && (*a == v);
    }

只有 expected 有值且值等于 v 时才返回 true。这让你可以写 e == 42 这样的直观比较。

和 unexpected 的比较:

cpp
    template <typename E2> friend constexpr bool
    operator==(const expected& a, const unexpected<E2>& u) {
        return !a.has_val_ && (a.error() == u.error());
    }

只有 expected 有错误且错误等于 unexpected 里的错误时才返回 true。所有 != 运算符都简单地委托给 == 然后取反。

swap:状态交叉互换

swap 在两边状态相同时很简单,直接 std::swap 就行。但两边状态不同时就比较有趣了:

cpp
    void swap(expected& o) noexcept(...) {
        using std::swap;
        if (has_val_ && o.has_val_) {
            swap(storage_.val, o.storage_.val);
        } else if (!has_val_ && !o.has_val_) {
            swap(storage_.err, o.storage_.err);
        } else {
            if (has_val_) {
                // this 有值,o 有错误
                T tmp_val(std::move(storage_.val));
                storage_.val.~T();

                ::new (&storage_.err) E(std::move(o.storage_.err));
                o.storage_.err.~E();

                ::new (&o.storage_.val) T(std::move(tmp_val));

                has_val_ = false;
                o.has_val_ = true;
            } else {
                // this 有错误,o 有值(镜像操作)
                // ...
            }
        }
    }

当 this 有值、o 有错误时,我们不能直接在 union 上赋值(因为两个成员类型不同),必须用一个临时变量中转。步骤是:先把 this 的值 move 到临时变量,析构 this 的值,在 this 的错误区域 placement new 构造 o 的错误,析构 o 的错误,在 o 的值区域 placement new 构造临时变量里的值。最后交换两边的 has_val_ 标志。

⚠️ 这段代码里析构和构造的顺序非常重要。如果 ::new (&storage_.err) E(...) 抛异常了,那 storage_.val 已经被析构了但 storage_.err 还没构造成功——此时 expected 处于一个半死不活的状态。这是 swap 操作的固有风险,也是为什么 noexcept 条件里要求类型必须 nothrow move constructible。

expected<void, E> 特化

有些操作只关心成功/失败,不返回任何值——比如验证操作、写入操作。对于这些场景,expected<void, E> 去掉了值存储,只保留错误存储:

cpp
template <typename E> class expected<void, E> {
    union Storage {
        E err;
        Storage() {}
        ~Storage() {}
    };
    Storage storage_;
    bool has_val_;

Storage 里只有 err,没有 val——void 不是对象类型,不能在 union 里声明。构造函数只初始化 has_val_

cpp
    constexpr expected() noexcept : has_val_(true) {}

默认构造就是成功状态,不需要 placement new 任何东西。

value() 的返回类型变成了 void:

cpp
    constexpr void value() const {
        if (!has_val_) {
            throw bad_expected_access<E>(storage_.err);
        }
    }

调用 e.value() 要么什么都不做(成功),要么抛异常(失败)。这看起来有点奇怪——一个返回 void 的函数?但它的语义是"确认操作成功,否则抛异常",和 expected<T,E>::value() 一致,只不过成功时没有值可以返回。

单子操作也相应简化了。and_then 的 callable 不接受参数(因为 void 没有值可以传):

cpp
    template <typename F> auto and_then(F&& f) & {
        using Ret = std::invoke_result_t<F>;
        if (has_val_) {
            return std::forward<F>(f)();
        }
        return Ret(unexpect, storage_.err);
    }

注意 std::invoke_result_t<F> 没有Second个模板参数——callable 是零参数的。transform 同理,callable 接受零参数,返回值被包装成 expected<U, E>

拷贝赋值也比主模板简单不少——不需要处理值的销毁:

cpp
    expected& operator=(const expected& o) {
        if (has_val_ && o.has_val_) {
            // 都成功,什么都不做
        } else if (!has_val_ && !o.has_val_) {
            storage_.err = o.storage_.err;
        } else if (has_val_) {
            // 成功 -> 错误:构造错误
            ::new (&storage_.err) E(o.storage_.err);
            has_val_ = false;
        } else {
            // 错误 -> 成功:析构错误
            storage_.err.~E();
            has_val_ = true;
        }
        return *this;
    }

四个分支比主模板的赋值运算符清晰得多,因为 void 一侧没有值需要管理。

实战:测试模式和示例程序

我们来看看测试代码里的典型用法。首先是基本的构造和检查:

cpp
TEST(Expected, DefaultConstructHasValue) {
    expected<int, TestErr> e;
    EXPECT_TRUE(e.has_value());
    EXPECT_EQ(*e, 0);
}

TEST(Expected, UnexpectedConstruct) {
    auto e = expected<int, TestErr>(unexpected(TestErr::B));
    EXPECT_FALSE(e.has_value());
    EXPECT_EQ(e.error(), TestErr::B);
}

默认构造的 expected<int, E> 会持有值 0(int 的默认值),而通过 unexpected 构造的就是错误状态。用 has_value() 检查状态,operator*error() 访问内容。

生命周期测试验证了析构函数被正确调用:

cpp
TEST(Expected, DestructorOnReassign) {
    dtor_count = 0;
    {
        expected<TrackDtor, int> e(std::in_place, 1);
        e = TrackDtor(2); // destroys old value
    }
    // 1 from destroying old value inside assignment + 1 from e's destructor
    EXPECT_EQ(dtor_count, 2);
}

TrackDtor 是一个全局计数器辅助类,每次析构都让 dtor_count 加一。赋值时旧的 TrackDtor(1) 被销毁(计数变 1),然后作用域结束时 TrackDtor(2) 也被销毁(计数变 2)。如果赋值运算符里忘了 destroy(),这个测试就会失败——只析构一次。

最后看看示例程序,一个简单的错误处理场景:

cpp
enum class Error : uint8_t { OVERFLOW = 0, UNDERFLOW };

cvw::expected<int, Error> make_result(int value) {
    if (value < 0)
        return cvw::unexpected<Error>{Error::UNDERFLOW};
    if (value > 33)
        return cvw::unexpected<Error>{Error::OVERFLOW};
    return 114514;
}

这个函数返回 expected<int, Error>——成功时返回值,失败时返回具体的错误码。调用方用 has_value() 分支处理:

cpp
auto result = make_result(-10);
if (!result.has_value()) {
    std::cout << "Expected Underflow: "
              << static_cast<unsigned>(result.error()) << '\n';
} else {
    // 使用 result.value()
}

对比传统做法——如果 make_result 返回 int 并且用 -1 表示错误,调用方很容易忘记检查,而且也分不清是哪种错误。用 expected 之后,你必须面对错误分支(否则编译器不会帮你做什么,但代码审查时一眼就能看出来你忽略了错误处理),而且 error() 明确告诉你出了什么问题。

回顾

到这里我们从头手搓了一个完整的 expected<T, E>。它涉及的核心技术包括:union + placement new 做手动生命周期管理,has_val_ 判别式区分状态,四种值类别的重载保证移动语义正确传递,requires 约束让构造函数重载不冲突,以及 monadic 操作实现铁路导向的错误传播。这些都是 C++ 类型系统的高级特性,但一旦理解了模式,你会发现它们其实非常有规律。

这个 expected 是后续 Pipeline 章节的基础——pipeline 的每一步都会返回 expected,通过 and_thentransform 串联起来,任何一步出错就会自动短路,不用写一堆嵌套的 if-else。到那个时候你会真正体会到这一章的价值。

Built with VitePress