Привет, Хабр!

С выходом C++20 библиотека Range получила свое официальное место в языке, что ознаменовало некоторый важный шаг в развитии работы с контейнерами и итераторами. Это обновление ввело новый подход к манипуляциям с данными.

Итак, что же делает Range таким особенным? Традиционные итераторы требуют большого объема кода для выполнения простых операций вроде фильтрации или сортировки данных. С Range можно избавиться от этой сложности, с помощью интуитивно понятному и лаконичному способу работы с коллекциями данных. В этой статье мы и рассмотрим основные концепции библиотеки Range.

Основные концепции

Диапазоны — это основа библиотеки Range. Они представляют собой контейнеры или другие структуры данных, которые могут быть перебираемы. Основная идея состоит в том, чтобы описывать манипуляции с данными как последовательность преобразований.

Пример работы с диапазонами:

#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
    }
}

Используем функцию std::views::filter, чтобы получить только четные числа из исходного диапазона. Диапазоны выглядят более выразительно по сравнению с традиционными итераторами.

Диапазоны не являются просто одним из способов итерирования контейнеров, а представляют собой полноценную абстракцию для работы с данными. Стандартные итераторы требуют постоянного поддержания некой точки доступа к данным, тогда как диапазоны работают на более высоком уровне — они "абстрагируются" от реальных данных и позволяют работать с последовательностями независимо от их реализации (будь то массивы, вектора, списки и т.д.).

Одно из сильных преимуществ диапазонов — это возможность линейно комбинировать множество преобразований без создания промежуточных контейнеров. Если бы мы применяли подобные операции без диапазонов, нам пришлось бы сохранять результаты каждого шага в новом контейнере. Диапазоны решают эту проблему благодаря ленивой обработке.

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
    }
}

Здесь сначала фильтруются четные числа, а затем каждое из них возводится в квадрат с помощью std::views::transform. Поскольку представления ленивы, оба преобразования применяются только тогда, когда начинается итерация по результату.

Адаптеры — это функции, которые преобразуют диапазоны. Адаптеры применяются к диапазонам с помощью оператора | .

Наиболее часто используемые адаптеры:

  • std::views::filter — фильтрует элементы на основе условия.

  • std::views::transform — применяет функцию к каждому элементу диапазона.

  • std::views::take — берёт первые N элементов диапазона.

  • std::views::drop — пропускает первые N элементов диапазона.

Пример с адаптерами:

#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
    }
}

Комбинируем несколько адаптеров: сначала фильтруем четные числа, затем берём только первые два элемента и, наконец, удваиваем их. Всё это осуществляется в ленивой манере, без копирования данных.

Можно комбинировать диапазоны, представления и адаптеры, тем самым создавая цепочки преобразований данных, минимизируя сложность кода. Все эти преобразования будут проходить лениво — данные не обрабатываются до тех пор, пока не начнётся фактическая итерация.

Например:

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
}

Диапазоны с ленивыми вычислениями позволяют работать с большими наборами данных, не загружая память.

Range с контейнерами STL

std::vector — это наиболее распространённый контейнер в C++, и его можно использовать с библиотекой Range для выполнения фильтрации, сортировки и трансформаций данных.

Пример фильтрации четных чисел и возведение их в квадрат:

#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 фильтрует четные числа, а std::views::transform возводит их в квадрат. Оба этих процесса происходят лениво, и данные не преобразуются, пока мы не начнём итерировать по результату.

std::list отличается от std::vector тем, что предоставляет двусвязный список, который поддерживает вставки и удаления в произвольных местах без необходимости смещения всех последующих элементов.

Пример извлечения и возведение в квадрат элементов списка с условием:

#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
    }
}

Диапазоны могут работать с std::list, лениво преобразовывая и фильтруя его содержимое.

std::forward_list — это односвязный список, который поддерживает только последовательный доступ, и работа с ним через итераторы может быть несколько ограниченной. Однако благодаря библиотеке Range можно немного упростить этот процесс.

Например, пропустим первые два элемента и возьмем следующие три:

#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
    }
}

Даже с таким простым контейнером, как std::forward_list, Range позволяет управлять данными, используя такие адаптеры, как std::views::drop и std::views::take.

Пользовательские диапазоны и адаптера

Создание пользовательских диапазонов основывается на концепции итераторов и диапазонов в C++. Для этого достаточно реализовать необходимые методы begin() и end().

Пример простого пользовательского диапазона, который генерирует последовательность чисел:

#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
    }
}

Создали простой диапазон, который можно использовать в цикле for.

А вот уже создание пользовательского адаптера требует реализации функции, которая возвращает новый диапазон или изменённый вид существующего диапазона.

Пример создания пользовательского адаптера:

#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
    }
}

Создаем кастомный адаптер, который умножает каждый элемент на заданное число.


Подробнее с Range можно ознакомиться здесь.

А на бесплатном вебинаре специализации C++ Developer коллеги из OTUS расскажут из каких этапов состоит компиляция программы на С++, покажут результаты выполнения каждого этапа, и проговорят возможные проблемы и их решения. Регистрация доступна по ссылке.

Комментарии (0)