博主介绍:程序喵大人
- 35 - 资深C/C++/Rust/Android/iOS客户端开发
- 10年大厂工作经验
- 嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手
- 《C++20高级编程》《C++23高级编程》等多本书籍著译者
- 更多原创精品文章,首发gzh,见文末
- 👇👇记得订阅专栏,以防走丢👇👇
😉C++基础系列专栏
😃C语言基础系列专栏
🤣C++大佬养成攻略专栏
🤓C++训练营
👉🏻个人网站
好文推荐:
【C++进阶】STL容器与迭代器 - 01 STL 容器先解决元素放在哪里
【C++进阶】STL容器与迭代器 - 02 vector 为什么是一段会长大的连续数组
【C++进阶】STL容器与迭代器 - 03 string、array 和 deque 各自守住什么边界
【C++进阶】STL容器与迭代器 - 04 list 和 forward_list 用节点换稳定位置
【C++进阶】STL容器与迭代器 - 05 map 和 set 为什么按键保持有序
【C++进阶】STL容器与迭代器 - 06 unordered_map 和 unordered_set 用哈希桶换平均效率
【C++进阶】STL容器与迭代器 - 07 迭代器是容器和算法之间的通用游标
下面这段代码是每个 C++ 程序员都写过(或差点写过)的错误:
cpp
std::vector<int> v{1, 2, 3, 4, 5, 6};
for (auto it = v.begin(); it != v.end(); ++it) {
if (*it % 2 == 0)
v.erase(it); // ❌ 危险:erase 之后 it 已经失效
}
在 vector 上 erase 一个元素后,被删位置之后的所有元素会向前移动一个位置,填补空缺。这意味着你手里的 it 指向的位置现在是原来的下一个元素,看起来好像还"能用",但实际上迭代器状态已经被容器的内部操作破坏了。继续 ++it 会跳过被移动过来的那个元素(因为移动完后它正好在 it 的原位置,而你紧接着递增就越过了它),而且如果删除的是最后一个元素,it 直接等于 end(),继续递增就是未定义行为。
迭代器失效是 STL 使用中最容易踩的坑。它是所有容器都面对的现实,各容器的触发条件、影响范围和后果各不相同。

失效的本质:结构变了,坐标过期了
迭代器失效的核心逻辑很简单:迭代器是容器在某个时刻的"坐标",指向容器内部某个确定的位置。当容器结构发生变化时,元素被移动、节点被销毁、内存被重新分配,之前的坐标就不再对应有效位置了。这就像你拿着一张老地图找路,地图上的街道还在,但门牌号已经重新排过了。
失效的具体形式有三种,经常同时发生:
迭代器失效:it 不再指向有效元素,继续用它来解引用、递增、递减都是未定义行为。这是最常见的失效形式。
引用失效:T& ref = v[3] 之后如果 v 扩容,ref 就悬空了,它引用的对象已经被移动(或销毁),继续通过 ref 读写就是未定义行为。
指针失效:T* p = &v[0] 之后如果 v 扩容,p 指向旧内存,那块内存可能已经归还给分配器,内容随时被覆盖。
这三者是同源的:容器内部维护的合法"入口"被改变了。哪个失效、什么时候失效,完全取决于容器的底层存储策略。接下来我们按存储类型逐个分析。
vector:扩容和插入是大敌
vector 的失效规则源于它的连续存储本质。
扩容会导致所有迭代器、引用、指针全部失效。push_back、insert、emplace_back、resize,任何可能让 size() 超过 capacity() 的操作,都会触发重新分配。重新分配后,旧内存中的所有元素都被移动到了新地址,所有指向旧位置的入口都成了悬空指针。即使你只追加了一个元素,只要它触发了扩容,你之前保存的首元素迭代器也一样作废。
在非尾部位置插入或删除,会导致插入/删除点之后的所有迭代器、引用、指针失效。insert(pos, x) 在 pos 处插入新元素,pos 之后的所有元素都要向后移一格,它们的地址全变了。erase(pos) 删除 pos 处的元素,后续元素向前移一格,它们的地址也变了。这两种情况下,指向操作位置之前的元素的迭代器仍然有效。
在尾部追加(不触发扩容的条件下)只让 end() 迭代器失效。push_back 后旧的 end() 不再是"尾后哨兵",因为尾后位置现在是一个有效元素了。新 end() 的位置指向了这个新元素之后的虚拟位置。

