27、STL陷阱与最佳实践:迭代器失效场景汇总、erase在循环中的正确用法、reserve预分配、string的COW与SSO、实战:代码审查清单
STL 用起来很爽,但坑也不少。我见过太多人因为迭代器失效而 debug 到崩溃。今天咱们就把这些坑一个个填平。
迭代器失效:你踩过哪些雷?
迭代器失效,说白了就是迭代器指向的内容变了,或者内存被回收了。你再拿它去访问,轻则读到脏数据,重则直接崩溃。
我总结了一张表,把常见容器的失效场景列出来:
| 容器 | 插入操作 | 删除操作 | 重新分配 |
|---|---|---|---|
| vector / string | 所有迭代器失效(可能重新分配) | 被删元素及之后迭代器失效 | 全部失效 |
| deque | 所有迭代器失效(中间插入) | 被删元素及附近迭代器失效 | 全部失效 |
| list / forward_list | 迭代器不失效 | 仅被删元素迭代器失效 | 不适用 |
| map / set / unordered_map | 迭代器不失效 | 仅被删元素迭代器失效 | 不适用 |
嗯,这里要注意:vector 的插入操作,只要导致容量不够,就会触发重新分配。一旦重新分配,所有迭代器、指针、引用全部失效。我在项目中遇到过有人把 vector 的迭代器存起来,后面插入几个元素后再用,结果程序直接挂掉。
erase 在循环中的正确用法
这是面试高频题,也是实战高频坑。很多人写循环删除时,会写出这样的代码:
// 错误示例
std::vector<int> v = {1, 2, 3, 4, 5};
for (auto it = v.begin(); it != v.end(); ++it) {
if (*it % 2 == 0) {
v.erase(it); // 迭代器 it 失效了!
}
}
为什么会这样?因为 erase 之后,it 指向的元素被删除了,后面的元素往前移动。你再 ++it,可能跳过元素,也可能访问到无效内存。
正确的写法是利用 erase 的返回值:
// 正确示例
std::vector<int> v = {1, 2, 3, 4, 5};
for (auto it = v.begin(); it != v.end(); ) {
if (*it % 2 == 0) {
it = v.erase(it); // erase 返回下一个有效迭代器
} else {
++it;
}
}
我个人习惯用 std::remove_if 配合 erase,也就是 erase-remove 惯用法:
v.erase(std::remove_if(v.begin(), v.end(),
[](int x) { return x % 2 == 0; }), v.end());
这样写更清晰,而且性能更好——它只移动一次元素,而不是每次删除都移动。
reserve 预分配:别让 vector 频繁搬家
vector 的动态扩容是个性能杀手。每次容量不够时,它会重新分配一块更大的内存,然后把所有元素搬过去。这个过程是 O(n) 的。
我举个例子:
std::vector<int> v;
for (int i = 0; i < 1000000; ++i) {
v.push_back(i); // 可能触发多次重新分配
}
这段代码可能会触发 20 次左右的重新分配。每次分配都要拷贝所有已有元素。你想想看,这得多慢?
解决办法很简单:
std::vector<int> v;
v.reserve(1000000); // 提前分配好空间
for (int i = 0; i < 1000000; ++i) {
v.push_back(i); // 不会触发重新分配
}
我曾经在一个数据处理模块里,用 reserve 把插入 100 万条记录的时间从 2.3 秒降到了 0.4 秒。性能提升非常明显。
string 的 COW 与 SSO:两个经典优化
string 的实现有很多讲究。两个最著名的优化是 COW 和 SSO。
COW(Copy-On-Write,写时复制):多个 string 对象共享同一块内存,只有当某个对象要修改内容时,才真正复制一份。这个策略在 C++11 之前很流行,但 C++11 之后基本被废弃了。为什么?因为多线程环境下,COW 的引用计数需要原子操作,性能反而下降。而且 COW 会导致迭代器失效的语义变得复杂。
SSO(Small String Optimization,小字符串优化):对于短字符串,string 直接存在栈上,不分配堆内存。这个优化现在很普遍。不同编译器的阈值不同,GCC 是 15 个字符,MSVC 是 16 个字符。
我测试过,在 MSVC 下:
std::string s1 = "hello"; // SSO,无堆分配
std::string s2 = "hello world!!!"; // 超过阈值,堆分配
SSO 的好处很明显:小字符串的操作非常快,没有堆分配的开销。但要注意,如果你频繁构造和销毁长字符串,堆分配的开销还是很大的。
实战:代码审查清单
最后,我整理了一份 STL 代码审查清单。每次 review 代码时,我都会对照着看一遍:
- 迭代器是否可能失效?——检查插入/删除操作后是否继续使用旧迭代器。
- erase 循环是否正确?——检查是否使用了 erase 的返回值。
- 是否用了 reserve?——如果知道元素数量,却没有 reserve,打回去重写。
- 是否用了 shrink_to_fit?——如果 vector 长期闲置,却占用大量内存,建议释放。
- string 的 COW 依赖?——检查是否假设了 string 的写时复制行为。
- 是否用了 std::remove_if 而不是手写循环?——手写循环容易出错,能用算法就用算法。
- 是否在循环中调用了 size()?——如果 size() 是 O(1) 的还好,但有些容器不是。
- 是否用了 emplace_back 而不是 push_back?——emplace_back 可以避免临时对象的构造和拷贝。
这份清单我用了好几年,帮团队避免了不少线上事故。你可以直接拿去用。