CodeGym /Courses /C++ SELF /Erasing while iterating a container

Erasing while iterating a container

C++ SELF
Level 59, Lesson 0
Available

1. Introduction

Erasing elements from a container is a moment when a programmer stops being romantic and becomes a grown-up. While we just read elements or create a new container, everything is fairly safe. But the moment you say, “I walk the container and right now I remove some elements”, you start sawing the branch you are standing on. Sometimes that is fine (and even required), but only if you know where the branch is supported.

The practical motivation usually looks like this: you need not just to “filter everything”, but to do the work as you iterate. For example, you walk through a task list and remove completed ones while printing what you removed. Or you iterate a map and erase entries based on a complex condition that depends on the current element and, say, some counters/logic. In such situations you want to stay in a single loop and avoid making a “second pass”.

To avoid vague talk about “elements”, we continue the TaskBook mini-app (a simple console task manager), where we have a list of tasks and sometimes need to clean the list during iteration.

2. Why erase in a loop often breaks code

The first thing a beginner does (and sometimes even an experienced developer, if tired) looks roughly like this: “I iterate a vector, find a bad element, call erase, then somehow continue”. Two classic problems appear: skipping elements and using “stale” iterators/indices.

The worst part is that the code may sometimes work. And sometimes — “not erase everything”, “erase every other one”, “crash” or “behave strangely”. It's like repairing a socket with a chocolate screwdriver: in theory you have a tool, but in practice — surprises.

Bad idea example: erase in range-for

Range-for hides the iterator from us. And erase is an operation that changes the container structure. So you need to control “where to go next”, but range-for does not give you that.

#include <vector>

int main() {
    std::vector<int> v{1, 2, 3, 4};

    // BAD: range-for hides the iterator.
    // When trying to erase you lose control over how to continue iteration.
    // for (int x : v) {
    //     if (x % 2 == 0) {
    //         v.erase(...); // from here pain begins
    //     }
    // }
}

Yes, sometimes people try to find an index inside a range-for and then erase. That doesn't become better — it just turns into “range-for + hacks”.

Bad idea example: index loop and erase without adjusting the index

With indices there's another trap: after removal elements shift, but your i keeps increasing as if nothing happened. As a result you often skip the element that moved into the removed position.

#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));
            // After erase the element at position i is the next one,
            // but the loop will do ++i and skip it.
        }
    }
}

You can, of course, do --i after erase (and ensure i does not go negative), but this quickly becomes a brittle construction that's scary to touch.

3. Safe pattern: it = c.erase(it)

Now the good news. In the STL there is a very practical idea: erasing by iterator usually returns an iterator to the next element. This is not accidental: it's exactly what we need to continue iteration correctly.

The reliable pattern looks like this:

  • if the element must be erased, do it = c.erase(it) and do not increment it manually;
  • if the element should not be erased, do the usual ++it.

From an algorithmic point of view this is almost like “shifting a pointer”, but taking into account that the container may reorganize elements internally.

Mini-diagram (loop logic)

flowchart TD
    A["it points to the current element"] --> B{Erase?}
    B -- "yes" --> C["it = c.erase(it)"]
    C --> A
    B -- "no" --> D["++it"]
    D --> A

Example for std::vector: erase negative numbers

#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);   // take the next valid iterator
        } else {
            ++it;               // move ourselves
        }
    }
}

Why does this work reliably? Because we always know exactly that it after the erase branch points to where we need to continue. Not “somewhere nearby”, not “likely the next”, but precisely the correct continuation point.

Embedding in TaskBook: erasing empty titles

Assume a task must have at least some text. If the title is empty, we consider the task junk and remove it. We keep it simple — use a 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;
        }
    }
}

Note: we use it->title because it is an iterator, and -> is a convenient access to struct fields (as if it “points” to the element).

4. Where the pattern applies: vector, list, map, set

When you write STL code, keep two things in mind. First — not all containers are organized the same internally. Second — containers react differently to erasure in the middle.

Now we are interested in a practical question: “can I erase by iterator in this container inside a loop and continue with the same pattern?”. For most standard containers the answer is: yes. But details (what gets invalidated, how expensive it is) differ.

Below is a very practical table. It does not claim standards formality, but gives a “working memory”.

Container Can you do it = erase(it)? What a beginner should remember
std::vector
yes After erase elements shift, iterators to elements after the erased one are usually invalidated. But the returned iterator is valid.
std::deque
yes Similar in idea to vector, but arranged differently internally. The rule “use the returned iterator” is especially important.
std::list
yes Erasure is cheap, other iterators are not invalidated as massively as in vector. Still, the pattern is the same.
std::map / std::set
yes Elements live in nodes. erase(it) returns the next iterator — very convenient for filtering.
std::unordered_map / std::unordered_set
yes Usually fine as well, but remember: some operations may cause rehashing. In a simple “erase current” loop this pattern is normal practice.
std::forward_list
"almost", but different There's no erase(it) because the iterator is singly linked: use erase_after(prev). That's a separate style.

It's psychologically important to accept one thought: in most containers erase “breaks” some of your old iterators/references, but the returned iterator is your lifeline. That's why we build the loop around it.

Example for std::map: erase tasks with small id

In TaskBook we can store tasks not only in a vector, but also in a map<int, Task>, where the key is the id. This is convenient when you need to find a task by number quickly. And yes, erasing during iteration is fine here too.

#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;
        }
    }
}

