基于开源项目的现代C++工程实践——OnceCallback 前置知识(下):C++20/23 高级特性

OnceCallback 前置知识(下):C++20/23 高级特性

引言

上篇我们回顾了 C++11/14/17 的基础特性。这一篇我们进入 C++20/23 的高级特性------它们不是什么"锦上添花"的语法糖,而是 bind_oncethen()run() 得以实现的关键机制

学习目标

  • 掌握 Lambda 高级特性:mutable、初始化捕获、C++20 包展开
  • 理解 Concepts 如何保护模板构造函数不被劫持
  • 掌握 std::move_only_function 的核心操作
  • 理解 Deducing this 如何实现编译期左值/右值拦截

Lambda 高级特性

mutable lambda:为什么不能省

Lambda 默认生成的 operator()const 的------这意味着 lambda 内部不能修改值捕获的变量。加 mutable 关键字后,operator() 变成非 const 的。

cpp 复制代码
int x = 10;

// const lambda:不能修改捕获的变量
auto f1 = [x]() {
    // x++;  // 编译错误
    return x;
};

// mutable lambda:可以修改
auto f2 = [x]() mutable {
    x++;  // OK
    return x;
};

在 OnceCallback 中的角色

bind_oncethen() 的 lambda 都必须声明为 mutable。原因是这些 lambda 的捕获列表里包含 OnceCallback 对象,而调用 std::move(self).run() 会修改 self 的内部状态。

cpp 复制代码
// then() 内部的 lambda------mutable 不可省略
[self = std::move(*this), cont = std::forward<Next>(next)]
(FuncArgs... args) mutable -> NextRet {
    auto mid = std::move(self).run(std::forward<FuncArgs>(args)...);
    return std::invoke(std::move(cont), std::move(mid));
}

如果 lambda 是 const 的,self 在 lambda 内部就是 const 的,没法调用修改状态的操作。


初始化捕获(Init Capture)

C++14 引入了初始化捕获(init capture)语法,允许你在捕获列表中执行表达式并用结果初始化一个捕获变量。语法是 name = expression

和简单捕获的区别

cpp 复制代码
auto ptr = std::make_unique<int>(42);

// 移动捕获------把 unique_ptr 搬进 lambda
auto f1 = [p = std::move(ptr)]() { return *p; };

// 存储计算结果
std::string s = "hello";
auto f2 = [len = s.size()]() { return len; };

// 捕获不存在于外部的变量
auto f3 = [counter = 0]() mutable { return ++counter; };

在 OnceCallback 中的使用

then() 的实现用初始化捕获做了两件关键的事情。

把整个 OnceCallback 对象搬进 lambda:

cpp 复制代码
self = std::move(*this)

*this 是当前 OnceCallback 对象,std::move(*this) 把它转成右值,初始化捕获触发 OnceCallback 的移动构造,把 func_status_token_ 全部搬进 lambda 的闭包对象里。

把后续回调搬进来:

cpp 复制代码
cont = std::forward<Next>(next)

std::forward<Next>(next) 保持 next 的值类别------如果传入的是右值,它就是移动;如果传入的是左值,它就是拷贝。

所有权链

整个所有权链条是这样的:

text 复制代码
新 OnceCallback -> move_only_function -> lambda 闭包 -> [原 OnceCallback + 后续回调]

每一层都通过移动语义传递所有权,没有任何共享或拷贝。


C++20 Lambda Capture Pack Expansion

这是 bind_once 得以用几行代码实现的关键。C++20 之前,可变参数模板的参数包不能直接展开到 lambda 的捕获列表里。

旧方案(C++17):tuple + apply

cpp 复制代码
template<typename F, typename... BoundArgs>
auto bind_old(F&& f, BoundArgs&&... args) {
    return [f = std::forward<F>(f),
            tup = std::make_tuple(std::forward<BoundArgs>(args)...)]
        (auto&&... call_args) mutable -> decltype(auto) {
        return std::apply([&](auto&... bound) -> decltype(auto) {
            return f(bound..., std::forward<decltype(call_args)>(call_args)...);
        }, tup);
    };
}

新语法(C++20):直接展开

C++20 允许在 lambda 的初始化捕获中使用包展开:

cpp 复制代码
template<typename F, typename... BoundArgs>
auto bind_new(F&& f, BoundArgs&&... args) {
    return [f = std::forward<F>(f),
            ...bound = std::forward<BoundArgs>(args)]  // ← 包展开!
        (auto&&... call_args) mutable -> decltype(auto) {
        return std::invoke(std::move(f),
                          std::move(bound)...,
                          std::forward<decltype(call_args)>(call_args)...);
    };
}

