Perfect forwarding is a powerful feature in C++ that allows template functions to retain the lvalue/rvalue nature of their arguments. This helps prevent unnecessary copies and enables reference semantics without requiring multiple overloads. However, capturing perfectly-forwarded objects in lambdas can lead to unexpected results if not handled correctly.
This article explores the nuances of perfect forwarding within lambda captures, identifying potential pitfalls and presenting an elegant solution.
What We’ll Cover:
- Why using
std::forward
in lambda captures can cause unexpected behavior. - Implementing a wrapper to correctly handle perfect forwarding in lambdas.
- Using macros to reduce verbosity.
- Extending the solution to variadic argument packs.
The Problem
Consider the following struct definition:
struct A
{
int _value{0};
};
Now, let’s define a lambda function:
auto foo = [](auto& a)
{
return [&a]{ ++a._value; };
};
Using foo
with an instance of A
behaves as expected:
A my_a;
foo(my_a)();
std::cout << my_a._value << "\n"; // Prints `1`
Now, let’s generalize foo
to use perfect forwarding:
auto foo = [](auto&& a)
{
return [a = std::forward<decltype(a)>(a)]() mutable
{
++a._value;
std::cout << a._value << "\n";
};
};
Unexpected Behavior
While this works for rvalue references:
auto l_inner = foo(A{});
l_inner(); // Prints `1`
l_inner(); // Prints `2`
It fails for lvalue references:
A my_a;
auto l_inner = foo(my_a);
l_inner();
l_inner();
std::cout << my_a._value << "\n"; // Prints `0` (unexpected)
Why Does This Happen?
The issue lies in how lambda captures work. When we use a = std::forward<decltype(a)>(a)
, a
is always captured as a value, not a reference. This means mutations inside the lambda do not affect the original object.
The Solution: A Capture Wrapper
To ensure the correct behavior, we introduce a wrapper class that can store either a reference or a value, depending on how it is initialized.
Implementing fwd_capture_wrapper
template <typename T>
struct fwd_capture_wrapper : impl::by_value<T>
{
using impl::by_value<T>::by_value;
};
// Specialized version for references
template <typename T>
struct fwd_capture_wrapper<T&> : impl::by_ref<T>
{
using impl::by_ref<T>::by_ref;
};
The implementation details of by_value
and by_ref
are:
template <typename T>
class by_value
{
private:
T _x;
public:
template <typename TFwd>
by_value(TFwd&& x) : _x{std::forward<TFwd>(x)} {}
auto& get() & { return _x; }
const auto& get() const& { return _x; }
auto get() && { return std::move(_x); }
};
For references, we use std::reference_wrapper
to avoid issues with copying:
template <typename T>
class by_ref
{
private:
std::reference_wrapper<T> _x;
public:
by_ref(T& x) : _x{x} {}
auto& get() & { return _x.get(); }
const auto& get() const& { return _x.get(); }
auto get() && { return std::move(_x.get()); }
};
We define a helper function for easy usage:
template <typename T>
auto fwd_capture(T&& x)
{
return fwd_capture_wrapper<T>(std::forward<T>(x));
}
Updating foo
auto foo = [](auto&& a)
{
return [a = fwd_capture(std::forward<decltype(a)>(a))]() mutable
{
++a.get()._value;
std::cout << a.get()._value << "\n";
};
};
This ensures correct behavior for both lvalue and rvalue references.
Reducing Noise with Macros
Using std::forward
and fwd_capture
explicitly can be cumbersome. We define a macro:
#define FWD(...) std::forward<decltype(__VA_ARGS__)>(__VA_ARGS__)
#define FWD_CAPTURE(...) fwd_capture(FWD(__VA_ARGS__))
Now, we can simplify foo
:
auto foo = [](auto&& a)
{
return [a = FWD_CAPTURE(a)]() mutable { /* ... */ };
};
Supporting Variadic Argument Packs
If foo
takes multiple arguments, capturing them correctly requires additional handling:
auto foo = [](auto&&... xs)
{
return [xs_pack = std::make_tuple(FWD_CAPTURE(xs)...)]() mutable
{
std::apply([](auto&&... xs)
{
((++xs.get()._value, std::cout << xs.get()._value << "\n"), ...);
}, xs_pack);
};
};
This allows foo
to handle multiple perfectly-forwarded arguments while maintaining reference semantics.
A Simpler Alternative
An even simpler approach is to directly use std::tuple
as the wrapper:
template <typename... Ts>
auto fwd_capture(Ts&&... xs)
{
return std::tuple<Ts...>(FWD(xs)...);
}
Accessing the captured values requires std::get
:
template <typename T>
decltype(auto) access(T&& x)
{
return std::get<0>(FWD(x));
}
This approach achieves the same goal with significantly less boilerplate.
Conclusion
Capturing perfectly-forwarded objects in lambdas requires careful handling to maintain the correct reference semantics. Using a wrapper like fwd_capture_wrapper
or leveraging std::tuple
provides an effective solution. Macros help reduce verbosity, and extending the approach to variadic arguments ensures flexibility.
By understanding these nuances, C++ developers can write more robust and efficient generic code when working with lambda captures and perfect forwarding.