1. Введение
Удаление элементов из контейнера — это момент, когда программист перестаёт быть романтиком и становится взрослым человеком. Пока мы просто читаем элементы или создаём новый контейнер, всё довольно безопасно. Но как только вы говорите: «иду по контейнеру и прямо сейчас выкидываю некоторые элементы», вы начинаете пилить ветку, на которой стоите. Иногда это нормально (и даже нужно), но только если вы знаете, где у ветки опора.
Практическая мотивация обычно такая: вам важно не просто “отфильтровать всё”, а делать работу по мере обхода. Например, вы идёте по списку задач и удаляете выполненные, параллельно печатая, что именно удалили. Или вы просматриваете map и удаляете элементы по сложному условию, где решение зависит от текущего элемента и, скажем, каких-то счётчиков/логики. В таких ситуациях хочется остаться в одном цикле и не городить «второй проход».
Чтобы в примерах не говорить абстрактно про «элементы», продолжим мини-приложение TaskBook (условный менеджер задач в консоли), где у нас есть список задач, и иногда нужно чистить список прямо во время обхода.
2. Почему erase в цикле часто ломает код
Первое, что делает новичок (и иногда даже опытный, если устал), выглядит примерно так: «иду по vector, вижу плохой элемент, делаю erase, потом как-нибудь продолжаю». И тут появляются две классические проблемы: пропуск элементов и использование «протухших» итераторов/индексов.
Самое неприятное в том, что код может иногда работать. А иногда — “удалять не всё”, “удалять через один”, “падать” или “вести себя странно”. Это как чинить розетку отвёрткой из шоколада: в теории инструмент есть, а на практике — сюрпризы.
Пример плохой идеи: erase в range-for
Range-for скрывает от нас итератор. А erase — это операция, которая меняет структуру контейнера. То есть вам нужно управлять «куда идти дальше», но range-for вам этого не даёт.
#include <vector>
int main() {
std::vector<int> v{1, 2, 3, 4};
// ПЛОХО: range-for скрывает итератор.
// При попытке erase вы теряете контроль над продолжением обхода.
// for (int x : v) {
// if (x % 2 == 0) {
// v.erase(...); // дальше начинается боль
// }
// }
}
Да, иногда люди пытаются «внутри range-for» найти индекс, а потом удалить. Это не становится лучше — это просто превращается в “range-for + костыли”.
Пример плохой идеи: индексный цикл и erase без коррекции индекса
С индексами другая ловушка: после удаления элементы сдвигаются, а ваш i продолжает расти, будто ничего не случилось. В итоге вы часто пропускаете элемент, который переехал на место удалённого.
#include <vector>
int main() {
std::vector<int> v{1, 2, 2, 3};
for (std::size_t i = 0; i < v.size(); ++i) {
if (v[i] == 2) {
v.erase(v.begin() + static_cast<long long>(i));
// После erase на позицию i приехал следующий элемент,
// а цикл сделает ++i и пропустит его.
}
}
}
Можно, конечно, после erase делать --i (и следить, чтобы i не ушёл в минус), но это быстро превращается в хрупкую конструкцию, которую страшно трогать.
3. Безопасный паттерн: it = c.erase(it)
Вот теперь — хорошая новость. В STL есть очень практичная идея: удаление по итератору обычно возвращает итератор на следующий элемент. И это не случайно: это ровно то, что нам нужно, чтобы продолжить обход корректно.
Надёжный шаблон выглядит так:
- если элемент надо удалить, делаем it = c.erase(it) и не инкрементим it вручную;
- если элемент удалять не надо, делаем обычный ++it.
С точки зрения алгоритма это почти как «сдвиг указателя», но с учётом того, что контейнер внутри может переорганизовать элементы.
Мини-схема (логика цикла)
flowchart TD
A["it указывает на текущий элемент"] --> B{Удаляем?}
B -- "да" --> C["it = c.erase(it)"]
C --> A
B -- "нет" --> D["++it"]
D --> A
Пример на std::vector: удаляем отрицательные числа
#include <vector>
int main() {
std::vector<int> v{3, -1, 2, -5, 7};
for (auto it = v.begin(); it != v.end(); ) {
if (*it < 0) {
it = v.erase(it); // берём следующий валидный итератор
} else {
++it; // двигаемся сами
}
}
}
Почему это работает стабильно? Потому что мы всегда точно знаем, что it после ветки erase указывает туда, куда надо продолжать. Не «куда-то рядом», не «вроде бы на следующий», а именно на корректное место продолжения обхода.
Встраиваем в TaskBook: удаление пустых названий
Пусть задача — это хотя бы текст. Если текст пустой, считаем задачу мусором и удаляем. Мы пока без сложных классов — используем struct.
#include <string>
#include <vector>
struct Task {
int id = 0;
std::string title;
};
int main() {
std::vector<Task> tasks{
{1, "Buy milk"},
{2, ""},
{3, "Read C++ book"},
{4, ""}
};
for (auto it = tasks.begin(); it != tasks.end(); ) {
if (it->title.empty()) {
it = tasks.erase(it);
} else {
++it;
}
}
}
Обратите внимание: мы используем it->title, потому что it — итератор, а -> это удобный доступ к полям структуры (как будто it “указывает” на элемент).
4. Где паттерн применим: vector, list, map, set
Когда вы пишете код со STL, полезно держать в голове две вещи. Первая — не все контейнеры одинаково устроены внутри. Вторая — не все контейнеры одинаково “болезненно” реагируют на удаление в середине.
Сейчас нас интересует практическое: «могу ли я в этом контейнере удалять по итератору в цикле и продолжать дальше тем же паттерном?». В большинстве стандартных контейнеров ответ: да. Но детали (что инвалидируется, насколько это дорого) различаются.
Ниже — очень прикладная таблица. Она не претендует на формальность стандарта, а даёт “рабочую память”.
| Контейнер | Можно ли делать it = erase(it)? | Что важно помнить новичку |
|---|---|---|
|
да | После erase элементы сдвигаются, итераторы на элементы после удалённого обычно инвалидируются. Но возвращаемый итератор — валиден. |
|
да | Похож на vector по идее, но внутренне устроен иначе. Правило «используй возвращаемый итератор» особенно важно. |
|
да | Удаление дешёвое, остальные итераторы не “портятся” так массово, как у vector. Но паттерн всё равно тот же. |
|
да | Элементы живут в узлах. erase(it) возвращает следующий итератор — очень удобно для фильтрации. |
|
да | Тоже обычно можно, но помните: при некоторых операциях контейнер может перераспределяться (rehash). В рамках простого цикла «удаляю текущий» этот шаблон — нормальная практика. |
|
«почти», но иначе | Там нет erase(it), потому что итератор односвязный: используется erase_after(prev). Это отдельный стиль. |
Психологически важно принять одну мысль: в большинстве контейнеров erase “ломает” какие-то ваши старые итераторы/ссылки, но возвращаемый итератор — ваш спасательный круг. Поэтому мы и строим цикл вокруг него.
Пример на std::map: удаляем задачи с маленьким id
В TaskBook мы можем хранить задачи не только в vector, но и в map<int, Task>, где ключ — id. Это удобно, когда нужно быстро найти задачу по номеру. И да, удалять при обходе тут тоже можно.
#include <map>
#include <string>
struct Task {
std::string title;
};
int main() {
std::map<int, Task> tasks{
{1, {"Buy milk"}},
{2, {"Read C++ book"}},
{3, {"Walk"}}
};
for (auto it = tasks.begin(); it != tasks.end(); ) {
if (it->first < 2) {
it = tasks.erase(it);
} else {
++it;
}
}
}
Ключ map лежит в it->first, значение — в it->second. И снова: мы или удаляем и берём новый it, или делаем ++it.
5. Отличие от erase-remove и std::erase_if
Сейчас будет важное разделение “по смыслу”, а не “по синтаксису”. Есть два стиля удаления.
Первый стиль — удаление во время обхода, когда вам нужен контроль над процессом: вы удаляете, что-то считаете, логируете, иногда принимаете решения на основе того, что видели секунду назад. Здесь наш герой — цикл и паттерн it = c.erase(it).
Второй стиль — массовое удаление по условию, когда вы хотите сказать: “удали все элементы, удовлетворяющие предикату, и точка”. Это ближе к «фильтрации». Там часто используют erase-remove идиому (remove_if + erase), а в современном C++ ещё и готовые функции std::erase_if.
Начиная с C++20 в стандартной библиотеке есть свободные функции std::erase и std::erase_if, которые как раз выражают идею “удали всё по условию и верни, сколько удалили”. Это отличный инструмент, но он решает другую задачу: не “удаляю и иду дальше по пути”, а “чисто фильтрую”.
Мини-контраст на одном примере
Представим, что мы хотим удалить все пустые задачи. Если нам не нужно печатать каждую удалённую задачу и вообще не нужны побочные эффекты, то можно мыслить как “фильтр”. Для vector<Task> (и многих других контейнеров) это читается очень прямо:
#include <string>
#include <vector>
// #include <algorithm> // может не понадобиться, если используете std::erase_if
struct Task {
int id = 0;
std::string title;
};
int main() {
std::vector<Task> tasks{{1, ""}, {2, "Read"}, {3, ""}};
// Идея: "удали всё, где title пустой"
// std::erase_if(tasks, [](const Task& t){ return t.title.empty(); });
}
Мы не будем здесь разворачивать erase-remove “по косточкам”, потому что это отдельная техника и у вас в курсе уже было место, где её можно подробно обсудить. Важно другое: если вы удаляете “как фильтр” — используйте фильтровый стиль; если вы удаляете “как процесс” — используйте it = c.erase(it).
6. Когда цикл с erase выигрывает
Здесь хочется дать ощущение “зачем это вообще помнить”. Потому что std::erase_if (или erase-remove) кажется проще, и иногда действительно проще. Но есть сценарии, где цикл с erase — это не “старомодно”, а уместно.
Сценарий A: удаляем и печатаем, что именно удалили
Сделаем мини-функцию, которая удаляет задачи с пустым названием и печатает их id. Это уже не просто фильтр: у нас есть побочный эффект (вывод), и он привязан к моменту удаления.
#include <iostream>
#include <string>
#include <vector>
struct Task {
int id = 0;
std::string title;
};
int main() {
std::vector<Task> tasks{{1, ""}, {2, "Read"}, {3, ""}};
for (auto it = tasks.begin(); it != tasks.end(); ) {
if (it->title.empty()) {
std::cout << "Removed task id=" << it->id << '\n'; // Removed task id=1 ...
it = tasks.erase(it);
} else {
++it;
}
}
}
Можно ли было бы “как-то” сделать это через массовое удаление? Теоретически да, но тогда придётся отдельно собирать список удаляемых и отдельно печатать. Иногда так и надо, но часто это лишняя сложность. Здесь цикл читается честно: “иду и удаляю, сообщая”.
Сценарий B: удаляем «пачками», но решение зависит от соседей
Представим правило: если встречаем задачу с названием "TEMP", удаляем её и следующую задачу тоже, потому что следующая — “промежуточная”. Это уже вообще не похоже на простой предикат t -> bool, потому что решение зависит от позиции в последовательности.
#include <string>
#include <vector>
struct Task {
std::string title;
};
int main() {
std::vector<Task> tasks{{"A"}, {"TEMP"}, {"B"}, {"C"}};
for (auto it = tasks.begin(); it != tasks.end(); ) {
if (it->title == "TEMP") {
it = tasks.erase(it); // удалили TEMP, it теперь на "B"
if (it != tasks.end()) {
it = tasks.erase(it); // удалили "B"
}
} else {
++it;
}
}
}
Да, это уже “логика”, и именно поэтому нам нужен контроль над итератором.
Сценарий C: удаление в set при обходе
Для множества задач (например, набор “заблокированных слов” или “тегов”) set удобен уникальностью. И там часто делают “пройти и удалить лишнее”.
#include <set>
#include <string>
int main() {
std::set<std::string> tags{"work", "temp", "cpp"};
for (auto it = tags.begin(); it != tags.end(); ) {
if (*it == "temp") {
it = tags.erase(it);
} else {
++it;
}
}
}
Код выглядит одинаково, хотя контейнер совершенно другой. И это, честно говоря, одна из причин любить STL: при аккуратном стиле код становится переносим между контейнерами.
Нюанс: erase может вернуть end()
Если вы удаляете последний элемент, то “следующий” — это end(). И это нормально. Паттерн цикла как раз это и учитывает: условие it != end() проверится, и цикл корректно завершится.
Важно не пытаться разыменовывать итератор после erase, не проверив, что это не end(). В примере с "TEMP" выше мы сделали if (it != tasks.end()) перед вторым удалением — это ровно такая защита.
7. Типичные ошибки при удалении элементов при обходе контейнера
Ошибка №1: делать ++it после it = c.erase(it).
Это самая популярная “ошибка на автомате”. Кажется, что раз мы в цикле, то надо обязательно инкрементить итератор. Но erase уже вернул вам следующий элемент. Если вы сделаете ++it ещё раз, вы перескочите через один элемент и получите загадочное “удаляет не всё”.
Ошибка №2: удалять элементы внутри range-for и надеяться, что «оно само разрулится».
Range-for создан для чтения (или аккуратного изменения значений), но не для структурных изменений контейнера. Удаление меняет контейнер, и без явного итератора вы теряете контроль над тем, что такое “следующий элемент”. Обычно это заканчивается пропусками или падениями, а иногда — ещё хуже — «иногда работает».
Ошибка №3: смешивать индексы и erase в vector, не меняя логику индекса.
В vector удаление элемента сдвигает хвост. Если вы удалили v[i], то бывший v[i+1] становится новым v[i]. Если ваш цикл дальше делает ++i, вы пропускаете этот элемент. Часто новички потом добавляют --i, и начинается танец с проверками, где легко ошибиться на границах.
Ошибка №4: хранить итераторы/ссылки на элементы vector, а потом удалять из него элементы.
Даже если вы удаляете “не тот элемент”, vector может сдвинуть элементы, и ваша ссылка/итератор на “старый объект” станет невалидной. В результате ошибка может проявиться далеко от места удаления. В простом и безопасном стиле лучше считать: “после структурных изменений vector нельзя доверять старым ссылкам и итераторам”.
Ошибка №5: забывать проверку it != end() в сценариях, где вы после удаления делаете ещё действия.
Паттерн it = erase(it) сам по себе безопасен, но часто после удаления хочется удалить ещё что-то или прочитать следующий элемент. Если удалили последний, итератор станет end(). Поэтому при “двухшаговых” удалениях (как в примере с "TEMP") обязательно добавляйте проверку на end(), иначе вы разыменуете конец диапазона.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