容器、迭代器与内存失效 / Containers, Iterators, and Invalidation
很多 C++ 内存错误并不来自手写
new/delete。它们来自容器内部搬家之后,你还拿着旧地址、旧引用、旧迭代器。
1. vector 的对象本体和元素缓冲区
看代码:
#include <vector>
int main() {
std::vector<int> values{1, 2, 3};
}values 这个对象本体通常在当前作用域中。
但元素缓冲区通常是动态分配的。
stack frame
+------------------------------+
| values object |
| begin ----+ |
| end ----|----+ |
| cap ----|----|----+ |
+------------|----|----|-------+
| | |
v v v
heap +------------------+
| 1 | 2 | 3 | ... |
+------------------+vector 的核心承诺是元素连续存放。
这让它访问很快,也适合和 C API 交互。
但连续存放也带来一个问题:容量不够时,原地扩展不一定可能。
2. 扩容时发生什么
看代码:
#include <vector>
#include <iostream>
int main() {
std::vector<int> values;
values.push_back(1);
int* p = &values[0];
values.push_back(2);
values.push_back(3);
values.push_back(4);
std::cout << *p << '\n';
}如果后面的 push_back 导致扩容,过程大致是:
old buffer
+-----+
| 1 |
+-----+
^
|
p
vector needs more capacity
|
v
allocate new buffer
|
v
move/copy old elements
|
v
destroy old elements
|
v
release old buffer
new buffer
+-----+-----+-----+-----+
| 1 | 2 | 3 | 4 |
+-----+-----+-----+-----+
p still points to old released buffer所以 values 还活着,不代表 p 还有效。
3. 引用和迭代器也会失效
不仅指针会失效。
引用也会:
std::vector<int> values{1, 2, 3};
int& first = values[0];
values.push_back(4);
first = 10; // 如果扩容发生,可能危险迭代器也会:
auto it = values.begin();
values.push_back(5);
// it 可能已经失效迭代器可以理解成容器里的“位置对象”。
但它不是永久门牌号。
容器内部结构变化后,旧位置对象可能不再合法。
4. reserve 能减少扩容
如果你提前知道大概元素数量,可以用:
std::vector<int> values;
values.reserve(1000);reserve 的意思是:
提前准备至少能容纳这么多元素的容量。它不改变 size(),只影响 capacity()。
std::vector<int> values;
values.reserve(10);
std::cout << values.size() << '\n'; // 0
std::cout << values.capacity() << '\n'; // 至少 10reserve 不能让所有失效问题消失。
但在批量插入场景,它能减少重新分配次数,也能减少旧指针失效的机会。
5. string 也有类似问题
std::string 也可能管理动态字符缓冲区。
#include <string>
std::string s = "abc";
const char* p = s.c_str();
s += "defghijklmnopqrstuvwxyz";
// p 可能已经失效c_str() 返回的是指向字符串内部字符存储的指针。
只要字符串修改导致重新分配,旧指针就不能继续依赖。
正确习惯是:
不要长期保存 string 内部指针。
需要时临时获取。
修改 string 后重新获取。6. span 和 string_view 是非拥有视图
std::span<T> 表示一段连续对象的视图。
std::string_view 表示一段字符的视图。
它们通常只保存:
起始地址
长度它们不拥有底层对象。
所以:
#include <string>
#include <string_view>
std::string_view bad() {
std::string s = "hello";
return s;
}返回后 s 已经析构。
string_view 只剩旧地址和长度。
它不会让字符串继续活着。
7. map/list/deque 的失效规则不同
不同容器的内部结构不同。
所以失效规则也不同。
学习阶段可以先记住方向:
vector/string
连续存储,扩容会搬家,旧指针/引用/迭代器容易失效
deque
分段连续,规则比 vector 复杂
list
节点式结构,插入删除通常不搬其他节点,但局部性差
map/unordered_map
节点或桶结构,插入删除和 rehash 会影响迭代器规则不要靠感觉猜。
写重要代码时,要查具体容器的 iterator invalidation 规则。
8. 更稳的写法
少长期保存内部地址:
// 容易出问题
int* p = &values[0];
modify(values);
use(*p);改成保存下标:
std::size_t index = 0;
modify(values);
use(values[index]);保存下标也不是万能。
如果元素被删除,或者顺序改变,下标含义也会变。
但它至少不会指向释放后的旧缓冲区。
另一种方式是把对象放进稳定拥有结构中:
std::vector<std::unique_ptr<Widget>> widgets;vector 扩容时会移动 unique_ptr 对象。
但每个 Widget 自身仍在各自的动态存储位置上。
这可以让指向 Widget 的裸观察指针更稳定一些。
不过它也带来额外动态分配和缓存局部性成本。
9. 本篇总结
容器内存问题的核心是:
容器对象活着
!=
旧指针活着
!=
旧引用有效
!=
旧迭代器有效判断时问:
容器是否可能重新分配?
元素是否可能被移动?
旧位置对象是否仍然有效?
我保存的是 owner,还是内部观察入口?容器是 RAII 的好例子。
它帮你释放资源。
但它不能保证你保存的每个旧地址永远有效。