cpp
std::vector<int> v{10, 20, 30, 40};
auto it2 = v.begin() + 1; // 指向 20
auto it3 = v.begin() + 2; // 指向 30
v.erase(v.begin() + 1); // 删除 20
// it2 失效(它指向被删的元素)
// it3 现在指向移动后的 30,但迭代器本身已失效
// 安全做法:接住 erase 的返回值
// 正确写法:
std::vector<int> v2{10, 20, 30, 40, 50, 60};
for (auto it = v2.begin(); it != v2.end(); /* 空递增 */) {
if (*it % 2 == 0)
it = v2.erase(it); // erase 返回下一个有效位置
else
++it;
}
关键细节在 for 循环的递增部分留空,因为 erase 已经返回了下一个有效位置,如果统一 ++it 就会跳过被移动来的元素。erase(it) 的返回值是你安全推进的锚点,必须接住。

deque:头尾安全,中间危险
deque 的失效规则介于 vector 和 list 之间:
- 在头部或尾部插入元素时,已有元素的引用保持有效,但迭代器可能失效(因为用于定位块的索引数组可能需要扩容)。这是一个重要区别:引用和指针可能仍然指向正确的元素地址,但迭代器因为索引数组的变化而无法再正确工作。
- 在中间插入元素时,所有迭代器、引用、指针都失效。
- 在头部或尾部删除元素时,只有被删元素和对应的
end()/begin()失效,其他迭代器和引用有效。 - 在中间删除元素时,所有迭代器、引用、指针都失效。
这个规则群的逻辑是:deque 维护一个索引数组指向各分块,头尾插入只影响边界块,不碰中间元素数据,所以引用保持稳定;但如果索引数组自己需要扩容,迭代器(它依赖索引数组)就失效了。中间插入因为需要搬动某一侧的所有元素,连带所有引索都失效。
list 和 forward_list:最仁慈的失效规则
节点容器提供最宽松的失效保证:
插入操作绝不导致任何迭代器、引用、指针失效。在已有 it 的位置之前插入新节点,只改动 it 前后两个节点的链接指针,不碰 it 指向的节点本身,也不碰其他节点。
删除操作只让被删除元素的迭代器、引用、指针失效。erase(it) 删除 it 指向的节点,其他所有节点的地址和内容不变,指向其他节点的迭代器全部有效。
这个规则是你选择 list 的最硬理由之一:插入删除不碰其他元素,已有位置在局部修改中保持稳定。在 list 上遍历删除的写法也最自然:
cpp
std::list<int> lst{1, 2, 3, 4, 5, 6};
for (auto it = lst.begin(); it != lst.end(); ) {
if (*it % 2 == 0)
it = lst.erase(it); // 仍需要接住返回值
else
++it;
}
虽然 list 删除不破坏其他迭代器,但 it 自己指向的元素被销毁了,it 本身失效。所以你还是需要接住 erase 的返回值来获得下一个位置。vector 和 list 在这个写法上是一致的,差异在于,如果除了 it 你还持有其他元素的迭代器,它们在 list erase 后仍然有效,而在 vector erase 后,被删位置之后的所有迭代器都会失效。

