Missing concept: the quest for range_of
Reeling back a bit
What is a range?
Let us have a look into the C++20 standard
|
|
A range is a C++ concept, a set of constraint, that must be fulfilled for a type to be called range. As per the definition above, any type T that provides valid definition for the calls to ranges::begin
and ranges::end
. Following decades of usage in the STL, well known (maybe dreaded) by C++ developers, a range consists in a pair of iterators returned by begin and end. Why those two instead of the existing std::begin
and std::end
? Simply to enable more customization. Remember that except in some rare case (looking at you std::hash
), thou shall not add, specialize, or overload anything in the std::
namespace ! A range can be satisfied by either providing member functions .begin()
and .end()
or via call to free functions begin()
and end()
that can be found via Argument Dependent Lookup.
A range for what ?
The principle behind ranges is to compose algorithms over data that can be iterated over in a more expressive, declarative, and concise way. Do you remember the good old algorithms header: std::accumulate
, std::transform
? Yes, me too, and I still wince thinking about it.
|
|
Sorry for the jump scare, I know many of us have rough trauma typing all those begin/end all over the place 😄
An example to illustrate ranges could be
|
|
The footprint is smaller and the intent is clearer, don’t you think so ? Moreover, it is not possible to compose regular algorithms because they are eager, and act directly on the input, which means filter
would need to be implement as a for loop.
Alright, some may argue that lambdas are the new clutter, and I cannot really disagree with that, they do take a lot of visual space, as opposed to similar tools in other languages, e.g. Java, seen the extensive list given here. One can always go with Boost.Lambda or hardcore macros. Unfortunately, it is not easy, like everything in C++ dare I say, to get short lambda in the Core language due to every possibilities that must be considered, discussed, and approved.
What with a range ?
Thanks to the ranges library, one can easily restrict template functions to perform an operation on a sequence of elements. Let us consider briefly this slightly contrived example:
|
|
Great isn’t it ? …or is it ? Yes we constrained the template from an infinite amount of possibilities, but to an infinite subset of possibilities. A simple range requirement is not sufficient to constrain meaningfully the function to our goal. Therefore, we would need to add further requires
clauses, static_assert
, or other techniques, cluttering the declaration and/or definition to harden it properly. Although, in the end, what we wish for is simply a range of floating point values.
Getting to the point
Now that the context is set, let us get back to the main topic of this post, and express how we wish we could write “a range of integers”. Like a wise man said “Design your abstractions from what you would like to type !” (Vincent Reverdy, at his talk from CppCon2023), which is a principle I strongly adhere.
|
|
To make it work currently with standard library facilities (in C++20 and above), one would need to resort to the following
|
|
It works, it is verbose, and it needs to be done at every function where you wish to accept a range of something.
The direct path
The naïve way to go at it is to simply wrap the above snippet into a concept.
|
|
The magic of the standard allows us to use concepts as simply as this:
|
|
Because the argument R is automagically injected as the first argument of the constraint (type-constraint in the standard linguo), so that the compiler effectively checks: range_of<R,int>
Concepts can also be named in a type-constraint, as part of
In a type-constraint, a concept takes one less template argument than its parameter list demands, because the contextually deduced type is implicitly used as the first argument of the concept.
1 2 3 4 5
template<class T, class U> concept Derived = std::is_base_of<U, T>::value; template< Derived<Base> T > void f(T); // T is constrained by Derived<T, Base>
The exact wording can be checked in the released C++20 standard document, or more conveniently (and freely) in the working draft
Limitations
L minor 🎵
What if we want to query something else than the range value type, but the range reference type, like explained here
|
|
One can get away with this problem relatively simply by adding the argument in the template argument list, at the cost of a slightly heavier interface.
|
|
L major 🎶
The other issue is much hairier, what if instead of a range of just integers, we want to accept a range of integral types ? Suddenly our concrete type is abstracted away and must satisfy a generic constraint ! We would like to be able to write:
|
|
Which would translate into
|
|
Can your compiler do it ? At least not at the time of release of this post ! Still living in C++23 or below here, my sweet reader from the future with cutting edge compilers.
The standard will save us all…
Hopefully, some brave souls are heralding the fight for change with proposal P2841 aimed to land landing in C++26. It would enable concept argument in the template argument list.
|
|
Until it reaches us mere common developer, the proposal provides a compiler explorer link listing exhaustively possible workarounds to pass constraints concepts around, from least palatable but more efficient via lambda tricks, to inefficient but user friendly via type indirection.
…or not
Unfortunately, the proposal does not solve a second major issue at hand: what if we want to use our concept with a direct type?
|
|
The standard does not do not allow redefinition of concepts, and template arguments will have to be either a concept or a type, precluding the other from existing. Stalemate ! Maybe this will all be resolved with Universal Template Parameters, a proposal for unifying template parameters (type, variable, concepts) into a single representation ?
Everything can be solved with (yet) another layer of indirection
Let us dig the idiom 4 from the list of alternatives above as a starting point.
|
|
The idiom converts a concept down to a type, which can be passed around as template arguments more easily (thereby nullifying the benefits of concept
as core language construct, but this is the topic for another day). Adapted to our situation, it becomes
|
|
It is a fine workaround, but it does not solve yet our previous issue of being able to pass either type or constraint. By resorting to logical short-circuiting, we can reach the desired result.
|
|
Generalization for binary, ternary, n-ary constraints
In the end, we convert concepts back into type traits, where they themselves somewhat originate from, indeed type traits were used along with SFINAE to replicate to some extents constraint on templates. Type traits in C++ were introduced at a time where variable template were not part of the standard, hence the generic definition pooling all types within the outermost template argument list. For example, for std::is_same
|
|
All type traits were then given a direct boolean access thanks to the introduction of variable template.
|
|
Those ways of defining type traits do not enable composition as all arguments must be known at instantiation point, which is not feasible during composition where the queried type is unknown until the last step. Yet, we can use the language feature to reformulate type traits into a two-phase look-up process, more suitable for our goal.
|
|
Now the queried type T is left unevaluated until requested, and we can carry the generic constraint as a concrete type. We can therefore create is_same<int>
and check if any other type is actually similar to the curried requirement is_base_of<int>::value<double>
at the last moment. The principle is straightforwardly extended to concepts, and for any arity (ternay, n-ary).
|
|
Range of range of range of
Equipped with this new definition of type traits, we can create concepts which are compatible for composition, like range_of
. Since concepts are not composable unto themselves, we just have to apply the same process of typification, type traitification (or whatever is the name), and convert that concept into a type, thereby enabling composable range of.
|
|
Conclusion
In this post, we have seen how to build a friendly composable concept through the often desired range_of
example. The solution was not about achieving top compilation speed, but instead, about developer-friendly API (well, at least, according to my taste). Nevertheless, handling type traits via dependent template variable reduces the amount of type instantiation, which might be easier for the compiler to generate, if variables are less costly to instantiate than types.
I wrapped up the outcome of this little exploration into a library named comcept
, the contraction of composable concept. The next steps would be enriching the library with more composable concepts, such as tuple_of
and discuss about concept definition and limitations.