JavaRush /Курси /C++ SELF /std::copy_if і std::transform

std::copy_if і std::transform

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

1. Іноді краще не змінювати std::vector на місці

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

У реальному коді це виглядає так: ви обходите vector, у середині циклу вирішуєте: «Цей елемент нам більше не потрібен», робите erase — і раптом або пропускаєте наступний елемент, або читаєте вже щось не те, або, що гірше, отримуєте аварійне завершення. Навіть якщо все зроблено правильно, код стає напруженим: треба памʼятати, де робити ++it, а де ні, що повертає erase і чому тут не підійде range-for.

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

Для STL-алгоритмів така філософія цілком природна: їх часто описують через «ефекти» й «повернення», тобто через те, що алгоритм робить і що повертає як результат обчислення, а не через «як ми там вручну рухали індекс».

2. Модель перескладання: джерело → результат

Давайте обережно введемо нову модель мислення. Тепер у нас часто буде два контейнери: src (вихідні дані) і dst (результат). Ми читаємо src, вирішуємо: «залишити / перетворити / пропустити» — і записуємо результат у dst.

Можна уявити це як конвеєр:

flowchart LR
    A["src (вихідний vector)"] -->|читаємо елементи| B["алгоритм + лямбда"]
    B -->|записуємо| C["dst (новий vector)"]

Важливо, що «перескладання» — це не обовʼязково про видалення. Іноді нам потрібно просто подати дані у зручному для друку або аналізу вигляді: наприклад, із Task зробити рядок "[x] Купити молоко". У такому разі dst буде вектором рядків.

Сьогодні ми розберемо два алгоритми, які добре вписуються в цю модель:

  • std::copy_if — фільтрація: копіюємо лише ті елементи, які нам підходять.
  • std::transform — перетворення: з кожного елемента робимо новий елемент, зазвичай іншого типу або з іншим значенням.

Обидва алгоритми знаходяться в <algorithm> і працюють із діапазонами [begin, end), як і більшість алгоритмів стандартної бібліотеки.

Контекст прикладів: міні‑історія «TaskBoard»

Щоб приклади не були абстрактними на кшталт «нехай є числа», продовжимо маленький консольний застосунок, який ми поступово розвиваємо впродовж курсу: список задач.

Поки що модель задачі проста: id, текст і прапорець «виконано / не виконано».


#include <string>

struct Task {
    int id = 0;
    std::string title;
    bool done = false;
};

І зазвичай ми зберігаємо задачі так:

#include <vector>

std::vector<Task> tasks;

Сьогодні ми навчимося:

  • із tasks створювати новий вектор «лише активних задач» (фільтрація),
  • із tasks створювати новий вектор рядків для друку (перетворення).

3. std::copy_if: фільтруємо елементи в новий контейнер

Коли ви чуєте copy_if, це можна прочитати як «скопіюй, якщо…». Тобто алгоритм читає елементи вихідного діапазону й копіює їх у цільовий діапазон лише тоді, коли предикат, тобто умова, повертає true.

І тут є важлива практична деталь, про яку часто забувають: copy_if не вміє сам збільшувати vector, якщо ви записуєте в dst.begin(). Він не «робить push_back сам собою». Він чесно записує рівно туди, куди ви сказали, і якщо місця немає — усе закінчиться погано, зазвичай UB.

Тому сьогодні ми використаємо безпечну навчальну схему:

  1. створюємо dst розміром src.size() (тобто місця точно вистачить),
  2. робимо copy_if,
  3. отримуємо out_end — ітератор, до якого дійшов запис,
  4. вкорочуємо dst через erase(out_end, dst.end()).

Міні‑приклад: залишити лише невиконані задачі

#include <algorithm>
#include <vector>

std::vector<Task> keep_active(const std::vector<Task>& src) {
    std::vector<Task> dst(src.size()); // місце із запасом
    auto out_end = std::copy_if(src.begin(), src.end(), dst.begin(),
                                [](const Task& t) { return !t.done; });
    dst.erase(out_end, dst.end());     // прибираємо хвіст
    return dst;
}