map 和 set:树结构决定规则
有序关联容器的失效规则和 list 类似,原因在于它们本质上也是节点容器(树节点),每个元素占据独立的节点,删除只影响自己:
erase(it)让被删除元素的迭代器、引用、指针失效,其他元素不受影响。insert不导致任何现有迭代器、引用、指针失效。operator[](仅map)如果触发插入,不影响现有迭代器(但插入一个新节点到树中,其他节点不动)。extract(C++17)允许从容器中"拔出"一个节点而不销毁其数据,返回一个 node handle,可以通过insert把节点挂到另一个容器中。这个过程中元素数据在原地址不动,只是节点连接关系改变,类似list的splice。
因为关联容器从不进行批量搬动,它们的失效规则非常简单:只对被操作的单个节点有影响,其他位置纹丝不动。这意味着在 map 上遍历删除时,你只需要关注 erase 返回下一个位置,不用担心其他迭代器的命运。
unordered_map 和 unordered_set:rehash 是全局杀手
无序关联容器的失效规则有一条额外的全局风险线,rehash:
- rehash(自动或手动触发)会让所有迭代器失效。桶阵列被重建,所有元素的桶归属被重新计算,元素在桶链表中的位置被打散重组。
- 在不触发 rehash 的前提下,
insert不影响现有迭代器有效性和引用有效性。 erase(it)只让被删元素的迭代器、引用、指针失效,和list、map一样的节点容器逻辑。
理解这点的关键是:unordered_map 的存储本质也是节点容器,所以单个元素的插入删除不影响其他节点。但桶阵列是全局结构,当一个 rehash 触发时,整个桶阵列被替换,所有的桶链表被重新组装。即使元素数据本身没移动(因为节点还在原来的堆地址上),迭代器的内部状态(通常关联到桶位置)已经全部失效了。
因此,如果你在 unordered_map 上保存了迭代器后进行了插入操作,你不能假设它们仍然有效,哪怕你做了 reserve 预分配了足够容量,标准也不保证 reserve 之后的插入不再触发 rehash(虽然常见实现会遵守这个承诺,但不要依赖未写进标准的保证)。最安全的策略是:每次插入后重新获取迭代器。

remove-erase 惯用法:把删除分成两步
对于 vector、deque、string 这类连续或分段容器,删除符合某个条件的全部元素不能像 list 那样在遍历中逐个 erase,因为每次 erase 都会搬动后续元素,即使用安全的 it = erase(it) 写法,大量搬动导致 O(n²) 的代价。
C++ 标准库提供了一种两阶段删除模式,remove-erase 惯用法:
cpp
std::vector<int> v{1, 2, 3, 4, 5, 6};
// 第一阶段:std::remove 把"要保留的"元素移到前面,"要删除的"挤到末尾
auto new_end = std::remove(v.begin(), v.end(), 4);
// 现在 v = {1, 2, 3, 5, 6, ?},new_end 指向 6 之后的位置
// 第二阶段:真正从容器中删除末尾的"垃圾"元素
v.erase(new_end, v.end());
// v = {1, 2, 3, 5, 6}
std::remove 并不真正删除任何元素,它只是一个算法,不能改变容器的大小。它做的事情是遍历范围,把不等于目标值的元素往前挪,覆盖等于目标值的元素,最后返回一个迭代器指向"有效元素之后"的位置。然后 erase 把从这个位置到 end() 之间的"剩余"元素真正销毁并收缩容器。
两步分开的原因在于职责分离:算法只能通过迭代器操作元素,不能改变容器的结构(增删元素);而 erase 是容器的成员函数,它能实实在在地销毁元素、更新 size。remove 负责决定"哪些保留、哪些丢弃",erase 负责真正执行丢弃,两者配合,完成 O(n) 的高效条件删除。

