Creating Your Own Ranges/Views (C++20)

(From Andre Eisenbach's Making a custom C++ ranges view, 16/01/2025)

To keep the example here as simple as possible, we will demonstrate a custom nm_pairs_view. This is a specialized version of k-combinations, where k is 2. In otherwords, the bubble sort iterator.

The Bubble Sort Algorithm and Example

Here's the classic bubble sort algorithm in C++:

for (size_t n = 0; n != values.size() - 1; ++n) {
  for (size_t m = n + 1; m != values.size(); ++m) {
    if (values[m] < values[n])
      std::swap(values[n], values[m]);
  }
}

This is the standard nested for-loop version of the algorithm. The nm_pairs_view outlined in this example allows us to write bubble sort using std::ranges:

auto less = [](const auto& pair) { return pair.second < pair.first; };
auto swap = [](auto pair) { std::swap(pair.first, pair.second); };

std::ranges::for_each(
      range | views::nm_pairs | std::views::filter(less),  swap);

This particular example is obviously contrived, but chosen to be simple and to showcase the core usage of the algorithm. It also outlines two out of the three ways a custom view interacts with std::ranges.

Left side of the pipe (|)

The first, and simplest (?) way your custom view can interact with a range pipeline is to be on the left side of the pipe operator (|). Or, in other words, just to provide data.

To do this, all your view has to do is to satisfy the std::ranges::view_interface. That is as simble as providing a begin() and end() function that returns a valid iterator.

Here's a simple "hello_view" example:

class hello_view : public std::ranges::view_interface<hello_view> {
  static constexpr auto hello = std::string_view("Hello, World!\n");

 public:
  auto begin() { return hello.begin(); };
  auto end() { return hello.end(); }
};

auto main() -> int {
  for (const auto& chr : hello_view{} | std::views::drop(7))
    std::print("{}", chr);
}

Note that inheriting from std::ranges::view_interface here is only for clarity/documentation and not necessary for this example to work. Also note that std::string_view itself can be used within a pipe operation since it provides the begin() and end() functions itself, but again, this is just to illustrate how simple this can be.

Right side of the pipe (|)

For a custom view to be on the right side of a pipe, it has to be ready to receive data from previous ranges. Data is generally received in the form of iterators, due to the lazy evaluation capability of many ranges and pipeline operations.

Your custom view has choices here as to what kind of ranges it accepts. C++ provides a number of range concepts that your view can limit itself to. For the sake of simplicity, we will limit our view to accept a std::ranges::forward_range.

Before actually integrating into a pipe operator chain, it's easiest to first make a custom constructor of your view accept such a forward range. This is not strictly necessary, but will make things a lot easier to test and debug.

Here's an (incomplete?) example for our nm_pairs_view:

template <std::ranges::forward_range RANGE>
  requires std::ranges::view<RANGE>
class nm_pairs_view : public std::ranges::view_interface<nm_pairs_view<RANGE>> {
  RANGE base_;

 public:
  [[nodiscard]] constexpr nm_pairs_view() = default;

  [[nodiscard]] constexpr explicit nm_pairs_view(RANGE range)
      : base_{std::move(range)} {}
...
};

Supplying a Deduction Guide

Note that our view is a template and we certainly don't want the user to have to figure out what (probably very complicated) type of range is passed into the constructor. Therefore, we provide a deduction guide so the user does not have to worry about that:

template <std::ranges::sized_range RANGE>
nm_pairs_view(RANGE&&) -> nm_pairs_view<std::views::all_t<RANGE>>;

With the constructor and deduction guide in place, we can test this view already without the use of a pipe operator to make sure it accepts the type of ranges we had in mind.

std::vector<int> numbers{1, 2, 3, 4};
for (const auto& [a, b] : nm_pairs_view(numbers))
  std::print("[{}, {}] ", a, b);
// Prints: [1, 2]  [1, 3]  [1, 4]  [2, 3]  [2, 4]  [3, 4]

Pipelining by dint of std::ranges::range_adapter_closure

To get to next step we will make use of C++23's std::ranges::range_adapter_closure helper class.

The documentation on cppreference has a great explanation here:

Range adaptor closure objects are FunctionObjects that are callable via the pipe operator: if C is a range adaptor closure object and R is a range, these two expressions are equivalent:

  • C(R)
  • R | C

This is the magic glue that allows us to use the constructor we've outlined above and use it in a pipe expresson on the right side of the pipe operator.

We can do this simply by providing a std::ranges::range_adaptor_closure derived class (CRTP) that calls our constructor:

namespace views {

struct nm_pairs_fn : std::ranges::range_adaptor_closure<nm_pairs_fn> {
  template <typename RANGE>
  constexpr auto operator()(RANGE&& range) const {
    return nm_pairs_view{std::forward<RANGE>(range)};
  }
};

constexpr inline auto nm_pairs = nm_pairs_fn{};

}  // namespace views

There are two things provided here: The actual range_adapter_closure is called views::nm_pairs_fn. It could be invoked by creating a temporary object of this type as part of the ranges pipeline:

for (const auto& [a, b] : numbers | views::nm_pairs_fn())
  ...

For convenience, the "nm_pairs" inline object is provided to make the usage simpler:

for (const auto& [a, b] : numbers | views::nm_pairs)
  ...

Once such a closure object is provided, your custom view can now be on both sides of the pipe operator!

On iterators and C++ template error messages...

The above explanations are correct (to the best of my abilities) and do work. BUT, they do not work in isolation. Further more, very minor mistakes (typos even), can lead to the infamous mile long C++ template error messages and other pitfalls.

Jacob Rice, in his talk Custom Views for the rest of us notes that the iterators returned require a postfix increment operator. This is one of the many details often ommited in other talks and one I have not seen explained elsewhere.

To qualify as a range, a valid iterator has to be returned by the begin() function of your view. This iterator can have many types, but similarly to std::forward_iterator, it is one of the simpler ones. std::forward_iterator ultimately requires the iterator to be weakly_incrementable, which in turn requires working pre- and pos-fix increment operators. Failure to meet this and other concepts will yield a very long, seemingly unrelated error message.

It gets worse. The iterator provided by the view also must have all relevant iterator traits. Failure to provide them, or even a simple typo here will create endlessly long, completely unrelated error messages with seemingly no hope of recovery.

If you're so inclined, download and compile the example nm_pairs_view, then change difference_type in nm_pairs.hh to difference_t and compile again...

Conclusions, source code and more

Custom views in C++23 are awesome and, all things considered, pretty easy to implement. As with many things in C++, details matter and can be frustrating at times, but the payoff is rewarding.

Source code for this example above can be found here: https://github.com/int2str/nm_pairs

A Third Way to Interact with C++ Ranges: to Sink into a Custom Container

In the beginning, I stated there are three principle ways to interact with ranges:

So what's the third? As a sink for ranges to be committed into a container.

>C++ provides the std::ranges::to<CONTAINER> conversion construct that allows a (potentially lazily evaluated) range to be committed/converted into a suitable container.

For example:

auto my_vector = std::views::iota(1)
  | std::views::take(5)
  | std::ranges::to<std::vector>();

Here, the range is committed into a std::vector.


While not related to the custom view example above, a custom container can be adapted so it can be used in the std::ranges::to<CONTAINER> construct.

To enable this, the following constructor should be added to your custom container:

template <typename RANGE>
constexpr MyCustomContainer(std::from_range_t /*unused*/, RANGE&& range) {
  for (auto coordinate : range) insert(coordinate);
}

The std::from_range_t parameter tag is used here to invoke the correct constructor for your custom container (in this case MyCustomContainer). Very simple and effective in allowing your custom container to be integrated into a range pipeline.

For example:

auto data_set = my_data
  | std::views::filter(some_requirement)
  | std::ranges::to<MyCustomContainer>();