Когда я впервые столкнулся с концепцией ranges в C++20, это было как глоток свежего воздуха. Больше никаких вложенных циклов for, замусоривающих код, никаких итераторов begin/end, портящих читаемость. И вот, спустя несколько лет, стандарты C++23 и предстоящий C++26 делают очередной большой шаг вперед, предлагая нам еще более гибкие и мощные инструменты для работы с последовательностями данных. Давайте честно признаемся – большинство из нас все еще пишет традиционные циклы. Ну или использует алгоритмы стандартной библиотеки, обвешивая их итераторами как новогоднюю елку игрушками. И это нормально! Но мир не стоит на месте, и концепция ranges постепенно становится той технологией, которая меняет правила игры.
В чем главное отличие от традиционных подходов? Если раньше для обработки данных мы сначала получали доступ к элементам, а потом что-то с ними делали, то теперь мы описываем трансформации самого диапазона. Нужно отфильтровать, преобразовать, разделить или объединить – вперед, без громоздких временных переменных и вложенных циклов.
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // До C++20 это было больно
std::vector<int> result;
for (const auto& sub_vec : nested_vectors) {
for (int val : sub_vec) {
if (val % 2 == 0) {
result.push_back(val * val);
}
}
}
// C++20+ с ranges
auto result = nested_vectors
| std::ranges::views::join
| std::ranges::views::filter([](int n) { return n % 2 == 0; })
| std::ranges::views::transform([](int n) { return n * n; }); |
|
Что касается производительности – тут меня всегда мучал вопрос: "А не проиграю ли я в скорости?". Мои эксперименты показали, что при правильном использовании разница практически отсутствует, а иногда ranges даже выигрывают! Современные компиляторы прекрасно оптимизируют цепочки ranges-преобразований, сводя их к тем же циклам, но с потенциально лучшими оптимизациями.
Стоит понимать, что под капотом ranges используют ленивые вычисления (lazy evaluation). Это значит, что операции не выполняются сразу, а откладываются до момента, когда результат действительно понадобится. Такой подход часто экономит и память, и процессорное время, позволяя избежать создания промежуточных контейнеров.
В C++23 библиотека ranges получила несколько важных дополнений. Появились views::chunk и views::chunk_by для группировки элементов, улучшился views::split для разделения последовательностей, добавился views::join_with для объединения с разделителем. А на горизонте C++26 маячит многообещающий views::concat, позволяющий объединять несколько независимых диапазонов.
Внутренний механизм ranges основан на концепциях (concepts) и привязан к типажам итераторов. Каждый range – это, по сути, пара итераторов или что-то, что может быть к ним приведено. Views (представления) – это легковесные range-адаптеры, не владеющие данными. Actions – это, напротив, операции, модифицирующие данные. Эти компоненты составляют гибкую и мощную экосистему для обработки данных. Честно говоря, я давно не был так воодушевлен изменениями в языке. Мне кажется, что ranges и связанные с ними функциональности – это огромный шаг вперед в удобстве и выразительности C++. И в этой статье я хочу разобрать самые интересные и полезные нововведения C++23 и грядущего C++26 в области работы с диапазонами данных.
Объединение диапазонов
Работая над большими проектами, я постоянно сталкиваюсь с необходимостью объединять разные последовательности данных. Типичная ситуация: у вас есть несколько векторов, списков или других контейнеров, которые нужно как-то слить воедино для дальнейшей обработки. Раньше это означало создание нового контейнера, циклы по каждому из источников, копирование элементов... В общем, много шаблонного кода. Но теперь у нас есть целый набор инструментов для объединения диапазонов, и каждый из них решает свою конкретную задачу. Давайте разберемся, чем они отличаются и когда какой использовать.
std::ranges::views::join (C++20)
Начнем с самого базового - join, который появился еще в C++20. Его главная задача - *выравнивание* (flattening) вложенных диапазонов. Другими словами, он берет диапазон диапазонов и превращает его в плоский диапазон.
| C++ | 1
2
3
4
5
| std::vector<std::vector<int>> nested{{1, 2}, {3, 4, 5}, {6, 7}};
auto joined = std::views::join(nested);
for (int i : joined)
std::println(i); // выведет 1 2 3 4 5 6 7 |
|
Что здесь происходит? join удаляет структурные границы между внутренними векторами, создавая единый диапазон. При этом важно понимать, что join работает только с одним диапазоном диапазонов. Вы не можете передать ему два независимых контейнера - он просто не для этого.
Еще одна фича, которую я активно использую при работе с текстом - это объединение символов из разных слов:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
| std::vector<std::string> words { "Hello", "World", "Coding" };
// вместо:
std::map<char, int> freq;
for (auto &w : words)
for (auto &c : w)
freq[::tolower(c)]++;
// с join:
std::map<char, int> freq;
for (auto &c : words | std::views::join)
freq[::tolower(c)]++; |
|
Видите, как упрощается код? Вместо двух вложенных циклов - всего один. И это не просто синтаксический сахар, это делает код более понятным и менее подверженым ошибкам.
Правда, нужно помнить про одно ограничение: join не поддерживает произвольный доступ (operator[]). Вы можете только последовательно итерироваться по результирующему диапазону.
std::ranges::views::join_with (C++23)
Что, если нам нужно не просто соединить диапазоны, но и добавить между ними какой-то разделитель? Например, объединить строки с пробелами между ними? Тут нам приходит на помощь join_with, появившийся в C++23.
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| std::vector<std::string_view> words{
"The", "C++", "ranges", "library"
};
auto words_up = words | std::views::transform([](std::string_view word) {
std::string result(word);
for (char& c : result)
c = std::toupper(static_cast<unsigned char>(c));
return result;
});
auto joined = std::views::join_with(words_up, std::string_view(" "));
for (auto c : joined)
std::cout << c; // Выведет: THE C++ RANGES LIBRARY |
|
join_with принимает два аргумента: диапазон диапазонов (как и join) и разделитель, который будет вставляться между каждой парой вложенных диапазонов. Разделитель может быть как одиночным элементом, так и целым диапазоном.
Этот адаптер оказался для меня настоящим спасением при работе с текстовыми данными. Раньше, чтобы объеденить список строк с разделителем, приходилось писать что-то вроде:
| C++ | 1
2
3
4
5
6
| std::string result;
for (size_t i = 0; i < words.size(); ++i) {
result += words[i];
if (i < words.size() - 1)
result += " ";
} |
|
А теперь все это заменяется одной строчкой! И опять же, никаких промежуточных контейнеров, никаких лишних копий, все вычесляется лениво, по мере необходимости.
std::ranges::views::concat (C++26)
А вот здесь начинается самое интересное. Если join и join_with работают с вложеными диапазонами, то concat, который появится в C++26, позволяет объединять совершенно независимые диапазоны в один.
| C++ | 1
2
3
4
5
6
7
8
9
10
11
| std::vector<std::string> v1{"world", "hi"};
std::vector<std::string> v2 { "abc", "xyz" };
std::string arr[]{"one", "two", "three"};
auto v1_rev = v1 | std::views::reverse;
auto concat = std::views::concat(v1_rev, v2, arr);
concat[0] = "hello"; // доступ и модификация по индексу
for (auto& elem : concat)
std::print("{} ", elem); // hello world abc xyz one two three |
|
Заметьте ключевое отличие: concat принимает переменое число аргументов, каждый из которых - отдельный диапазон. Эти диапазоны могут быть разных типов, с разными элементами (если они совместимы). Более того, concat поддерживает произвольный доступ и модификацию элементов, если все входящие диапазоны это позволяют.
Давайте рассмотрим более практичный пример. Представьте, что у вас есть транзакции из разных источников, и вы хотите объединить только те, которые соответствуют определенным критериям:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| struct Transaction {
std::string type;
double amount;
};
// Банковские транзакции
std::vector<Transaction> bank_transactions{
{"Deposit", 100.0},
{"Withdraw", 50.0},
{"Deposit", 200.0}
};
// Транзакции по кредитной карте
std::list<Transaction> credit_card_transactions{
{"Charge", 75.0}, {"Payment", 50.0}
};
// Фильтруем только крупные транзакции из каждого источника
auto filtered_bank = bank_transactions
| std::views::filter([](const Transaction& t) {
return t.amount >= 100.0;
});
auto filtered_credit = credit_card_transactions
| std::views::filter([](const Transaction& t) {
return t.amount > 60.0;
});
// Объединяем отфильтрованные транзакции
auto all_transactions = std::views::concat(filtered_bank, filtered_credit);
// Теперь можем работать со всеми крупными транзакциями как с единым диапазоном
for (const auto& t : all_transactions)
std::println("{} - {}$", t.type, t.amount); |
|
Код становится не просто короче - он становится более декларативным. Мы описываем, что хотим получить, а не как именно это сделать шаг за шагом. И все это без лишних копирований и аллокаций памяти!
Думаю, уже видно, насколько concat мощнее своих предшественников. Но чтобы окончательно прояснить различия между тремя адапторами для объединения диапазонов, давайте составим небольшую сравнительную таблицу.
| Code | 1
2
3
4
5
6
7
| | Функционал | join (C++20) | join_with (C++23) | concat (C++26) |
|------------|----------------|---------------------|-------------------|
| Работает с множеством независимых диапазонов | - | - | + |
| Выравнивает вложенные диапазоны | + | + | - |
| Поддерживает разделители | - | + | - |
| Произвольный доступ | - | - | + (если входные диапазоны поддерживают) |
| Модификация элементов | + (если входной диапазон поддерживает) | + (если входной диапазон поддерживает) | + (если входные диапазоны поддерживают) | |
|
Вот теперь картина проясняется. Если вам нужно:- просто выровнять вложенные диапазоны → используйте
join
- выровнять и добавить разделители → используйте
join_with
- объединить несколько независимых диапазонов → используйте
concat
Производительность и оптимизация
"Хорошо, а что насчет производительности?" — спросите вы. Это вопрос, который я задавал себе каждый раз, встречая новую абстракцию в C++. К счастью, результаты моих тестов весьма обнадеживают.
Современные компиляторы отлично справляются с оптимизацией range-адаптеров. При использовании -O2 или -O3 цепочки преобразований часто оптимизируются до уровня ручного кода с циклами. Более того, иногда компилятор способен применять оптимизации, которые мы бы упустили при ручном написании. Вот неболшой бенчмарк, который я провел для сравнения:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // Традиционный подход
void traditional_join(const std::vector<std::vector<int>>& nested) {
std::vector<int> result;
for (const auto& inner : nested) {
for (int val : inner) {
result.push_back(val);
}
}
return result;
}
// Подход с ranges
auto ranges_join(const std::vector<std::vector<int>>& nested) {
return nested | std::views::join;
} |
|
При измерении времени выполнения обеих функций на больших наборах данных (миллионы элементов) разница обычно составляет менее 1-2%. А если мы не материализуем результат в новый контейнер, а просто итерируемся по нему, ranges-подход часто оказывается быстрее!
Секрет в том, что join, join_with и concat используют ленивые вычисления. Они не создают новых контейнеров и не копируют данные - они просто предоставляют "вид" на данные, как если бы они были объеденены. Это экономит память и время на копирование.
Работа с вложенными контейнерами
Одна из самых распространеных задач - обработка многомерных структур данных. Например, матрицы, представленные как вектор векторов, или нерегулярные данные с разным числом элементов во вложенных контейнерах.
Для простого выравнивания двумерных структур join работает отлично:
| C++ | 1
2
3
| std::vector<std::vector<int>> matrix = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};
auto flattened = matrix | std::views::join;
// flattened содержит 1, 2, 3, 4, 5, 6, 7, 8, 9 |
|
Но что если структура данных имеет более двух уровней вложености? Например, трехмерная матрица или еще более сложные структуры? В таких случаях приходится применять join несколько раз:
| C++ | 1
2
3
4
5
6
7
8
9
| std::vector<std::vector<std::vector<int>>> volume = {
{{{1, 2}, {3, 4}}, {{5, 6}, {7, 8}}},
{{{9, 10}, {11, 12}}, {{13, 14}, {15, 16}}}
};
auto flattened = volume
| std::views::join // Убирает первый уровень вложенности
| std::views::join; // Убирает второй уровень вложенности
// flattened содержит 1, 2, 3, 4, 5, 6, ..., 16 |
|
С появлением concat в C++26 некоторые из этих операций станут еще удобнее, особенно когда структура данных не так регулярна.
Обработка пустых диапазонов и граничных случаев
Важный вопрос при работе с range-адаптерами - как они обрабатывают граничные случаи? Что произойдет, если один из диапазонов пуст? Для join и join_with ситуация интуитивно понятна: пустые внутренние диапазоны просто пропускаются.
| C++ | 1
2
3
| std::vector<std::vector<int>> nested = {{}, {1, 2}, {}, {3, 4}, {}};
auto joined = nested | std::views::join;
// joined содержит 1, 2, 3, 4 |
|
С join_with разделители вставляются только между непустыми диапазонами:
| C++ | 1
2
3
| std::vector<std::vector<int>> nested = {{}, {1, 2}, {}, {3, 4}, {}};
auto joined = nested | std::views::join_with(0);
// joined содержит 1, 2, 0, 3, 4 |
|
А что насчет concat? Он просто соединяет диапазоны последовательно, так что пустые диапазоны никак не влияют на результат:
| C++ | 1
2
3
4
5
| std::vector<int> v1 = {1, 2};
std::vector<int> v2 = {};
std::vector<int> v3 = {3, 4};
auto concatenated = std::views::concat(v1, v2, v3);
// concatenated содержит 1, 2, 3, 4 |
|
Если все входные диапазоны пусты, результат тоже будет пустым диапазоном. Это логично и предсказуемо, что делает эти адаптеры безопасными для использования даже в неоднозначных ситуациях.
При работе с этими адаптерами важно помнить о времени жизни: результирующий range не владеет данными, а только ссылается на них. Это значит, что входные диапазоны должны существовать, пока вы используете результат операции объединения.
Как перестать создавать монстров? Я начинающий программист. Вот уже несколько программ подряд, я замечаю, что начинаю создавать... Попросить Visual Studio (и Windows) перестать рандомизировать динамическую память Берем простую С++ программу, конфигурация x64
#include <iostream>
int main()
{
int *p =... Как программно определить диапазоны базовых типов? То есть, скажем, нужно определить максимальное и минимальное значение типа long double. Как это... Как запустить процесс, чтоб при выходе из программы он оставался жить? Kubuntu 14.04
Qt 5.2.1
Всё, нашел, QProcess::startDetached(const QString & command)
Разделение данных
Объединять диапазоны - это здорово, но часто нам нужно решать противоположную задачу: разбить данные на группы или подпоследовательности. Эта операция настолько фундаментальна, что я даже не могу сосчитать, сколько раз мне приходилось писать подобный код:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
| std::vector<std::string> split(const std::string& text, char delimiter) {
std::vector<std::string> result;
std::stringstream ss(text);
std::string item;
while (std::getline(ss, item, delimiter)) {
if (!item.empty()) {
result.push_back(item);
}
}
return result;
} |
|
Боже, сколько раз я писал такие функции! Для строк, для векторов, для любых контейнеров... Как же я был рад, когда в C++20 появился views::split, а в C++23 к нему добавились views::chunk и views::chunk_by!
std::ranges::views::split (C++20)
Начнем с базового - split. Этот адаптер разделяет диапазон по указанному разделителю (паттерну), возвращая диапазон подпоследовательностей.
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
| #include <print>
#include <ranges>
#include <string_view>
int main() {
using namespace std::string_view_literals;
constexpr auto text = "C++ is powerful and elegant"sv;
for (auto part : std::views::split(text, ' '))
std::print("'{}' ", std::string_view(part));
} |
|
Это выведет: 'C++' 'is' 'powerful' 'and' 'elegant'
Обратите внимание - результатом split является диапазон диапазонов! Каждая подпоследовательность - это тоже диапазон. Я преобразую его в std::string_view для удобства печати, но это необязательно.
Одна из крутых фишек split - он работает не только с символами, но и с произвольными паттернами:
| C++ | 1
2
3
4
| constexpr auto text = "C++breakisbreakpowerfulbreakandbreakelegant"sv;
for (auto part : std::views::split(text, "break"sv))
std::print("'{}' ", std::string_view(part)); |
|
Что особенно хорошо - split не ограничивается текстом. Он может разделить любой диапазон по произвольному разделителю, если только тип элементов совпадает. Вот пример разделения списка точек на сегменты по маркеру:
| C++ | 1
2
3
4
5
6
7
8
9
10
| using Point = std::pair<int, int>;
std::vector<Point> path = {
{0, 0}, {1, 1}, {-1, -1}, // маркер: {-1, -1}
{2, 2}, {3, 3}, {-1, -1},
{4, 4}, {5, 5}
};
for (auto segment : std::views::split(path, Point{-1, -1}))
std::print("Сегмент: {}\n", segment); |
|
Это выведет:
| C++ | 1
2
3
| Сегмент: [(0, 0), (1, 1)]
Сегмент: [(2, 2), (3, 3)]
Сегмент: [(4, 4), (5, 5)] |
|
Что такое lazy_split и когда его использовать?
В C++20 также появился views::lazy_split. Название интригует, не так ли? Разве обычный split не ленивый? На самом деле, разница тонкая, но важная.
lazy_split специализирован для работы с входными диапазонами (input-only ranges) - например, чтение из потоков или генераторов. Он не требует буферизации или нескольких проходов, но его подпоследовательности не являются смежными и не предоставляют методы .data() или .size(). Если вы не работаете с потоками в однопроходном режиме, рекомендую использовать обычный views::split для простоты. Я сам редко нахожу ситуации, где lazy_split действительно необходим.
std::ranges::views::chunk (C++23)
Если split разделяет по разделителю, то chunk разбивает диапазон на группы фиксированного размера. Это чрезвычайно удобно для пакетной обработки данных:
| C++ | 1
2
3
4
5
6
7
8
9
10
| #include <print>
#include <ranges>
#include <vector>
int main() {
std::vector<int> data{1, 2, 3, 4, 5, 6, 7, 8};
for (auto chunk : data | std::views::chunk(3))
std::print("{}\n", chunk);
} |
|
Результат:
| C++ | 1
2
3
| [ 1 2 3 ]
[ 4 5 6 ]
[ 7 8 ] |
|
Заметьте, что последняя группа может содержать меньше элементов, если общее количество не делится нацело на размер группы.
Когда я начал использовать chunk, это радикально упростило обработку блочных данных. Представьте обработку сетевых пакетов:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
| #include <print>
#include <ranges>
#include <sstream>
#include <vector>
int main() {
std::istringstream stream{"AB CD EF 12 34 56 78 95 FF"};
auto bytes = std::ranges::istream_view<std::string>(stream);
for (auto packet : bytes | std::views::chunk(4))
std::print("Пакет: {}\n", packet);
} |
|
Вывод:
| C++ | 1
2
3
| Пакет: ["AB", "CD", "EF", "12"]
Пакет: ["34", "56", "78", "95"]
Пакет: ["FF"] |
|
Это симулирует обработку потока байтов фиксированными пакетами - очень типичная задача для сетевого программирования.
std::ranges::views::chunk_by (C++23)
А что, если размер группы не фиксирован, а определяется условием? Тут на сцену выходит chunk_by. Он группирует последовательные элементы, пока выполняется заданное условие.
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
| #include <print>
#include <ranges>
#include <vector>
int main() {
std::vector<int> values{1, 3, 5, 2, 4, 6, 7, 9, 8};
for (auto group : values | std::views::chunk_by([](int a, int b) {
return (a % 2) == (b % 2); // Одинаковая четность
})) {
std::print("размер {}, {}\n", group.size(), group);
}
} |
|
Результат:
| C++ | 1
2
3
4
| размер 3, [1, 3, 5]
размер 3, [2, 4, 6]
размер 2, [7, 9]
размер 1, [8] |
|
Этот пример динамически группирует последовательные числа на основе их четности - каждая группа содержит либо только нечетные, либо только четные числа. Обратите внимание, что предикат сравнивает соседние элементы - это важное отличие от filter, который проверяет каждый элемент по отдельности.
chunk_by оказался неожиданно мощным инструментом, когда мне понадобилось извлекать предложения из текста:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
| constexpr auto text = "C++ is powerful. Ranges are elegant. This is fun!"sv;
for (auto sentence : text | std::views::chunk_by([](char a, char b) {
return a != '.' && a != '!' && a != '?';
})) {
// Удаляем лишние пробелы и печатаем только осмысленные группы
auto view = std::string_view(&*sentence.begin(), std::ranges::distance(sentence));
view = view.substr(0, view.find_first_of(".!?") + 1);
if (!view.empty() && view.size() > 1)
std::print("Предложение: [{}]\n", view);
} |
|
Это, конечно, упрощенный пример - настоящий парсер предложений должен учитывать сокращения, кавычки и прочие сложности. Но для быстрого прототипа это работает удивительно хорошо!
Сравнение адаптеров разделения
Теперь, когда мы рассмотрели все три основных инструмента для разделения данных, давайте их сравним:
| Code | 1
2
3
4
5
6
| | Функционал | split | chunk | chunk_by |
|------------|---------|---------|------------|
| Стандарт C++ | C++20 | C++23 | C++23 |
| Группы фиксированного размера | - | + | - |
| Группировка по условию | - | - | + |
| Разделение по паттерну | + | - | - | |
|
Каждый из этих адаптеров имеет свою уникальную область применения, и важно выбирать правильный инструмент для конкретной задачи.
Работа с пользовательскими типами и предикатами
Особенно впечатляет гибкость этих инструментов при работе с пользовательскими типами. Для chunk_by можно создавать очень специфичные предикаты. Например, при анализе финансовых данных:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| struct Trade {
std::string symbol;
double price;
int volume;
std::chrono::system_clock::time_point timestamp;
};
std::vector<Trade> trades = /* ... */;
// Группируем сделки по символу
auto trades_by_symbol = trades | std::views::chunk_by([](const Trade& a, const Trade& b) {
return a.symbol == b.symbol;
});
// Обрабатываем каждую группу
for (auto group : trades_by_symbol) {
process_symbol_group(group);
} |
|
Тут мы группируем последовательные сделки с одинаковым символом - типичная задача при анализе рыночных данных.
Обработка подстрок и сложные паттерны
Иногда требуется более сложная логика разделения, чем просто поиск конкретного разделителя. Например, парсинг CSV файла, где разделители могут находиться внутри кавычек:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| std::string csv_line = "field1,\"field2,with,commas\",field3";
// Это не сработает корректно из-за запятых внутри кавычек
auto simple_split = csv_line | std::views::split(',');
// Нужен более сложный подход
auto tokenize_csv = [](std::string_view line) {
std::vector<std::string> result;
bool in_quotes = false;
std::string current_field;
for (char c : line) {
if (c == '"') {
in_quotes = !in_quotes;
} else if (c == ',' && !in_quotes) {
result.push_back(current_field);
current_field.clear();
} else {
current_field += c;
}
}
if (!current_field.empty()) {
result.push_back(current_field);
}
return result;
}; |
|
К сожалению, для таких сложных случаев стандартные ranges-адаптеры не всегда подходят, и приходится возвращаться к ручному парсингу. Впрочем, в C++26 и будущих стандартах могут появиться более мощные инструменты для обработки текста.
Производительность и оптимизация
Что касается производительности разделения - тут все неоднозначно. При работе с небольшими наборами данных разница практически неощутима. Но на больших объемах она становится заметной.
Традиционные методы разделения (с созданием контейнера) обычно требуют больше памяти, но иногда быстрее при последующем доступе к элементам. Range-адаптеры экономят память, но могут немного проигрывать в скорости итерации, особенно при многократном проходе. Вот несколько советов по оптимизации:
1. Если вам нужно пройти по результату разделения только один раз - используйте ranges-адаптеры.
2. Если требуется многократный доступ - рассмотрите материализацию результата в контейнер.
3. Для больших наборов данных с простыми разделителями split обычно работает быстрее, чем регулярные выражения.
4. При работе с Unicode текстом учитывайте особенности многобайтовых символов - не все range-адаптеры корректно работают с ними.
Особености работы с Unicode и многобайтовыми символами
Кстати о Unicode - это отдельная головная боль. Стандартные range-адаптеры работают на уровне байтов, а не символов, что может привести к неожиданному поведению при работе с многобайтовыми кодировками.
| C++ | 1
2
| std::string utf8_text = "привет, мир!";
auto words = utf8_text | std::views::split(' '); |
|
Этот код будет работать, но если разделитель окажется частью многобайтового символа - результат будет некорректным. Для правильной работы с Unicode рекомендуется использовать специализированные библиотеки типа ICU или же предварительно преобразовывать текст к представлению, где каждый символ имеет фиксированную длину (например, std::u32string).
Подводные камни и ограничения
При всех достоинствах, у range-адаптеров есть и свои ограничения:
1. Время жизни - результат операции разделения не владеет данными, а только ссылается на них. Исходный диапазон должен существовать, пока вы используете результат.
2. Для chunk_by диапазон должен быть как минимум моделью forward_range. Это значит, что он не будет работать с input_range, такими как потоки ввода.
3. Производительность может быть непредсказуемой на очень больших наборах данных - компиляторы все еще совершенствуют оптимизации для ranges.
4. Отладка range-выражений бывает сложной, особенно при длинных цепочках преобразований.
Меня всегда удивляла одна странность при работе с split: результатом является диапазон диапазонов, но каждый внутренний диапазон не имеет стандартного строкового представления. Поэтому для печати приходится дополнительно преобразовывать его:
| C++ | 1
2
| for (auto part : std::views::split(text, ' '))
std::print("'{}' ", std::string_view(part)); |
|
Надеюсь, в будущих стандартах это будет упрощено.
Дальнейшие возможности
Несмотря на все достижения, в области разделения диапазонов остается простор для развития. Вот что хотелось бы увидеть в будущих стандартах:
1. Поддержка регулярных выражений в split.
2. Более прямая поддержка Unicode и многобайтовых символов.
3. Адаптеры для работы со структурированными форматами (JSON, CSV, XML).
4. Лучшая поддержка параллельной обработки разделенных данных.
Недавно я экспериментировал с собственной реализацией split на основе регулярных выражений:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| template <typename Range>
auto regex_split(Range&& r, const std::regex& pattern) {
return std::forward<Range>(r) | std::views::transform([&pattern](auto&& item) {
using std::regex_token_iterator;
using value_type = std::decay_t<decltype(item)>;
if constexpr (std::is_convertible_v<value_type, std::string_view>) {
std::string_view sv = item;
std::string s(sv);
return std::vector<std::string>(
regex_token_iterator<std::string::iterator>(s.begin(), s.end(), pattern, -1),
regex_token_iterator<std::string::iterator>()
);
} else {
// Для нестроковых типов просто возвращаем исходный элемент
return std::vector<value_type>{item};
}
}) | std::views::join;
} |
|
Это не идеальное решение (оно материализует промежуточные результаты), но показывает, что функциональность ranges можно расширять собственными адаптерами.
Композиция операций
Обсуждая отдельные адаптеры диапазонов, мы едва коснулись настоящей силы ranges – их композиции. Ведь именно возможность комбинировать несколько операций в единый конвейер делает эту библиотеку по-настоящему мощной. Давайте погрузимся глубже в механизмы композиции диапазонов и посмотрим, как создавать элегантные цепочки преобразований.
Создание цепочек преобразований
Основная идея ranges в том, что мы можем выстраивать преобразования последовательно, применяя операторы конвейера (|). Каждая операция берет диапазон на входе и возвращает новый диапазон на выходе, который становится входом для следующей операции.
| C++ | 1
2
3
4
5
6
7
8
| std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto result = numbers
| std::views::filter([](int n) { return n % 2 == 0; }) // Только четные
| std::views::transform([](int n) { return n * n; }) // Возведение в квадрат
| std::views::take(3); // Только первые 3 элемента
// result содержит {4, 16, 36} |
|
Здесь мы фильтруем, преобразуем и ограничиваем последовательность всего одним выражением. И что самое важное – все эти операции выполняются лениво! Ни одно промежуточное значение не вычисляется, пока мы не начнем обращаться к элементам результирующего диапазона. Вдумайтесь: компилятор может полностью оптимизировать этот код, сводя его к одному циклу без промежуточных коллекций. Это фантастически эффективно!
Оптимизация ленивых вычислений
Но у ленивости есть и обратная сторона. Рассмотрим такой код:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| auto expensive_op = [](int x) {
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // Имитация тяжелой операции
return x * 2;
};
auto view = numbers
| std::views::transform(expensive_op)
| std::views::filter([](int n) { return n > 5; });
// Первый проход
for (auto val : view) {
std::cout << val << " ";
}
// Второй проход
int sum = 0;
for (auto val : view) {
sum += val;
} |
|
Что произойдет? expensive_op будет вызвана дважды для каждого элемента! Это может стать серьезной проблемой производительности. В таких случаях лучше материализовать промежуточный результат:
| C++ | 1
2
3
4
5
6
7
| auto intermediate = numbers
| std::views::transform(expensive_op)
| std::ranges::to<std::vector>(); // C++23 материализация
auto view = intermediate | std::views::filter([](int n) { return n > 5; });
// Теперь expensive_op вызывается только один раз для каждого элемента |
|
Я обычно следую правилу: если операция дорогая и будет использоваться многократно – материализуй результат. В противном случае – используй ленивые вычисления.
Управление временем жизни
Один из самых коварных аспектов ranges – управление временем жизни. Рассмотрим такой невинный код:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
| auto get_filtered() {
std::vector<int> local_data = {1, 2, 3, 4, 5};
return local_data | std::views::filter([](int x) { return x > 2; });
}
void use_filtered() {
auto filtered = get_filtered();
for (int x : filtered) { // Упс! Неопределенное поведение!
std::cout << x << " ";
}
} |
|
Что пошло не так? local_data уничтожается при выходе из get_filtered(), но filtered все еще ссылается на нее! Это классический случай висячей ссылки (dangling reference).
Есть несколько способов решить эту проблему:
1. Возвращать сам контейнер, а не представление:
| C++ | 1
2
3
4
5
| auto get_filtered() {
std::vector<int> local_data = {1, 2, 3, 4, 5};
auto filtered = local_data | std::views::filter([](int x) { return x > 2; });
return filtered | std::ranges::to<std::vector>();
} |
|
2. Использовать std::views::owning_view (C++20) для явного указания владения:
| C++ | 1
2
3
4
| auto get_filtered() {
auto data = std::make_shared<std::vector<int>>(std::vector{1, 2, 3, 4, 5});
return std::views::owning_view(*data) | std::views::filter([data](int x) { return x > 2; });
} |
|
3. Передавать диапазон как параметр:
| C++ | 1
2
3
| auto apply_filter(std::ranges::range auto& r) {
return r | std::views::filter([](int x) { return x > 2; });
} |
|
Лично я предпочитаю первый вариант – он самый прямолинейный и понятный.
Совместимость с параллельными алгоритмами
Еще одна интересная возможность – совмещение ranges с параллельными алгоритмами из C++17. Многие не знают, но большинство алгоритмов в пространстве имен std::ranges поддерживают политики выполнения:
| C++ | 1
2
3
4
5
| std::vector<int> big_data(1'000'000);
// Заполняем данными...
auto result = std::ranges::sort(big_data | std::views::filter([](int x) { return x > 0; }),
std::execution::par); // Параллельная сортировка |
|
Этот код отсортирует все положительные числа параллельно! Правда, есть нюанс – большинство view-адаптеров не поддерживают произвольный доступ, что ограничивает возможности параллелизации. В таких случаях часто приходится материализовать промежуточный результат:
| C++ | 1
2
3
4
5
| auto positive = big_data
| std::views::filter([](int x) { return x > 0; })
| std::ranges::to<std::vector>();
std::ranges::sort(positive, std::execution::par); |
|
Кэширование и оптимизация промежуточных результатов
При создании сложных цепочек иногда стоит задуматься о кэшировании. Недавно я работал над анализом временных рядов, где часто применял одни и те же преобразования к разным подмножествам данных:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| auto time_series = load_data();
// Повторяющееся преобразование
auto common_transform = std::views::transform([](auto& point) {
return process_point(point); // Дорогая операция
});
// Разные фильтры с одинаковым преобразованием
auto dataset1 = time_series
| std::views::filter([](auto& p) { return p.region == "North"; })
| common_transform;
auto dataset2 = time_series
| std::views::filter([](auto& p) { return p.region == "South"; })
| common_transform; |
|
Проблема в том, что process_point будет вызываться заново для каждого представления. Я решил это, создав кэширующий слой:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| auto cached_data = time_series
| std::views::transform([](auto& point) {
static std::map<decltype(point.id), decltype(process_point(point))> cache;
auto it = cache.find(point.id);
if (it != cache.end())
return it->second;
auto result = process_point(point);
cache[point.id] = result;
return result;
})
| std::ranges::to<std::vector>();
// Теперь используем кэшированные данные
auto dataset1 = cached_data | std::views::filter([](auto& p) { return p.region == "North"; });
auto dataset2 = cached_data | std::views::filter([](auto& p) { return p.region == "South"; }); |
|
Такой подход значительно ускорил обработку в моем случае, хотя потребовал дополнительной памяти. Как всегда, все сводится к компромиссу между временем и пространством.
Интеграция с корутинами
Одно из самых захватывающих направлений развития C++ – интеграция ranges с корутинами. Это открывает новые возможности для асинхронной обработки данных:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| cppcoro::generator<int> fibonacci() {
int a = 0, b = 1;
while (true) {
co_yield b;
auto next = a + b;
a = b;
b = next;
}
}
void use_fibonacci() {
auto fib = fibonacci()
| std::views::take(10) // Первые 10 чисел Фибоначчи
| std::views::transform([](int x) { return x * x; });
for (auto val : fib) {
std::cout << val << " ";
}
} |
|
В этом примере с числами Фибоначчи корутина генерирует бесконечную последовательность, а range-адаптеры ограничивают ее и трансформируют - потрясающая синергия двух мощных концепций современного C++! Лично я считаю такое сочетание идеальным для обработки потоковых данных, особено когда данные поступают асинхронно.
Вот еще один пример, который я недавно использовал для асинхронной обработки потока событий:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // Генератор событий
cppcoro::generator<Event> event_stream() {
while (true) {
auto event = wait_for_next_event(); // Блокирующий вызов, ожидающий событие
co_yield event;
}
}
// Использование с ranges
auto important_events = event_stream()
| std::views::filter([](const Event& e) { return e.priority > 5; })
| std::views::take_while([](const Event& e) { return !e.is_shutdown_signal; });
for (const auto& event : important_events) {
handle_important_event(event);
} |
|
Обратите внимание, что цикл обработки событий выглядит синхронным, хотя под капотом происходит асинхронное ожидание. Это один из случаев, когда сочетание корутин и ranges делает код одновременно более элегантным и понятным.
Создание собственных view-адаптеров
Иногда существующих range-адаптеров недостаточно для специфических задач. В таких случаях я создаю собственные. Это может показаться сложным, но современный C++ предоставляет множество инструментов, упрощающих эту задачу.
Вот пример простого адаптера, который добавляет смещение к каждому элементу числового диапазона:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| template <std::ranges::input_range R>
requires std::ranges::view<R> && std::is_arithmetic_v<std::ranges::range_value_t<R>>
class offset_view : public std::ranges::view_interface<offset_view<R>> {
private:
R base_ = {};
std::ranges::range_value_t<R> offset_ = 0;
template <bool Const>
class iterator {
// ... детали реализации итератора
};
public:
offset_view() = default;
offset_view(R base, std::ranges::range_value_t<R> offset)
: base_(std::move(base)), offset_(offset) {}
auto begin() { return iterator<false>(std::ranges::begin(base_), offset_); }
auto end() { return sentinel<false>(std::ranges::end(base_)); }
// ... остальные методы
};
// Шаблон конвейерного адаптера
inline constexpr auto offset(auto&& value) {
return std::views::view_closure(
[value = std::forward<decltype(value)>(value)](auto&& r) {
return offset_view(std::forward<decltype(r)>(r), value);
});
} |
|
Теперь мы можем использовать его в цепочках преобразований:
| C++ | 1
2
3
4
5
| std::vector<int> nums = {1, 2, 3, 4, 5};
auto result = nums | std::views::filter([](int x) { return x % 2 == 1; })
| offset(10)
| std::views::transform([](int x) { return x * 2; });
// result содержит {22, 26, 30} |
|
Создание собственных адаптеров - мощный инструмент, позволяющий расширять функциональность ranges для конкретных задач вашего домена. Хотя это требует некоторого погружения в детали реализации ranges, результат часто стоит затраченных усилий.
Отладка сложных цепочек
Одна из проблем, с которой я часто сталкивался при работе со сложными цепочками range-операций - это сложность отладки. Когда что-то идет не так, бывает трудно понять, на каком именно этапе произошла ошибка.
Для решения этой проблемы я создал простой отладочный адаптер:
| C++ | 1
2
3
4
5
6
7
| template <typename T>
auto debug_view(std::string_view label = "Debug") {
return std::views::transform([label](T&& item) {
std::cerr << label << ": " << item << '\n';
return std::forward<T>(item);
});
} |
|
Его можно вставить в любое место цепочки, чтобы увидеть промежуточные значения:
| C++ | 1
2
3
4
5
| auto result = numbers
| std::views::filter([](int n) { return n % 2 == 0; })
| debug_view<int>("After filter")
| std::views::transform([](int n) { return n * n; })
| debug_view<int>("After transform"); |
|
Это невероятно упрощает отладку, особенно в случаях, когда цепочка преобразований становится длинной и сложной.
Ограничения компиляторов
Стоит упомянуть, что работа со сложными композициями ranges иногда наталкивается на ограничения компиляторов. Долгое время сообщения об ошибках при неправильном использовании ranges были настолько запутанными, что расшифровать их могли только самые стойкие.
К счастью, в последних версиях GCC и Clang качество диагностических сообщений значительно улучшилось. Тем не менее, если вы получаете странные ошибки компиляции при работе с ranges, попробуйте разбить сложную цепочку на несколько простых шагов - это часто помогает локализовать проблему.
Сравнение с другими языками и библиотеками
Интересно сравнить подход C++ ranges с похожими концепциями в других языках:
Java Streams API предлагает похожий функциональный стиль, но без возможности ленивой оценки всей цепочки (промежуточные операции ленивы, терминальные - нет).
Python's itertools тоже обеспечивает ленивую обработку последовательностей, но с меньшим количеством встроеных адаптеров.
Rust's iterators удивительно похожи на C++ ranges, но с более строгим контролем владения и времени жизни.
C# LINQ был одним из пионеров такого подхода, но в нем больше фокуса на запросах к данным, чем на обработке последовательностей.
Что выделяет C++ ranges на фоне других - это их полная интеграция с системой типов языка и возможность статической оптимизации во время компиляции. Это часто позволяет достичь производительности ручного кода при гораздо более высокоуровневом стиле программирования.
Практическое применение
Теория - это отлично, но давайте теперь перейдем к самому вкусному: практическим примерам, где ranges действительно раскрывают свой потенциал. Я хочу поделиться несколькими реальными сценариями, где использование диапазонов не просто упрощает код, но и делает его более надежным и понятным.
Анализ логов и обработка текстовых данных
Типичная задача - парсинг и анализ логов. Представим, что у нас есть файл логов веб-сервера, и мы хотим извлечь из него информацию о запросах, которые вернули ошибку 404:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| std::ifstream log_file("server.log");
std::string line;
std::vector<std::string> error_requests;
// Традиционный подход
while (std::getline(log_file, line)) {
if (line.find("HTTP 404") != std::string::npos) {
std::stringstream ss(line);
std::string timestamp, ip, method, url, status, size;
ss >> timestamp >> ip >> method >> url >> status >> size;
error_requests.push_back(url);
}
}
// Подход с ranges (C++23)
auto error_lines = std::ranges::istream_view<std::string>(log_file)
| std::views::filter([](const std::string& line) {
return line.find("HTTP 404") != std::string::npos;
});
auto parsed_errors = error_lines
| std::views::transform([](const std::string& line) {
std::stringstream ss(line);
std::string timestamp, ip, method, url, status, size;
ss >> timestamp >> ip >> method >> url >> status >> size;
return url;
});
auto result = parsed_errors | std::ranges::to<std::vector>(); |
|
Второй вариант не только более декларативный, но и потенциально эффективнее - мы можем обрабатывать строки по мере их чтения, не загружая весь файл в память. Но главное преимущество - ясное разделение этапов обработки: фильтрация и трансформация.
Обработка CSV файлов
CSV (Comma-Separated Values) - один из самых распространеных форматов для обмена табличными данными. Вот как может выглядеть обработка CSV файла с использованием ranges:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| struct Record {
std::string name;
int age;
std::string city;
};
std::vector<Record> parse_csv(const std::string& file_path) {
std::ifstream file(file_path);
std::string header;
std::getline(file, header); // Пропускаем заголовок
auto lines = std::ranges::istream_view<std::string>(file);
auto records = lines
| std::views::transform([](const std::string& line) {
auto parts = line | std::views::split(',')
| std::views::transform([](auto&& part) {
return std::string(part.begin(), part.end());
})
| std::ranges::to<std::vector>();
return Record{
parts[0],
std::stoi(parts[1]),
parts[2]
};
})
| std::views::filter([](const Record& r) { return r.age > 0; }) // Валидация
| std::ranges::to<std::vector>();
return records;
} |
|
Конечно, это упрощеный пример. В реальном мире потребовалась бы более сложная обработка для учета экранированных запятых, кавычек и прочих особеностей CSV. Но даже здесь видно, насколько естественно ranges позволяют выразить последовательные этапы обработки данных.
Я часто использую этот паттерн при работе с данными: цепочка трансформаций, за которой следует фильтрация, и только потом материализация результата. Это позволяет отложить создание нового контейнера до самого последнего момента.
Обработка изображений
Ranges оказались удивительно полезны и для обработки изображений. Вот пример простого размытия изображения с помощью ranges:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| using Pixel = std::array<uint8_t, 3>; // RGB
using Image = std::vector<Pixel>;
Image blur_image(const Image& image, int width, int height, int kernel_size) {
auto get_pixel = [&](int x, int y) -> const Pixel& {
x = std::clamp(x, 0, width - 1);
y = std::clamp(y, 0, height - 1);
return image[y * width + x];
};
auto pixel_indices = std::views::iota(0, width * height);
auto blurred_pixels = pixel_indices
| std::views::transform([&](int idx) {
int x = idx % width;
int y = idx / width;
std::array<double, 3> sum = {0, 0, 0};
int count = 0;
auto offsets = std::views::iota(-kernel_size/2, kernel_size/2 + 1);
auto neighbor_pixels = std::views::cartesian_product(offsets, offsets)
| std::views::transform([&](auto&& p) {
auto [dx, dy] = p;
return get_pixel(x + dx, y + dy);
});
for (const auto& p : neighbor_pixels) {
sum[0] += p[0];
sum[1] += p[1];
sum[2] += p[2];
count++;
}
return Pixel{
static_cast<uint8_t>(sum[0] / count),
static_cast<uint8_t>(sum[1] / count),
static_cast<uint8_t>(sum[2] / count)
};
})
| std::ranges::to<Image>();
return blurred_pixels;
} |
|
Обратите внимание на использование cartesian_product - это мощный адаптер, который генерирует все пары элементов из двух диапазонов. В данном случае он помогает нам перебрать все соседние пиксели, не пише вложенные циклы.
Обработка финансовых данных
В финансовом секторе часто требуется анализировать временные ряды. Вот пример расчета скользящего среднего с помощью ranges:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| std::vector<double> calculate_moving_average(const std::vector<double>& prices, int window) {
return std::views::iota(0, static_cast<int>(prices.size() - window + 1))
| std::views::transform([&](int start) {
auto window_view = prices
| std::views::drop(start)
| std::views::take(window);
double sum = 0.0;
int count = 0;
for (double price : window_view) {
sum += price;
count++;
}
return sum / count;
})
| std::ranges::to<std::vector>();
} |
|
Здесь iota генерирует последовательность индексов начала окна, drop и take формируют само окно, а transform вычисляет среднее значение. Весь алгоритм выражается как композиция простых операций!
Разработка простого парсера JSON
Давайте разработаем очень упрощенную версию парсера JSON, используя ranges для лексического анализа:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| enum class TokenType {
LeftBrace, RightBrace, LeftBracket, RightBracket,
Colon, Comma, String, Number, True, False, Null
};
struct Token {
TokenType type;
std::string value;
};
std::vector<Token> tokenize_json(std::string_view json) {
auto is_whitespace = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; };
// Пропускаем пробелы
auto non_whitespace = json | std::views::filter([&](char c) { return !is_whitespace(c); });
std::vector<Token> tokens;
auto it = non_whitespace.begin();
auto end = non_whitespace.end();
while (it != end) {
char c = *it++;
switch (c) {
case '{': tokens.push_back({TokenType::LeftBrace, "{"}); break;
case '}': tokens.push_back({TokenType::RightBrace, "}"}); break;
case '[': tokens.push_back({TokenType::LeftBracket, "["}); break;
case ']': tokens.push_back({TokenType::RightBracket, "]"}); break;
case ':': tokens.push_back({TokenType::Colon, ":"}); break;
case ',': tokens.push_back({TokenType::Comma, ","}); break;
case '"': {
// Строки обрабатываем отдельно
auto start = it;
while (it != end && *it != '"') ++it;
tokens.push_back({TokenType::String, std::string(start, it)});
if (it != end) ++it; // Пропускаем закрывающую кавычку
break;
}
default:
// Числа и ключевые слова требуют более сложной обработки...
// (упрощено для примера)
if (std::isdigit(c) || c == '-') {
auto start = it - 1;
while (it != end && (std::isdigit(*it) || *it == '.')) ++it;
tokens.push_back({TokenType::Number, std::string(start, it)});
}
break;
}
}
return tokens;
} |
|
Этот простой токенизатор - только первый шаг в создании JSON парсера. Теперь давайте добавим функцию, которая будет анализировать последовательность токенов и строить структуру данных:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
| struct JsonValue {
enum class Type { Null, Boolean, Number, String, Array, Object };
Type type;
std::variant<std::nullptr_t, bool, double, std::string,
std::vector<JsonValue>, std::map<std::string, JsonValue>> value;
// Вспомогательные методы доступа
bool is_null() const { return type == Type::Null; }
bool is_boolean() const { return type == Type::Boolean; }
// ... и так далее
};
JsonValue parse_json_tokens(const std::vector<Token>& tokens) {
size_t pos = 0;
std::function<JsonValue()> parse_value = [&]() -> JsonValue {
if (pos >= tokens.size()) throw std::runtime_error("Unexpected end of input");
const Token& token = tokens[pos++];
switch (token.type) {
case TokenType::LeftBrace: {
// Объект
std::map<std::string, JsonValue> obj;
if (pos < tokens.size() && tokens[pos].type == TokenType::RightBrace) {
pos++; // Пустой объект
} else {
while (true) {
// Ключ должен быть строкой
if (tokens[pos].type != TokenType::String)
throw std::runtime_error("Expected string key");
std::string key = tokens[pos++].value;
// За ключом должно идти двоеточие
if (pos >= tokens.size() || tokens[pos++].type != TokenType::Colon)
throw std::runtime_error("Expected colon after key");
// Разбираем значение
obj[key] = parse_value();
// После пары может быть запятая или закрывающая скобка
if (pos >= tokens.size())
throw std::runtime_error("Unexpected end of object");
if (tokens[pos].type == TokenType::RightBrace) {
pos++;
break;
}
if (tokens[pos].type != TokenType::Comma)
throw std::runtime_error("Expected comma or closing brace");
pos++; // Пропускаем запятую
}
}
return {JsonValue::Type::Object, std::move(obj)};
}
// Аналогично для массивов, строк, чисел и т.д...
// (код упрощен для примера)
}
throw std::runtime_error("Unknown token type");
};
return parse_value();
} |
|
Пример получился упрощенным, но он демонстрирует, как можно применить ranges для первого этапа парсинга (токенизации), а затем использовать традиционный рекурсивный спуск для построения структуры данных.
Применение для генерации отчетов
Еще одно классное применение ranges - генерация отчетов. Допустим, у нас есть данные о продажах, и мы хотим создать сводку по месяцам:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| struct Sale {
std::string product;
double amount;
std::chrono::year_month date;
};
auto generate_monthly_report(const std::vector<Sale>& sales) {
// Группируем продажи по месяцам
auto sales_by_month = sales
| std::views::chunk_by([](const Sale& a, const Sale& b) {
return a.date == b.date;
});
// Преобразуем каждую группу в сводку
return sales_by_month
| std::views::transform([](const auto& month_sales) {
auto first_sale = *month_sales.begin();
auto total = std::accumulate(month_sales.begin(), month_sales.end(), 0.0,
[](double sum, const Sale& sale) { return sum + sale.amount; });
return std::make_pair(first_sale.date, total);
})
| std::ranges::to<std::map>();
} |
|
Этот код группирует продажи по месяцам и вычисляет общую сумму для каждого месяца. Благодаря chunk_by группировка выполняется одной операцией, а преобразование в итоговый отчет - другой.
Обработка потоковых данных
Ranges особено хороши для работы с потоковыми данными, которые поступают порциями. Представим, что мы разрабатываем систему мониторинга, которая получает показания датчиков и должна выявлять аномалии:
| C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| struct SensorReading {
int sensor_id;
double value;
std::chrono::system_clock::time_point timestamp;
};
void process_sensor_stream(std::istream& data_stream) {
auto readings = std::ranges::istream_view<SensorReading>(data_stream);
// Группируем показания по ID датчика
auto by_sensor = readings
| std::views::chunk_by([](const auto& a, const auto& b) {
return a.sensor_id == b.sensor_id;
});
// Для каждой группы показаний одного датчика
for (auto sensor_group : by_sensor) {
// Рассчитываем скользящее среднее
auto moving_avg = sensor_group
| std::views::adjacent<3> // Берем группы по 3 показания
| std::views::transform([](const auto& window) {
double sum = 0.0;
for (const auto& reading : window) {
sum += reading.value;
}
return sum / window.size();
});
// Проверяем на выбросы (упрощено)
for (auto [reading, avg] : std::views::zip(sensor_group, moving_avg)) {
if (std::abs(reading.value - avg) > threshold) {
report_anomaly(reading);
}
}
}
} |
|
В этом примере мы используем несколько мощных возможностей ranges: группировку показаний по ID датчика, вычисление скользящего среднего с помощью adjacent, и сопоставление исходных значений со средними с помощью zip.
Подобный подход можно применить для анализа логов, мониторинга систем, обработки телеметрии - везде, где данные поступают потоком и требуют многоступенчатой обработки.
Перспективы развития
Первое, что стоит ожидать — это дальнейшее расширение набора ranges algorithms. Если в C++20 мы получили базовые алгоритмы, а в C++23 — специализированные адаптеры типа chunk и chunk_by, то в C++29 можно ожидать появления алгоритмов второго поколения. Это могут быть более продвинутые инструменты для обработки графов, деревьев и других сложных структур данных.
Я слежу за proposals для будущих стандартов, и особено меня интересуют следующие направления:
1. Расширение интеграции с корутинами — вероятно, появятся специальные адаптеры для работы с генераторами и асинхронными последовательностями. Представьте, насколько элегантно можно будет обрабатывать асинхронные потоки данных!
2. Улучшение семантики владения — одна из самых коварных проблем при работе с ranges — это управление временем жизни. Возможно, в C++29 появятся более надежные механизмы для автоматического продления жизни временных объектов в цепочках.
3. Специализированные range adaptors для текста — обработка текста все еще остается не самой сильной стороной стандартной библиотеки. Было бы здорово увидеть адаптеры для работы с Unicode, регулярными выражениями и форматированием текста.
4. Ranges с произвольным доступом — многие существующие range-адаптеры не поддерживают произвольный доступ, что ограничивает возможности параллелизма. Решение этой проблемы открыло бы новые горизонты оптимизации.
Есть и более амбициозные идеи. Например, автоматическая материализация промежуточных результатов, когда компилятор сам решает, когда создавать временные коллекции для оптимизации производительности. Или адаптеры, оптимизированные для гетерогенных вычислений (CPU/GPU).
Не исключено, что будущие стандарты принесут и удивительные новшества вроде "partial views" — возможности работать только с частью диапазона без копирования всех данных. Или "indexed views" — представления, которые поддерживают эффективную индексацию сложных структур данных. Еще одна многообещающая область — интеграция с reflection и metaprogramming. Представьте возможность применять range-операции к полям классов или членам кортежей на этапе компиляции! В то же время я ожидаю и упрощения существующего API. Нынешний синтаксис ranges иногда излишне многословен, особено для новичков. Возможны улучшения в области inference типов и более короткие синтаксические конструкции.
Что касается более отдаленного будущего — я бы предсказал постепенное смещение парадигмы C++ в сторону декларативного стиля программирования. Ranges — это лишь первый шаг на этом пути. С каждым стандартом мы все дальше уходим от императивного "сделай это, потом то" к декларативному "вот что я хочу получить".
Конечно, все эти предсказания основаны лишь на текущих тенденциях и обсуждениях в сообществе. Реальность может оказаться совсем иной — и в этом прелесть эволюции языка. Главное, чтобы новые возможности делали наш код более читаемым, надежным и эффективным.
Заключение
Объединение диапазонов с join, join_with и грядущим concat дает нам гибкость, о которой раньше можно было только мечтать. Разделение с помощью split, chunk и chunk_by превращает сложные алгоритмы парсинга в элегантные цепочки трансформаций. А композиция операций позволяет выражать сложные алгоритмы в декларативном стиле, фокусируясь на что, а не как. Да, у этого подхода есть свои сложности. Иногда производительность может быть неочевидной, отладка затруднена, а проблемы с временем жизни могут стать настоящей головной болью. Я сам не раз натыкался на эти грабли. Но плюсы явно перевешивают минусы.
Что радует больше всего — язык продолжает развиваться в сторону большей выразительности без ущерба для производительности. Компиляторы становятся умнее, оптимизации — эффективнее, а диагностика ошибок — понятнее.
Если вы до сих пор избегали ranges — сейчас самое время их попробовать. Начните с простых адаптеров вроде filter и transform, постепенно добавляя более специализированные инструменты. Уверен, вы быстро почувствуете, насколько этот подход может упростить ваш код.
Как жить с массивом переменной величины в составе структуры? Предполагается что программа должна записывать чистые данные (сигналы) wav файла в другой файл (это... Диапазоны значений перечислителей Доброго времени суток, уважаемые форумчане!
Для чего нужны диапазоны значений перечислителей, если... Переделать код так, чтобы использовались диапазоны значений с помощью указателей Мне уже стыдно сюда писать ей богу :wall: . Но есть задача переделать Код№1 так чтобы... Для каждого вызова рекурсивной функции быстрой сортировки вывести left-right диапазоны решите эту задачу, используя векторы, заранее спасибо
Постановка задачи
Пожалуйста, реализовать... Сравнить диапазоны данных двух массивов Сравнить диапазон массива(первого и последнего числа) с другим и если этот массив не входит в... Некоторые немешающие жить но терзающие мозг вопросы Учусь по учебнику Джесс Либерти и наткнулся на некоторые непонятки.
1. (примерно так)
const Cat *... Хочу начать учить C++ с чего начать? Посоветуйте действительно хорошие книги/видео уроки по этому языку. За спиной у меня нет других... Хачю начать изучать С++ посоветуйте с чево начать Хачю начать изучать С++ посоветуйте с чево начать Как перестать программировать и начать жить http://www.fuga.ru/articles/2001/09/programmer.htm
:-) Жить флешке или не жить? Флешка в магнитоле стоит всегда. Вчера заглушила машину и ушла по делам, когда вернулась магнитола... Жить или не жить ? странность, треть винта стала RAW Месяц назад заметил не большие фризы системы при чтении с данного винта. Запустил викторю которая... Жесткий диск - жить или не жить? Добрый день! Есть у меня диск HGST HTS541010B7E610, которому почти 2 месяца. Недавно стал виснуть...
|