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(...)
читається як конвеєр: на кожному кроці ми не «будуємо новий вектор», а створюємо чергове представлення.
2. Модель даних: 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>.
3. Базові адаптери 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';
}
}
Навіть якщо завдань 1 000, ми «пройдемо» лише перші 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
важливі й невиконані]
B --> C[views::transform
Task -> заголовок]
C --> D[views::take
перші 3]
D --> E[range-for + cout]
Зауважте, що «обчислення» не відбуваються в момент оголошення pipeline. Вони починаються під час обходу в for.
4. Лінивість і читабельність пайплайнів
Коли насправді викликаються лямбди
Слово «лінивий» звучить як характеристика кота, який ігнорує ваш 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)».
5. Типові помилки під час роботи з 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 будують представлення, тобто описують, як дивитися на дані, а std::ranges-алгоритми виконують роботу: сортують, копіюють, шукають. Запамʼятайте коротку фразу: views описують, ranges-алгоритми роблять — і орієнтуватися стане значно легше.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