告别 and_then 嵌套:用 C++20 协程实现 Rust 风格的 “问号操作符”
在现代 C++ 开发中,错误处理一直是一个充满争议的话题。传统的异常(Exceptions)虽然强大,但在性能敏感或需要显式错误流的场景下往往被禁用;而返回错误码的方式又容易导致代码被大量的 if (!res) return res.error(); 淹没。
C++23 引入了 std::expected,它允许我们将正常返回值和错误信息封装在一起。虽然它提供了 .and_then() 和 .transform() 等单子操作(Monadic Operations),但在业务逻辑较长时,链式调用会变得难以阅读和调试。
今天,我们将利用 C++20 协程(Coroutines) 的黑魔法,为 std::expected 穿上“糖衣”,实现类似 Rust 语言中 ? 操作符的顺滑体验。
1. 痛点:被拉长的 .and_then()
假设我们有一个复杂的业务流程:从数据库获取用户、校验权限、最后生成 Token。使用 std::expected 的标准写法如下:
std::expected<Token, ErrorCode> get_user_token(int user_id) {
return find_user_by_id(user_id)
.and_then([](const User& user) {
return check_permission(user);
})
.and_then([](const Permission& perm) {
return generate_token(perm);
});
}
虽然比嵌套 if 好看,但当逻辑包含条件分支或多个变量交互时,这种闭包套闭包的写法会变得非常臃肿。我们更希望代码看起来像这样:
// 幻想中的代码 (类似 Rust)
std::expected<Token, ErrorCode> get_user_token(int user_id) {
User user = co_await find_user_by_id(user_id); // 如果失败,直接退出协程并返回错误
Permission perm = co_await check_permission(user);
co_return generate_token(perm);
}
2. 核心原理:让 std::expected 变得“可等待”
要让 co_await 作用于 std::expected,我们需要做两件事:
- 定义一个协程返回类型:我们需要一个包装类(如
ExpectedTask),它的promise_type能够处理std::expected。 - 实现
operator co_await:告诉编译器如何等待一个std::expected对象。
实现 Awaiter
我们需要拦截 co_await 的行为。如果 expected 有值,返回该值;如果没值,则将错误存入 promise 并停止执行。
template <typename T, typename E>
struct ExpectedAwaiter {
std::expected<T, E> exp;
// 如果已经有值,不需要挂起
bool await_ready() const noexcept { return exp.has_value(); }
// 如果没值(报错),在这里处理“提前返回”逻辑
void await_suspend(std::coroutine_handle<void> h) const noexcept {
// 这里的逻辑需要配合后文的 promise_type 实现
}
// 恢复执行时,直接返回内部的值
T await_resume() { return std::move(exp.value()); }
};
3. 完整框架实现
为了实现上述目标,我们需要构建一个简易的协程骨架。核心在于如何把 expected 的错误传播给协程的返回值。
#include <iostream>
#include <expected>
#include <coroutine>
// 1. 定义协程返回类型
template <typename T, typename E>
struct Res {
struct promise_type {
std::expected<T, E> result;
Res get_return_object() { return {std::coroutine_handle<promise_type>::from_promise(*this)}; }
std::suspend_never initial_suspend() noexcept { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_value(T value) { result = std::move(value); }
void return_value(std::expected<T, E> exp) { result = std::move(exp); }
void unhandled_exception() { std::terminate(); }
};
std::coroutine_handle<promise_type> handle;
operator std::expected<T, E>() { return handle.promise().result; }
};
// 2. 为 std::expected 重载 operator co_await
template <typename T, typename E>
auto operator co_await(std::expected<T, E> exp) {
struct Awaiter {
std::expected<T, E> exp;
bool await_ready() { return exp.has_value(); }
// 关键:如果失败,我们直接设置 promise 的错误并返回 false (或停止)
// 注意:标准协程实现提前返回较为复杂,通常通过 promise 共享状态
void await_suspend(std::coroutine_handle<typename Res<T, E>::promise_type> h) {
h.promise().result = std::unexpected(exp.error());
h.destroy(); // 销毁协程实现提前退出
}
T await_resume() { return std::move(exp.value()); }
};
return Awaiter{std::move(exp)};
}
注:上述代码为简化演示版。在生产环境实现中,通常需要处理 void 返回值以及更完善的生命周期管理。
4. 实战对比
有了这套机制,我们的代码发生了质变:
传统方式:
auto process() {
auto r1 = step1();
if (!r1) return make_err(r1.error());
auto r2 = step2(*r1);
if (!r2) return make_err(r2.error());
return step3(*r2);
}
协程方式:
Res<Data, Error> process() {
auto v1 = co_await step1(); // 失败自动返回
auto v2 = co_await step2(v1);
co_return step3(v2);
}
5. 为什么这比 .and_then() 更好?
- 控制流直观:你可以像写同步代码一样写异步或可能失败的代码。可以使用
if、while、for循环来包裹co_await,而and_then在处理循环逻辑时非常痛苦。 - 作用域友好:在
and_then的 Lambda 闭包中访问外部作用域的变量需要捕获,容易产生生命周期问题;协程天然处于同一作用域。 - 调试更易:断点可以顺着执行流往下走,而不需要在多个 Lambda 之间跳跃。
6. 注意事项与展望
- 性能开销:协程涉及到堆内存分配(虽然有 HALO 优化)和状态机切换。对于极其微小的函数,这可能比直接返回
std::expected慢。 - C++23 兼容性:虽然 C++20 提供了协程基础,但
std::expected是 C++23 的产物。如果你还在使用 C++20,可以使用tl::expected等第三方实现。 - 异常安全:确保你的
promise_type正确处理了unhandled_exception。
总结
通过 C++20 协程定制化,我们成功在 C++ 中模拟了 Rust 的问号操作符效果。这不仅仅是语法的改变,更是一种编程范式的演进——它让我们能够以最符合人类直觉的方式处理复杂的错误流。
现代 C++ 不再只是关于性能,更是关于如何写出更安全、更易读的代码。下次当你面对深层的 and_then 嵌套时,不妨考虑祭出协程这把利剑!