Зверніть увагу на приємний момент: вхід — const std::vector<Task>&. Це буквально означає: «я не збираюся змінювати src». Контракт функції видно просто із сигнатури, і це величезний плюс для розуміння коду.

Міні‑приклад: фільтр за словом у назві

Нехай ми хочемо залишити задачі, у назві яких трапляється "C++". Поки що не заглиблюємося в складний розбір рядків — просто використовуємо find.

#include <algorithm>
#include <string>
#include <vector>

std::vector<Task> keep_cpp(const std::vector<Task>& src) {
    std::vector<Task> dst(src.size());
    auto out_end = std::copy_if(src.begin(), src.end(), dst.begin(),
        [](const Task& t) { return t.title.find("C++") != std::string::npos; });
    dst.erase(out_end, dst.end());
    return dst;
}

Тут важливо розуміти, що «предикат повертає true» означає «копіюємо», а не «видаляємо». Це не remove_if. Це саме «залишаємо те, що підходить».

Що таке out_end і навіщо він узагалі потрібен

Після copy_if ви отримуєте ітератор out_end. Це «новий кінець записаних даних» — місце в dst, де закінчуються реальні елементи результату.

Якби dst був масивом на папері, то out_end — це позиція одразу за останнім записаним елементом. Усе, що далі, може бути заповнене значеннями «за замовчуванням» (для Task це буде id=0, title="", done=false) і не є частиною результату.

Саме тому ми робимо dst.erase(out_end, dst.end()): фактично вкорочуємо вектор до «реального» розміру результату.

4. std::transform: перетворюємо елементи на інші елементи

std::transform — це алгоритм на кшталт «пропустіть кожен елемент через функцію і запишіть результат». Він зручний, коли кількість елементів не змінюється, але змінюється їхнє представлення.

Типовий сценарій у застосунках: у вас є «внутрішня модель» (Task), а для інтерфейсу або друку потрібні рядки.

Робимо рядок для друку однієї задачі

Спочатку напишемо маленьку функцію форматування. Вона повертає рядок виду [x] (3) Купити молоко.

#include <string>

std::string format_task(const Task& t) {
    const char mark = t.done ? 'x' : ' ';
    return "[" + std::string(1, mark) + "] (" + std::to_string(t.id) + ") " + t.title;
}

Так, конкатенація рядків виглядає трохи «шумно», але зараз нам важливіша прозорість, ніж ідеальна краса.

Перетворюємо vector<Task> на vector<string> за допомогою transform

Ключовий момент: цільовий контейнер має мати потрібний розмір, тому що transform записуватиме за ітераторами.

#include <algorithm>
#include <string>
#include <vector>

std::vector<std::string> make_lines(const std::vector<Task>& tasks) {
    std::vector<std::string> lines(tasks.size());
    std::transform(tasks.begin(), tasks.end(), lines.begin(),
                   [](const Task& t) { return format_task(t); });
    return lines;
}

У результаті ви отримуєте «готові рядки» для друку й можете окремо вирішувати, як саме їх виводити.

Міні‑приклад: друк рядків

#include <iostream>
#include <string>
#include <vector>

void print_lines(const std::vector<std::string>& lines) {
    for (const std::string& s : lines) {
        std::cout << s << '\n';
    }
}

Якщо десь у main() ви виконаєте:

auto lines = make_lines(tasks);
print_lines(lines);

то виведення буде приблизно таким:

[ ] (1) Прочитати про std::vector
[x] (2) Здати домашку

(Здавати домашку все ще корисно. Навіть якщо ви робите це через transform.)

5. Фільтрація + перетворення: два кроки, але менше болю

Іноді хочеться зробити «все й одразу»: залишити лише активні задачі й відразу перетворити їх на рядки.

Спокуса новачка — написати один великий цикл, усередині якого if, усередині якого формування рядка, усередині якого ще один if. Працює? Так. Читається? Наче давній сувій ельфійською.