The key of the map lives in it->first, the value — in it->second. Again: either we erase and take a new it, or we do ++it.

5. Difference from erase-remove and std::erase_if

Now an important semantic distinction, not syntactic. There are two deletion styles.

The first style — erasing during iteration — is when you need control over the process: you erase, count something, log, sometimes make decisions based on what you saw a moment ago. Here the hero is the loop and the pattern it = c.erase(it).

The second style — bulk removal by predicate — is when you want to say: “remove all elements that satisfy the predicate, period”. This is closer to “filtering”. There people often use the erase-remove idiom (remove_if + erase), and in modern C++ there are also ready-to-use functions std::erase_if.

Since C++20 the standard library provides free functions std::erase and std::erase_if, which express the idea “remove everything by predicate and return how many were removed”. That's a great tool, but it solves a different problem: not “erase and continue on the path”, but “purely filter”.

Mini-contrast in one example

Suppose we want to remove all empty tasks. If we don't need to print each removed task and generally have no side effects, think “filter”. For vector<Task> (and many other containers) this reads very directly:

#include <string>
#include <vector>
// #include <algorithm>  // may be unnecessary if you use std::erase_if

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

int main() {
    std::vector<Task> tasks{{1, ""}, {2, "Read"}, {3, ""}};

    // Idea: "erase everything where title is empty"
    // std::erase_if(tasks, [](const Task& t){ return t.title.empty(); });
}

We won't unpack the erase-remove technique here, because it's a separate topic and you already have places in the course to discuss it in detail. The important point: if you erase as a filter — use the filtering style; if you erase as a process — use it = c.erase(it).

6. When the erase-in-loop wins

Here I want to give you a sense of “why remember this at all”. Because std::erase_if (or erase-remove) seems simpler, and sometimes indeed is. But there are scenarios where a loop with erase is not “old-fashioned” but appropriate.

Scenario A: erase and print what was removed

Let's make a mini-function that erases tasks with empty titles and prints their ids. This is not just a filter: we have a side effect (output) tied to the moment of erasure.

#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;
        }
    }
}

Could this be done via bulk removal? Theoretically yes, but then you'd have to separately collect the list of erased items and print them. Sometimes that's appropriate, but often it's extra complexity. Here the loop reads honestly: “I go and erase, reporting”.

Scenario B: erase in batches, but decision depends on neighbors

Imagine a rule: if you encounter a task with the title "TEMP", erase it and also erase the next task, because the next one is “intermediate”. This is not a simple predicate t -> bool, because the decision depends on position in the sequence.

#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);          // erased TEMP, it now points to "B"
            if (it != tasks.end()) {
                it = tasks.erase(it);      // erased "B"
            }
        } else {
            ++it;
        }
    }
}

Yes, this is logic, and that's exactly why we need control over the iterator.

Scenario C: erasing from a set during iteration

For a set of tags (for example, a set of “blocked words” or “tags”) set is convenient because of uniqueness. There you often traverse and remove unwanted items.

#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;
        }
    }
}

The code looks the same, although the container is completely different. And honestly, that's one reason to love the STL: with a careful style code becomes portable between containers.

Note: erase may return end()

If you erase the last element, the “next” is end(). And that's fine. The loop pattern accounts for that: the condition it != end() will be tested and the loop will finish correctly.

Important: do not try to dereference an iterator after erase without checking it's not end(). In the "TEMP" example above we did if (it != tasks.end()) before the second erase — that's exactly such a guard.

7. Common mistakes when erasing while iterating a container

Mistake #1: doing ++it after it = c.erase(it).
This is the most popular “automatic” mistake. It seems that since we are in a loop, we must increment the iterator. But erase already returned the next element. If you do ++it again, you'll skip an element and get the mysterious “not erasing everything”.

Mistake #2: erasing inside a range-for and hoping it will “just work”.
Range-for is designed for reading (or careful modification of values), but not for structural changes to the container. Erasing mutates the container, and without an explicit iterator you lose control over what “next element” means. This usually ends in skips or crashes, and sometimes — worse — “it sometimes works”.

Mistake #3: mixing indices and erase in a vector without changing index logic.
In a vector erasing an element shifts the tail. If you removed v[i], then the former v[i+1] becomes the new v[i]. If the loop then does ++i, you skip that element. Beginners then add --i, and boundary checks become a dance prone to errors.

Mistake #4: storing iterators/references to elements of a vector and then erasing from it.
Even if you erase a different element, vector may shift elements and your reference/iterator to the “old object” becomes invalid. The bug may surface far from the erasure site. In a simple and safe style, assume: “after structural changes to a vector you can't trust old references and iterators”.

Mistake #5: forgetting to check it != end() in scenarios where you perform additional actions after erasing.
The pattern it = erase(it) is safe by itself, but often after erasure you want to erase more or read the next element. If you erased the last element, the iterator becomes end(). So in two-step erasures (like the "TEMP" example) always add an end() check, otherwise you will dereference past-the-end.

1
Task
C++ SELF, level 59, lesson 0
Locked
Zero Cleanup
Zero Cleanup
1
Task
C++ SELF, level 59, lesson 0
Locked
Even-number Quarantine
Even-number Quarantine
1
Task
C++ SELF, level 59, lesson 0
Locked
Empty cards
Empty cards
1
Task
C++ SELF, level 59, lesson 0
Locked
Broken chart
Broken chart
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION