C++ Logo

std-discussion

Advanced search

Metaprogramming with the range-based "for" loop.

From: Andrew Schepler <aschepler_at_[hidden]>
Date: Thu, 9 Dec 2021 12:30:52 -0500
I recently wanted a way to determine whether or not an expression can
appear to the right of ":" in the range-based for syntax, and what the type
of the element (*__begin) is if so. But I don't see a 100% correct way to
determine whether the type is allowed.

class Thing;
class ThingCollection {
public:
  template <typename T>
  requires std::is_convertible_v<T, Thing>
  void add(T&& arg);

  template <typename Range>
  requires !std::is_convertible_v<Range, Thing> &&
    std::is_convertible_v<range_element_type<Range>, Thing>
  void add(Range&& range)
  {
    for (auto&& elem : std::forward<Range>(range))
      add(std::forward<decltype(elem)>(elem));
  }

  // A raw array is a range element type, but an overload is needed to
  // prevent decay to pointer.
  template <typename T, std::size_t N>
  requires std::is_convertible_v<T, Thing>
  void add(T (&arr)[N]) { add<T(&)[N]>(arr); }
  // ...
};

If a type is a valid "for" range, then we can easily enough get the element
type with

template <typename Range>
decltype(auto) range_element_type_helper(Range&& range) {
  for (decltype(auto) elem : std::forward<Range>(range))
    return elem;
}
template <typename Range>
using range_element_type =
decltype(range_element_type_helper(std::declval<Range>()));

But that's not SFINAE-friendly: if Range is not a valid type, the error is
not "in the immediate context".

Instead I can try going off the semantics defined in [stmt.ranged], and do
all the metaprogramming things to handle a raw array type, a class type
with members begin() and end(), and a type with free functions begin() and
end() using ADL, then requiring that the resulting types of __begin and
__end support the expected copy-initialization, destruction, "*", "++", and
"!=" operations. This works most of the time, except for one detail of
unusual classes: Per the Standard, the for loop uses member __range.begin()
and __range.end() if class member lookup finds at least one declaration for
both "begin" and "end" - even if the resulting use would be invalid and the
free function semantics would be valid. The usual template metaprogramming
tricks can detect only if Range has members "begin" and "end" which are
accessible and can be invoked with zero arguments. (Or less usefully, if
Range has members "begin" and "end" which are accessible, are not
overloaded and not function templates.) So if the names "begin" and "end"
selected by overload resolution are not public, or can't be invoked with
zero arguments, or Range is a reference to const class type and the member
functions are not const-qualified, AND the free function lookup does find
valid "begin" and "end" returning appropriate types, the trait could claim
that the class type expression is valid in a range-based for loop when it
is not.

I admit, the hole in that only comes up with Machiavellian class designs,
and seems unlikely to cause Murphy's Law sorts of issues. Maybe if
someone's begin(expr) and end(expr) function templates are not constrained
enough? In my example use case, it's not much of an issue that add() on
such a type would select an overload but then end up with helpful compiler
errors about attempting to use the strange type as a range.

Still, would it maybe be worth a standard proposal to provide a truly
correct way to do this, plus make it easier than all that code imitating
what a compiler needs to do internally anyway?

-- Andrew Schepler

Received on 2021-12-09 11:31:05