Logo

Blog


C++20 benefits: consistency with ranges

This post is a short version of Chapter 3 Ranges from my latest book Programming with C++20. The book contains a more detailed explanation and more information around this topic.

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
struct Container {};    A Container without begin
int* begin(Container);  B Free-function begin for Container

struct OtherContainer {  C Container with begin
  int* begin();
};

void Use(auto& c)
{
  begin(c);       D Call ::begin(Container)
  std::begin(c);  E Call STL std::begin
}

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
void Use(auto& c)
{
  using std::begin;  E Bring std::begin in the  namespace

  F Now both functions are in scope
  begin(c);
}

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) for Container
  • std::begin and OtherContainer::begin for OtherContainer

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
void Use(auto& c)
{
  G Use ranges
  std::ranges::begin(c);
}

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
void Use(auto& c)
{
  custom::begin(c);
}

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 move, 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
namespace custom {
  namespace details {
    struct begin_fn {  A Callable
      template<class R>
      constexpr auto operator()(R&& rng) const
      {
        B Free-function
        if constexpr(requires(R rng) { begin(std::forward<R>(rng)); }) {
          return begin(std::forward<R>(rng));

          C Same as above for containers
        } else if constexpr(requires(R rng) {
                              std::forward<R>(rng).begin();
                            }) {
          return std::forward<R>(rng).begin();
        }
      }
    };
  }  // namespace details

  D Callable variable named begin
  inline constexpr details::begin_fn begin{};
}  // namespace custom

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
namespace custom {
  namespace details {
    constexpr auto begin_fn = []<class R>(R&& rng) {  // #B Callable
      // #C Free-function
      if constexpr(requires(R rng) { begin(std::forward<R>(rng)); }) {
        return begin(std::forward<R>(rng));

        // #D Same as above for containers
      } else if constexpr(requires(R rng) {
                            std::forward<R>(rng).begin();
                          }) {
        return std::forward<R>(rng).begin();
      }
    };
  }  // namespace details

  D Callable variable named begin
  inline constexpr auto begin = details::begin_fn;

}  // namespace custom

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 Twitter or via email.

Andreas