list 和 forward_list 不需要这个两步走,它们有自己的 remove() 和 remove_if() 成员函数,直接在遍历中删除匹配的节点,一步完成。原因还是那套逻辑:在节点容器中删除单个节点不碰其他元素,所以直接在遍历中删除是安全且高效的。而在连续存储中每次删除都触发搬动,所以必须用 remove-erase 把搬动次数降为 O(n)。
失效规则速查
把各容器的失效规则浓缩成一张对照表:
| 操作 | vector | deque | list | map/set | unordered_map/set |
|---|---|---|---|---|---|
| 尾部插入(不扩容) | end() 失效 | end() 失效,迭代器可能失效 | 不变 | 不变 | 可能 rehash→ 全部失效 |
| 头部插入 | N/A | begin() 失效,迭代器可能失效 | 不变 | N/A | 可能 rehash→ 全部失效 |
| 中间插入 | 插入点之后全部失效 | 全部失效 | 不变 | 不变 | 可能 rehash→ 全部失效 |
| 尾部删除 | 被删元素和 end() | 被删元素和 end() | 被删节点 | 被删节点 | 被删节点 |
| 中间删除 | 删除点之后全部失效 | 全部失效 | 被删节点 | 被删节点 | 被删节点 |
| rehash | N/A | N/A | N/A | N/A | 全部失效 |

