concepts и rangesconcepts and ranges

std::ranges

Последовательность как единый объект вместо пары итераторов, плюс views — ленивые, компонуемые через | адаптеры над ней, без промежуточных контейнеров на каждом шаге.A sequence as a single object instead of a pair of iterators, plus views — lazy adaptors over it, composable with |, with no intermediate containers at each step.

переписывает стиль работы с последовательностямиrewrites the style of working with sequences частый вопрос: чем view отличается от контейнераcommon question: how a view differs from a container

Что этоWhat it is

<ranges> вводит range — concept, описывающий что угодно, у чего есть begin() и end() (сам std::vector, например, автоматически ему удовлетворяет). Поверх range строятся views — ленивые, некопирующие адаптеры, каждый из которых оборачивает исходный range дополнительной логикой (фильтрация, преобразование, взятие первых N элементов) и сами являются range. Views компонуются через оператор |, как конвейер в Unix-shell.The <ranges> header introduces range — a concept describing anything that has a begin() and an end() (a plain std::vector, for instance, automatically satisfies it). Views are built on top of range — lazy, non-owning adaptors, each wrapping the underlying range with extra logic (filtering, transforming, taking the first N elements) while themselves being a range. Views compose with the | operator, like a pipe in a Unix shell.

auto result = data
    | std::views::filter([](int x) { return x % 2 == 0; })
    | std::views::transform([](int x) { return x * x; });
// ничего не вычислено на этой строке — result хранит только цепочку адаптеров// nothing has been computed on this line — result only holds the chain of adaptors

for (int v : result) { // вычисление происходит здесь, элемент за элементом// evaluation happens here, element by element
    // v — квадрат очередного чётного числа из data// v — the square of the next even number from data
}

Зачем ввелиWhy it was introduced

До ranges цепочка операций над последовательностью упиралась в компромисс между читаемостью и лишними затратами:Before ranges, a chain of operations over a sequence forced a trade-off between readability and wasted overhead:

01

Цепочка filter → transform → take требовала либо ручных циклов, либо промежуточных контейнеровA filter → transform → take chain required either manual loops or intermediate containers

Ручной цикл с условиями внутри читается тяжело по мере роста числа шагов — логика фильтрации, преобразования и ограничения перемешивается в одном теле цикла. Альтернатива — std::copy_if в один vector, затем std::transform в другой — читается по шагам, но выделяет память и копирует данные на каждом промежуточном этапе, даже если итоговых элементов нужно всего несколько.A manual loop with conditions inside gets hard to read as the number of steps grows — filtering, transforming, and limiting logic all get mixed into one loop body. The alternative — std::copy_if into one vector, then std::transform into another — reads step by step, but allocates memory and copies data at every intermediate stage, even if only a handful of final elements are actually needed.

02

Views вычисляют элементы по требованию — lazy evaluationViews compute elements on demand — lazy evaluation

Цепочка filter + transform из примера выше не создаёт ни одного промежуточного vector — каждый view хранит лишь ссылку на исходный range и параметры своей логики (предикат, функцию преобразования), а сама работа выполняется лениво, при обращении к очередному элементу результата, как если бы это была обёрнутая логика поверх обычного итератора.The filter + transform chain from the example above doesn't create a single intermediate vector — each view only stores a reference to the underlying range and its own logic's parameters (the predicate, the transform function), and the actual work is done lazily, when the next element of the result is accessed, as if it were wrapped logic layered on top of a plain iterator.

03

range — это concept, а не конкретный типrange is a concept, not a concrete type

Функция, принимающая std::ranges::range auto&&, автоматически работает и со std::vector, и со std::list, и с любым самописным типом, у которого есть begin()/end() — не нужно писать отдельную перегрузку или шаблон под каждый вид последовательности, а concept даёт понятную ошибку компиляции, если требование не выполнено (см. concepts — синтаксис языка).A function accepting std::ranges::range auto&& automatically works with std::vector, with std::list, and with any hand-rolled type that has a begin()/end() — there's no need to write a separate overload or template per kind of sequence, and the concept gives a clear compile error when the requirement isn't met (see concepts — language syntax).

Как это работает под капотомHow it works under the hood

