一、STL容器安全:从一次线上事故说起
我记得刚带团队那会儿,有个同事半夜打电话把我叫醒——线上服务挂了。查了半天,问题出在一个std::vector的迭代器上。他在遍历时往容器里插入了新元素,迭代器全部失效,程序直接崩溃。
嗯,STL容器看着简单,用起来坑不少。今天我就把这些年踩过的坑、总结的经验,一次性说清楚。
1.1 vector:动态数组的“暗箭”
std::vector是我用得最多的容器。它连续存储,随机访问快,但有两个致命弱点:
- 插入/删除导致迭代器失效
- 内存重新分配时所有迭代器、引用、指针全部失效
核心规则:任何可能改变vector容量的操作(push_back、insert、resize等),都会使所有迭代器失效。
来看一个我项目中真实遇到的代码:
std::vector<int> vec = {1, 2, 3, 4, 5};
for (auto it = vec.begin(); it != vec.end(); ++it) {
if (*it == 3) {
vec.insert(it, 100); // 危险!迭代器it失效
}
}
这段代码跑起来,大概率是段错误。为什么?insert可能导致vector重新分配内存,原来的迭代器指向的内存已经释放了。
正确做法:
std::vector<int> vec = {1, 2, 3, 4, 5};
for (size_t i = 0; i < vec.size(); ++i) {
if (vec[i] == 3) {
vec.insert(vec.begin() + i, 100);
++i; // 跳过刚插入的元素
}
}
我的习惯:如果需要在遍历时修改vector,优先用下标索引,而不是迭代器。下标至少不会“凭空失效”。
1.2 list:双向链表的“温柔陷阱”
很多人觉得list安全——毕竟它不连续存储,插入删除不影响其他元素。这话对了一半。
list的迭代器失效规则:
| 操作 | 迭代器失效情况 |
|---|---|
| 插入(insert、push_back等) | 不影响已有迭代器 |
| 删除(erase、pop_back等) | 只有被删除元素的迭代器失效 |
| splice(接合操作) | 被移动元素的迭代器仍然有效 |
看起来list很友好对吧?但我在项目中遇到过另一种问题——迭代器指向的元素被其他代码删除了。
std::list<int> lst = {1, 2, 3, 4, 5};
auto it = std::next(lst.begin(), 2); // 指向3
// 某处代码删除了这个元素
lst.erase(it); // 正确,it指向的元素被删除
// 但如果你不知道it已经失效...
++it; // 未定义行为!
我曾经在一个多线程项目中吃过这个亏。一个线程持有迭代器,另一个线程删除了元素。排查了两天才找到原因——迭代器失效不报错,只是静默地产生错误结果。
1.3 map/set:关联容器的“删除陷阱”
map和set的迭代器失效规则和list类似:只有被删除元素的迭代器失效。但有一个经典陷阱——遍历时删除。
新手常犯的错误:
std::map<int, std::string> m = {{1, "a"}, {2, "b"}, {3, "c"}};
for (auto it = m.begin(); it != m.end(); ++it) {
if (it->first == 2) {
m.erase(it); // 危险!it失效后还执行了++it
}
}
正确写法:
for (auto it = m.begin(); it != m.end(); ) {
if (it->first == 2) {
it = m.erase(it); // erase返回下一个元素的迭代器
} else {
++it;
}
}
关键点:map的erase返回被删除元素的下一个迭代器。这个特性在C++11之后才稳定,老代码要注意。
1.4 容器元素的安全访问
访问容器元素时,我见过太多“想当然”的代码:
std::vector<int> vec;
int val = vec[0]; // 空vector,未定义行为!
std::map<int, std::string> m;
auto s = m[42]; // 如果key不存在,会插入一个默认构造的元素!
安全访问的原则:
- vector:用
at()代替operator[],越界会抛异常 - map:用
find()检查是否存在,不要直接用operator[]读取 - 所有容器:访问前检查
empty()或size()
// 安全版本
if (!vec.empty()) {
int val = vec.at(0); // 安全,越界抛std::out_of_range
}
auto it = m.find(42);
if (it != m.end()) {
auto s = it->second; // 安全
}
我个人习惯:在性能不敏感的代码中,一律用at()。只有在性能热点且确定不会越界时,才用operator[]。
1.5 自定义分配器的安全考虑
自定义分配器是STL的高级特性,用好了能提升性能,用不好就是灾难。
我见过一个项目,为了“优化性能”,写了个简单的内存池分配器:
template <typename T>
class SimplePoolAllocator {
public:
// ... 省略实现细节
T* allocate(size_t n) {
// 从预分配的内存池中返回
return pool->get(n * sizeof(T));
}
void deallocate(T* p, size_t n) {
// 不真正释放,只是标记可用
pool->mark_free(p);
}
};
这个分配器有两个安全问题:
- 线程安全:多线程同时allocate/deallocate,pool内部没有锁
- 内存泄漏:如果容器异常退出,deallocate可能不会被调用
我曾经在一个高并发服务中踩过自定义分配器的坑。分配器没处理好线程同步,导致内存错乱,数据被覆盖。从那以后,我定了个规矩:非必要不用自定义分配器,用的话必须经过严格的代码审查和压力测试。
自定义分配器的安全准则:
- 必须满足分配器要求(Allocator requirements)
- 必须支持相等比较(两个分配器能否互相释放内存)
- 必须线程安全(如果跨线程使用)
- 必须正确处理异常安全
1.6 知识体系总览
下面这张图总结了STL容器安全的核心知识点:
1.7 避坑指南:我的三条铁律
做了这么多年C++,我总结了三条约束自己的规则:
- 遍历时绝不修改容器结构——除非你清楚知道自己在做什么,并且用了正确的模式(比如map的erase返回迭代器)
- 多线程环境下,容器加锁或使用并发容器——STL容器不是线程安全的,别指望它
- 自定义分配器前,先问自己三个问题:真的需要吗?测试过性能提升吗?代码审查过了吗?
最后说一句:STL容器是C++的利器,但也是双刃剑。用好了事半功倍,用不好就是线上事故的源头。我见过太多“STL很简单”然后翻车的案例。嗯,希望这一章能帮你少踩几个坑。
公众号:蓝海资料掘金营,微信deep3321