OnceCallback 前置知识(下):C++20/23 高级特性
引言
上篇我们回顾了 C++11/14/17 的基础特性。这一篇我们进入 C++20/23 的高级特性------它们不是什么"锦上添花"的语法糖,而是 bind_once、then() 和 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_once 和 then() 的 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 的实战环节。
参考资源
- cppreference: Lambda 表达式
- P0780R2 - Pack Expansion in Lambda Init-Capture
- cppreference: Constraints and concepts
- cppreference: std::move_only_function
- P0847R7 - Deducing this 提案