JavaRush /Курси /C++ SELF /std::views::filter / transform / take

std::views::filter / transform / take

C++ SELF
Рівень 62 , Лекція 1
Відкрита

1. view і пайплайни ranges

Коли ви лише вчитеся програмувати, цикл схожий на швейцарський ніж: ним можна розвʼязати майже будь-яку задачу. І це справді так. Та з часом зʼясовується інше: той самий «ніж» починає розростатися в набір майже однакових копій, а в коді зʼявляється шум. Десь ми фільтруємо, десь перетворюємо, десь обмежуємо кількість результатів.

Уявіть, що в нас є навчальний консольний застосунок «TaskLite» — невеликий список завдань. Ми зберігаємо завдання в std::vector, уміємо друкувати їх, а тепер хочемо вивести «перші 3 важливі незавершені завдання». На циклах для цього знадобляться кілька if, лічильник, break і трохи дрібної рутини. З views можна записати це так, щоб код читався як фраза: «візьми завдання → відфільтруй → перетвори → візьми перші N».

Що таке view

Почнімо з правильної ментальної моделі. std::vector — це коробка з речами: він володіє памʼяттю, зберігає елементи й відповідає за їхнє життя. А view — це радше «розумне вікно» або «сценарій перегляду»: воно зазвичай не володіє даними, а лише зберігає правила, як їх обходити й що показувати.

Якщо вам подобається кухонна аналогія, то контейнер — це каструля із супом, а view — ополоник та інструкція «зачерпуй лише шматочки картоплі». Суп від цього не зникає й не копіюється: ви просто дивитеся на нього під потрібним кутом.

Невелика таблиця, щоб закріпити різницю:

Сутність Приклад Володіє даними? Де «живуть» елементи? Коли виконується робота?
Контейнер
std::vector<Task>
Так Усередині контейнера Під час додавання/видалення/зміни
View
tasks | std::views::filter(...)
Зазвичай ні У вихідному контейнері Під час обходу 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

Обмеження кількості результатів — класична задача: ви хочете показати користувачу не весь список, а перші 310 пунктів. У циклі це зазвичай виглядає як 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 проявляють себе саме в момент обходу.

Коли краще розбити на кроки

Пайплайн в одну «драбинку» читається добре… доти, доки там не зʼявляються три умови, два перетворення й пів сторінки логіки. Тоді код перетворюється на «функціональну локшину»: ніби модно, але читати неможливо.

Нормальна інженерна звичка: якщо ланцюжок перестає читатися зліва направо за 510 секунд, дайте крокам імена. Це не слабкість, а турбота про майбутнього себе, який за два тижні дивитиметься на цей код як на чужий.

Наприклад, так:

#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-алгоритми роблять — і орієнтуватися стане значно легше.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