C++20 benefits: consistency with ranges
You all probably already have heard of C++20's ranges. With ranges-v3, Eric Niebler provided us with a solution already, independent of C++20. In this post, I like to shed some light on how C++20's ranges work and the benefits you get from them. There are multiple benefits from ranges. Today I like to talk about consistency. I assume that you already know ranges or that you can catch up quickly. I'm focussing less on the various algorithms ranges bring us, nor the pipe syntax. I like to teach you how ranges achieve consistency, what it means, and how you can apply it to your own codebase, independent from C++20. Let's get started.
What's consistency in this context?
The first question is, what is consistency? Let's have a look at the following example:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
Essentially, we can see two types there Container
A and OtherContainer
C. The internals does not matter in this post. What matters is the function begin
. We see it in B as a free-function for Container
and as a member-function in OtherContainer
.
In Use
, we look at an abbreviated function template from C++20. For those who haven't seen this, think of it as a function template. The key here is that we don't know the type of parameter c
-- A situation we have regularly in generic code. The question now is, what is the correct way to call begin
? I'm showing you two approaches here. D does call a free function begin
, relying on overload-resolution. E, on the other hand, does explicitly call the std
version of begin
.
The issue is, we don't know which type c
is, and both attempts are good for only one of the containers. This is a usual burden in generic code. The workaround is a so-called two-step using. We use using
to bring std::begin
into the overload-set. Now, we use an unqualified call to begin
. This picks up the version in std
and the free-function we provided for Container
. In code, it looks like this:
1 2 3 4 5 6 7 |
|
Arthur O’Dwyer wrote a post about two-step with std::swap
, What is the std::swap two-step? which explains it from a different angle.
The one issue in pre C++20 is that std::begin
deals only with member-functions which brings an inconsistency. While we can get the example above working in generic code, we end up with at least three different functions being called:
begin(Container)
forContainer
std::begin
andOtherContainer::begin
forOtherContainer
In the case of the member function, when std::begin
can be used, it calls the member function for us. The inconsistency is that not all calls are routed via std::begin
. What if std::begin
does a couple of checks on the type and puts some safety measures on if these checks fail? Then we do get these benefits for OtherContainer
but not Container
. This is not only sad. It can be a nightmare to debug.
Ranges for consistency
Of course, we wouldn't talk about ranges if they would not solve this situation. Here is what you do with ranges available:
1 2 3 4 5 |
|
ranges::begin
looks for free- and member-functions. This makes it so much better. But why doesn't std::begin
do the same? Well, because of ADL (argument dependent lookup). Once we provide our own free function, begin
, for a type this one beats std::begin
. Why? Because this is how ADL works (I'm not going into the details here, it could fill at least another post).
Simply use ranges in this case, and you don't need to learn the two-step using and about ADL. At this point, you can stop reading. You already learned how you could improve your code with ranges. But you like to learn more? Good. Why should only ranges do this magic?
Consistency for your code-base
Okay, we do want to get the same as ranges. We like to have a function, let's say begin
, which users can customize, but all calls should first go to our begin
function.
We use the data types from before. The goal is to provide our own begin
function in the namespace custom
, giving us the same consistent behavior as ranges do.
1 2 3 4 |
|
The code above is what we like to use. Now let's see how we build custom::begin
.
A function object to avoid ADL
The first step is to avoid ADL. It is great, but in our case effectively prevents us from having custom::begin
call regardless of existing free functions. How can we do this? Well, we avoid the function call. Like in this famous space movie, these are not the functions you're looking for. Instead of a function begin
, we provide a callable with the name begin
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
In A, we see our callable begin
. It is a plain struct
with a templated call operator. Inside this call-operator, in B, we use constexpr if
from C++17 together with C++20's Concepts ("I love it when a plan comes together" comes to my mind) first to check whether the type Rng
provides a free-function begin
. If so, we call it by moving the data to it. Otherwise, the else if
checks with the same utilities whether Rng
has a member-function begin
. The procedure is the same. If found, the member function is called, and the parameter is moved into it.
Congrats! With this simple change, I hope you agree that it is simple or at least manageable, your code is now more consistent. As long as we call custom::begin
, this function is called first and routes the call to the free or member-function. But there is more.
Chipping in a bit more C++20?
Since we already used abbreviated function templates and Concepts from C++20, why not see what other features from the future that is now here we can apply?
The callable seems a bit much to write. Plus, you all probably know by know that a lambda is a callable as well. In fact, what I presented above could as well be a lambda. The only thing pre-C++20 was that there was no nice way to have a template type-parameter. Yes, C++14's generic lambdas together with decltype
allowed us this already, but isn't the version below cleaner?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
This code here does the same as before. Just that here we use C++20's lambdas with a template-head, allowing us to specify the template type parameter R
. The body of the lambda is a copy of the callable's body.
What's next?
Next time we continue here and learn how we can avoid dangling pointers, much as ranges do.
I hope you learned something. I appreciate your feedback. Please reach out to me on X or via email.
Andreas