Ranges (C++20)

Ranges are a new element of the STL (in C++20, with extensions in C++23) that makes using algorithms more concise and adds the ability to write code in a functional style.

Range algorithms:

Furthermore, most of the classical STL algorithms have range versions, which support projections and provide additional safety guarantees.

[...]

Motivation/Advantages

Avoid code duplication*

Consistency*

Safety

Concepts ensure that algorithms can operate on ranges in a type-safe manner.

std::list<int> numbers{5, 6, 1, 3, 7};
  // std::list is bidirectional_range
std::ranges::sort(numbers);
  // Compilation error: random_access_range not satisfied

...

Lazy Evaluation

A view is just the specification of a processing. The processing does not happen when one defines the view but is actually performed element by element when the next value is asked for.

An example:

int main() {
  auto lazySquares = std::views::iota(1, 10)
                   | std::views::transform([](int i){ return i * i; });
  for (auto n : lazySquares) {
    std::cout << n << ' ';
  // Squares are calculated here
}

Composability*

A Very Simple Example

// rangesFilterTransform.cpp

#include<iostream>
#include<ranges>
// ...
std::vector<int>numbers = {1, 2, 3, 4, 5, 6};

autoresults = numbers
            | std::views::filter([]    (int n) {return n%2 == 0;})
            | std::views::transform([] (int n) {return n*2;});
for(auto v : results)
  std::cout<<v<<" ";// 4 8 12

You have to read the expression from left to right. The pipe symbol (|) stands for function composition: First, all numbers can pass which are even (std::views::filter([](int n){ return n % 2 == 0; })). Afterwards, each remaining number is mapped to its double (std::views::transform([](int n){ return n * 2; })).

Terminology

Range

A range is a type with an iterator pair consisting of a begin() and end() function:

struct Range {
  T begin();
  T end();
};

The code above models a classical STL container. For at least some ranges, we need something more general that does not only model a finite range. We usually compare begin and end for equality and stop the iterations as soon as that condition is true. For an infinite range, which is possible with ranges, end is usually a different type than that for begin. The STL brings us a new type for convenience: std::default_sentinel_t:

struct Range {
  T                       begin();
  std::default_sentinel_t end();
};

You can see std::default_sentinel_t as a tag type, which simply enables end() to have a return type and match the correct overload of operator== later on.

Sentinel

The sentinel specifies the end of a range. For the containers of the STL, the end iterator is the sentinel. Starting C++20, the type of the sentinel can be different from the type of the begin iterator.

The following example uses a space as a sentinel.

#include<algorithm>
#include<iostream>

struct Space{
bool operator==(auto pos) const {
  return *pos == ' ';
}

// ...

const char *rainerGrimm = "Rainer Grimm";
std::ranges::for_each(rainerGrimm, Space{}, [](char c){std::cout<<c;});

Output:

Rainer

Since the space works as a sentinel, only Rainer is displayed in the last line.


          

common_range

Ranges, as explained, consist of an end() member function that provides a sentinel. A common_range, on the other hand, is a range where begin(r) and end(r) return the same type. This implies that a common_range's end() does not return a sentinel.

All classic iterators model a common_range. Moreover, most STL types have a common_range constructor.

sized_range*

Views

Views are lightweight ranges. A view allows you to access ranges, iterate through ranges, or modify or filter elements of a range. A view does not own data, and its time complexity to copy, move, or assign is constant.

Range Adaptors

A range adaptor transforms a range into a view.

Example:

std::vector<int> numbers = {1, 2, 3, 4, 5, 6};
auto results = numbers
             | std::views::filter([]   (int n) {return n%2==0;})
             | std::views::transform([](int n){return n*2;});

In this code snippet, numbers is the range, and std::views::filter and std::views::transform are the views.

The ranges library in C++20 has a rich set of views...

Owning and non-owning C++ Ranges

(From https://hannes.hauswedell.net/post/2025/05/17/non-owning-range/, by Hannes Hauswedell)

Containers are the ranges everybody used before ranges were a thing. They own their elements, i.e. the storage of the elements is managed by the container and the elements disappear when the container does.

Containers are multi-pass ranges, i.e. you can iterate over them multiple times and will always observe the same elements.

If containers are owning ranges, what are non-owning ranges? C++17 introduced a first example: std::string_view, a range that consists just of a begin and end pointer into another range's elements.

In general, non-owning ranges are the subset of ranges that do not manage the memory of their elements. std::string_view is such a range, and it even fulfils the stricter requirements of a borrowed range: A range that stores nothing beside the iterators, and where the iterators may not refer back to the range object. This has the important implication that the iterators of a borrowed range remain valid when the borrowed range itself goes out-of-scope. For a string_view, this design seems natural, but we will later encounter ranges where a trade-off has to be made.

ranges::to

std::ranges::to is a convenient way in C++23 to construct a container from a range:

std::vector<int> range(int begin, int end, int stepsize=1) {
  auto boundary = [end](int i) {return i<end;};
  std::vector<int> result = std::ranges::views::iota(begin)
  | std::views::stride(stepsize)
  |std::views::take_while(boundary)
  |std::ranges::to<std::vector>();
  return result;
}

The preceding function range creates a std::vector<int> consisting of all elements for begin to end with the stepsize stepsize. Understandably, begin must be smaller than end.

Views

Function Composition, Pipelines, Chaining

Because a view is a range, one can use a view as an argument for another view to enable chaining:

std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
  std::views::reverse(
    std::views::take(
      std::views::transform(
        std::views::filter(numbers, [](const auto& n) { return n % 2 == 0; }),
      [](const auto& n) { return n * n; }), // transformation
    4) // how many items to "take" or allow through
)

Excessive nesting can lead to readability and maintenance issues.

There is another way to combine ranges and views by using operator | (pipe):

std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto result = numbers | std::views::filter([](const auto& n) { return n % 2 == 0; })
                      | std::views::transform([](const auto& n) { return n * n; })
                      | std::views::take(4)
                      | std::views::reverse;

Bounded vs. Unbounded (Infinite) Range

We shall be using std::views::iota, a range factory that generates a sequence of elements by repeatedly incrementing an initial value.

An iota invokation can be either bounded or unbounded (infinite). For instance, std::views::iota(10) is lazy. It only produces a new value if it is asked for, starting at number 10.

std::vector<int> vec1, vec2, vec3;

// Use bounded std::views::iota with begin and end value:
for (int i : std::views::iota(0, 10)) vec1.push_back(i);

// Use infinite std::views::iota in combination with std::views::take:
for (int i : std::views::iota(0) | std::views::take(10))
  vec2.push_back(i);

// Use infinite std::views::iota in combination with std::views::take_while:
for (int i : std::views::iota(0) | std::views::take_while([](int y) { return y < 10; }))
  vec3.push_back(i);

All 3 vectors are equal. They are: 0 1 2 3 4 5 6 7 8 9

Projections

Many range-based algorithms have a projection parameter, a callback that transforms each element before it is processed.

One example of projection parameter is found in the declaration of the std::ranges::sort() function.

template<ranges::random_access_range R,
                          class Comp = ranges::less,
                          class Proj = std::identity>
requires std::sortable<ranges::iterator_t<R>, Comp, Proj>
constexpr ranges::borrowed_iterator_t<R>
sort( R&& r, Comp comp = {}, Proj proj = {} );

For instance, you could use a projection to sort numbers by absolute value. Just make the last, third parameter a lambda transforming to absolute:

std::vector<int> numbers = {-8, 4, -3, 2, -5, 10, 7, 1, -9};
std::ranges::sort(numbers,
                  std::ranges::less{},
                  [] (auto n) {
                    return std::abs(n);
                  });

Yet another possibility is to sort (say, a structure o class) by a specific field. For instance, often enough you would rather sort Person objects

struct Person{
  std::string name{};
  std::size age{};
};

by name:

std::vector<Person> persons{ {"A", 10}, {"B", 2},{"C", 30},
{"D", 67}, {"E", 23}, {"F", 42} };
std::ranges::sort(persons, {}, &Person::name);

We could alternatively settle of descending order: std::ranges::greater():

std::ranges::sort(persons, std::ranges::greater(), &Person::name);

Keys View and Values View

You might want to get the keys and values of an associative container such as a std::map, or an std::unordered_map etc.

In the examples below, note different ways to write the same code.

std::map<std::string, int> numberMap{ {"one", 1}, {"two", 2}, {"three", 3}, {"four", 4}, {"five", 5}};
auto strings = std::views::keys(numberMap);
for (const auto& s : strings){ std::cout << s << " "; }
for (const auto& s : std::views::keys(numberMap)){ std::cout << s << " "; }
for (const auto& s : numberMap | std::views::keys){ std::cout << s << " "; }
auto numbers = std::views::values(numberMap);
for (const auto& n : numbers){ std::cout << n << " "; }
for (const auto& n : std::views::values(numberMap)) { std::cout << n << " ";}
for (const auto& n : numberMap | std::views::values) { std::cout << n << " ";}

Predefined Views in C++20

Here's the list of predefined views that we get with C++20:

Name Notes
views::all returns a view that includes all elements of its range argument.
filter_view/filter returns a view of the elements of an underlying sequence that satisfy a predicate.
transform_view/transform returns a view of an underlying sequence after applying a transformation function to each element.
take_view/take returns a view of the first N elements from another view, or all the elements if the adapted view contains fewer than N.
take_while_view/take_while Given a unary predicate pred and a view r, it produces a view of the range [begin(r), ranges::find_if_not(r, pred)).
drop_view/drop returns a view excluding the first N elements from another view, or an empty range if the adapted view contains fewer than N elements.
drop_while_view/drop_while Given a unary predicate pred and a view r, it produces a view of the range [ranges::find_if_not(r, pred), ranges::end(r)).
join_view/join It flattens a view of ranges into a view
split_view/split It takes a view and a delimiter and splits the view into subranges on the delimiter. The delimiter can be a single element or a view of elements.
counted A counted view presents a view of the elements of the counted range ([iterator.requirements.general]) i+[0, n) for an iterator i and non-negative integer n.
common_view/common takes a view which has different types for its iterator and sentinel and turns it into a view of the same elements with an iterator and sentinel of the same type. It is useful for calling legacy algorithms that expect a range’s iterator and sentinel types to be the same.
reverse_view/reverse It takes a bidirectional view and produces another view that iterates the same elements in reverse order.
elements_view/elements It takes a view of tuple-like values and a size_t, and produces a view with a value-type of the Nth element of the adapted view’s value-type.
keys_view/keys Takes a view of tuple-like values (e.g. std::tuple or std::pair), and produces a view with a value-type of the first element of the adapted view’s value-type. It’s an alias for elements_view<views::all_t<R>, 0>.
values_view/values Takes a view of tuple-like values (e.g. std::tuple or std::pair), and produces a view with a value-type of the second element of the adapted view’s value-type. It’s an alias for elements_view<views::all_t<R>, 1>.

Predefined Views in C++20

Here's the list of additional predefined views that we get with C++23:

Name Notes
repeat_view/views::repeat a view consisting of a generated sequence by repeatedly producing the same value
cartesian_product_view/views::cartesian_product a view consisting of tuples of results calculated by the n-ary cartesian product of the adapted views
zip_view/views::zip a view consisting of tuples of references to corresponding elements of the adapted views
zip_transform_view/views::zip_transform a view consisting of tuples of results of application of a transformation function to corresponding elements of the adapted views
adjacent_view/views::adjacent a view consisting of tuples of references to adjacent elements of the adapted view
adjacent_transform_view/views::adjacent_transform a view consisting of tuples of results of application of a transformation function to adjacent elements of the adapted view
join_with_view/views::join_with a view consisting of the sequence obtained from flattening a view of ranges, with the delimiter in between elements
slide_view/views::slide a view whose Mth element is a view over the Mth through (M + N - 1)th elements of another view
ranges::chunk_view/views::chunk a range of views that are N-sized non-overlapping successive chunks of the elements of another view
ranges::chunk_by_view/views::chunk_by splits the view into subranges between each pair of adjacent elements for which the given predicate returns false
ranges::as_const_view/views::as_const converts a view into a constant_range
ranges::as_rvalue_view/views::as_rvalue a view of a sequence that casts each element to an rvalue
ranges::stride_view/views::stride a view consisting of elements of another view, advancing over N elements at a time

Some Noteworthy, Illustrative, perhaps Useful Examples

Examplle with std::variant

We are going to:

  • Create a vector containing numbers and strings (mixedNumbers) as variants
  • Filter out the strings using std::views::filter
  • Print only the items actually holding a string instead of a number (holds_alternative<std::string>)
int main() {
  std::vector<std::variant<int, std::string>> mixedNumbers
    = {1, 2, 3, "four", "five"};
  auto stringValues = mixedData | std::views::filter([](const auto& val) {
    return std::holds_alternative<std::string>(val);
  });

  for (const auto& str : stringValues) {
    std::cout << std::get<std::string>(str) << " ";
  }// Output: four five

  return 0;
}

Mapping/Transforming Elements (into a Different Type, Actually)

Transforming elements of a range (mapping in other contexts) doesn't require the resulting range to have elements of the same type. One can map elements to another type. For instance, from integers to strings.

int main() {
  std::vector<int> numbers { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
  std::map<int, std::string> numberMap{
    {1, "one"},
    {2, "two"},
    {3, "three"},
    {4, "four"},
    {5, "five"}
  };
  auto result = numbers
              | std::views::filter([] (const auto& n) { return n <= 5; })
              | std::views::transform([&numberMap]
                           (const auto& n)
                           { return numberMap[n]; });
  for (const auto& str : result) {
    std::cout << str << " ";
  }// Output: one two three four five
}

Filtering and Accumulating Elements (Using std::views::common)

We will be:

  • Combining std::ranges::views and std::accumulate
  • Since std::accumulate is not range-based algorithm, we will be relying on std::views::common, which creates a view with same-typed begin and end iterators.

Note std::views::common is similar to std::views::all, but it generates a std::ranges::common_view if the range has iterators with different types.

// ...
#include <numeric>
#include <ranges>

int main() {
  std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9};
  auto numbers = std::views::iota(0) | std::views::take(10);
  auto evenNumbers = numbers
    | std::views::filter([](int n){ return n % 2 == 0; })
    | std::views::common;
  int sum = std::accumulate(evenNumbers.begin(), evenNumbers.end(), 0);
  std::cout << sum;// Output: 20
}

Views all_of, any_of, none_of

In the example below, given a vector of integers (numbers), find (1) if any element is negative, (2) if none of the elements are negative, (3) if all elements are even

// ...

int main() {

  std::vector<int> numbers = {2, 4, 6, 8, 10};

  bool anyNegative
    = std::ranges::any_of(numbers, [](int x) { return x < 0; });
    // false
  bool noneNegative
    = std::ranges::none_of(numbers, [](int x) { return x < 0; });
    // true
  bool allEven
    = std::ranges::all_of(numbers, [](int x) { return x % 2 == 0; });
  //true

  return 0;
}