Forwarding References? Forwardable References! (or: don't use std::forward just because you can)

Forwarding references are a somewhat controversial topic, starting from their name. When working with them, we’ve been taught to use std::forward. After all, it’s in their name!

In this article, we’ll see when using std::forward is not only detrimental, it’s plain wrong.

If you’re already tired of reading introductions on rvalue references, move semantics and perfect forwarding, skip to the end.

🔗rvalue references and move semantics

Since C++11, we have the difference between lvalue and rvalue references:

1
2
3
4
5
// A function with an lvalue reference parameter
void lvalue_function(type& reference);

// A function with an rvalue reference parameter
void rvalue_function(type&& reference);

With them, we can implement something similar to std::unique_ptr, which only became possible in C++11:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<typename T>
class my_unique_ptr{
public:
// Default constructor: Let's default construct the data.
my_unique_ptr() : ptr{new T{}} {}

// Copy constructor: would copy the contents.
// In unique_ptr semantics, we cannot use this.
my_unique_ptr(my_unique_ptr&) = delete;

// Move constructor: we strip the contents of the parameter.
// For unique_ptr, we guarantee only one class points to the data
my_unique_ptr(my_unique_ptr&& other) : ptr{std::exchange(other.ptr, nullptr)} {}

// Destructor: only one object deletes the allocated pointer.
// This way, we never have a dangling pointer. RAII FTW!
~my_unique_ptr() { delete ptr; }

// operators * and -> are not relevant for this example.
private:
T* ptr;
};

Now, to use this move-only type, we need to explicitly indicate our intention:

1
2
3
4
5
6
7
{
my_unique_ptr<int> p;
// my_unique_ptr<int> p_copy(p); // Compiler error: can't be copied!
my_unique_ptr<int> p_move{std::move(p)}; // Explicitly moving (reminder: std::move doesn't actually move anything by itself!)
}
// At the end of the scope, both p and p_move's destructors are called.
// Thanks to move semantics, we don't have a double-free problem!

Move semantics were a paradigm shift in C++. They not just allowed the creation of move-only “handle-like” types, but, more importantly, replaced the old copy-and-swap idiom. std::vector, as many other containers, now move its internal pointers to data on the heap with move semantics:

1
2
3
4
5
6
7
8
9
// Imagine a function where we need a mutable vector for operations inside.
// If we have a reference parameter and copy inside, it may be less optimal.
void do_something(std::vector<int>);

std::vector<int> v = some_generator_function(); // We generate a vector with a lot of data in the heap.
do_something(v); // v is copied. We need to allocate an entire copy of its contents.
do_something(std::move(v)); // v's contents are moved. Expensive copy was prevented.

assert(v.empty()); // Guaranteed by the standard: v is empty after move.

🔗Forwarding references

After almost a decade, it’s no doubt move semantics are important to the language.

However, they’re not perfectly straight-forward. The problem starts to arise when we combine the declaration with templates:

1
2
3
4
5
6
template<typename T>
void do_something(T&& data){
// Do something first...
// ... then move the rvalue to complete doing something else
do_something_else(std::move(data));
}

What looks like a trivial function gets a little complicated when, somehow, it accepts non-rvalue references:

1
2
3
std::vector<int> my_vector{1, 2, 3, 4, 5};
do_something(my_vector); // Accepted an lvalue reference without calling std::move?
bool empty = my_vector.empty(); // Is this true or false?

What if we const it instead?

1
2
3
const std::vector<int> my_vector{1, 2, 3, 4, 5};
do_something(my_vector); // Accepted an lvalue reference without calling std::move?
bool empty = my_vector.empty(); // Is this true or false?

What are the values of empty in both cases? Make your bets and check it on Compiler Explorer.

Why does this happen? It all comes down to the inferred types for the template parameter T (see in Compiler Explorer). These happen due to reference collapsing rules. In short, an rvalue reference to an lvalue reference collapses into an lvalue reference.

The great Scott Meyers explained it way better than I can. He called these references universal references. The committee, however, decided calling them forwarding references instead.

So, the correct way to implement our do_something function is using std::forward instead of std::move. This is what we call perfect forwarding:

1
2
3
4
template<typename T>
void do_something(T&& data){
do_something_else(std::forward<T>(data));
}

🔗 When is using std::forward wrong?

So what? We all know we should use std::forward by now! Yes, rvalue references and perfect forwarding are known concepts. For almost a decade.

The reason I wrote this article is for a cautionary reminder: just because it’s a forwarding reference, it doesn’t mean you must call std::forward.

Why did I come to this conclusion? Story time!

While working on a library, I wrote something like this:

1
2
3
4
5
6
template<typename F, typename... Args>
void invoke_n_times(F&& f, std::size_t count, Args&&... args){
while(count--){
std::invoke(f, args...);
}
}

Then, I realized: I have forwarding references, so I should probably std::forward them, right?

1
2
3
4
5
6
template<typename F, typename... Args>
void invoke_n_times(F&& f, std::size_t count, Args&&... args){
while(count--){
std::invoke(std::forward<F>(f), std::forward<Args>(args)...);
}
}

… right?

Wrong, and in both cases.

When working with rvalue references, we need to assume move semantics. For our invoke arguments Args, we might need to consider the possibility of value parameters from our functor:

1
2
3
4
5
6
7
void print_shuffled(std::vector<int> v){
// v is captured by value, so it's more efficient than copying inside
shuffle(v);
print(v);
}

invoke_n_times(print_shuffled, 5, std::vector<int>{1,2,3,4,5}); // the third argument is a temporary, i.e. an rvalue

What would be the output of this, with perfect forwarding? Definitely not what one would expect when doing that call. See in Compiler Explorer.

And the function, what could go wrong? This:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct prefixed_shuffle_printer{
std::vector<int> prefix;

// Concatenates a prefix with the input vector, then shuffles it.
// prefix is copied, then v is appended.
void operator()(const std::vector<int>& v) & {
print_shuffled(concat(prefix, v));
}

// We can optimize a copy by not copying our prefix.
// Surprise: an r-value qualified member function!
void operator()(const std::vector<int>& v) && {
print_shuffled(concat(std::move(prefix), v));
}
};

We optimized a copy. But will it work with our perfect forwarding? You guessed it. See in Compiler Explorer.

So, to answer our initial question: should we perfect forward here? No, in none of the cases. The original one was the right one (Compiler Explorer link).

Note: One might ask if the forwarding reference parameters are the right way to go in this example. The possibility of passing temporaries, mutable functors or non-const lvalue parameters force us to use them, for API soundness. The variadics also don’t allow us to just declare an overload set with const/non-const lvalue references.

🔗Optimizing?

We now know we shouldn’t just blindly call std::forward in every forwarding reference.

But we can forward one option, when we’re sure we’re not using our parameters anymore (Compiler Explorer link):

1
2
3
4
5
6
7
8
9
10
template<typename F, typename... Args>
void invoke_n_times(F&& f, std::size_t count, Args&&... args){
while(count-- > 1){
std::invoke(f, args...);
}
// Move only the last one, if anything is movable.
if(count == 0){
std::invoke(std::forward<F>(f), std::forward<Args>(args)...);
}
}

Does it make any difference? Possibly, in the right case. Is it worth it for the general case? Who knows?

🔗In conclusion…

Like std::move, std::forward should only be used when you want to move. (from a forwarding rvalue reference)

In general, use std::forward only on the last time a forwarding reference parameter is used.

Naming is hard. std::move doesn’t move, and std::forward doesn’t forward. std::move implies intent to move, but so does std::forward.

And that’s somewhat trivial. C++ is fine.

🔗Is there any hope?

In his latest talks (Empirically Measuring, & Reducing, C++’s Accidental Complexity), Herb Sutter has been presenting his draft proposal “Parameter passing -> guaranteed unified initialization and unified value-setting“. With this proposal, he intends to simplify function parameters by using intent keywords, rather than reference semantics.

The proposed attributes for parameter declaration are:

  • in - will only be read from. Treated as const lvalue.
  • inout - can read and write to. Treated as non-const lvalue.
  • out - will write into, as the first thing in each path.
  • move - will move from. Treated as non-const lvalue.
  • forward - will pass along. Treated as a const lvalue, except in the last use, which “forwards” it.

The way it’s currently written, forward parameters won’t work as we want for our example. There might hope, though, if a forward parameter keeps its cv-qualifiers as an lvalue.

If so, we might end up with this:

1
2
3
4
5
void invoke_n_times(forward auto f, std::size_t count, forward auto... args){
while(count--){
std::invoke(f, args...);
}
}

Well, if the compiler is smart enough to infer the definite last use from a loop… If not, that’s still a special case, and we still can take advantage of this change:

1
2
3
4
void invoke_twice(forward auto f, forward auto... args){
std::invoke(f, args...); // Automatically const-correct
std::invoke(f, args...); // Automatically forwards
}

Of course, I’m only naive, and may be completely wrong about everything, just like I was on adding an attribute that affected overload resolution. But we should always strive to learn from our mistakes… right?