C++ 迭代器失效:删掉一个元素,循环为什么漏掉一个
先看一段几乎每个人都写过的代码:
1#include <iostream>
2#include <vector>
3
4int main() {
5 std::vector<int> v{1, 2, 2, 2, 3};
6
7 for (auto it = v.begin(); it != v.end(); ++it) {
8 if (*it == 2) {
9 v.erase(it);
10 }
11 }
12
13 for (int x : v) std::cout << x << ' '; // 期望:1 3
14}在 libstdc++ 的 Release 模式下,这段程序很可能输出:
11 2 3循环跑完了,没崩溃,也没报错,但结果里偏偏还剩一个 2。更麻烦的是,这段代码的行为是未定义的:换一个标准库、换一个优化级别,它可能崩溃,也可能“看起来正常”,然后在某次上线后出问题。
erase 到底做了什么
std::vector 的元素在内存里是连续存放的。erase(it) 并不是把那个位置“挖空”,而是把后面的元素整体往前挪一格,再把 size 减一:
1删除前: [1][2][2][2][3]
2 ↑ it
3删除后: [1][2][2][3]
4 ↑ it 仍然指着这个地址,但这里现在是原来的第三个元素所以 erase 之后,it 这个迭代器已经失效了。对它做 ++it 是未定义行为;在常见实现里,它恰好指向了“被前移过来的下一个元素”,而循环的 ++it 又往前跳了一格,于是那个元素被跳过。
一句话总结:erase 已经帮你把迭代器推进到了下一个位置,循环头里的 ++it 是多余的一步。
正确写法只有两种
第一种,把推进交给 erase 的返回值。从 C++11 起,序列容器的 erase 返回被删元素之后那个位置的迭代器:
1for (auto it = v.begin(); it != v.end(); ) {
2 if (*it == 2) {
3 it = v.erase(it); // 不在这里 ++
4 } else {
5 ++it;
6 }
7}第二种,用“先搬移、再统一删除”的惯用法。它把删除的复杂度从 O(n²) 降回 O(n),也是标准库推荐的写法:
1#include <algorithm>
2
3v.erase(std::remove_if(v.begin(), v.end(),
4 [](int x) { return x == 2; }),
5 v.end());C++20 之后还可以更短:
1std::erase(v, 2); // 删掉所有等于 2 的元素
2std::erase_if(v, pred); // 按条件删关联容器也是同样的道理,只是 erase 的返回值同样从 C++11 才开始有:
1std::map<int, int> m{{1, 1}, {2, 2}, {3, 3}};
2
3for (auto it = m.begin(); it != m.end(); ) {
4 if (it->second % 2 == 0) {
5 it = m.erase(it);
6 } else {
7 ++it;
8 }
9}失效规则比你想的更细
很多人只记住“vector 扩容会让迭代器失效”,但真正的规则是按容器、按操作区分的:
| 容器 | 插入操作 | 删除操作 | 引用/指针 |
|---|---|---|---|
vector / string | 触发扩容时全部失效;否则插入点之后失效,end() 总是失效 | 删除点及之后全部失效 | 扩容时全部失效 |
deque | 两端插入:迭代器全失效,引用仍有效;中间插入:全部失效 | 两端删除:仅被删元素失效;中间删除:全部失效 | 中间操作时全部失效 |
list / forward_list | 不影响其他元素 | 仅被删元素失效 | 仅被删元素失效 |
map / set | 不影响其他元素 | 仅被删元素失效 | 仅被删元素失效 |
unordered_* | 触发 rehash 时迭代器全失效,但引用和指针仍有效 | 仅被删元素失效 | rehash 不影响 |
这张表里有两个容易被忽略的点。
第一,end() 很脆弱。对 vector 来说,任何一次 push_back 都会让 end() 失效,哪怕没有扩容——因为 end() 指向的是“最后一个元素之后”,而这个位置变了。
第二,失效的不只是迭代器。引用和指针同样会失效,而它们失效时往往没有编译错误:
1std::vector<int> v{1, 2, 3};
2int& r = v[0];
3
4v.push_back(4); // 可能重新分配,r 变成悬空引用
5r = 42; // 未定义行为如果确实要一边持有引用一边插入,可以先用 v.reserve(n) 把容量一次性留够;但要注意 reserve 只保证“不重新分配”,如果是在中间 insert,被移动元素上的引用照样会失效。
四种常见的踩法
一、在范围 for 里修改容器结构
1for (int x : v) {
2 if (x < 10) v.push_back(x * 2); // 未定义行为
3}范围 for 在循环开始时就把 end() 存了下来,容器一旦变长,这个缓存的结束位置就不再有效。想在遍历时增删元素,就用显式迭代器,或者先收集到临时容器再统一处理。
二、把迭代器存起来跨操作使用
1auto it = std::find(v.begin(), v.end(), 42);
2v.insert(v.begin(), 0); // it 可能已经失效
3*it; // 危险“先找到、再修改、再使用”是很自然的写法,但两步之间只要发生了一次插入或删除,之前的迭代器就不再可信。安全起见,跨操作时保存下标或键,用的时候重新查找。
三、以为 list 可以随便存迭代器,就忽略了“删掉的那个”
std::list 的插入不会让其他迭代器失效,这没错;但被 erase 的那个迭代器本身立刻失效,指向它的指针也一样。节点稳定不等于“所有迭代器永远有效”。
四、在 unordered_map 里边遍历边插入
1for (auto& [k, val] : m) {
2 m[k + 1] = val; // 插入可能触发 rehash,迭代器全部失效
3}unordered_map 的 rehash 只影响迭代器,不影响已有元素的引用和指针——但范围 for 用的正是迭代器,所以照样会出问题。要插入就先记下待插入的键值对,循环结束后再统一插入。
让工具替你抓出来
这类 bug 的特点是“Release 下看起来很乖”,所以调试构建一定要开检查:
- libstdc++(GCC):编译时加
-D_GLIBCXX_DEBUG,迭代器越界、失效使用会直接报错并打印容器状态。代价是性能下降,且这个宏会改变 ABI,不能和未开启的翻译单元混链。 - libstdc++ 轻量检查:
-D_GLIBCXX_ASSERTIONS开销小得多,能覆盖operator[]、front()、back()等的越界检查。 - MSVC:Debug 配置下
_ITERATOR_DEBUG_LEVEL默认为 2,失效迭代器会被断言拦下;Release 下为 0,等于没有保护。 - AddressSanitizer:
-fsanitize=address能抓住悬空引用、悬空指针的读写,但对“迭代器跳格”这类逻辑错误无能为力,它和上面的迭代器调试是互补关系,不是替代关系。
选容器时就想清楚“你要的是位置还是身份”
迭代器失效之所以难缠,是因为它把“容器结构变了”和“你手里的坐标还有效吗”这两件事绑在了一起。写代码时可以先问自己一句:
- 我要的是位置(第几个、下一段连续内存)?那就用
vector加下标,操作后重新定位。 - 我要的是元素身份(这个节点本身)?那就用
list、map、unordered_map,它们的节点不会因为别的插入而搬家。 - 我要的是稳定遍历 + 频繁中间删除?
std::list的erase返回下一个迭代器,配合it = c.erase(it)是最省心的组合。
最后留一份自检清单:循环里有没有 erase?erase 的返回值用上了吗?范围 for 里改过容器大小吗?跨函数保存过迭代器或引用吗?调试构建开了迭代器检查吗?
这五个问题答完,大部分“删掉一个、漏掉一个”的怪事就不会再出现了。