Оператор | для views — это перегруженный operator|, который принимает range слева и объект-адаптер справа и возвращает новый view, оборачивающий исходный range. Каждый шаг цепочки — это отдельный тип (например, std::ranges::filter_view<Base, Pred>), а не результат вычисления — сама цепочка описывает план вычисления, который разворачивается только при итерации.The | operator for views is an overloaded operator| that takes a range on the left and an adaptor object on the right, and returns a new view wrapping the underlying range. Each step in the chain is a distinct type (for instance, std::ranges::filter_view<Base, Pred>), not a computed result — the chain itself describes a computation plan that only unfolds during iteration.

// базовые views: коротко о каждом// basic views: a quick rundown of each
std::vector<int> data = {1, 2, 3, 4, 5, 6};

auto evens   = data | std::views::filter([](int x){ return x % 2 == 0; });
// evens: только чётные — 2, 4, 6// evens: only the even ones — 2, 4, 6

auto squares = data | std::views::transform([](int x){ return x * x; });
// squares: 1, 4, 9, 16, 25, 36// squares: 1, 4, 9, 16, 25, 36

auto firstThree = data | std::views::take(3);   // 1, 2, 3// 1, 2, 3
auto skipTwo    = data | std::views::drop(2);   // 3, 4, 5, 6// 3, 4, 5, 6
auto backwards  = data | std::views::reverse;   // 6, 5, 4, 3, 2, 1// 6, 5, 4, 3, 2, 1

auto naturals = std::views::iota(1);        // 1, 2, 3, 4, ... — бесконечная последовательность// 1, 2, 3, 4, ... — an infinite sequence
auto firstFive = naturals | std::views::take(5);  // безопасно — take останавливает итерацию// safe — take stops the iteration

views::iota без второго аргумента сама по себе бесконечна — итерация по ней напрямую (например, в range-based for без take) никогда не завершится. Именно поэтому iota почти всегда используется в связке с take или с явной верхней границей вторым аргументом (views::iota(1, 100)) — сама по себе она лишь описывает правило генерации следующего значения, не количество элементов.views::iota without a second argument is infinite by itself — iterating over it directly (say, in a range-based for with no take) never terminates. That's exactly why iota is almost always paired with take or given an explicit upper bound as a second argument (views::iota(1, 100)) — on its own it only describes the rule for generating the next value, not how many elements there are.

// три view подряд одной цепочкой — порядок вычисления построчно// three views chained together — the evaluation order, step by step
std::vector<int> nums = {1, 2, 3, 4, 5, 6, 7, 8};

auto pipeline = nums
    | std::views::filter([](int x) { return x % 2 == 0; })   // шаг 1: пропустить только чётные// step 1: let only even numbers through
    | std::views::transform([](int x) { return x * 10; })   // шаг 2: умножить каждое прошедшее фильтр значение на 10// step 2: multiply each value that passed the filter by 10
    | std::views::take(2);                                        // шаг 3: взять только первые два результата// step 3: take only the first two results

for (int v : pipeline) { /* v = 20, затем v = 40 *//* v = 20, then v = 40 */ }
// вычисление идёт не "сначала весь filter, потом весь transform", а поэлементно:// evaluation isn't "run all of filter, then all of transform" — it's element by element:
// take запрашивает у transform первый элемент, transform запрашивает у filter первый// take asks transform for the first element, transform asks filter for the first
// подходящий элемент у nums (2), transform умножает его на 10 (20) — и take его отдаёт;// matching element from nums (2), transform multiplies it by 10 (20) — take yields it;
// цикл нигде не проходит по nums целиком раньше времени// the loop never walks the whole of nums ahead of time

Примеры из реального кодаReal-world examples

типичные сценарии использованияtypical usage scenarios
01

Фильтрация и трансформация данных без промежуточных копийFiltering and transforming data with no intermediate copies

Отбор чётных чисел из вектора и их удвоение — раньше требовало либо ручного цикла с накоплением в новый vector, либо copy_if в один контейнер и transform в другой. С views — одна цепочка, ни одного лишнего vector.Picking even numbers out of a vector and doubling them — used to require either a manual loop accumulating into a new vector, or copy_if into one container followed by transform into another. With views — one chain, not a single extra vector.

std::vector<int> nums = {1, 2, 3, 4, 5, 6};

auto doubledEvens = nums
    | std::views::filter([](int x) { return x % 2 == 0; })
    | std::views::transform([](int x) { return x * 2; });

for (int v : doubledEvens) std::cout << v << ' ';  // 4 8 12// 4 8 12
02

views::reverse вместо явных reverse_iteratorviews::reverse instead of explicit reverse_iterator

