从零手搓 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 特化版本:
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>& 就够了。
接下来是带错误类型的模板版本:
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> 这个包装类型来做这个区分——它就是一个只装着错误值的壳,但类型不同,所以可以用来触发不同的构造函数重载。
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 里存不住)。
构造函数方面,最常用的是这个:
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 构造能力:
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 值:
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):
template <typename E> unexpected(E) -> unexpected<E>;有了它,你就能写 unexpected(42) 而不是 unexpected<int>(42),编译器会自动从构造参数推导出 E 的类型。这在日常使用中极大减少了模板参数的书写量。
很好,零件准备齐了,接下来我们组装主体。
核心:expected<T, E> 的存储设计
expected<T, E> 的核心挑战在于:同一块内存里要么放 T,要么放 E,但不是两个都放。C++ 的 union 天然支持这种"多选一"的语义,但代价是你必须手动管理对象的生命周期。
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:
union Storage {
T val;
E err;
Storage() {}
~Storage() {}
};
Storage storage_;
bool has_val_;Storage 是一个 tagged union。val 和 err 共享同一块内存(union 的基本特性)。构造函数和析构函数都是空的——这是因为 union 不会自动构造或析构它的成员,这个责任完全落在了我们肩上。has_val_ 是我们的判别式(discriminant),true 表示 val 有效,false 表示 err 有效。
⚠️ 这里的核心陷阱:placement new 创建了对象之后,你必须手动调用析构函数来销毁它,否则资源泄漏。这就是 destroy() 辅助函数的作用:
void destroy() noexcept {
if (has_val_) {
storage_.val.~T();
} else {
storage_.err.~E();
}
}根据 has_val_ 的状态,调用对应成员的析构函数。注意这里用的是 storage_.val.~T() 这种"显式析构函数调用"语法,它不会释放内存(因为内存是 union 自己的),只会执行 T 的析构逻辑。
构造函数:在 union 里播种
默认构造函数让 expected 进入"有值"的状态:
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 做了精心的约束:
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:
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 构造:
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 进去,必须原地构造。
析构函数就一行:
~expected() { destroy(); }根据当前状态销毁对应的成员。简单,但别忘了一件事——如果你在写赋值运算符时没有先 destroy() 就直接 placement new,那之前那个活着的对象就泄漏了。真正的坑在后面。
最难的部分:拷贝与移动
说真的,如果你觉得前面的构造函数已经很复杂了,那赋值运算符才是真正的 Boss 战。原因在于赋值时 this 已经持有一个对象了——你得先处理旧对象,再构造新对象,而且两者的状态组合有四种可能。
先看拷贝赋值:
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:
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:
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:
[[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 的——如果你在错误状态下调用了它们,行为是未定义的:
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>:
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 处于错误状态:
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 就是干这个的:
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 是镜像操作——如果有错误就返回错误,没有错误就返回默认值:
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:成功时继续
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&&:
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_else 是 and_then 的镜像——它在错误轨道上放道岔:
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:变换值
transform 和 and_then 的区别在于:and_then 的 callable 返回 expected<U, E>,而 transform 的 callable 返回一个裸值 U,transform 会自动把它包进 expected<U, E> 里:
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);
}这个操作让你不用每次都手动包装返回值——如果你的函数不会失败(比如一个纯计算函数),用 transform 比 and_then 简洁得多。而且 transform 能改变值的类型:expected<int, E> 经过 [](int v) -> string { ... } 之后就变成了 expected<string, E>,但错误类型 E 保持不变。
transform_error:变换错误
transform_error 是 transform 的错误版本——它变换错误而不是值:
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 把它们统一成一种错误类型。
链式调用
这四个操作真正的威力在于链式组合:
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_then 和 transform 都会自动跳过——错误沿着失败轨道一路滑到底。你不需要写一堆嵌套的 if-else,整条 pipeline 的错误传播是自动的。
如果中间某个环节失败了,但你想要恢复呢?用 or_else:
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 三种对象做相等比较:
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。
和裸值的比较:
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 的比较:
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 就行。但两边状态不同时就比较有趣了:
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> 去掉了值存储,只保留错误存储:
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_:
constexpr expected() noexcept : has_val_(true) {}默认构造就是成功状态,不需要 placement new 任何东西。
value() 的返回类型变成了 void:
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 没有值可以传):
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>。
拷贝赋值也比主模板简单不少——不需要处理值的销毁:
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 一侧没有值需要管理。
实战:测试模式和示例程序
我们来看看测试代码里的典型用法。首先是基本的构造和检查:
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() 访问内容。
生命周期测试验证了析构函数被正确调用:
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(),这个测试就会失败——只析构一次。
最后看看示例程序,一个简单的错误处理场景:
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() 分支处理:
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_then 和 transform 串联起来,任何一步出错就会自动短路,不用写一堆嵌套的 if-else。到那个时候你会真正体会到这一章的价值。