
前言std::vector是 C 标准库容器里使用频率最高的一个它是一块连续内存上的动态数组支持随机访问、尾部摊还常数时间插入并且保证元素连续存放C11 起data()与该保证一起进入标准因此可以安全地传给接受指针加长度的 C 接口。围绕它有三个高频误解。第一个是push_back是 O(1)——准确说法是摊还amortizedO(1)绝大多数次是 O(1)但触发扩容的那一次要搬运全部元素是 O(n)。第二个是有了iterator就可以一直用——恰恰相反任何可能触发扩容的操作都会让此前拿到的所有迭代器、指针、引用失效这是 vector 相关 bug 的头号来源。第三个是把std::vectorbool当普通 vector 用——它是标准库里唯一的容器特化operator[]返回的不是bool而是代理对象data()在该特化中根本不存在。本文先讲清构造、容量、访问三组接口再讲扩容机制与迭代器失效规则明确区分标准规定和各家实现的取舍最后用一个简化的模拟实现把为什么移动构造的noexcept会影响性能说透。代码以 C17 为基准GCC 13 / Clang 17 / MSVC 19.3x 均可编译。一、构造与初始化三组容易混淆的重载std::vector的构造函数有好几个重载其中(size_type n, const T value)与(std::initializer_listT)的竞争是新手翻车重灾区。#include iostream #include vector int main() { std::vectorint a; // 空 std::vectorint b(3); // 3 个值初始化的 int即 {0, 0, 0} std::vectorint c(3, 7); // 3 个 7即 {7, 7, 7} std::vectorint d{3}; // ⚠️ 1 个元素值为 3 std::vectorint e{3, 7}; // 2 个元素{3, 7} std::vectorint f(d.begin(), d.end()); // 区间拷贝即 {3} std::vectorint g {1, 2, 3}; // 拷贝列表初始化 std::cout b.size() c.size() d.size() e.size() \n; // 3 3 1 2 return 0; }关键规则是只要花括号里的元素都能转换成元素类型initializer_list构造函数就会获胜。所以d{3}是含一个元素 3 的列表而不是三个默认元素想表达后者必须用小括号d(3)。一个有用的边界情况如果花括号里的内容无法转换成元素类型initializer_list版本就从重载集合里消失调用会退回到其他构造函数。例如元素类型是std::string时整数 3 转不过去因此std::vectorstd::string v{3}得到的是 3 个空字符串。这个规则很反直觉工程上的建议是构造 vector 时统一用圆括号表示个数用花括号表示内容清单不要混用。另外C17 起支持从同类型vector的推导指引CTAD写std::vector v{1, 2, 3}就能推导出std::vectorintC14 及以前必须写全模板参数。二、容量接口size、capacity、reserve、shrink_to_fit这四个接口的区别是理解 vector 性能的关键接口含义会影响size()会影响capacity()复杂度size()已构造的元素个数——O(1)capacity()当前已分配内存能容纳的元素个数否—O(1)reserve(n)请求容量至少为 n若 n 不大于当前容量则什么都不做否可能增大至多 O(size)resize(n)把元素个数改成 n多出的部分值初始化是可能增大O(\shrink_to_fit()请求非强制把容量缩到 size否可能减小至多 O(size)clear()销毁所有元素是变 0不变O(size)reserve是唯一一个只动容量、不动元素个数的接口这是它最有价值的地方#include iostream #include vector int main() { std::vectorint v; v.reserve(1000); std::cout v.size() v.capacity() \n; // 0 1000至少 1000 for (int i 0; i 1000; i) { v.push_back(i); // 全程不会再分配内存 } return 0; }注意capacity()打印出来的是至少 1000标准只要求capacity() 1000实现可以给得更多实际输出以你的实现为准。shrink_to_fit()是个非强制请求non-binding request标准用的是may实现可以忽略它。想强制释放内存可移植的写法是std::vectorint().swap(v)。关于扩容因子标准没有规定按什么比例扩容这是实现定义的。libstdc 与 libc 约翻倍max(size(), n)那一档MSVC STL 用的约 1.5 倍size() size() / 2。这就是每隔几年就有人争论 1.5 还是 2的原因——它不是标准问题而是各家 STL 的选择。三、元素访问与迭代器失效规则访问接口按是否检查边界分成两组接口边界检查越界行为备注v[i]无未定义行为UB最快只做一次指针加法v.at(i)有抛std::out_of_range需要stdexceptv.front()无空容器上调用是 UB等价于*v.begin()v.back()无空容器上调用是 UB等价于*(v.end() - 1)v.data()——C11 起返回T*受特化影响见下std::vectorbool是唯一的例外它为了做位压缩operator[]返回的是代理对象std::vectorbool::reference不是bool所以bool* p vb[0];无法编译它也没有data()成员。需要真正连续的bool数组时用std::vectorchar、std::dequebool或std::arraybool, N。接下来是迭代器失效规则——这是本文最需要背下来的部分操作迭代器 / 指针 / 引用是否失效push_back/emplace_back未触发扩容仅end()迭代器失效其余指向已有元素的都仍有效push_back/emplace_back触发扩容全部失效元素已被搬到新缓冲区insert/emplace中间位置插入点及其之后的所有迭代器失效触发扩容则全部失效erase中间位置删除点及其之后的所有迭代器失效end()也会变resize增大触发扩容则全部失效否则仅end()失效reserve请求值大于当前容量全部失效reserve请求值不大于当前容量不失效clear()全部失效元素已销毁capacity()不变swap标准规定不使任何迭代器或引用失效但end()是例外实践中不要缓存它典型的踩一脚写法是遍历中删除元素❌for (auto it v.begin(); it ! v.end(); it) { if (*it % 2 0) v.erase(it); }——erase之后it已失效it是对失效迭代器操作UB✅ 用erase返回的、指向被删元素之后那个元素的有效迭代器续上it v.erase(it)且不要再额外自增完整写法见坑 7。C20 起可以直接用std::erase/std::erase_if自由函数一行搞定C17 及以前请用it v.erase(it)这个惯用法。四、从原理看为什么移动构造是 noexcept会影响 vector 的性能这一节用一个简化的MyVector把关键机制演示出来。它只覆盖最核心的路径但每一行都是可编译的。#include cstddef #include iostream #include new // 放置 new 与 ::operator new #include stdexcept // std::out_of_range #include string #include utility // std::move、std::move_if_noexcept template typename T class MyVector { public: MyVector() noexcept default; MyVector(MyVector rhs) noexcept // 移动构造O(1)不搬元素 : data_(rhs.data_), size_(rhs.size_), cap_(rhs.cap_) { rhs.data_ nullptr; rhs.size_ 0; rhs.cap_ 0; } ~MyVector() { clear_and_free(); } void reserve(std::size_t new_cap) { if (new_cap cap_) return; T* new_data static_castT*(::operator new(new_cap * sizeof(T))); std::size_t moved 0; try { for (; moved size_; moved) { // 关键T 的移动构造若为 noexcept 就移动否则退化为拷贝 ::new (static_castvoid*(new_data moved)) T(std::move_if_noexcept(data_[moved])); } } catch (...) { for (std::size_t i 0; i moved; i) new_data[i].~T(); ::operator delete(new_data); throw; // 强异常安全旧缓冲区原封不动 } for (std::size_t i 0; i size_; i) data_[i].~T(); ::operator delete(data_); data_ new_data; cap_ new_cap; } void push_back(const T value) { if (size_ cap_) reserve(grow_capacity()); ::new (static_castvoid*(data_ size_)) T(value); size_; // 只有构造成功才自增失败自动回滚 } void push_back(T value) { if (size_ cap_) reserve(grow_capacity()); ::new (static_castvoid*(data_ size_)) T(std::move(value)); size_; } T operator[](std::size_t i) noexcept { return data_[i]; } T at(std::size_t i) { if (i size_) throw std::out_of_range(MyVector::at); return data_[i]; } std::size_t size() const noexcept { return size_; } std::size_t capacity() const noexcept { return cap_; } T* begin() noexcept { return data_; } T* end() noexcept { return data_ size_; } private: // 每次至少翻倍这里的 2 是 libstdc/libc 的取法MSVC STL 约为 1.5 倍 std::size_t grow_capacity() const { return cap_ 0 ? 1 : cap_ * 2; } void clear_and_free() noexcept { for (std::size_t i 0; i size_; i) data_[i].~T(); ::operator delete(data_); // 传 nullptr 是合法的空操作 data_ nullptr; size_ 0; cap_ 0; } T* data_ nullptr; std::size_t size_ 0; std::size_t cap_ 0; }; int main() { MyVectorstd::string v; v.push_back(alpha); v.push_back(beta); v.push_back(gamma); std::cout v.size() v.capacity() \n; // 3 4翻倍的结果 MyVectorstd::string moved(std::move(v)); // 移动构造O(1) std::cout moved.size() v.size() \n; // 3 0 return 0; }这段实现里有三处值得展开第一std::move_if_noexcept是性能的关键。如果T的移动构造不是noexcept扩容搬运途中抛异常时源容器已被改乱、新缓冲区也只构造了一部分标准给vector的强异常安全保证就守不住了。所以它的规则是T的移动构造是noexcept或T没有可用的拷贝构造时返回右值引用否则返回const T退化成拷贝。这就是那条性能建议的由来——给移动构造加noexcept是为了让std::vector以及std::string扩容时真的用上移动。第二push_back里size_的位置决定了异常安全等级。放置 new 若抛异常元素没构造成功size_也就没被自增容器状态完全等同于调用前——这就是强异常安全保证。若把size_写在构造之前抛异常后容器里会留下一个已计数但未构造的槽位析构时对它调用~T()就是 UB。第三at与operator[]的取舍。上面的operator[]标了noexcept它只做一次指针加法和解引用但它不做边界检查越界是 UB标准库的std::vector::operator[]并没有标noexcept这点不要去对标。另外这个简化实现没有处理过对齐类型over-aligned type对alignas(64)这类类型::operator new(size)不保证返回 64 字节对齐的地址正确做法是 C17 的::operator new(size, std::align_val_t)。真实std::vector走std::allocatorTC17 起会感知对齐要求并自动用上对齐版本。常见坑点坑 1花括号初始化与圆括号初始化搞混。❌std::vectorint v{3};是 1 个值为 3 的元素不是 3 个默认元素✅ 个数用圆括号v(3)内容清单用花括号v{1, 2, 3}。坑 2扩容后继续使用旧迭代器 / 指针 / 引用。❌int first v[0]; v.push_back(4); first 100;——push_back可能扩容first变成悬垂引用写它是 UB✅ 换过缓冲区就重新取v[0]。一个更容易漏掉的版本是for (auto x : v) { if (...) v.push_back(...); }——循环变量x在扩容后就是悬垂引用在 range-for 里改容器尺寸是禁止的。坑 3reserve之后直接用下标访问还没构造的元素。❌v.reserve(10); v[0] 42;是 UB——reserve只分配内存、不构造元素size()仍是 0✅ 先用v.resize(10)会值初始化或push_back把元素真正建出来。坑 4把std::vectorbool当普通容器。❌bool* p vb[0];和bool* q vb.data();都无法编译operator[]返回代理对象、特化里没有data()✅ 需要真正的bool数组就改用std::vectorchar它是普通 vectordata()正常返回。坑 5clear()之后以为内存被释放了。❌ 错误预期clear()只销毁元素capacity()仍是 1000标准规定它不改变容量✅ 真要释放可移植的强制写法是std::vectorint().swap(v)而shrink_to_fit()只是非强制请求。坑 6往vector里存基类多态没了——对象切片slicing。❌std::vectorBase v; v.push_back(d);只会拷贝对象的 Base 部分虚表也变成 Base 的v[0].id()调不到派生类的实现✅ 存一层间接例如std::vectorstd::unique_ptrBase w; w.push_back(std::make_uniqueDerived());多态就能保住。坑 7erase用错返回值。// ❌ for (auto it v.begin(); it ! v.end(); it) // if (cond(*it)) v.erase(it); // erase 后再 it对失效迭代器自增UB for (auto it v.begin(); it ! v.end(); ) { // ✅ 不要再自增 if (cond(*it)) it v.erase(it); // erase 返回下一个有效位置 else it; }总结主题要点复杂度随机访问 O(1)push_back/pop_back摊还 O(1)中间插入删除 O(n)扩容因子实现定义。libstdc / libc 约 2 倍MSVC STL 约 1.5 倍标准不作规定扩容搬运用什么由std::move_if_noexcept决定移动构造noexcept就移动否则拷贝强异常安全push_back在元素构造失败时容器不变代价是size_必须在构造成功后才自增迭代器失效触发扩容 全部失效不触发扩容 只有end()失效erase使删除点及其之后失效reserve只增容量、不改 size越界写未构造的元素是 UBclear清空元素但不释放容量强制释放用std::vectorT().swap(v)vectorbool唯一的容器特化operator[]返回代理对象无data()成员三句话收尾构造看括号圆括号给个数、花括号给内容扩容防失效可能扩容的操作之后一切旧迭代器作废预留省搬运知道规模就先reserve。至于扩容因子是 1.5 还是 2那是各家 STL 的取舍不是你在代码里能承诺的东西。