这张表的关键信息不在于每一项具体是什么,在于失效范围是全局(vector 扩容、deque 中间插入、unordered_map rehash)还是局部(节点容器的单个删除、连续容器的尾部操作)。全局失效意味着任何保存过的迭代器都不可信;局部失效意味着你只需要关心与被操作元素直接相关的迭代器。代码审查时,看到"在持有迭代器后修改容器"的代码,第一反应是根据容器类型确定失效范围,然后判断修改操作是否在范围内。
失效规则和容器选择的关系
迭代器失效属于设计阶段就应该考虑的维度。如果你的程序逻辑要求长期持有某个元素的引用,在各种修改操作后仍然通过它访问,你需要的核心能力是迭代器稳定性,也就是从一开始就在设计目标中包含位置稳定的容器,通常是节点容器(list、map、set、unordered_map)或 deque(仅限头尾操作的场景)。
反过来,如果你发现容器选错了,写了一大堆"每次修改后重新获取迭代器"的代码,这些代码本身就是一种设计信号,它告诉你程序的数据访问模式和你选的容器结构之间存在张力。此时值得停下来想一秒:换一种容器能不能让代码更干净?这种从失效规则反推容器选择的能力,比死记硬背规则更有价值。
工程上最常见的失效问题来自两类代码。第一类是循环删除,尤其是 for (++it) 写在循环头里,循环体里又调用 erase(it)。安全写法必须让 erase 返回下一位置,或者在删除前先保存下一位置。第二类是保存地址或引用后继续追加元素,尤其是 vector 里先拿 T* p = &v[0],后面又 push_back。只要扩容发生,p 就指向旧内存。代码审查时,这两类模式应该自动触发警惕。
还有一种更隐蔽的失效来自跨函数边界。函数 A 返回某个容器元素的迭代器,调用方拿着它继续使用;与此同时,函数 B 可能修改同一个容器。单线程代码里这已经难以追踪,异步代码和多线程代码里更危险。标准库容器本身不会帮你同步结构变化,也不会给迭代器附带版本号。要么把修改集中在清晰的阶段,要么把返回值设计成稳定的 key,再由使用方按需重新查找。很多时候,保存 key 比保存迭代器更安全。
失效规则和引用稳定性也不是同一件事。某些操作会让迭代器失效,但引用可能仍然有效;某些容器的 rehash 会让迭代器失效,但元素对象可能仍在节点里。标准对这些情况有细分规则,写工程代码时不要凭直觉扩展。最保守的原则是:只要文档或标准说迭代器失效,就不要继续使用它;如果你确实需要依赖引用稳定性,必须明确查证该容器在该操作下对引用、指针、迭代器分别给出什么保证。
调试这类 bug 的难点在于它们往往不是立刻崩溃。旧迭代器可能恰好还指向一块没被复用的内存,程序跑了几千次都正常;某次容量增长、分配器复用、优化级别变化后,错误才暴露出来。地址消毒器、调试迭代器、标准库 debug 模式都能提高发现概率,但最可靠的防线还是写法本身:修改容器后不继续用旧位置,删除元素时接住返回迭代器,批量插入前避免保存位置,rehash 前后重新查找。
把这套规则落到代码习惯上,可以形成几条非常稳定的写法。遍历删除时,不把 ++it 写在 for 头里,而是在循环体里根据是否删除决定推进方式;批量追加前,先把需要长期使用的位置转成 key 或下标,追加完成后重新定位;哈希表批量插入前先 reserve,插入过程中不保存迭代器;从函数返回容器元素时,优先返回值、ID 或智能指针,少返回裸迭代器。习惯稳定后,失效问题会少很多。
下标也不是万能替代品。对 vector 来说,下标在扩容后仍然是一个整数,重新访问 v[i] 通常能拿到逻辑上的第 i 个元素;但如果中间做了删除或插入,原来的第 i 个元素可能已经换了对象。对 deque 来说,下标访问可用,但中间修改仍会改变元素排列。对 map 和 unordered_map 来说,根本没有按位置下标。用下标替代迭代器只能解决一部分位置失效问题,不能解决业务身份变化。
跨线程代码还要更严格。标准库容器的结构修改和迭代通常需要外部同步,一个线程遍历 vector,另一个线程 push_back,即使没有数据竞争到同一个元素,也可能因为扩容让遍历线程手里的迭代器全部失效。锁、读写锁、快照副本、消息队列,这些同步方案都在解决同一个问题:容器结构变化期间,不能让另一个执行流继续相信旧范围有效。迭代器失效规则在并发里会被放大,不是局部小 bug。
文档化失效假设也很重要。一个函数如果返回迭代器,应该说明调用方在下一次修改容器前使用它;一个类如果允许外部遍历内部容器,应该说明遍历期间不能调用会修改结构的成员函数;一个批处理流程如果分成"构建索引"和"查询索引"两个阶段,最好让这两个阶段在代码结构上分开。失效规则一旦只靠口头约定,后续维护者很容易在安全范围外新增一次插入或删除。
很多时候,最稳的设计是返回稳定 key 而不是迭代器。用户 ID、SKU、任务句柄、节点编号这些业务标识不会因为容器扩容而失效,使用方每次需要访问时重新查找即可。重新查找有成本,但换来的是清晰生命周期和较少悬空位置。只有当性能测量证明重新查找不可接受,并且容器结构能提供稳定位置时,长期保存迭代器才值得进入设计。
失效规则还会影响日志和排查方式。遇到随机崩溃时,日志里只打印元素值往往不够,还要记录容器大小、容量、是否发生 rehash、删除发生在哪个阶段。对 vector,容量变化是关键线索;对 unordered_map,桶数量变化是关键线索;对节点容器,被删除的 key 或迭代器来源是关键线索。不同容器的失效机制不同,排查证据也应该不同。
真正成熟的写法会让失效点在代码里很醒目。erase 返回值被立刻接住,reserve 写在批量插入前,修改阶段和遍历阶段分成两个函数,返回给外部的是 key 而不是内部位置。代码结构越清晰,失效规则越不容易被后续改动踩破。
这也是为什么本章要把规则讲得这么细。迭代器失效不是某个冷门角落,它直接决定循环怎么写、接口怎么设计、测试怎么覆盖、并发怎么同步。只要代码中出现"先拿位置,再改容器,再继续用位置",就必须回到本章的表里确认规则。确认不了,就重新获取位置或换一个更稳定的设计。
学会失效规则以后,容器选择会更具体。你不再只问哪个容器查找快、哪个容器插入快,还会问旧位置能不能活下来、失效范围有多大、外部是否会保存引用。这个问题一进入选择过程,很多原来看起来差不多的容器就会分出清楚边界。
这也解释了为什么失效规则适合放在容器选择之前讲。没有这层知识,容器选择只剩复杂度表;加上失效规则之后,选择才会覆盖生命周期、接口承诺和维护成本。真实 C++ 工程里,后面这些因素经常比单次操作快一点更重要。

拿失效规则作为输入,结合前面几章对容器结构、复杂度、缓存局部性的理解,就可以进入本系列最核心的工程话题:面对一个真实的数据集合和操作需求,到底该选哪个容器?
码字不易,欢迎大家点赞,关注,评论,谢谢!