WEBKT

告别 and_then 嵌套:用 C++20 协程实现 Rust 风格的 “问号操作符”

10 0 0 0

在现代 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,我们需要做两件事:

  1. 定义一个协程返回类型:我们需要一个包装类(如 ExpectedTask),它的 promise_type 能够处理 std::expected
  2. 实现 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() 更好?

  1. 控制流直观:你可以像写同步代码一样写异步或可能失败的代码。可以使用 ifwhilefor 循环来包裹 co_await,而 and_then 在处理循环逻辑时非常痛苦。
  2. 作用域友好:在 and_then 的 Lambda 闭包中访问外部作用域的变量需要捕获,容易产生生命周期问题;协程天然处于同一作用域。
  3. 调试更易:断点可以顺着执行流往下走,而不需要在多个 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 嵌套时,不妨考虑祭出协程这把利剑!

码农架构师 C20协程错误处理

评论点评