手动展开一个具体例子

假设调用 bind_new([](int a, std::string b, int c) { ... }, 10, std::string("hello")),此时 BoundArgs = {int, std::string}。编译器把包展开成:

cpp 复制代码
[f = std::forward<F>(f),
 b1 = std::forward<int>(arg1),
 b2 = std::forward<std::string>(arg2)]
(auto&&... call_args) mutable -> decltype(auto) {
    return std::invoke(std::move(f),
                      std::move(b1), std::move(b2),
                      std::forward<decltype(call_args)>(call_args)...);
}

为什么用 std::move 而不是 std::forward

lambda 是 mutable 的,捕获变量 bound 在 lambda 内部是左值 (具名变量永远是左值)。由于我们希望绑定参数在回调被调用时以右值的方式传出,所以用 std::move 把它们转成右值。


Concepts 与 requires 约束

OnceCallback 的构造函数上有这么一行约束:

cpp 复制代码
template<typename Functor>
    requires not_the_same_t<Functor, OnceCallback>
explicit OnceCallback(Functor&& function);

这个约束是为了防止模板构造函数在 OnceCallback cb2 = std::move(cb1) 这种场景下劫持移动构造函数的调用。

问题:模板构造函数的"越位"

cpp 复制代码
struct Wrapper {
    template<typename T>
    Wrapper(T&& x) {}  // 模板构造函数

    Wrapper(Wrapper&& other) noexcept {}  // 移动构造函数
};

Wrapper a;
Wrapper b = std::move(a);  // 可能调用模板构造函数而不是移动构造函数

Concept 基本语法

cpp 复制代码
template<typename T>
concept Integral = std::is_integral_v<T>;

template<typename T>
    requires Integral<T>
void foo(T x) {}

not_the_same_t:逐行拆解

cpp 复制代码
template<typename F, typename T>
concept not_the_same_t = !std::is_same_v<std::decay_t<F>, T>;

它做的事情:F 退化后的类型不是 T

  • std::decay_t<F>:去掉引用、const/volatile 限定符
  • std::is_same_v<A, B>:A 和 B 是否是同一类型
  • !:取反,F 不是 T 时约束通过

加上约束后的效果

当传入的是 OnceCallback 本身时,not_the_same_t<OnceCallback, OnceCallback> 求值为 false,约束不满足,模板被排除出候选列表,编译器只能选择移动构造函数。


std::move_only_function (C++23)

std::move_only_function 是 OnceCallback 的心脏------它承担了所有类型擦除的脏活累活。

从 std::function 到 std::move_only_function

std::function 有一个根本性的限制:它要求存储的可调用对象必须可拷贝

cpp 复制代码
// 编译错误!unique_ptr 不可拷贝
std::function<int()> f = [p = std::make_unique<int>(42)]() { return *p; };

std::move_only_function(C++23)删除了拷贝操作,只保留移动操作:

cpp 复制代码
// OK!move_only_function 不要求可拷贝
std::move_only_function<int()> f = [p = std::make_unique<int>(42)]() { return *p; };

四个核心操作

cpp 复制代码
// 构造:从可调用对象创建
std::move_only_function<int(int, int)> f1 = [](int a, int b) { return a + b; };

// 移动:转移所有权
auto g = std::move(f1);  // f1 的状态未指定

// 调用:通过 operator() 执行
int result = g(3, 4);

// 判空:检查是否持有可调用对象
if (!g) { /* g is empty */ }

SBO:小对象优化

std::move_only_function 内部实现了小对象优化(Small Buffer Optimization,SBO)。对象内部预留一块固定大小的缓冲区(通常 16-32 字节),如果可调用对象足够小,就把它直接存到缓冲区里,避免堆分配。

为什么 OnceCallback 需要独立的 Status 枚举

std::move_only_function 的判空只能区分"空"和"非空"两种状态。但 OnceCallback 需要知道回调是"从来没被赋过值"还是"曾经有值但已经被调用了"。

cpp 复制代码
enum class Status : uint8_t {
    kEmpty,     // 从未被赋值
    kValid,     // 持有有效的可调用对象
    kConsumed   // 已被 run() 消费
};

