Привет, Хабр!
Сегодня рассмотрим Range-v3 — библиотеку, которая изменила подход к обработке последовательностей в C++ и стала основой для std::ranges
в C++20.
Range-v3
— это библиотека, расширяющая стандартную библиотеку C++ возможностью работать с диапазонами вместо begin()
/end()
. В основе идеи лежат три концепции:
Views — ленивые представления данных
Actions — eager-операции над контейнерами
Pipeline (|) — декларативный синтаксис для обработки последовательностей
Почему Range-v3 может быть лучше стандартных алгоритмов?
Допустим, есть стандартный код на std::algorithm
:
std::vector<int> data = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// Фильтрация + преобразование
std::vector<int> filtered;
std::copy_if(data.begin(), data.end(), std::back_inserter(filtered),
[](int i) { return i % 2 == 0; });
std::vector<int> transformed;
std::transform(filtered.begin(), filtered.end(), std::back_inserter(transformed),
[](int i) { return i * i; });
for (int x : transformed) {
std::cout << x << " ";
}
Проблема:
Куча промежуточных контейнеров
filtered
,transformed
→ лишние аллокацииМного boilerplate кода →
std::copy_if
+std::transform
Можно забыть
std::back_inserter
и получить UB
А теперь на Range-v3:
#include <iostream>
#include <vector>
#include <range/v3/all.hpp>
int main() {
using namespace ranges;
std::vector<int> data = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto rng = data
| views::filter([](int i) { return i % 2 == 0; }) // Оставляем чётные
| views::transform([](int i) { return i * i; }); // Возводим в квадрат
for (int x : rng) std::cout << x << " ";
}
Теперь у нас нет промежуточных векторов, никакого std::copy_if
. Код читается сверху вниз, легко понять логику. Работает лениво и элементы вычисляются только при for
Вывод:
4 16 36 64 100
Как это работает?
Когда пишем:
auto rng = data | views::filter([](int i) { return i % 2 == 0; });
происходит следующее:
Создаётся ленивый View, который не делает никаких вычислений сразу
rng
хранит ссылку наdata
+ фильтрующую лямбду-
При итерации по
rng
:Вызывается
operator++
Проверяется
filter
Неподходящие элементы пропускаются
Некоторые views
копируют контейнер. Например:
auto rng = views::transform(data, [](int x) { return x * 2; }); // data копируется!
Ошибка: data
передаётся по значению!
Правильный вариант:
auto rng = views::all(data) | views::transform([](int x) { return x * 2; });
Теперь data
не копируется.
Actions
Если Views ленивы, то Actions сразу модифицируют контейнер.
Пример сортировки и удаления дубликатов одной цепочкой:
#include <iostream>
#include <vector>
#include <range/v3/all.hpp>
int main() {
std::vector<int> data = {4, 2, 3, 1, 4, 3, 2, 5};
data |= ranges::actions::sort | ranges::actions::unique;
for (int x : data) std::cout << x << " ";
}
Выход:
1 2 3 4 5
actions::sort
сортирует контейнер, actions::unique
удаляет дубликаты
Альтернативный вариант (без |=
):
std::vector<int> result = data | actions::sort | actions::unique;
Но он требует копирования.
Как писать свой кастомный View?
Допустим, нужен views::pow
, который возводит элементы в степень. Стандартные views::transform
работают, но это некрасиво.
Сделаем свой View, который будет читаться так:
auto rng = data | views::pow(3); // Возводим всё в куб
Создадим адаптер. Любой View в Range-v3 строится через view_adaptor
. Это базовый класс, который управляет итерацией.
#include <range/v3/view/adaptor.hpp>
#include <cmath>
struct pow_view : ranges::view_adaptor<pow_view, ranges::view_base> {
friend ranges::range_access;
int power_;
explicit pow_view(int p) : power_(p) {}
// Магия: возведение в степень при итерации
template<typename It>
auto read(It it) const { return std::pow(*it, power_); }
};
// Удобный адаптер
auto pow(int p) { return pow_view{p}; }
Этот pow_view
работает как views::transform
, но нагляднее.
Теперь кастомный View можно использовать точно так же, как стандартные.
#include <iostream>
#include <vector>
#include <range/v3/all.hpp>
int main() {
std::vector<int> data = {1, 2, 3, 4, 5};
auto rng = data | pow(3); // Возводим в куб
for (int x : rng) std::cout << x << " ";
}
Выход:
1 8 27 64 125
В проде View должен уметь работать с разными контейнерами. Добавим поддержку любой последовательности.
template <typename Rng>
struct pow_view : ranges::view_adaptor<pow_view<Rng>, Rng> {
friend ranges::range_access;
int power_;
explicit pow_view(Rng rng, int p) : pow_view::view_adaptor(std::move(rng)), power_(p) {}
// Возводим в степень
auto read(auto it) const { return std::pow(*it, power_); }
};
// Обёртка для удобного использования
template <typename Rng>
auto pow(Rng &&rng, int p) {
return pow_view<Rng>{std::forward<Rng>(rng), p};
}
Теперь это работает с любыми контейнерами:
std::list<double> numbers = {1.5, 2.0, 3.7, 4.1};
auto rng = pow(numbers, 2); // Возведение в квадрат
Теперь поддерживаем и std::list
, и std::vector
, и std::set
!
Подробнее про Range-v3 можно посмотреть здесь.
12 февраля пройдет открытый урок «Отладка в С++. Место в жизненном цикле разработки», на котором:
- узнаете, как использовать отладчик GNU (GDB) для эффективной отладки программ на C++;
- научитесь выявлять и устранять проблемы, связанные с памятью в C++;
- разберетесь с понятием неопределенного поведения в C++ и с тем как его отладить;
- научитесь читать и интерпретировать трассировки стека, чтобы быстро определять местоположение ошибок.
Записаться на урок бесплатно можно на странице курса по C++.
Комментарии (3)
Tuxman
06.02.2025 05:57Поговаривают, что std::ranges из C++20, а именно стандартную библиотеку мы хотим в обычной жизни использовать, уступает по производительности стандартным C++11/17 алгоритмам из <algorithm>. Хотелось бы подискутировать на эту тему, а не просто прочитать, чем view лучше классики.
Объяснений худшей производительности std::ranges я знаю два.
а). не успели ещё сделать оптимизацию для конкретных типов в std::ranges
б). из-за того, что конвейер создаётся из нескольких объектов-адаптеров, каждый из них хранит в себе какие-то лямбда-функции, указатели или ссылки и т.д., а сами объекты образуют сложную цепочку типов, то компилятор не может сквозь это всё увидеть суть, и сгенерировать код, сравнимый по эффективности с классическим "ручным" циклом, или применить SIMD-оптимизации.
yatanai
06.02.2025 05:57Это фундаментальная проблема диапазонов в целом.
Для начала оптимизации - они возможны только для одного типа итераторов, а именно contiguous. В остальных случаях стандарт говорит что нет НИКАКИХ гарантий того что данные лежат последовательно в памяти, что исключает любые возможные оптимизации. Потому делать цепочку вызовов кажется самым рациональным.
Во вторых, для введения оптимизаций требуется создать непонятные расширения которые бы позволяли обрабатывать диапазоны по группам, но для корректной работы эти группы должны поддерживать все адаптеры в цепочке, что технически возможно, но требует довольно мудрёных метавыражений, которые поймут 2.5 динозавра. А некоторые вещи, типа трансформов сделать не получится без мольбы богу-компилятору.
Выводы? Пользуйтесь тем что есть, а хотите нормально, пишите интристики.
mortiz64
Excelente información. Gracias Master! Saludos desde México :)