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 | // A function with an lvalue reference parameter |
With them, we can implement something similar to std::unique_ptr
, which only became possible in C++11:
1 | template<typename T> |
Now, to use this move-only type, we need to explicitly indicate our intention:
1 | { |
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 | // Imagine a function where we need a mutable vector for operations inside. |
🔗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 | template<typename T> |
What looks like a trivial function gets a little complicated when, somehow, it accepts non-rvalue references:
1 | std::vector<int> my_vector{1, 2, 3, 4, 5}; |
What if we const it instead?
1 | const std::vector<int> my_vector{1, 2, 3, 4, 5}; |
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 | template<typename T> |
🔗 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 | template<typename F, typename... Args> |
Then, I realized: I have forwarding references, so I should probably std::forward
them, right?
1 | template<typename F, typename... 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 | void print_shuffled(std::vector<int> v){ |
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 | struct prefixed_shuffle_printer{ |
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 | template<typename F, typename... 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 | void invoke_n_times(forward auto f, std::size_t count, forward auto... 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 | void invoke_twice(forward auto f, forward auto... args){ |
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?