Prerequisites for OnceCallback (Part 3): Advanced Lambda Features
Introduction
In the previous cheat sheet, we quickly reviewed the basic syntax of lambda expressions. In this post, we dive into three advanced lambda features actually used in the OnceCallback implementation. These are not just "nice-to-have" syntactic sugar, but the key mechanisms that make bind_once and then() possible. Without understanding these features, the implementation code ahead will be quite painful to read.
Specifically, we cover three things: why mutable lambdas cannot be omitted in OnceCallback, how init capture lets then() move the entire OnceCallback object into a lambda, and how C++20's lambda capture pack expansion reduces the code in bind_once to a third of its original size.
Learning Objectives
- Understand the behavioral differences between
mutablelambdas and const lambdas, and their necessity in OnceCallback- Master the syntax and semantics of init capture, and understand the ownership transfer in
self = std::move(*this)- Learn C++20 lambda capture pack expansion, and understand the concise implementation of
bind_once- Understand the essence of generic lambda
(auto&&... args)
mutable Lambda: Why It Cannot Be Omitted in OnceCallback
The operator() generated by a lambda by default is const—meaning the lambda cannot modify value-captured variables internally. Adding the mutable keyword makes the operator() non-const, allowing modifications.
Behavior Comparison
int x = 10;
// const lambda:不能修改捕获的变量
auto f1 = [x]() {
// x++; // 编译错误:operator() 是 const 的
return x;
};
// mutable lambda:可以修改捕获的变量
auto f2 = [x]() mutable {
x++; // OK:operator() 是非 const 的
return x;
};
f2(); // 返回 11,x 的副本被修改
f2(); // 返回 12,同一个 lambda 对象再次调用,x 继续增加Note the second example—the state of a mutable lambda persists across multiple invocations. This is because the lambda's closure object holds copies of the captured variables, and mutable allows the operator() to modify these copies.
Role in OnceCallback
The lambdas in both bind_once and then() must be declared as mutable. The reason is that their capture lists contain a OnceCallback object (captured via self = std::move(*this)), and calling std::move(self).run() modifies the internal state of self (changing status_ from kValid to kConsumed). If the lambda were const, self would be a const reference inside the lambda, and you cannot call state-modifying operations on a const object—the compiler would error out directly.
Simply put: once a lambda captures an object that needs to be modified upon invocation (such as OnceCallback), you must add mutable. This is not optional—the code will not compile without it.
// then() 内部的 lambda——mutable 不可省略
[self = std::move(*this), cont = std::forward<Next>(next)]
(FuncArgs... args) mutable -> NextRet {
// self 在这里需要被修改(run() 会消费它)
auto mid = std::move(self).run(std::forward<FuncArgs>(args)...);
return std::invoke(std::move(cont), std::move(mid));
}Init Capture: Moving Objects into a Lambda
C++14 introduced the init capture syntax, which allows you to execute an expression in the capture list and use the result to initialize a captured variable. The syntax is name = expression.
Difference from Simple Capture
Simple capture [x] can only capture existing variables, using either copy or reference semantics. Init capture [name = expr] allows you to do three things that simple capture cannot:
auto ptr = std::make_unique<int>(42);
// 1. 移动捕获——把 unique_ptr 搬进 lambda
auto f1 = [p = std::move(ptr)]() { return *p; };
// ptr 在外面已经被搬空了
// 2. 存储计算结果
std::string s = "hello";
auto f2 = [len = s.size()]() { return len; }; // len 是 size_t 类型
// 3. 捕获不存在于外部的变量
auto f3 = [counter = 0]() mutable { return ++counter; }; // counter 是 lambda 自己的变量Usage in OnceCallback
The implementation of then() uses init capture to do two critical things.
The first is moving the entire OnceCallback object into the lambda:
self = std::move(*this)*this is the current OnceCallback object, and std::move(*this) casts it to an rvalue. The init capture self = std::move(*this) triggers OnceCallback's move constructor, moving func_, status_, and token_ entirely into the lambda's closure object. After the move, *this (the original OnceCallback object) enters a "moved-from" state—func_ and token_ are already empty or null.
The second is moving the subsequent callback in:
cont = std::forward<Next>(next)std::forward<Next>(next) preserves the value category of next—if an rvalue is passed in, it moves; if an lvalue is passed in, it copies. Typically, then() receives temporary lambdas (rvalues), so this is a move.
Ownership Chain
Looking at these two captures together, the new lambda created by then() holds complete ownership of both the original callback and the subsequent callback. This lambda is then stored in the std::move_only_function of a new OnceCallback. The entire ownership chain looks like this:
新 OnceCallback -> move_only_function -> lambda 闭包 -> [原 OnceCallback + 后续回调]Each layer passes ownership via move semantics, without any sharing or copying. This is the complete embodiment of OnceCallback's move-only semantics in then()—ownership transfers layer by layer from outside to inside, leaving no gaps.
C++20 Lambda Capture Pack Expansion: The Secret to bind_once's Conciseness
This is the most important feature in this post, and the key to why bind_once can be implemented in just a few lines of code. Before C++20, the parameter pack of a variadic template could not be directly expanded into a lambda's capture list—you had to first pack the arguments using std::tuple, and then expand and invoke them inside the lambda using std::apply.
The Old Approach (C++17): tuple + apply
template<typename F, typename... BoundArgs>
auto bind_old(F&& f, BoundArgs&&... args) {
// 把所有绑定参数打包进 tuple
return [f = std::forward<F>(f),
tup = std::make_tuple(std::forward<BoundArgs>(args)...)]
(auto&&... call_args) mutable -> decltype(auto) {
// 用 std::apply 展开 tuple 并调用
return std::apply([&](auto&... bound) -> decltype(auto) {
return f(bound..., std::forward<decltype(call_args)>(call_args)...);
}, tup);
};
}This works, but the code bloats considerably—you need an intermediate tuple, an std::apply call, and a nested lambda to handle the expansion.
The New Syntax (C++20): Expanding Packs Directly in the Capture List
C++20 allows pack expansion in a lambda's init capture. The syntax is ...name = expression, and its effect is to generate a corresponding captured variable for each type in the parameter pack.
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)...);
};
}Manually Expanding a Concrete Example
Suppose we call bind_new([](int a, std::string b, int c) { ... }, 10, std::string("hello")), at which point BoundArgs = {int, std::string}. The compiler expands the pack expansion ...bound = std::forward<BoundArgs>(args) into:
[f = std::forward<F>(f),
b1 = std::forward<int>(arg1), // int 直接转发
b2 = std::forward<std::string>(arg2)] // std::string 移动转发
(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)...);
}Each bound argument becomes an independent member variable in the lambda's closure, and when the lambda is invoked, they are expanded together via std::move(bound)... and passed to std::invoke.
Why std::move Instead of std::forward
You might notice that we use std::move(bound)... instead of std::forward<BoundArgs>(bound)... inside the lambda. The reason is that the lambda is mutable, and the captured variables bound are lvalues inside the lambda (named variables are always lvalues). Since we want the bound arguments to be passed out as rvalues when the callback is invoked (triggering move semantics), we use std::move to cast them to rvalues. If we used std::forward, because bound is already an lvalue, std::forward would only return an lvalue reference—move semantics would be lost.
Generic Lambda: auto&& as a Forwarding Reference
The lambda inside bind_once uses (auto&&... call_args) to accept the arguments passed in at runtime. Here, auto&& is a forwarding reference—because auto in a lambda parameter is equivalent to a template parameter, auto&& has the same deduction rules as T&& (when T is a template parameter).
auto f = [](auto&& x) {
// x 是转发引用
// 传入左值:auto = int&, x 的类型是 int&(左值引用)
// 传入右值:auto = int, x 的类型是 int&&(右值引用)
};
int v = 10;
f(v); // x 绑定到左值
f(10); // x 绑定到右值The combination of auto&&... means this lambda can accept any number of arguments of any type, while preserving the value category of each argument. Paired with std::forward<decltype(call_args)>(call_args)..., these arguments can be perfectly forwarded to the final callable object.
Summary
In this post, we mastered the three most critical lambda features in the OnceCallback implementation. The mutable lambda allows modifying captured objects inside the lambda, and OnceCallback's bind_once and then() must use it to call std::move(self).run() inside the lambda to modify the callback state. Init capture name = expr allows then() to move the entire OnceCallback object into the lambda closure via move semantics, establishing a complete ownership chain. C++20's lambda capture pack expansion ...name = expr allows the bound arguments of bind_once to be expanded directly into the capture list, replacing the bloated tuple + apply approach of the C++17 era.
In the next post, we will look at Concepts and requires constraints—they are the key defensive measures that prevent OnceCallback's template constructor from being incorrectly matched.