此外,std::move_only_function 移动后的状态是未指定的 ------标准不保证移动后源对象的 operator bool() 返回 false。独立的 Status 枚举完全由我们控制------移动构造函数显式把源对象设为 kEmpty


Deducing this (C++23)

OnceCallback 的 run() 方法是整个组件的灵魂:

cpp 复制代码
template<typename Self>
auto run(this Self&& self, FuncArgs&&... args) -> ReturnType;

这是 C++23 引入的"显式对象参数"特性,官方名称叫 deducing this

问题:如何让 cb.run() 编译失败

OnceCallback 的核心语义是"只能调用一次,而且必须通过右值调用":

cpp 复制代码
cb.run(5);                  // 应该编译失败:cb 是左值
std::move(cb).run(5);       // 应该编译通过:std::move(cb) 是右值

deducing this 的语法与推导规则

cpp 复制代码
struct MyStruct {
    void f(this auto&& self) {
        // self 就是 this------但它的类型是推导出来的
    }
};

self 的类型推导规则和转发引用完全一样:

  • 左值调用 obj.f()self 推导为 MyStruct&
  • 右值调用 std::move(obj).f()self 推导为 MyStruct
  • const 左值调用 std::as_const(obj).f()self 推导为 const MyStruct&

在 OnceCallback::run() 中的应用

cpp 复制代码
template<typename Self>
auto run(this Self&& self, FuncArgs&&... args) -> ReturnType {
    static_assert(!std::is_lvalue_reference_v<Self>,
        "OnceCallback::run() must be called on an rvalue. "
        "Use std::move(cb).run(...) instead.");
    return std::forward<Self>(self).impl_run(std::forward<FuncArgs>(args)...);
}

std::is_lvalue_reference_v<Self> 检查 Self 是否是左值引用类型。当调用方写 cb.run(args) 时,Self 被推导为 OnceCallback&,这是一个左值引用类型,static_assert 失败,编译器报错。

与传统 ref-qualifier 的对比

OnceCallback 里有两个方法表达了"只能通过右值调用"的语义------run() 用 deducing this,then() 用传统的 ref-qualifier &&

  • then() 只需要"只接受右值"的约束,用 && 限定更简洁
  • run() 还需要对左值调用给出自定义的错误信息,用 deducing this 更合适

踩坑预警

显式对象参数不能与 cv-qualifier 或 ref-qualifier 共存:

cpp 复制代码
struct Bad {
    void f(this auto&& self) const;   // 编译错误
    void g(this auto&& self) &&;      // 编译错误
};

小结

这一篇我们掌握了 OnceCallback 实现中最关键的 C++20/23 高级特性。mutable lambda 允许在 lambda 内部修改捕获的对象,初始化捕获让 then() 能把整个 OnceCallback 对象通过移动语义搬进 lambda。C++20 的 lambda capture pack expansion 让 bind_once 的绑定参数可以直接展开到捕获列表中。Concepts 和 requires 约束保护模板构造函数不被劫持。std::move_only_function 是 OnceCallback 的核心存储类型。Deducing this 让 run() 用一个函数模板就实现了编译期的左值/右值拦截。

到这里,所有前置知识都讲完了。下一篇我们正式进入 OnceCallback 的实战环节。

参考资源


相关推荐
文慧的科技江湖1 小时前
零碳园区综合管理平台PRD需求文档 - 慧知开源充电桩平台
spring cloud·微服务·开源·能源·慧知开源光储充管理平台·慧知开源光储充管理系统·零碳园区管理平台
蜡笔小马1 小时前
04.C++设计模式-桥接模式
c++·设计模式·桥接模式
XD7429716361 小时前
科技早报晚报|2026年5月10日:Agent 安全沙箱、可审计编程代理与持久化产品上下文,今晚更值得做的 3 个开源机会
科技·安全·开源·开源项目·ai agent·开发者工具
金玉满堂@bj2 小时前
PostgreSQL:企业级全能开源数据库
数据库·postgresql·开源
宏笋2 小时前
C++ using typedef #define 三者的优缺点比较
c++
枕星而眠2 小时前
一篇吃透 C++ 核心基础:初始化、引用、指针、内联、重载、右值引用
开发语言·数据结构·c++·后端·visual studio
小明同学012 小时前
计算机网络编程---系统调用到并发模型
linux·c++·计算机网络
Season4502 小时前
C/C++的类型转换
c语言·开发语言·c++
Titan20242 小时前
C++特殊类设计
c++·学习