Making a custom C++ ranges view

@int2str.net

When starting to experiment more with C++20 / C++23's std::ranges and std::views, the question inevitably comes up as to what it takes to make a custom view. While there are articles and examples out there, they range from overly simplistic (just an alias/using) to overly complicated. Further, there are many examples, even in popular talks, that simply don't compile.

There are three main ways in which a custom view can interact with ranges/views which we will explain in more detail below.

Full (compiling ;) ) source code for this example can be found here, if you want to read along: https://github.com/int2str/nm_pairs

Bubbles!

To keep the example 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.

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, it provides 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);
}

This example should simply print 'World!'. Our custom view here simply has the two required members. 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.

That is really it. Being a data provider for pipe operation is THAT simple. Good news too, because on the other side it gets trickier...

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 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 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)} {}
...
};

Note that our view is a template and we certainly don't want the user to have to figure out what (likely 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.

Simple example:

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]

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. If 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 similar to std::forward_iterator 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

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

  • On the left side of a pipe operator, as data provider.
  • On the right side of a pipe operator, as data consumer.

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

C++ provides the std::ranges::to<> 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 relating to the custom view example above, a custom container can be adapted so it can be used in the std::ranges::to<> construct.

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

template <typename RANGE>
constexpr CoordinateSet(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 CoordinateSet). Very simple and effective in allowing your custom container to be integrated into a range pipeline.

For example:

auto coordinate_set = coordinates
  | std::views::filter(in_bounds)
  | std::ranges::to<CoordinateSet>();
int2str.net
int2str 🇺🇦

@int2str.net

Post reaction in Bluesky

*To be shown as a reaction, include article link in the post or add link card

Reactions from everyone (0)