Ranges and Forwarding References
Arthur O’Dwyer recently blogged about “universal references” vs “forwarding references”, where he puts the case that T&&
(where T
is a deduced template parameter) should be properly called a “forwarding reference” rather than the original term (coined by Scott Meyers), “universal reference”.
I happen to agree that “forwarding reference” is a better name. But Arthur goes on to say
If you see code using
std::forward<T>
without an originatingT&&
, it’s almost certainly buggy. If you see code using (deduced)T&&
withoutstd::forward<T>
, it’s either buggy or it’s C++20 Ranges. (Ranges ill-advisedly uses value category to denote lifetime rather than pilferability, so Ranges code tends to forward rvalueness much more conservatively than ordinary C++ code does.)
It’s true that ranges uses value category as a proxy for lifetime in the shape of borrowed_range
, which I’ve written about before. But that’s not actually why ranges uses T&&
without std::forward
– it would do so even if it didn’t care about trying to save you from dangling iterators. It’s not buggy, it’s the right thing to do, and it’s something to consider for your own code as well where appropriate.
What does ranges do, anyway?
Every ranges algorithm starts with a function1 taking an iterator and a sentinel, much like the classic STL algorithms. We can write one ourselves (constraints, projections etc omitted for brevity):
// Overload (0)
template <typename I, typename S, typename T>
size_t count(I iter, S last, const T& value)
{
size_t n = 0;
while (iter != last) {
if (*iter == value) {
++n;
}
++iter;
}
return n;
}
Now, like the std::ranges
algorithms, we want to add a second overload taking a range as a whole, which in turn dispatches to the iter/sentinel version. We could take the range argument by value:
// Don't do this
template <typename R, typename T>
size_t count(R rng, const T& value)
{
return count(ranges::begin(rng), ranges::end(rng), value);
}
but of course this would be a silly thing to do, at least for non-view
ranges. Instead, we should take the range by reference. In this particular case, we’re not modifying the range elements, just examining them, so we could take the range parameter by const ref:
// Overload (1)
template <typename R, typename T>
size_t count(const R& rng, const T& value)
{
return count(ranges::begin(rng), ranges::end(rng), value);
}
This works perfectly well in many cases. Unfortunately not all cases though – not every range is const-iterable, and in C++20 it’s not particularly difficult to come up with examples which are not. So we’re going to need a third overload, taking a range by non-const reference:
// Overload (2)
template <typename R, typename T>
size_t count(R& rng, const T& value)
{
return count(ranges::begin(rng), ranges::end(rng), value);
}
With these in place, we’ll now do the right thing for both const- and non-const ranges:
const std::vector vec1{1, 2, 3, 4, 5};
count(vec1, 3); // calls overload (1)
std::vector vec2{6, 7, 8, 9, 10};
count(vec2, 8); // calls overload (2)
So, are we done now? Alas not. Consider what happens if we call count()
with an rvalue range:
std::vector<int> get_vector();
count(get_vector(), 3);
Here, overload (2) is not viable, because we cannot bind an rvalue argument to a non-const lvalue reference. Overload (0) is of course not viable either as we have a mismatch in the number of arguments, so we’ll end up calling overload (1) taking const R&
. But as we’ve already mentioned, not all ranges are const-iterable – and sure enough it’s easy to come up with examples where this would be a problem.
So what’s the solution? You guessed it: yet another count()
overload, this time taking an rvalue reference as an argument:
// Overload (3)
template <typename R, typename T>
requires (not std::is_lvalue_reference_v<R>)
size_t count(R&& rng, const T& value)
{
return count(ranges::begin(rng), ranges::end(rng), value);
}
Here, R&&
is in fact a universal forwarding reference, so we’ve added a constraint that it’s only viable when R
is deduced to a non-reference type – that is, when the argument is an rvalue. Now, things work as we would like:
const std::vector vec1{1, 2, 3, 4, 5};
count(vec1, 3); // const lvalue, calls (1)
std::vector vec2{6, 7, 8, 9, 10};
count(vec2, 8); // non-const lvalue, calls (2)
std::vector<int> get_vector();
count(get_vector(), 99); // rvalue, calls (3)
Happy days! All we need to do is write four separate overloads for every ranges algorithm.
How many overloads?!
But let’s back up a moment. We added a constraint to overload (3) because we didn’t want it to be called with lvalues. But let’s just suppose we… didn’t. What’s the worst that could happen if we called it with lvalue arguments?
template <typename R, typename T>
size_t forwarding_count(R&& rng, const T& value) {
return count(ranges::begin(rng), ranges::end(rng), value);
}
const std::vector vec1{1, 2, 3, 4, 5};
forwarding_count(vec1, 3);
In this case, R
would be deduced to be const std::vector<int>&
, and after type substitution we’d end up with a specialisation like this:
size_t forwarding_count(const std::vector<int>& rng, const int& value)
{
return count(ranges::begin(rng), ranges::end(rng), value);
}
This is exactly the same function as we’d get from calling overload (1) of our original count()
!
The same thing happens if we call forwarding_count()
with a non-const lvalue argument:
std::vector vec2{6, 7, 8, 9, 10};
forwarding_count(vec2, 8);
This time R
would be deduced as (non-const) std::vector<int>&
and we’d end up with the exact same function as we would from overload (2) of count()
.
In other words, if we remove the constraint from overload (3) it will correctly handle const lvalues, non-const lvalues and rvalues – meaning don’t need overloads (1) or (2) at all!
Our complete and correct count()
implementation thus becomes
template <typename I, typename S, typename T>
size_t count(I iter, S last, const T& value)
{
size_t n = 0;
while (iter != last) {
if (*iter == value) {
++n;
}
++iter;
}
return n;
}
template <typename R, typename T>
size_t count(R&& rng, const T& value)
{
return count(ranges::begin(rng), ranges::end(rng), value);
}
…and that’s it
For examples like this, using a forwarding reference does the right thing in all cases, even though we aren’t std::forward
-ing anything. It’s correct, it’s not buggy, and it saves us from doing extra redundant work.
And that’s why ranges does it.
1 Ranges algorithms are in fact currently implemented using function objects, but that’s not important for the present discussion ↩