Home > Blockchain >  Is there a benefit for functions to take in a forwarding reference to a range instead of a view?
Is there a benefit for functions to take in a forwarding reference to a range instead of a view?

Time:08-18

Pre-C 20, it is necessary to use forwarding references in template functions when a std::ranges::range is expected as a parameter. Since concepts are available in C 20, it is now possible to pass a std::ranges::view by value to a generic function. Per the standard, a view is a range.

Consider the following code.

#include <vector>
#include <ranges>
#include <iterator>
#include <iostream>

template <std::ranges::range Range>
void fn1(Range range)   // intentionally not a forwarding reference
{
    for (auto& elem : range) {
          elem;
    }
}

template <std::ranges::view View>
void fn2(View view)
{
    for (auto& elem : view) {
          elem;
    }
}

int main()
{
    std::vector<int> v{1,2,3};
    fn1(v); // doesn't increment, since a copy of 'v' is used in 'fn1'.
    /* fn2(v); // fails to compile, since 'v' cannot be implicitly converted to a view */
    fn1(std::views::all(v)); // increments, since a ref_view is passed to fn1
    fn2(std::views::all(v)); // increments, as expected
    for (int val : v)
        std::cout << val << ' '; // 3 4 5
    return 0;
}

I have nothing specifically against forwarding references. One can make the claim of a readability benefit to being able to directly pass an object that models a range into a generic function (e.g. fn1(v)).

Is this purely a matter of preference, or are there other considerations when making the decision to implement the generic function parameter as Range&& or View?

CodePudding user response:

This is, broadly speaking, a bad idea.

Most ranges are not views. The ranges::view concept requires that a type opt-into being a view. Containers are not views; they're containers. span is a view, but vector is not. Also, most containers cannot be views, as view requires that copying/moving is a constant-time operation. And for most containers, this is not possible.

So by constraining your template to view specifically, you basically force the user to wrap anything they pass to the function in some kind of view type. This creates a lot of syntactic noise on the caller side that serves no useful purpose.

CodePudding user response:

There are basically several ways that you could conceive of declaring an algorithm that takes some kind of range:

  • constrain on range vs constrain on view
  • take by T&&, T const&, T&, or T

That cartesian product gives you 8 options to consider.

The problem with T const&, even for non-mutating algorithms, is that we have non-const-iterable ranges (range<R> doesn't necessitate range<R const>). There may a hypothetical alternative design where view actually requires const-iteration, but that's not the design we have. So while T const& seems like the obviously right choice for non-mutating algorithms, it ends up limiting functionality a lot.

The problem with T& is that Ranges leads to many situations of having rvalue ranges, and taking a T& would prevent users from writing algo(r | views::transform(f)) -- and it's basically the selling point of the library to be able to write that.

That leaves T and T&&.

By-value range -- template <ranges::range R> void f(R r) -- is problematic since ranges can be arbitrarily expensive to copy (bad for performance) and can be owning (in which case your algorithm probably isn't doing what you think it's doing -- it may do the right thing for some types but not others).

Forwarding-reference view -- template <ranges::view V> void g(V&& v) -- actually disallows passing lvalues, because V& is never a view, so likewise is surprisingly limited in functionality.

This effectively reduces the universe to two options:

  • template <ranges::range R> void f(R&& r);
  • template <ranges::view V> void g(V v);

These do have functional differences. If I have a non-copyable view, then f(move_only_view) would work while g(move_only_view) would not. But then the reason you'd have a move-only view is that it's an input view, so perhaps g(std::move(move_only_view)) would be syntactically better anyway. Otherwise, f(copyable_view) doesn't copy the view while g(copyable_view) does. Copying views is cheap, as in doesn't copy all the elements, but it's not necessarily free (consider a transform_view with an arbitrarily fat projection, or just many layers of adaptors). So the forwarding-reference-range version does offer the most functionality, and is the cheapest.

Other than that, these two are quite similar. f(e) and g(views::all(e)) , when they both compile, are basically the same? Which kind of begs the question of why you'd write the value-view version over the forwarding-range one. There's not really a significant motivator for doing so.


The primary place where you need view, specifically, rather than range is when constructing range adaptors. Those need to have member views (not ranges), since they themselves need to be views. But even there, the entry-point isn't:

template <ranges::view V> auto make_adaptor(V );

It's, instead:

template <ranges::viewable_range R> auto make_adaptor(R&& );

That is, we still take a forwarding range - it's a refined version of range that ensures that we can actually convert it to a view.

  • Related