Implementation Challenge: Replacing std::move and std::forward
When C++11 introduced move semantics, it also added two important helper functions: std::move
and std::forward
.
They are essential when you want to manually indicate that you no longer care about an object or need to propagate the value category in generic code.
As such, I’ve used them countless times in the past.
However, they are functions. Plain, old, standard library functions.
This is problematic for multiple reasons.
First, some programmers dislike them for philosophical reasons:
Why put something required for a language feature into the library?
Why is it std::forward<T>(foo)
instead of something built-in like >>foo
, which has been proposed in the past?
Second, using them requires a function call (duh).
This is annoying when you use a debugger and are constantly stepping through the standard library definition for std::move()
,
and can also have performance implications at runtime if you don’t have optimizations enabled.
A language feature wouldn’t have those issues.
Third – and this is the main reason I dislike it – they have compile-time implications.
I’m currently working on a library that makes heavy use of meta programming, which already increases compile-times a lot.
Still, I can compile the entire test suite in about five seconds (~12K lines).
If I were to start using std::move
and std::forward
, I first need to include <utility>
where they’re defined (the majority of the headers don’t need anything besides <type_traits>
, <cstddef>
etc.).
An empty C++ file that just #include <utility>
takes 250ms
(i.e. 5% of my test suite compilation time) and pulls in about 3K lines of code.
Add to that the cost of name lookup, overload resolution and template instantiation every time I want to use them, and compilation times increase by an additional 50ms
.
Yes, modules can probably help with the cost of including utility. But CMake doesn’t support them, so I don’t care yet.
You might think that those problems aren’t really problems – and that’s okay, you don’t need to care about those things. But if you do care, there are better alternatives.
Replacing std::move
std::move(obj)
indicates that you no longer need the value of obj
and something else is free to steal it.
But what does std::move()
actually do?
Copying the standard library implementation and cleaning it up a bit, we get this:
template<typename T>
constexpr std::remove_reference_t<T>&& move(T&& t) noexcept
{
return static_cast<std::remove_reference_t<T>&&>(t);
}
It’s essentially a glorified static_cast
.
What we’re doing is taking in some reference – lvalue or rvalue, const or non-const – and casting it to an rvalue reference.
And this makes sense.
When we write Type obj = std::move(other_obj);
we want overload resolution to call the move constructor Type(Type&& other)
instead of the copy constructor Type(const Type& other)
. So we simply cast the argument to an rvalue reference and let the compiler work it out.
So replacing std::move()
is really trivial.
Instead of writing:
#include <utility>
…
Type obj = std::move(other_obj);
We write:
// no #include necessary
…
Type obj = static_cast<Type&&>(other_obj);
No #include
, no function call, nothing.
That was easy; let’s look at std::forward
.
Replacing std::forward
std::forward
is used as part of perfect forwarding, where we take a bunch of arguments and want to pass them to another function.
#include <utility>
template <typename Fn, typename ... Args>
void call(Fn fn, Args&&... args)
{
// Forward the arguments to the function.
fn(std::forward<Args>(args)...);
}
When we pass an lvalue, we want fn()
to be called with an lvalue.
When we pass an rvalue, we want fn()
to be called with an rvalue.
Simply writing fn(args...)
though isn’t enough:
Inside the function, the rvalue arguments create rvalue reference parameters, which are themselves lvalues as they are named!
For the same reason, we still need to call std::move()
when dealing with an rvalue reference:
Type& operator=(Type&& other)
{
// Need move here, otherwise we'd copy.
Type tmp(std::move(other));
swap(*this, tmp);
return *this;
}
While other
is an rvalue reference, the reference has a name and as such is an lvalue.
To treat an rvalue reference as an rvalue, you need a std::move()
– which does the static_cast
to rvalue.
Anyways, long story short: when forwarding you need to leave lvalue references alone but std::move()
rvalue references.
And this is precisely what std::forward
does; let’s take a look:
template<typename T>
constexpr T&& forward(std::remove_reference_t<T>& t) noexcept
{
return static_cast<T&&>(t);
}
template<typename T>
constexpr T&& forward(std::remove_reference_t<T>&& t) noexcept
{
static_assert(!std::is_lvalue_reference_v<T>);
return static_cast<T&&>(t);
}
There are two overloads of std::forward
.
The first one takes an lvalue reference and returns static_cast<T&&>
.
Because T
is an lvalue reference, reference collapsing rules kick in and T&&
is the same as T
(an lvalue reference).
This means we’re just taking an lvalue reference in and returning an lvalue reference out.
The second one takes an rvalue reference and also returns static_cast<T&&>
.
Because T
is an rvalue reference, reference collapsing rules kick in and T&&
is the same as T
(an rvalue reference).
This means we’re still taking an rvalue reference in and returning an rvalue reference out.
However, now the returned rvalue reference doesn’t have a name which makes it an rvalue!
The
static_assert
is just there to catch calls likestd::forward<Type&>(rvalue_ref)
. This wouldn’t actually move but return an lvalue reference to the object referenced by the rvalue reference, which is bad™. Yes, in addition to being a function, not an operator, you can even usestd::forward
incorrectly, isn’t that nice!
But wait, the implementation of forward for both overloads is identical, so why not just do the following?
template <typename T>
constexpr T&& forward(T&& t) noexcept
{
return static_cast<T&&>(t);
}
Well, that wouldn’t work.
Remember, inside the function all references are lvalues.
Writing the explicit argument forward<Arg>(arg)
would try to pass an lvalue to an rvalue reference – which doesn’t compile.
And letting template argument deduction figure it out would always deduce an lvalue.
That was a lot of lvalue and rvalue, so to summarize:
- We’re including 3K lines of C++.
- The compiler needs to perform name lookup to find
std::forward
. - The compiler needs to perform overload resolution between the two
forward
overloads. - The compiler needs to instantiate the chosen overload.
- The compiler needs to check whether we’ve used
std::forward
wrong.
All for something, that is a static_cast
to the same type we’re already having!
That’s right, the replacement for std::forward<Arg>(arg)
is just static_cast<Arg&&>(arg)
:
template <typename Fn, typename ... Args>
void call(Fn fn, Args&&... args)
{
// Forward the arguments to the function.
fn(static_cast<Args&&>(args)...);
}
If the argument is an lvalue reference, we’re casting it to an lvalue reference, which produces an lvalue. If the argument is an rvalue reference, we’re casting it to an rvalue reference, which produces an rvalue (because it loses the name).
That’s it.
If you don’t have the type as a template parameter (because you’re in a pre C++20 lambda), you can also use decltype()
:
auto call = [](auto fn, auto&&... args) {
// Forward the arguments to the function.
fn(static_cast<decltype(args)>(args)...);
};
Note that we don’t need to write
decltype(args)&&
, asargs
are all forwarding references. Whendecltype()
ing a reference, we get the reference-ness for free.
It’s weird that static_cast<decltype(x)>(x)
isn’t a no-op, but … C++.
Self-Documenting Code
At this point, some of you are saying that static_cast<Arg>(arg)
is a lot less readable compared to std::forward<Arg>(arg)
.
In the second case, it’s clear that we’re forwarding something, and in the first case you have to explain them how rvalue references are lvalues and why we chose to program in C++.
And I agree completely. That’s why I use macros:
// static_cast to rvalue reference
#define MOV(...) \
static_cast<std::remove_reference_t<decltype(__VA_ARGS__)>&&>(__VA_ARGS__)
// static_cast to identity
// The extra && aren't necessary as discussed above, but make it more robust in case it's used with a non-reference.
#define FWD(...) \
static_cast<decltype(__VA_ARGS__)&&>(__VA_ARGS__)
…
Type obj = MOV(other_obj);
…
fn(FWD(args)...);
How dare I!
I know, I know, macros are evil and I am evil for using them and I should follow proper, modern C++ guidelines and instead use templates and functions and overloads (which caused the problem in the first place).
I don’t care.
Bjarne – I think – once said something about macro usage being an indicator for a flaw in the language.
And this is exactly what std::move
and std::forward
are: indicators of a little flaw in the language.
I’m fixing it the only way I can – with macros.
And I will continue to use those macros until the flaw is fixed (which will probably never happen).
Note that I am not alone. There are various projects that use either macros or the static_cast directly.
It is the pragmatic thing to do.