| Document #: | P3963R1 Latest Status |
| Date: | 2026-10-23 |
| Project: | Programming Language C++ |
| Audience: |
EWG, CWG |
| Reply-to: |
Ruslan Arutyunyan (Intel) <ruslan.arutyunyan@intel.com> |
This paper proposes making lambdas with captures copy assignable and move assignable when all captured entities are themselves copy/move assignable respectively.
When lambda expressions were introduced in C++11, they were not assignable. [P0624R2] made lambdas without capture assignable. Unfortunately, lambdas with captures have deleted assignment operators, which prevents certain use cases.
One of the examples is interoperability with the C++20 Ranges library
that provides powerful abstractions for lazy evaluation pipelines. Range
adaptors like
std::views::transform
store callable objects, including lambdas, as part of their state.
Consider the following example:
std::vector<int> v = {1, 2, 3, 4, 5};
// This captureless lambda is trivially copyable
auto captureless = [](auto x) { return x + 1; };
static_assert(std::is_trivially_copyable_v<decltype(captureless)>); // ok
// This lambda with capture is trivially copyable
auto with_capture = [y = 1](auto x) { return x + y; };
static_assert(std::is_trivially_copyable_v<decltype(with_capture)>); // ok
// This view is trivially copyable
auto view1 = v | std::views::transform(captureless);
static_assert(std::is_trivially_copyable_v<decltype(view1)>); // ok
// This view is NOT trivially copyable
auto view2 = v | std::views::transform(with_capture);
static_assert(std::is_trivially_copyable_v<decltype(view2)>); // failsIn the example above, the lambdas (both with and without capture) are
trivially copyable. But while a captureless lambda results in a
trivially copyable transform_view, a
lambda capturing even a simple
int by value
does not. This distinction becomes problematic when targeting parallel
range algorithms for heterogeneous execution, as described in the
accepted [P3179R9] proposal.
The thing that makes views not trivially-copyable is
movable-box.
It has a non-trivial copy/move assignment operator when the type (in our
case, callable) does not model the
copyable concept. This is exactly
what happens with lambdas with capture.
Users can work around this limitation by manually writing function objects:
// verbose
struct add_y {
int y;
auto operator()(auto x) const { return x + y; }
};
auto view = v | std::views::transform(add_y{1});
static_assert(std::is_trivially_copyable_v<decltype(view)>); // OKThis defeats the whole purpose of lambda expressions as a concise way to define inline callable objects and makes it verbose again to work with C++ standard algorithms with hand-written callables like it was prior C++11. Users normally don’t favor writing callable objects by themselves until the latter have a significant reusability.
With the [P3179R9] proposal accepted, users should be able to write simple code like below without hand-written callables:
auto result = std::ranges::for_each(
ext::gpu_policy,
data | views::transform([val](auto x) { return x + val; }),
callable);One could say that
ext::gpu_policy
does not belong to the C++ standard; however, the implementations are
allowed to have implementation-defined execution policies, so the code
above could be standard conformant.
I propose that the assignment operators of a lambda closure type should follow the same rules as the hand-written callable objects: they should behave as if they are not explicitly written (compiler-generated) and then choose the proper behavior based on closure type layout.
In other words:
Copy assignment operator: If all captures are copy assignable, the closure type’s copy assignment operator should be implicitly defaulted. Otherwise, it should be deleted.
Move assignment operator: If all captures are move assignable, the closure type’s move assignment operator should be implicitly defaulted. Otherwise, it should be deleted.
Example with capture by value:
int y = 1;
auto f = [y](int x) { return x + y; };
auto g = f; // OK today - copy construction
g = f; // Currently ill-formed, proposed to be well-formed
static_assert(std::is_copy_assignable_v<decltype(f)>); // Currently fails, proposed to pass
static_assert(std::is_move_assignable_v<decltype(f)>); // Currently fails, proposed to passExample with capture by reference:
int y = 1;
auto f = [&y](int x) { return x + y; };
auto g = f; // OK today - copy construction
g = f; // Currently ill-formed, proposed to be ill-formed
static_assert(std::is_copy_assignable_v<decltype(f)>); // Fails, even with this proposal
static_assert(std::is_move_assignable_v<decltype(f)>); // Fails, even with this proposalWith this change, the motivating example with
transform_view (and similar views)
would work as expected:
auto view = v | std::views::transform([y = 1](auto x) { return x + y; });
static_assert(std::is_trivially_copyable_v<decltype(view)>); // Would passthisCapturing
this
deserves a separate discussion. On one hand, when we create
[this]{}
lambda one could say that we capture a pointer to the outer class.
However, standard says that
The simple-captures
thisand* thisdenote the local entity*this.
which says that even if
[this]{}
looks like a pointer capture, semantically it means “capturing the outer
object by reference”. The standard does not force having a pointer in
the lambda type layout, so having a reference as a part of the closure
type layout is also a valid implementation for a lambda that captures
this.
“Capturing the outer object by reference” semantics is also
indirectly proven by the capture-default forms:
[&]{}
and [=]{}.
While both implicitly capture
this, the
behavior of the latter form (capturing
this) is
deprecated in C++20.
It’s probably worth noting that if we have capture-init,
e.g.,
[x = this]{},
in this case x is a pointer but
that’s a different story because the type of
x is implicitly
auto and
it’s deduced based on the initializer on the right side of expression,
which has a pointer type.
Conclusion: Based on the analysis it is clear that
[this]{}
capture semantically means capturing the object reference and this
proposal says that if something is captured by reference the copy/move
assignment operators shall be deleted. Thus, for
[this]{}
the copy/move assignment operators shall also be deleted.
We asked [P0624R2] authors why they didn’t add assignment operators to lambdas with capture. The answer was that they just wanted to solve their immediate problem without making the proposal broader. So, they don’t see immediate issues with this proposal.
One might argue that assignment to a closure object is semantically
questionable; what does it mean to assign one closure to another? The
answer is simple. This assignment would do whatever the
compiler-generated assignment would normally do for hand-written
callables: either be similar to
= default
or similar to be
= delete
if captured objects are references or not assignable. Beyond the main
motivation, this change will make lambdas even closer to hand-written
callables.
One could ask about a default constructor for lambdas with capture. It is a valid question and we could even potentially say something along those lines: “if all the captured members are default constructible then the lambda is default constructible or it has a deleted default constructor otherwise”. However, it would require more exploration and I don’t see the immediate use-case for that.
It could be that move-only types are captured in a lambda. It is also hard to imagine how it would be possible to create a copy of such an object. So, the example below should be valid C++ but probably unlikely:
auto f1 = [p = std::make_unique<int>(1)](int x) { return x + *p; };
auto f2 = std::move(f1);
f1 = std::move(f2);Nevertheless, if one adds
static_assert(std::is_move_assignable_v<decltype(f1)>);
it would pass with this proposal. I do not see any particular problems
with that; it’s just worth noting.
This is a pure language extension. Code that previously failed to compile (due to deleted assignment operators) would now compile successfully. No existing valid code would change behavior.
Code that relies on
std::is_copy_assignable_v<T>
trait (where T is a lambda with
capture) for SFINAE purposes would see changed behavior. This example
does not sound realistic because it would work with regular callables
with some non-static data members compared to lambdas with captures.
Furthermore, such an example did not prevent us from adopting [P0624R2], thus making
captureless lambdas assignable in C++20.
I talked to NVIDIA representatives as a parallel algorithms vendor. They also consider the problem in the Motivation section important and believe that we need to solve it. They don’t have objections to this proposal.
It’s worth mentioning that there is [P3960R0] proposal that is
authored by both Intel and NVIDIA. It approaches the similar problem but
covers broader scope, thus it is complementary to this proposal. It’s
still worth adding lambdas assignability to not pessimize the cases like
with
ranges::transform_view
and make them more consistent with other class types.
The implementation experience is done in the fork of LLVM.
17 The closure type associated with a
lambda-expression has no default constructor if the
lambda-expression has a lambda-capture and a defaulted
default constructor otherwise. It has a defaulted copy constructor and a
defaulted move constructor (Copy/move constructors [class.copy.ctor]).
It has a deleted copy assignment operator if the
lambda-expression has a lambda-capture by reference or
this and
defaulted copy and move assignment operators otherwise (Copy/move
assignment operator [class.copy.assign]).
[ Note: These special member functions are implicitly defined as usual, which can result in them being defined as deleted. — end note ]