1. view и пайплайны ranges
Когда вы только учитесь программировать, цикл — это как швейцарский нож: им можно открыть почти всё. И это правда. Но на длинной дистанции получается другая правда: один и тот же “нож” начинает выглядеть как набор слегка отличающихся копий, а в коде появляется шум — где фильтруем, где преобразуем, где ограничиваем количество результатов.
Представьте, что у нас есть учебное консольное приложение “TaskLite” — мини-список задач. Мы храним задачи в std::vector, умеем печатать их, а теперь хотим вывести “первые 3 важные незавершённые задачи”. На циклах получится несколько if, счётчик, break и мелкая бытовуха. С views можно написать так, чтобы код читался как фраза: “возьми задачи → отфильтруй → преобразуй → возьми первые N”.
Что такое view
Важно начать с правильной ментальной модели. std::vector — это коробка с вещами: он владеет памятью, хранит элементы и отвечает за их жизнь. А view — это скорее “умное окно” или “сценарий просмотра”: оно обычно не владеет данными, а лишь хранит правила, как по ним пройти и что показывать.
Если вам нравится аналогия с кухней, то контейнер — это кастрюля с супом, а view — это половник и инструкция “зачерпывай только кусочки картошки”. Суп от этого не исчезает и не копируется, просто вы смотрите на него под нужным углом.
Небольшая табличка, чтобы закрепить различие:
| Сущность | Пример | Владеет данными? | Где “живут” элементы? | Когда выполняется работа? |
|---|---|---|---|---|
| Контейнер | |
Да | Внутри контейнера | При добавлении/удалении/изменении |
| View | |
Обычно нет | В исходном контейнере | При обходе view |
Ключевой вывод: view — это не “новый список”, а “новый способ смотреть на старый список”.
Пайплайн-оператор |
Пайплайны в ranges/views выглядят необычно: там используется оператор |. Исторически в C++ это “побитовое ИЛИ”, и мозг новичка честно пытается найти тут биты. Но в мире ranges это перегруженный оператор, который принято читать как “пропусти диапазон через адаптер”.
Идея “pipe” для range adaptors — это не случайная магия, а специально продуманная часть дизайна ranges. В материалах WG21 это явно обсуждалось (в частности, как “Pipe support for user-defined range adaptors”).
То есть выражение:
tasks | std::views::filter(pred)
читается как:
“Возьми tasks и пропусти через filter(pred)”.
А цепочка:
tasks | filter(...) | transform(...) | take(...)
читается как конвейер: на каждом шаге мы не “строим новый вектор”, а создаём очередное представление.
3. Модель данных: TaskLite
Чтобы примеры были связными, возьмём маленькую модель задачи. Предположим, что в прошлых темах вы уже знакомились со struct и enum class. Мы используем максимально простую версию.
#include <string>
enum class Priority { Low, Normal, High };
struct Task {
int id = 0;
std::string title;
Priority priority = Priority::Normal;
bool done = false;
};
И сделаем тестовые данные:
#include <vector>
std::vector<Task> makeSampleTasks() {
return {
{1, "Сдать лабу", Priority::High, false},
{2, "Купить хлеб", Priority::Low, true},
{3, "Починить сборку", Priority::High, false},
{4, "Погладить кота", Priority::Normal, false},
{5, "Посмотреть ошибки компилятора", Priority::High, true},
};
}
Дальше мы будем строить views поверх этого std::vector<Task>.
4. Базовые адаптеры filter, transform, take
std::views::filter
Когда вы пишете обычный цикл, вы часто делаете проверку if (условие) continue;. std::views::filter — это “тот же if”, но вынесенный в декларативный стиль: вы описываете правило отбора, а не вручную управляете каждым шагом обхода.
Сигнатура на уровне идеи такая: views::filter(pred) принимает предикат (обычно лямбду), а дальше даёт view, которое при обходе пропускает только те элементы, для которых pred(x) возвращает true.
Пример: возьмём только незавершённые задачи.
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<Task> tasks = makeSampleTasks();
auto notDone = tasks | std::views::filter([](const Task& t) { return !t.done; });
for (const Task& t : notDone) {
std::cout << t.id << ": " << t.title << '\n';
}
}
Обратите внимание на const Task& t в range-for: мы не хотим копировать Task (там внутри строка), а хотим читать элементы как ссылки.
Ещё пример: фильтруем “важные и не сделанные”.
#include <ranges>
bool isImportantAndNotDone(const Task& t) {
return t.priority == Priority::High && !t.done;
}
int main() {
auto tasks = makeSampleTasks();
auto important = tasks | std::views::filter(isImportantAndNotDone);
for (const Task& t : important) {
std::cout << t.title << '\n';
}
}
Здесь мы передали не лямбду, а обычную функцию. Для filter это нормально: главное — чтобы это было “что-то вызываемое” (callable).
std::views::transform
После фильтрации часто хочется “взять не весь объект”, а только нужную часть: например, вывести только заголовки, или посчитать “короткое представление” строки, или извлечь id.
В цикле это делается так: “взяли элемент → что-то вычислили → вывели”. std::views::transform превращает это в отдельный шаг пайплайна: вы описываете функцию преобразования f(x), а view при обходе возвращает значения f(x).
Пример: превратим задачи в их заголовки.
#include <iostream>
#include <ranges>
#include <vector>
int main() {
auto tasks = makeSampleTasks();
auto titles = tasks | std::views::transform([](const Task& t) { return t.title; });
for (const std::string& s : titles) {
std::cout << s << '\n';
}
}
Тут есть важный момент “на подумать”: мы возвращаем t.title — это std::string. В зависимости от деталей, transform может отдавать значение как ссылку или как копию. Чтобы не превращать лекцию в “шаблонный детектив”, держим практическое правило: если внутри Task тяжёлые поля, не удивляйтесь, что иногда удобнее возвращать не строку, а, например, int id или что-то лёгкое.
Пример: преобразуем задачи в их id.
#include <iostream>
#include <ranges>
int main() {
auto tasks = makeSampleTasks();
auto ids = tasks | std::views::transform([](const Task& t) { return t.id; });
for (int id : ids) {
std::cout << id << ' ';
}
std::cout << '\n'; // 1 2 3 4 5
}
Это уже очень “чистый” transform: на выходе лёгкие числа.
std::views::take
Ограничение количества результатов — классика: вы хотите показать пользователю не весь список, а первые 3–10 пунктов. На цикле это обычно выглядит как int shown = 0; ... if (++shown == N) break;. Работает, но читать такое — как инструкцию к микроволновке: вроде всё логично, но радости мало.
std::views::take(n) решает задачу декларативно: “взять первые n элементов диапазона”. При этом важно, что это тоже view, и оно тоже ленивое: никакой новый контейнер не создаётся.
Пример: возьмём первые 2 задачи из списка.
#include <iostream>
#include <ranges>
int main() {
auto tasks = makeSampleTasks();
auto first2 = tasks | std::views::take(2);
for (const Task& t : first2) {
std::cout << t.id << ": " << t.title << '\n';
}
}
Даже если задач 1000, мы “пройдём” только первые 2 при печати.
Собираем всё вместе: filter | transform | take
Теперь самый вкусный кусок: составим ту самую фразу “первые 3 важные незавершённые задачи”, но так, чтобы код читался почти как русская речь (ну, насколько это вообще возможно в C++).
Сначала фильтруем важные и не сделанные, потом превращаем в заголовки, потом берём первые 3.
#include <iostream>
#include <ranges>
#include <vector>
int main() {
auto tasks = makeSampleTasks();
auto pipeline =
tasks
| std::views::filter([](const Task& t) { return t.priority == Priority::High && !t.done; })
| std::views::transform([](const Task& t) { return t.title; })
| std::views::take(3);
for (const std::string& title : pipeline) {
std::cout << title << '\n';
}
}
Если вы сейчас подумали “это похоже на Unix pipes” — поздравляю, вы поймали правильное ощущение. В C++ это обсуждается ровно в той же логике “pipe” для range adaptors.
Чтобы закрепить, вот простая схема пайплайна:
flowchart LR
A[std::vector<Task> tasks] --> B[views::filter
важные и не done]
B --> C[views::transform
Task -> title]
C --> D[views::take
первые 3]
D --> E[range-for + cout]
Заметьте, что “вычисление” не происходит в момент объявления pipeline. Оно происходит при обходе в for.
5. Ленивость и читаемость пайплайнов
Когда реально вызываются лямбды
Слово “ленивый” звучит как характеристика кота, который игнорирует ваш std::cout. Но в ranges это важная характеристика: view обычно ничего не считает заранее, а делает работу только при попытке получить очередной элемент при обходе.
Самый простой способ это почувствовать — добавить счётчик вызовов внутри лямбды. Да, это “грязный” приём (побочный эффект), но как демонстрация он работает идеально.
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3, 4, 5};
int calls = 0;
auto evens = v | std::views::filter([&](int x) { ++calls; return x % 2 == 0; });
std::cout << calls << '\n'; // 0 (ещё ничего не обходили)
for (int x : evens) { (void)x; }
std::cout << calls << '\n'; // 5 (предикат вызвали при обходе)
}
Здесь нужно запомнить практическое правило: view — это “описание”, а не “результат”. Поэтому и filter, и transform, и take проявляют себя в момент обхода.
Когда лучше разбить на шаги
Пайплайн в одну “лесенку” читается красиво… до тех пор, пока там не появляются три условия, два преобразования и полстраницы логики. Тогда код превращается в “функциональную лапшу”: вроде модно, но есть невозможно.
Нормальная инженерная привычка: если цепочка перестаёт читаться слева направо за 5–10 секунд, дайте шагам имена. Это не слабость, а забота о будущем себе (который через две недели будет смотреть на код как на чужой).
Например, так:
#include <ranges>
#include <vector>
int main() {
auto tasks = makeSampleTasks();
auto important = tasks | std::views::filter([](const Task& t) {
return t.priority == Priority::High && !t.done;
});
auto titles = important | std::views::transform([](const Task& t) {
return t.title;
});
for (const std::string& s : titles | std::views::take(3)) {
// печать
}
}
Смысл тот же, но голова читает проще: “important” → “titles” → “take(3)”.
6. Типичные ошибки при работе с views::filter / transform / take
Ошибка №1: ожидать, что filter/transform/take создают новый std::vector.
Очень частая путаница: студент пишет auto x = ... и думает, что “x — это список”. В реальности x — это view, то есть представление. Оно не обязано владеть данными, и чаще всего ничего не копирует. Поэтому если вам нужен настоящий контейнер, его придётся создавать отдельным шагом (но это будет темой следующей лекции, не забегаем вперёд).
Ошибка №2: писать for (auto t : view) и случайно копировать тяжёлые элементы.
Если Task содержит std::string, то for (auto t : someView) может начать копировать элементы (или результаты transform) и внезапно сделать код медленнее. Практическая привычка: для просмотра исходных объектов используйте const auto&, а если вы точно хотите копию — делайте это осознанно и явно.
Ошибка №3: пытаться “увидеть результат” без обхода.
View ленивый: пока вы по нему не прошли (range-for, std::ranges::copy, алгоритм и т.п.), он может вообще ничего не вычислить. Поэтому std::cout << calls до обхода может показывать “0”, и это не баг, а ожидаемое поведение. В голове нужно чётко разделить “описание” и “выполнение”.
Ошибка №4: прятать сложную бизнес-логику внутрь лямбды и получать код-ребус.
views хорошо читаются, когда предикат и преобразование короткие и понятные. Если в filter у вас 8 условий, 3 локальные переменные и кусок “политики компании”, читателю становится больно. В таких случаях лучше вынести логику в отдельную функцию с нормальным именем, а в пайплайне оставить только её вызов.
Ошибка №5: путать std::views::... и std::ranges::....
Это разные “полки” библиотеки. views строят представления (то есть описывают, как смотреть на данные), а ranges-алгоритмы выполняют работу (сортируют, копируют, ищут). Запомните короткую фразу: views описывают, ranges-алгоритмы делают — и жить станет спокойнее.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