前言
std::vector是 C++ 标准库中最常用的序列容器。它在堆上维护一段连续存储,支持随机访问、尾部快速插入删除,并在容量不足时自动扩容。连续存储意味着它可以和 C 风格数组、系统调用、图形 API 直接对接——data()返回的指针可以传给任何需要连续内存的接口。这也是它比std::list、std::deque更常被选作默认容器的原因。
围绕vector有三类典型误解。第一,以为「扩容是按固定倍数进行的」——标准只要求push_back的均摊复杂度为常数,具体增长策略由实现决定,常见实现取 1.5 倍或 2 倍,但这不是标准保证。第二,以为「迭代器只在push_back时失效」——插入、删除、reserve、resize都可能让迭代器、指针、引用失效,规则需要逐条记。第三,以为vector<bool>和普通vector<T>一样——它是对bool的特化,按位存储,operator[]返回代理对象,拿不到bool*。
本文按内存模型、常用操作、迭代器失效、性能实践四部分展开,示例以 C++17 为基准,涉及 C++20 的地方会单独标注。
一、内存模型:size 与 capacity
vector内部通常有三个指针:起始、末尾、容量末尾。size()是已构造元素个数,capacity()是当前分配的空间能容纳的元素个数,二者之间是「已分配但未构造」的区域。理解这一点,才能理解为什么clear()不释放内存、为什么reserve()不改变元素个数。
// 适用于 C++17
#include <iostream>
#include <vector>
int main() {
std::vector<int> v;
std::cout << v.size() << ' ' << v.capacity() << '\n'; // 0 0
v.reserve(10); // 只改容量,不构造元素
std::cout << v.size() << ' ' << v.capacity() << '\n'; // 0 10
v.push_back(1);
v.push_back(2);
std::cout << v.size() << ' ' << v.capacity() << '\n'; // 2 10
v.resize(5); // 新增元素值初始化为 0
std::cout << v.size() << ' ' << v.capacity() << '\n'; // 5 10
v.clear(); // 销毁元素,容量不变
std::cout << v.size() << ' ' << v.capacity() << '\n'; // 0 10
return 0;
}resize(n)会改变元素个数:变小时销毁尾部元素,变大时追加值初始化的元素(对int即 0);resize(n, value)则用指定值填充。reserve(n)只在n大于当前容量时重新分配,它不会缩短容量,也不会构造元素。shrink_to_fit()是「请求」把容量降到与大小匹配,标准明确说它是非强制性的(non-binding),实现可以忽略;即使生效,也会导致重新分配和迭代器失效。
扩容时,vector需要申请更大的块,把现有元素搬过去,再释放旧块。搬移使用移动构造(如果元素的移动构造是noexcept或类型不可拷贝),否则使用拷贝构造,以保证强异常安全。这就是为什么自定义类型的移动构造函数应当标记noexcept:否则扩容时可能退化为逐个拷贝。标准不规定增长因子,也不保证扩容后容量恰好是原来的某个倍数;依赖具体数值的代码不可移植。
| 操作 | 是否改变 size | 是否可能重新分配 | 复杂度 |
|---|
reserve(n) | 否 | 是(n 大于容量时) | 至多线性 |
resize(n) | 是 | 是 | 线性 |
clear() | 是(变为 0) | 否 | 线性(销毁元素) |
shrink_to_fit() | 否 | 可能 | 线性 |
push_back | 是(加一) | 可能 | 均摊常数 |
pop_back | 是(减一) | 否 | 常数 |
二、常用操作与边界检查
构造方式很多:默认构造、指定个数、指定个数加初值、初始化列表、从迭代器区间构造、拷贝/移动构造。C++17 起还有类模板实参推导(CTAD),可以写std::vector v{1, 2, 3};让编译器推导元素类型,但要注意初始化列表与「个数加值」的歧义:std::vector<int> v(3, 5)是三个 5,std::vector<int> v{3, 5}是两个元素 3 和 5,圆括号与花括号语义不同。
访问元素有几种方式。operator[]不做边界检查,下标越界是未定义行为;at()做检查,越界抛std::out_of_range;front()/back()要求容器非空,对空容器调用是未定义行为;data()返回指向首元素的指针(C++11 起),空容器时返回值不保证可解引用。调试阶段可以用at()或开启标准库的调试模式,发布版本再按性能需要选择。
// 适用于 C++17
#include <iostream>
#include <stdexcept>
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
try {
std::cout << v.at(1) << '\n'; // 20
std::cout << v.at(3) << '\n'; // 抛 std::out_of_range
} catch (const std::out_of_range& e) {
std::cout << "out of range: " << e.what() << '\n';
}
if (!v.empty()) {
std::cout << v.front() << ' ' << v.back() << '\n';
}
return 0;
}插入与删除集中在中间时,vector需要搬移元素,复杂度是线性的。insert(pos, value)返回指向新元素的迭代器,erase(pos)返回被删元素之后元素的迭代器(C++11 起)。批量删除满足条件的元素,惯用法是「擦除—移除」(erase-remove idiom):
// 适用于 C++17
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3, 4, 5, 6, 7, 8};
// 删除所有偶数
v.erase(std::remove_if(v.begin(), v.end(),
[](int x) { return x % 2 == 0; }),
v.end());
for (int x : v) {
std::cout << x << ' ';
}
std::cout << '\n';
return 0;
}std::remove_if并不删除元素,它把保留的元素前移,返回新的逻辑末尾;真正的删除由erase完成。C++20 起可以直接用std::erase_if(v, pred),一行完成,且对容器大小处理更自然;使用前要确认工具链支持 C++20。
emplace_back与push_back的区别在于前者把参数完美转发给元素类型的构造函数,在容器内直接构造;后者接受一个已构造的对象(或可转换的值),再拷贝或移动进去。对于std::string这类类型,emplace_back(5, 'a')可以省掉临时对象。但emplace_back也可能匹配到非预期的构造函数,参数含义要看目标类型的构造函数集合。
三、迭代器、指针与引用的失效规则
vector的迭代器本质上是指针(或指针的包装),它指向内部缓冲区。一旦缓冲区重新分配,所有迭代器、指针、引用都会悬垂。即使没有重新分配,插入或删除也会让插入/删除点之后的迭代器失效,因为元素被搬移了。
| 操作 | 失效范围 |
|---|
push_back/emplace_back,未扩容 | 尾后迭代器end();元素迭代器仍有效 |
push_back/emplace_back,发生扩容 | 全部迭代器、指针、引用 |
insert,未扩容 | 插入点及之后的迭代器 |
insert,发生扩容 | 全部 |
erase | 删除点及之后的迭代器 |
reserve,容量变化 | 全部 |
resize,发生扩容 | 全部 |
clear | 全部元素迭代器 |
swap | 迭代器仍有效,但指向另一个容器中的元素 |
一个经典错误是在循环里缓存end():
// 错误示范:适用于 C++17,但逻辑有缺陷
std::vector<int> v{1, 2, 3};
auto endIt = v.end();
for (auto it = v.begin(); it != endIt; ++it) {
if (*it == 2) {
v.push_back(4); // 可能扩容,endIt 失效
}
}如果push_back触发扩容,endIt悬垂,比较或自增都是未定义行为。正确做法是每次重新取v.end()。另一种常见错误是在范围 for 循环中删除元素:
// 错误示范:erase 后迭代器失效
for (auto it = v.begin(); it != v.end(); ++it) {
if (*it < 0) {
v.erase(it); // it 失效,随后 ++it 是 UB
}
}erase返回下一个有效迭代器,应写成it = v.erase(it);,且删除时不要额外自增;或者用 erase-remove 惯用法一次性处理。范围 for 循环内部持有迭代器,循环体中修改容器大小同样可能导致失效,除非你能保证不触发重新分配且只修改当前元素。
四、性能与实践建议
第一,已知规模时先reserve。这能避免多次扩容与搬移,但不要为了「省一次分配」而预留远超实际需要的容量,内存也是资源。第二,优先emplace_back构造就地生成对象,尤其是元素类型构造代价高时;但不要把它当成绝对规则,语义清晰更重要。第三,删除大量元素用 erase-remove 或 C++20 的erase_if,不要在循环里逐个erase,后者是 O(n²)。第四,传递只读参数用const std::vector<T>&,需要拷贝时按值传递并std::move,不要既传引用又在内部拷贝。第五,与 C 接口交互时用data()和size(),注意空容器时data()可能返回空指针。
vector<bool>需要单独提醒。标准对bool做了特化,按位压缩存储以节省空间,代价是operator[]返回代理对象(proxy reference),不能取地址得到bool*,也不满足「连续存放bool元素」的通常预期。它不提供data()成员。若需要与 C 接口交互或需要真正的bool数组,用std::vector<char>、std::vector<unsigned char>或std::bitset(长度固定时)。vector<bool>本身不是错误,但要知道它的特殊性。
关于性能,不要相信「vector一定比链表快」或「预留一定更快」这类绝对结论。vector的优势来自连续内存带来的缓存局部性、随机访问和较低的单元素开销;劣势是中间插入删除的搬移成本。选择容器应基于访问模式与规模,性能数字要在自己的数据与工具链上测量(例如用std::chrono::steady_clock计时,注意预热、优化等级和测量噪声),而不是套用别人的倍数。
常见坑点
❌ 用operator[]访问可能越界的下标,并期待抛异常。 ✅ 需要检查时用at();确认下标合法的热路径再用operator[]。
❌ 认为clear()会释放内存。 ✅clear()只销毁元素;要释放容量用shrink_to_fit()(非强制)或与空容器交换。
❌ 在循环中缓存end(),循环体内又push_back。 ✅ 每次重新取v.end(),或在循环前reserve保证不扩容(仍需谨慎)。
❌erase(it)后继续++it。 ✅ 用it = v.erase(it);,删除分支不再自增。
❌ 在范围 for 中改变容器大小。 ✅ 改用迭代器循环并正确处理返回值,或先收集下标再统一删除。
❌ 依赖扩容倍数或扩容后的确切容量。 ✅ 只依赖标准保证的均摊复杂度和迭代器失效规则;需要确定容量就显式reserve。
❌ 对vector<bool>取元素地址或传给需要bool*的接口。 ✅ 改用std::vector<char>或std::bitset,或接受代理对象的限制。
❌ 自定义类型有移动构造却没标noexcept,导致扩容时拷贝。 ✅ 不抛异常的移动构造标noexcept,让容器选择移动路径。
总结
| 主题 | 关键结论 |
|---|
| 存储 | 连续内存,随机访问 O(1),尾部插入均摊 O(1) |
| size/capacity | size是元素数,capacity是已分配容量 |
| 扩容 | 标准不规定增长因子;扩容使全部迭代器失效 |
| 中间操作 | insert/erase为 O(n),并使插入/删除点之后失效 |
| 删除惯用法 | erase-remove;C++20 可用std::erase_if |
| 特化 | vector<bool>按位存储,返回代理引用 |
std::vector的 API 不复杂,难的是把「容量、元素个数、迭代器有效期」三件事分开想。写插入删除逻辑时先判断会不会扩容,再判断哪些迭代器还能用;需要稳定地址时考虑std::deque或std::list,但不要为了「怕失效」放弃vector的局部性优势。把reserve、erase-remove、at()与operator[]的边界用对,绝大多数问题都能在编译期或测试期暴露。