Кратко про библиотеку Range в C++

от автора

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

С выходом 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 расскажут из каких этапов состоит компиляция программы на С++, покажут результаты выполнения каждого этапа, и проговорят возможные проблемы и их решения. Регистрация доступна по ссылке.


ссылка на оригинал статьи https://habr.com/ru/articles/841456/