Briefly about the Range library in C++

So what makes Range so special? Traditional iterators require a lot of code to perform simple operations like filtering or sorting data. With Range, you can remove this complexity with an intuitive and concise way to work with collections of data. In this article, we'll cover the core concepts of the Range library.

Basic concepts

Ranges — are the core of the Range library. They represent containers or other data structures that can be iterated over. The basic idea is to describe data manipulations as a sequence of transformations.

Example of working with ranges:

#include <iostream>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5, 6};

    auto even_numbers = numbers | std::views::filter([](int n) { return n % 2 == 0; });

    for (int n : even_numbers) {
        std::cout << n << " "; // Вывод: 2 4 6
    }
}

We use the function std::views::filterto get only even numbers from the source range. Ranges are more expressive than traditional iterators.

Ranges are not just another way to iterate over containers, but a complete abstraction for working with data. Standard iterators require constant maintenance of some access points to the data, whereas ranges operate at a higher level – they “abstract” from real data and allow you to work with sequences regardless of their implementation (whether they are arrays, vectors, lists, etc.).

One of the great advantages of ranges is the ability to linearly combine multiple transformations without creating intermediate containers. If we were to perform similar operations without ranges, we would have to store the results of each step in a new container. Ranges solve this problem by lazily processing.

Views — are a special type of range that do not copy data but perform lazy evaluations. Views act as filters or transformers of data: they “see” the original data, but do not change it, but create a new sequence based on the original collection.

The peculiarity of the performances is that laziness. That is, the data is not processed immediately, but only when it is really needed (for example, during iteration).

Example of using views:

#include <iostream>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5, 6};

    auto square_even_numbers = numbers
                               | std::views::filter([](int n) { return n % 2 == 0; })
                               | std::views::transform([](int n) { return n * n; });

    for (int n : square_even_numbers) {
        std::cout << n << " "; // Вывод: 4 16 36
    }
}

Here, even numbers are first filtered out and then each of them is squared using std::views::transform. Since views are lazy, both transformations are applied only when iterating over the result.

Adapters — are functions that transform ranges. Adapters are applied to ranges using the operator | .

The most commonly used adapters are:

  • std::views::filter — filters elements based on a condition.

  • std::views::transform — applies a function to each element of the range.

  • std::views::take — takes the first N elements of the range.

  • std::views::drop — skips the first N elements of the range.

Example with adapters:

#include <iostream>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5, 6};

    auto result = numbers 
                  | std::views::filter([](int n) { return n % 2 == 0; })
                  | std::views::take(2)
                  | std::views::transform([](int n) { return n * 2; });

    for (int n : result) {
        std::cout << n << " "; // Вывод: 4 8
    }
}

We combine several adapters: first we filter even numbers, then we take only the first two elements, and finally we double them. All this is done in a lazy manner, without copying data.

You can combine ranges, views, and adapters to create chains of data transformations, minimizing code complexity. All of these transformations will be lazy—the data is not processed until the actual iteration begins.

For example:

auto result = std::views::iota(1, 100) // создаём диапазон от 1 до 100
               | std::views::filter([](int n) { return n % 2 == 0; }) // фильтруем только чётные
               | std::views::transform([](int n) { return n * n; }) // возводим в квадрат
               | std::views::take(10); // берём первые 10 элементов

for (int n : result) {
    std::cout << n << " "; // Вывод: 4 16 36 64 100 144 196 256 324 400
}

Lazy ranges allow you to work with large data sets without wasting memory.

Range with STL containers

std::vector — is the most common container in C++, and can be used with the Range library to perform filtering, sorting, and transformations on data.

An example of filtering even numbers and squaring them:

#include <iostream>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    auto even_squares = numbers
                        | std::views::filter([](int n) { return n % 2 == 0; })
                        | std::views::transform([](int n) { return n * n; });

    for (int n : even_squares) {
        std::cout << n << " "; // Вывод: 4 16 36 64 100
    }
}

std::views::filter filters out even numbers, and std::views::transform squares them. Both of these processes happen lazily, and the data is not transformed until we iterate over the result.

std::list differs from std::vector in that it provides a doubly linked list that supports insertions and deletions at arbitrary locations without having to shift all subsequent elements.

Example of extracting and squaring elements of a list with a condition:

#include <iostream>
#include <ranges>
#include <list>

int main() {
    std::list<int> numbers = {10, 15, 20, 25, 30};

    auto transformed = numbers
                       | std::views::filter([](int n) { return n % 5 == 0; })
                       | std::views::transform([](int n) { return n * n; });

    for (int n : transformed) {
        std::cout << n << " "; // Вывод: 25 100 225 400 900
    }
}

Ranges can work with std::listlazily transforming and filtering its contents.

std::forward_list is a singly linked list that only supports sequential access, and working with it via iterators can be somewhat limited. However, thanks to the Range library, this process can be simplified a bit.

For example, let's skip the first two elements and take the next three:

#include <iostream>
#include <ranges>
#include <forward_list>

int main() {
    std::forward_list<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8};

    auto result = numbers
                  | std::views::drop(2) // пропускаем первые два элемента
                  | std::views::take(3); // берем следующие три

    for (int n : result) {
        std::cout << n << " "; // Вывод: 3 4 5
    }
}

Even with a container as simple as std::forward_listRange allows you to manipulate data using adapters such as std::views::drop And std::views::take.

Custom Ranges and Adapters

Creating custom ranges is based on the concept of iterators and ranges in C++. To do this, it is enough to implement the necessary methods begin() And end().

An example of a simple custom range that generates a sequence of numbers:

#include <iostream>
#include <ranges>

class CustomRange {
public:
    CustomRange(int start, int end) : current(start), end_value(end) {}

    auto begin() const { return current; }
    auto end() const { return end_value; }

private:
    int current;
    int end_value;
};

int main() {
    CustomRange range(1, 10);

    for (int n : range) {
        std::cout << n << " "; // Вывод: 1 2 3 4 5 6 7 8 9
    }
}

Created a simple range that can be used in a loop for.

But creating a custom adapter requires implementing a function that returns a new range or a modified view of an existing range.

Example of creating a custom adapter:

#include <iostream>
#include <ranges>
#include <vector>

struct custom_transform {
    int multiplier;
    custom_transform(int m) : multiplier(m) {}

    auto operator()(int n) const {
        return n * multiplier;
    }
};

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5};

    auto transformed = numbers
                       | std::views::transform(custom_transform(3)); // Умножаем все элементы на 3

    for (int n : transformed) {
        std::cout << n << " "; // Вывод: 3 6 9 12 15
    }
}

We create a custom adapter that multiplies each element by a given number.


More details can be found with Range read here.

And at the free webinar of the C++ Developer specialization, colleagues from OTUS will tell you what stages the compilation of a C++ program consists of, show the results of each stage, and discuss possible problems and their solutions. Registration available via link.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *