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