上个月排查一个线上问题,磨了一下午。崩溃点在一个遍历删除的循环里,erase() 完之后,代码又去自增了那个迭代器。最气的是本地跑一万遍都没事,release 一上线就开始随机翻车。这种问题几乎是每个 C++ 项目都会遇到的经典坑,但它又极其擅长伪装:迭代器失效是未定义行为,不会立刻报错,只会偶尔给你一次神秘的崩溃,或者悄悄跳过某个元素,让你怀疑人生。
这篇文章就把 erase() 迭代器失效的底层逻辑完整拆一遍。为什么 vector 一删就废一片,list 却几乎不受影响,map 在 C++11 前后写法为什么不一样,以及怎样写删除循环才永远不会踩雷。不管你现在用的是 C++11、C++17 还是 C++20,不管你处理的是序列容器、关联容器还是哈希容器,下面都会有对应的结论和可直接复制的代码。
1. 先搞清楚:迭代器失效到底意味着什么
1.1 失效的本质:迭代器是“地址快照”,不是“实时导航”
很多初学者对迭代器的理解是:它像个智能指针,容器更新它会自动跟着变。错了。迭代器本质上是一个轻量的“位置快照”,保存的是某一时刻容器内部某个节点的指针、下标信息或者块偏移量。容器发生结构性修改(比如插入、删除)之后,内部的内存布局可能已经变了,但迭代器里保存的那份旧信息不会自动更新。
你可以把迭代器想象成小时候贴在同学家门上的地址条。人家搬家之后,地址条还在,但按着地址走过去,房子可能已经变成了超市,或者根本不存在了。容器就是那个搬家的家庭,迭代器就是过期的地址条。C++ 标准里所有“迭代器失效”的描述,本质上都在说同一件事:容器结构变了,但你的“地址条”没有变,继续用它就属于未定义行为。
这里有一个关键认知:迭代器失效不代表程序立刻崩溃。它可能解引用出一个旧值、一个被复用的内存块、一段早已被释放的堆内存,甚至在某些 debug 模式下抛一个断言。更麻烦的是,release 模式下编译器可能做各种优化,让本来“好像还能用”的失效迭代器产生完全不确定的结果。所以“我试了一下它能用”根本不代表安全,它只是 UB 没来得及爆发而已。
1.2 迭代器、指针、引用:三个层次的失效关系
很多资料把迭代器失效和指针失效混为一谈,实际它们虽然经常同步发生,但存在三个层次。第一层是迭代器失效:意味着这个迭代器对象不能再去解引用、自增、比较,也不能传给需要有效迭代器的算法。第二层是指针失效:T* 指向的那块内存可能已经不属于容器或已经被释放。第三层是引用失效:T& 绑定在原来的元素对象上,如果这个对象被删除或者被搬到别处,引用就成了悬空引用。
对于不同类型容器,这三者失效的步调不太一致。比如 vector 的 erase(),三个层次几乎会同时按相同范围失效,因为连续内存上元素搬移等于所有后续位置的“地址”都不准了。list 则是三者都只针对被删节点,其他节点指针、引用都完全不受影响。deque 更
