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

Сегодня рассмотрим Range-v3 — библиотеку, которая изменила подход к обработке последовательностей в C++ и стала основой для std::ranges в C++20.

Range-v3 — это библиотека, расширяющая стандартную библиотеку C++ возможностью работать с диапазонами вместо begin()/end(). В основе идеи лежат три концепции:

  1. Views — ленивые представления данных

  2. Actions — eager-операции над контейнерами

  3. 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 << " ";
}

Проблема:

  1. Куча промежуточных контейнеров filtered, transformed → лишние аллокации

  2. Много boilerplate кода → std::copy_if + std::transform

  3. Можно забыть 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; });

происходит следующее:

  1. Создаётся ленивый View, который не делает никаких вычислений сразу

  2. rng хранит ссылку на data + фильтрующую лямбду

  3. При итерации по 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)


  1. mortiz64
    06.02.2025 05:57

    Excelente información. Gracias Master! Saludos desde México :)


  1. Tuxman
    06.02.2025 05:57

    Поговаривают, что std::ranges из C++20, а именно стандартную библиотеку мы хотим в обычной жизни использовать, уступает по производительности стандартным C++11/17 алгоритмам из <algorithm>. Хотелось бы подискутировать на эту тему, а не просто прочитать, чем view лучше классики.

    Объяснений худшей производительности std::ranges я знаю два.

    а). не успели ещё сделать оптимизацию для конкретных типов в std::ranges

    б). из-за того, что конвейер создаётся из нескольких объектов-адаптеров, каждый из них хранит в себе какие-то лямбда-функции, указатели или ссылки и т.д., а сами объекты образуют сложную цепочку типов, то компилятор не может сквозь это всё увидеть суть, и сгенерировать код, сравнимый по эффективности с классическим "ручным" циклом, или применить SIMD-оптимизации.


  1. yatanai
    06.02.2025 05:57

    Это фундаментальная проблема диапазонов в целом.

    Для начала оптимизации - они возможны только для одного типа итераторов, а именно contiguous. В остальных случаях стандарт говорит что нет НИКАКИХ гарантий того что данные лежат последовательно в памяти, что исключает любые возможные оптимизации. Потому делать цепочку вызовов кажется самым рациональным.

    Во вторых, для введения оптимизаций требуется создать непонятные расширения которые бы позволяли обрабатывать диапазоны по группам, но для корректной работы эти группы должны поддерживать все адаптеры в цепочке, что технически возможно, но требует довольно мудрёных метавыражений, которые поймут 2.5 динозавра. А некоторые вещи, типа трансформов сделать не получится без мольбы богу-компилятору.

    Выводы? Пользуйтесь тем что есть, а хотите нормально, пишите интристики.