Значно спокійніше зробити два зрозумілі кроки: спочатку фільтрацію (copy_if), потім перетворення (transform). Так, це два проходи по даних, але на цьому етапі курсу важливіше те, що код стає передбачуваним і безпечним.

Композиція кроків в одній функції

#include <string>
#include <vector>

std::vector<std::string> active_task_lines(const std::vector<Task>& all) {
    auto active = keep_active(all);   // copy_if усередині
    return make_lines(active);        // transform усередині
}

Плюс такого підходу в тому, що кожна функція робить одну річ і робить її добре. А коли код ламається, вам простіше зрозуміти, де саме це сталося (спойлер: зазвичай проблема там, де хтось забув підготувати розмір контейнера).

6. Чому перескладання часто безпечніше (і де підступ)

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

Але є й ціна: ви створюєте додатковий контейнер, а отже, витрачаєте більше памʼяті й копіюєте елементи. Для Task це зазвичай нормально, але якби елементи були великими, наприклад довгі рядки, зображення чи складні структури, ви б замислилися про вартість копіювання. Однак на поточному етапі курсу важливіше навчитися писати коректний і читабельний код, ніж «вичавити» 3 % швидкості ціною сивого волосся.

Підказка: copy_if vs transform

Коли від слова «ітератор» уже починають злипатися очі, корисно мати перед собою просту опорну табличку.

Алгоритм Що робить Чи змінює кількість елементів? Типовий результат
copy_if
Копіює потрібні елементи Зазвичай зменшує або залишає як є Новий контейнер «лише потрібне»
transform
Перетворює кожен елемент Ні, кількість зберігається Новий контейнер «інший вигляд даних»

Важливо: в обох випадках ви найчастіше або заздалегідь задаєте розмір цільового контейнера, або використовуєте техніки запису «зі зростанням» (але їх ми сьогодні свідомо не чіпаємо, щоб не забігати наперед).

7. Типові помилки під час роботи з copy_if і transform

Помилка № 1: copy_if записують у порожній vector, використовуючи dst.begin().
Це класична помилка. Створюють std::vector<Task> dst, а потім роблять copy_if(src.begin(), src.end(), dst.begin(), ...). Але dst.begin() тут не вказує «на місце для запису», бо елементів немає. Правильна логіка така: або заздалегідь виділити елементи через dst(src.size()) і потім укоротити контейнер, або використати інший спосіб запису, який ми розберемо пізніше.

Помилка № 2: забувають укоротити dst після copy_if.
У результаті dst залишається довжини src.size(), і в кінці зʼявляються «порожні задачі» з id=0 і порожньою назвою. Це не баг компілятора й не містика: ви самі створили зайві елементи, а copy_if заповнив лише частину. Тому після copy_if майже завжди має бути крок dst.erase(out_end, dst.end()).

Помилка № 3: очікують, що transform «може зменшити список».
transform не фільтрує. Він перетворює елемент на елемент. Якщо потрібно «викинути зайве», це не його робота. Для фільтрації беруть copy_if (або інші техніки, які будуть далі за курсом). Якщо спробувати «фільтрувати через transform», вийде або дивний код із «порожніми» значеннями, або логічна помилка.

Помилка № 4: забувають підготувати розмір цільового контейнера для transform.
Пишуть std::vector<std::string> lines, а потім transform(tasks.begin(), tasks.end(), lines.begin(), ...). Підсумок такий самий, як і з copy_if: записувати нікуди. Треба зробити lines(tasks.size()), щоб lines.begin() вказував на реальний елемент.

Помилка № 5: намагаються «все зробити однією лямбдою» й отримують лямбду-монстра.
Технічно можна вмістити в лямбду і форматування рядка, і умову, і навіть побічні ефекти. Але код починає читатися як заклинання, а налагоджуватися — як квест. Набагато спокійніше винести форматування в format_task, фільтрацію — в keep_active, а потім спокійно поєднати кроки.

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