C++ Core Guidelines – Is Rule F19 Incomplete?

c++c++20cpp-core-guidelines

Cpp Core Guideline F19 tells us

Flag a function that takes a TP&& parameter (where TP is a template type parameter name) and does anything with it other than std::forwarding it exactly once on every static path.

Literally, that means something like

template<typename R, typename V> bool contains(R&& range, const V& value)
{
  return std::find(range, value) != range.end();
}

Should be flagged because std::forward was not called (and even if it was called, it'd call .end()).

But to my understanding, this contains-implementation is correct and sensible.
I need the universal reference R&& because in real world, I need it to bind to const& values (1, see below) but I also need it to bind to values
that cannot become const (because begin() and end() aren't const) (2).
If I'd change the signature to contains(const R& range, const V& value), it wouldn't be compatible with most views.

int main()
{
  const std::vector<int> vs{1, 2, 3, 4};
  std::cout << contains(vs, 7);  // (1)
  std::cout << contains(vs | std::views::transform(squared), 7);  // (2)
}

Question: Does F19 miss some edge case or am I missing some detail?
The std::contains possible implementation also passes R as a universal reference without calling std::forward.

Alternative contains implementation using std::forward (but also .end(), hence not compliant with F19, either):

template<typename R, typename V> bool contains(R&& range, const V& value)
{
  const auto& end = range.end();
  return std::find(std::forward<R>(range), value) != end;
}

Best Answer

Yes.

Arguably the problem here is that forwarding references were originally introduced to serve one specific purpose: forwarding. That's why we ended up calling them forwarding references. In that context, it certainly makes sense to question using a forwarding reference that isn't forwarded. And indeed this is by far the most common usage of forwarding references.

However, forwarding references also end up serving a different purpose - one more inline with their original name: a universal reference. If you want to write a function that can accept either an lvalue or an rvalue today, you only have three choices:

  • template <class T> void f(T a); - this may be what you want, but it requires copying lvalues and forces a move of xvalues.
  • template <class T> void g(T const& b); - this is a good default choice, no move or copy happens, but b is always const
  • template <class T> void h(T&& c); - no move or copy happens, and c can be mutable

If you want to avoid a copy/move and you need to preserve const-ness, the only option is to use a forwarding reference. Which in this context is better named a universal reference. But it's the same syntax either way. Ranges make use of this pattern heavily - and there's nothing wrong with the pattern. It's the tool available at our disposal to solve this problem.

You might imagine that maybe if we had something like a parameter label, we might be able to differentiate between the contexts where we just want to have a universal reference and where we want to actually forward. But barring something like that, the "universal reference" use-case of forwarding references is perfectly valid. We just cannot syntactically distinguish between the cases where we intended to forward but forgot to (which should be flagged) and the cases where we did not intend to forward, and should not (which should not be flagged).