一、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);
    }
};

这个分配器有两个安全问题:

  1. 线程安全:多线程同时allocate/deallocate,pool内部没有锁
  2. 内存泄漏:如果容器异常退出,deallocate可能不会被调用

我曾经在一个高并发服务中踩过自定义分配器的坑。分配器没处理好线程同步,导致内存错乱,数据被覆盖。从那以后,我定了个规矩:非必要不用自定义分配器,用的话必须经过严格的代码审查和压力测试。

自定义分配器的安全准则:

  • 必须满足分配器要求(Allocator requirements)
  • 必须支持相等比较(两个分配器能否互相释放内存)
  • 必须线程安全(如果跨线程使用)
  • 必须正确处理异常安全

1.6 知识体系总览

下面这张图总结了STL容器安全的核心知识点:

STL容器安全 vector list map / set 自定义分配器 迭代器失效 内存重分配 at() vs [] splice安全 删除迭代器 遍历时删除 find vs [] 线程安全 异常安全 相等比较要求 核心原则:操作前确认迭代器有效性 访问前检查容器是否为空

1.7 避坑指南:我的三条铁律

做了这么多年C++,我总结了三条约束自己的规则:

  1. 遍历时绝不修改容器结构——除非你清楚知道自己在做什么,并且用了正确的模式(比如map的erase返回迭代器)
  2. 多线程环境下,容器加锁或使用并发容器——STL容器不是线程安全的,别指望它
  3. 自定义分配器前,先问自己三个问题:真的需要吗?测试过性能提升吗?代码审查过了吗?

最后说一句:STL容器是C++的利器,但也是双刃剑。用好了事半功倍,用不好就是线上事故的源头。我见过太多“STL很简单”然后翻车的案例。嗯,希望这一章能帮你少踩几个坑。


公众号:蓝海资料掘金营,微信deep3321