OnceCallback 前置知识下C20/23 高级特性引言上篇我们回顾了 C11/14/17 的基础特性。这一篇我们进入 C20/23 的高级特性——它们不是什么锦上添花的语法糖而是bind_once、then()和run()得以实现的关键机制。学习目标掌握 Lambda 高级特性mutable、初始化捕获、C20 包展开理解 Concepts 如何保护模板构造函数不被劫持掌握 std::move_only_function 的核心操作理解 Deducing this 如何实现编译期左值/右值拦截Lambda 高级特性mutable lambda为什么不能省Lambda 默认生成的operator()是const的——这意味着 lambda 内部不能修改值捕获的变量。加mutable关键字后operator()变成非 const 的。intx10;// const lambda不能修改捕获的变量autof1[x](){// x; // 编译错误returnx;};// mutable lambda可以修改autof2[x]()mutable{x;// OKreturnx;};在 OnceCallback 中的角色bind_once和then()的 lambda 都必须声明为mutable。原因是这些 lambda 的捕获列表里包含OnceCallback对象而调用std::move(self).run()会修改self的内部状态。// then() 内部的 lambda——mutable 不可省略[selfstd::move(*this),contstd::forwardNext(next)](FuncArgs...args)mutable-NextRet{automidstd::move(self).run(std::forwardFuncArgs(args)...);returnstd::invoke(std::move(cont),std::move(mid));}如果 lambda 是 const 的self在 lambda 内部就是 const 的没法调用修改状态的操作。初始化捕获Init CaptureC14 引入了初始化捕获init capture语法允许你在捕获列表中执行表达式并用结果初始化一个捕获变量。语法是name expression。和简单捕获的区别autoptrstd::make_uniqueint(42);// 移动捕获——把 unique_ptr 搬进 lambdaautof1[pstd::move(ptr)](){return*p;};// 存储计算结果std::string shello;autof2[lens.size()](){returnlen;};// 捕获不存在于外部的变量autof3[counter0]()mutable{returncounter;};在 OnceCallback 中的使用then()的实现用初始化捕获做了两件关键的事情。把整个 OnceCallback 对象搬进 lambdaselfstd::move(*this)*this是当前 OnceCallback 对象std::move(*this)把它转成右值初始化捕获触发 OnceCallback 的移动构造把func_、status_、token_全部搬进 lambda 的闭包对象里。把后续回调搬进来contstd::forwardNext(next)std::forwardNext(next)保持next的值类别——如果传入的是右值它就是移动如果传入的是左值它就是拷贝。所有权链整个所有权链条是这样的新 OnceCallback - move_only_function - lambda 闭包 - [原 OnceCallback 后续回调]每一层都通过移动语义传递所有权没有任何共享或拷贝。C20 Lambda Capture Pack Expansion这是bind_once得以用几行代码实现的关键。C20 之前可变参数模板的参数包不能直接展开到 lambda 的捕获列表里。旧方案C17tuple applytemplatetypenameF,typename...BoundArgsautobind_old(Ff,BoundArgs...args){return[fstd::forwardF(f),tupstd::make_tuple(std::forwardBoundArgs(args)...)](auto...call_args)mutable-decltype(auto){returnstd::apply([](auto...bound)-decltype(auto){returnf(bound...,std::forwarddecltype(call_args)(call_args)...);},tup);};}新语法C20直接展开C20 允许在 lambda 的初始化捕获中使用包展开templatetypenameF,typename...BoundArgsautobind_new(Ff,BoundArgs...args){return[fstd::forwardF(f),...boundstd::forwardBoundArgs(args)]// ← 包展开(auto...call_args)mutable-decltype(auto){returnstd::invoke(std::move(f),std::move(bound)...,std::forwarddecltype(call_args)(call_args)...);};}手动展开一个具体例子假设调用bind_new([](int a, std::string b, int c) { ... }, 10, std::string(hello))此时BoundArgs {int, std::string}。编译器把包展开成[fstd::forwardF(f),b1std::forwardint(arg1),b2std::forwardstd::string(arg2)](auto...call_args)mutable-decltype(auto){returnstd::invoke(std::move(f),std::move(b1),std::move(b2),std::forwarddecltype(call_args)(call_args)...);}为什么用 std::move 而不是 std::forwardlambda 是mutable的捕获变量bound在 lambda 内部是左值具名变量永远是左值。由于我们希望绑定参数在回调被调用时以右值的方式传出所以用std::move把它们转成右值。Concepts 与 requires 约束OnceCallback 的构造函数上有这么一行约束templatetypenameFunctorrequiresnot_the_same_tFunctor,OnceCallbackexplicitOnceCallback(Functorfunction);这个约束是为了防止模板构造函数在OnceCallback cb2 std::move(cb1)这种场景下劫持移动构造函数的调用。问题模板构造函数的越位structWrapper{templatetypenameTWrapper(Tx){}// 模板构造函数Wrapper(Wrapperother)noexcept{}// 移动构造函数};Wrapper a;Wrapper bstd::move(a);// 可能调用模板构造函数而不是移动构造函数Concept 基本语法templatetypenameTconceptIntegralstd::is_integral_vT;templatetypenameTrequiresIntegralTvoidfoo(T x){}not_the_same_t逐行拆解templatetypenameF,typenameTconceptnot_the_same_t!std::is_same_vstd::decay_tF,T;它做的事情F 退化后的类型不是 T。std::decay_tF去掉引用、const/volatile 限定符std::is_same_vA, BA 和 B 是否是同一类型!取反F 不是 T 时约束通过加上约束后的效果当传入的是OnceCallback本身时not_the_same_tOnceCallback, OnceCallback求值为false约束不满足模板被排除出候选列表编译器只能选择移动构造函数。std::move_only_function (C23)std::move_only_function是 OnceCallback 的心脏——它承担了所有类型擦除的脏活累活。从 std::function 到 std::move_only_functionstd::function有一个根本性的限制它要求存储的可调用对象必须可拷贝。// 编译错误unique_ptr 不可拷贝std::functionint()f[pstd::make_uniqueint(42)](){return*p;};std::move_only_functionC23删除了拷贝操作只保留移动操作// OKmove_only_function 不要求可拷贝std::move_only_functionint()f[pstd::make_uniqueint(42)](){return*p;};四个核心操作// 构造从可调用对象创建std::move_only_functionint(int,int)f1[](inta,intb){returnab;};// 移动转移所有权autogstd::move(f1);// f1 的状态未指定// 调用通过 operator() 执行intresultg(3,4);// 判空检查是否持有可调用对象if(!g){/* g is empty */}SBO小对象优化std::move_only_function内部实现了小对象优化Small Buffer OptimizationSBO。对象内部预留一块固定大小的缓冲区通常 16-32 字节如果可调用对象足够小就把它直接存到缓冲区里避免堆分配。为什么 OnceCallback 需要独立的 Status 枚举std::move_only_function的判空只能区分空和非空两种状态。但 OnceCallback 需要知道回调是从来没被赋过值还是曾经有值但已经被调用了。enumclassStatus:uint8_t{kEmpty,// 从未被赋值kValid,// 持有有效的可调用对象kConsumed// 已被 run() 消费};此外std::move_only_function移动后的状态是未指定的——标准不保证移动后源对象的operator bool()返回false。独立的Status枚举完全由我们控制——移动构造函数显式把源对象设为kEmpty。Deducing this (C23)OnceCallback 的run()方法是整个组件的灵魂templatetypenameSelfautorun(thisSelfself,FuncArgs...args)-ReturnType;这是 C23 引入的显式对象参数特性官方名称叫deducing this。问题如何让 cb.run() 编译失败OnceCallback 的核心语义是只能调用一次而且必须通过右值调用cb.run(5);// 应该编译失败cb 是左值std::move(cb).run(5);// 应该编译通过std::move(cb) 是右值deducing this 的语法与推导规则structMyStruct{voidf(thisautoself){// self 就是 this——但它的类型是推导出来的}};self的类型推导规则和转发引用完全一样左值调用obj.f()self推导为MyStruct右值调用std::move(obj).f()self推导为MyStructconst 左值调用std::as_const(obj).f()self推导为const MyStruct在 OnceCallback::run() 中的应用templatetypenameSelfautorun(thisSelfself,FuncArgs...args)-ReturnType{static_assert(!std::is_lvalue_reference_vSelf,OnceCallback::run() must be called on an rvalue. Use std::move(cb).run(...) instead.);returnstd::forwardSelf(self).impl_run(std::forwardFuncArgs(args)...);}std::is_lvalue_reference_vSelf检查Self是否是左值引用类型。当调用方写cb.run(args)时Self被推导为OnceCallback这是一个左值引用类型static_assert失败编译器报错。与传统 ref-qualifier 的对比OnceCallback 里有两个方法表达了只能通过右值调用的语义——run()用 deducing thisthen()用传统的 ref-qualifier。then()只需要只接受右值的约束用限定更简洁run()还需要对左值调用给出自定义的错误信息用 deducing this 更合适踩坑预警显式对象参数不能与 cv-qualifier 或 ref-qualifier 共存structBad{voidf(thisautoself)const;// 编译错误voidg(thisautoself);// 编译错误};小结这一篇我们掌握了 OnceCallback 实现中最关键的 C20/23 高级特性。mutablelambda 允许在 lambda 内部修改捕获的对象初始化捕获让then()能把整个 OnceCallback 对象通过移动语义搬进 lambda。C20 的 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-Capturecppreference: Constraints and conceptscppreference: std::move_only_functionP0847R7 - Deducing this 提案