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), а для UI/печати вам нужны строки.

Делаем строку для печати одной задачи

Сначала напишем маленькую функцию форматирования. Она возвращает строку вида [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> dstcopy_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> linestransform(tasks.begin(), tasks.end(), lines.begin(), ...). Итог такой же, как с copy_if: писать некуда. Надо сделать lines(tasks.size()), чтобы lines.begin() указывал на реальный элемент.

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

1
Задача
C++ SELF, 24 уровень, 3 лекция
Недоступна
Плюсы в отчёт
Плюсы в отчёт
1
Задача
C++ SELF, 24 уровень, 3 лекция
Недоступна
Квадраты для графика
Квадраты для графика
1
Задача
C++ SELF, 24 уровень, 3 лекция
Недоступна
Ярлыки длинных слов
Ярлыки длинных слов
1
Задача
C++ SELF, 24 уровень, 3 лекция
Недоступна
TODO с ключом
TODO с ключом
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