Итерация контейнера в обратном порядке классически требует rbegin()/rend() или явного типа reverse_iterator. views::reverse даёт то же самое как обычный range, пригодный для передачи дальше по цепочке или в другой алгоритм.Iterating a container backwards classically requires rbegin()/rend() or an explicit reverse_iterator type. views::reverse gives the same thing as an ordinary range, ready to be passed further down a chain or into another algorithm.

std::vector<std::string> log = {"start", "processing", "done"};

for (const auto& line : log | std::views::reverse) {
    std::cout << line << '\n';   // done / processing / start// done / processing / start
}
03

Ленивое построение диапазона строк без чтения всего сразуLazily building a range of strings without reading everything up front

Идея (без реального файлового I/O): если источник строк сам по себе — range, оборачивающий его views не читает вперёд дальше, чем требует потребитель. Обработка обрывается на первой ненужной строке, не тратя время на остаток источника.The idea (without real file I/O): if the source of lines is itself a range, wrapping it in views doesn't read further ahead than the consumer demands. Processing stops at the first line that isn't needed, without spending time on the rest of the source.

// lines — концептуально "range строк", в реальном коде это была бы обёртка над потоком// lines — conceptually "a range of strings", in real code this would wrap a stream
auto firstFiveNonEmpty = lines
    | std::views::filter([](const std::string& s) { return !s.empty(); })
    | std::views::take(5);
// ни одна строка не читается из источника, пока не начнётся итерация по firstFiveNonEmpty// no line is read from the source until iteration over firstFiveNonEmpty actually starts
04

views::transform с проекцией на поле структурыviews::transform with a projection onto a struct field

Получить диапазон имён из вектора структур для вывода или дальнейшей обработки — без ручного цикла с накоплением строк в новый vector и без изменения исходных данных.Getting a range of names out of a vector of structs for printing or further processing — with no manual loop accumulating strings into a new vector, and no mutation of the source data.

struct Person { std::string name; int age; };
std::vector<Person> people = {{"Ann", 30}, {"Bob", 25}};

auto names = people | std::views::transform(&Person::name);
for (const auto& n : names) std::cout << n << ' ';  // Ann Bob// Ann Bob

ГраблиGotchas

Views не владеют данными — уничтожение исходного range раньше view это use-after-freeViews don't own data — destroying the underlying range before the view is a use-after-free

View обычно дёшев для копирования именно потому, что хранит не копию данных, а указатель/итератор на исходный range плюс параметры адаптера (предикат, функцию). Если исходный vector выходит из области видимости или изменяется раньше, чем используется view над ним, поведение аналогично std::string_view на удалённую строку — неопределённое поведение, а не ошибка компиляции.A view is usually cheap to copy precisely because it stores not a copy of the data but a pointer/iterator into the underlying range plus the adaptor's parameters (a predicate, a function). If the underlying vector goes out of scope or is modified before the view over it is used, the behavior is analogous to a std::string_view into a destroyed string — undefined behavior, not a compile error.

Ленивость означает повторное выполнение побочных эффектов при каждой итерацииLaziness means side effects re-run on every iteration

Лямбда внутри views::transform выполняется не один раз при построении цепочки, а заново при каждом обращении к соответствующему элементу результата — если пройтись по одному и тому же view дважды (например, в двух отдельных циклах), лямбда с побочным эффектом (логирование, инкремент счётчика) выполнится дважды. Это ломает интуицию тех, кто мысленно приравнивает view к "выполнить трансформацию один раз и сохранить результат".The lambda inside views::transform doesn't run once when the chain is built — it runs again every time the corresponding result element is accessed. Iterating over the same view twice (say, in two separate loops) runs a lambda with a side effect (logging, incrementing a counter) twice. This breaks the intuition of anyone who mentally equates a view with "run the transform once and store the result."

range — это concept, а не конкретный тип, поэтому "range" и "container" — не синонимыrange is a concept, not a concrete type, so "range" and "container" aren't synonyms

Любой контейнер является range, но не всякий range — контейнер: view сам удовлетворяет std::ranges::range, хотя ничем не владеет и не хранит элементы напрямую. Ожидать от произвольного "range"-параметра функции гарантий контейнера (владение данными, стабильность адресов элементов) — ошибка, вытекающая из смешения этих двух понятий.Any container is a range, but not every range is a container — a view itself satisfies std::ranges::range, even though it owns nothing and doesn't store elements directly. Expecting container guarantees (data ownership, stable element addresses) from an arbitrary "range" function parameter is a mistake that comes from conflating these two notions.