vector 扩容策略与迭代器失效全解
std::vector是 C 里最常用的容器也是踩坑最多的容器size()和capacity()为什么不是一个东西、push_back会不会让手里的迭代器失效、reserve到底省了什么。这些问题的答案都指向同一张图vector 内部其实只有三个指针。这篇从这张图出发用真跑出来的地址变化和拷贝次数把扩容、reserve、迭代器失效讲透。官方文档std::vector (cppreference)1. 引子一个「指针突然作废」的程序先看一段看起来完全正常的代码std::vectorintv{10,20,30};constint*pv.data();// 拿到首元素地址v.push_back(40);// 只是追加一个元素std::cout*p;// ← p 可能指向已释放的内存如果push_back时容量不够vector 会另找一块更大的内存、把老元素搬过去、释放老内存。这时候p指的是一块已经还给分配器的地址解引用就是未定义行为。要理解为什么会这样先得看 vector 长什么样。2. 内存布局三个指针vector 对象本身通常只有 24 字节64 位平台上三个指针元素全都存在堆上的一段连续内存里std::vectorint v{10, 20, 30}; 假设 capacity() 4具体值是实现定义 v —— 栈上的对象sizeof 通常 24 ┌──────────────────────┐ │ _M_start ──┼──────┐ │ _M_finish ──┼────┐ │ │ _M_end_of_storage ──┼──┐ │ │ └──────────────────────┘ │ │ │ │ │ │ 堆上的一块连续内存 │ │ │ ┌────┬────┬────┬────┐ │ │ │ │ 10 │ 20 │ 30 │ ?? │◄────┼─┘ │ ↑ ?? 已分配但未构造 └────┴────┴────┴────┘◄────┘ │ ▲ ▲ ▲ │ │ │ └───────┘ │ └── _M_finish → size() _M_finish - _M_start └── _M_start (begin()) → capacity() _M_end_of_storage - _M_start三个指针各管一件事_M_start是首元素地址_M_finish是「最后一个已构造元素的下一位」_M_end_of_storage是「已分配内存的末尾」。于是概念含义计算方式谁决定size()已构造的元素个数_M_finish - _M_start用户调用push_back/erase等capacity()能容纳的元素个数不重新分配的前提下_M_end_of_storage - _M_start库的扩容策略data()首元素地址_M_start—size()是「住了几户」capacity()是「盖了几间房」。用代码验证这两个值和「容量够不够决定扩容」// demo.cpp — 编译: g -stdc17 -Wall -O2 demo.cpp -o demo#includecstdint#includeiostream#includevectorintmain(){std::vectorintv;std::cout空 vector: sizev.size() capacityv.capacity()\n;for(inti0;i5;i){v.push_back(i);std::coutpush_back(i): sizev.size() capacityv.capacity()\n;}constautobefore_reservereinterpret_caststd::uintptr_t(v.data());v.reserve(20);constautoafter_reservereinterpret_caststd::uintptr_t(v.data());std::coutreserve(20) 后 capacityv.capacity() 内存首地址变了(before_reserve!after_reserve)\n;std::coutreserve 前 data()~~ reserve 后 data()~~\n;v.shrink_to_fit();std::coutshrink_to_fit 后 sizev.size() capacityv.capacity()\n;return0;}空 vector: size0 capacity0 push_back(0): size1 capacity1 push_back(1): size2 capacity2 push_back(2): size3 capacity4 push_back(3): size4 capacity4 push_back(4): size5 capacity8 reserve(20) 后 capacity20 内存首地址变了1 reserve 前 data()~~ reserve 后 data()~~ shrink_to_fit 后 size5 capacity5容量序列1 → 2 → 4 → 4 → 8暴露了两件事容量只在「装不下」时才变第 4 次push_back因为capacity() 4 size() 3不扩容每次扩容不是加一格而是翻倍。另外reserve(20)让首地址变了它是一次真正的重新分配shrink_to_fit又把容量缩回 5。官方文档std::vector::reserve (cppreference)3. 扩容与均摊复杂度 O(1)增长因子growth factor是实现定义的libstdcGCC是 2 倍MSVC 的 STL 是 1.5 倍都合法。标准只要求push_back的均摊复杂度是 O(1)。为什么翻倍就能做到均摊 O(1)关键在「扩容时搬了多少个元素」这件事上容量变化在第几次 push_back 时触发该次要搬运的元素数0 → 1第 1 次01 → 2第 2 次12 → 4第 3 次24 → 8第 5 次48 → 16第 9 次816 → 32第 17 次1632 → 64第 33 次3264 → 128第 65 次64搬够 100 个元素总搬运量是124...64 127比 100 大不了多少推广到 n 个元素总搬运量是124...n/2 n平均到每个元素上是常数。这就是均摊 O(1)。但均摊 O(1) 不代表「每次都不贵」第 65 次push_back要搬 64 个元素。所以能预知大小时要reserve。我们用带拷贝计数的类型把它量化这类故意不带移动构造强制扩容时逐元素拷贝// demo.cpp — 编译: g -stdc17 -Wall -O2 demo.cpp -o demo#includecstddef#includeiostream#includevectorstructItem{staticintcopies;intvalue{0};Item()default;explicitItem(intx):value{x}{}Item(constItemother):value{other.value}{copies;}Itemoperator(constItemother){valueother.value;copies;return*this;}};intItem::copies0;intmain(){constexprintkCount100;// 场景一不 reserve靠自动扩容std::vectorItemwithout;intreallocs0;std::size_t capwithout.capacity();Item::copies0;for(inti0;ikCount;i){without.push_back(Item{i});if(without.capacity()!cap){reallocs;capwithout.capacity();}}std::cout自动扩容: 重新分配reallocs 次 元素拷贝Item::copies 次 最终 capacitywithout.capacity()\n;// 场景二先 reserve一次分配到位std::vectorItemwith;with.reserve(kCount);reallocs0;capwith.capacity();Item::copies0;for(inti0;ikCount;i){with.push_back(Item{i});if(with.capacity()!cap){reallocs;capwith.capacity();}}std::cout先 reserve: 重新分配reallocs 次 元素拷贝Item::copies 次 最终 capacitywith.capacity()\n;return0;}自动扩容: 重新分配8 次 元素拷贝227 次 最终 capacity128 先 reserve: 重新分配0 次 元素拷贝100 次 最终 capacity100写法重新分配次数元素拷贝次数为什么直接push_back100 次82278 次扩容额外搬运 127 个元素分配器调用 缓存失效 野指针风险先reserve(100)0100一次分配够用的内存100 次拷贝是push_back(Item{i})传临时对象本身的代价227 100push_back各拷贝一次 127扩容的搬运。注意统计口径0 → 1那次也算「重新分配」但它没有旧元素要搬所以 8 次分配只对应 127 次搬运和上面的理论表完全对上。reserve省掉的不是那 100 次「必须发生」的拷贝而是 127 次搬运、8 次分配/释放以及随之而来的数据缓存局部性损失。元素被搬到新地址后老内存的缓存行全废了。官方文档std::vector::capacity (cppreference)4. 迭代器失效场景全表失效的根因只有两种内存搬了所有指针/迭代器/引用全废和元素挪了失效点之后的对象换了位置或没了。操作失效范围为什么push_back/emplace_back若size() capacity()全部失效否则只有end()失效前者触发重新分配内存搬了后者只在末尾构造新元素insert插入点及之后全部失效若触发扩容则全部失效插入点之后的元素整体后移元素挪了erase删除点及之后全部失效被删元素自身当然也失效删除点之后的元素整体前移reserve/resize/shrink_to_fit只要真的重新分配了全部失效内存搬了clear全部失效但capacity()不变元素被析构内存还在但已构造范围变成空swap两个容器的迭代器全部失效end()也换主了内存所有权互换operator[]/at/data一直有效但必须重新取它们不是迭代器是即时计算的地址clear那一条最反直觉元素全没了但内存不还。翻页到「vector只用不缩」的记忆上这行关键clear()后capacity()通常保持不变这是为了让容器能被复用想真的还内存才需要shrink_to_fit()而它也是非强制的标准只说「请求」实现可以不理。用地址变化来实证「扩容 换内存」和「插入/删除 元素搬家」// demo.cpp — 编译: g -stdc17 -Wall -O2 demo.cpp -o demo#includecstdint#includeiostream#includevectorintmain(){std::vectorintv{10,20,30,40,50};constautoaddr_beforereinterpret_caststd::uintptr_t(v.data());std::cout初始: sizev.size() capacityv.capacity()\n;v.push_back(60);// 容量不够 → 重新分配constautoaddr_afterreinterpret_caststd::uintptr_t(v.data());std::coutpush_back 后 capacityv.capacity() 首地址变了(addr_before!addr_after)\n;// 插入插入点之后的元素整体后移std::vectorintw{1,2,3};w.insert(w.begin()1,99);std::coutinsert(1, 99) 后: ;for(constintx:w)std::coutx ;std::cout\n;// 删除删除点之后的元素整体前移capacity 不变conststd::size_t cap_beforew.capacity();w.erase(w.begin()1);std::couterase 后: ;for(constintx:w)std::coutx ;std::cout| capacity 是否变化(w.capacity()!cap_before)\n;return0;}初始: size5 capacity5 push_back 后 capacity10 首地址变了1 insert(1, 99) 后: 1 99 2 3 erase 后: 1 2 3 | capacity 是否变化0push_back后容量从 5 变成 10首地址也变了。前一个data()拿到的指针已经作废这就是第 1 节那个 bug 的全过程。erase只删了一个元素但capacity纹丝不动因为「删除」只是把后面的元素往前挪并析构掉末尾那一个不会归还内存。官方文档std::vector::push_back · Iterator invalidation (cppreference)5. emplace_back 与 push_back 的开销差push_back收的是已经造好的对象emplace_back收的是构造参数直接在容器内存里就地构造。这个区别在参数是「构造代价高、又要临时对象」时非常明显// demo.cpp — 编译: g -stdc17 -Wall -O2 demo.cpp -o demo#includeiostream#includestring#includevectorstructWidget{staticintctors;staticintcopies;staticintmoves;std::string name;explicitWidget(std::string n):name{std::move(n)}{ctors;}Widget(constWidgetother):name{other.name}{copies;}Widget(Widgetother)noexcept:name{std::move(other.name)}{moves;}};intWidget::ctors0;intWidget::copies0;intWidget::moves0;voidreset(){Widget::ctorsWidget::copiesWidget::moves0;}intmain(){std::vectorWidgetv;v.reserve(4);// 先排除扩容干扰只看构造/移动reset();v.push_back(Widget{push});std::coutpush_back: 构造Widget::ctors 拷贝Widget::copies 移动Widget::moves\n;reset();v.emplace_back(emplace);std::coutemplace_back: 构造Widget::ctors 拷贝Widget::copies 移动Widget::moves\n;return0;}push_back: 构造1 拷贝0 移动1 emplace_back: 构造1 拷贝0 移动0两者都只构造了 1 次对象差别是push_back多出一次移动构造v.push_back(Widget{push})必须先在调用方造出临时对象纯右值再把它移动进容器emplace_back直接把emplace当构造参数在容器内存里就地构造一次到位。这里只差一次移动移动通常只是搬几个指针很便宜差距在不同形态下会拉开写法临时对象容器内构造总代价v.emplace_back(x)无直接构造 1 次1 次构造v.push_back(Widget{x})有可被消除移动构造 1 次1 次构造 1 次移动v.push_back(std::string(3,x))vectorstring有移动构造 1 次1 次构造 1 次移动v.push_back(w)w是已存在的对象无拷贝构造 1 次1 次拷贝结论是务实的参数需要「由多个值在容器内直接拼装」时用emplace_back例如v.emplace_back(3, x)造 string手上已经有对象时push_back更直白。Core Guidelines 的 SL.con.4 也提醒emplace_back接受的是构造函数参数别被隐式转换坑到它能绕过explicit的意图构造出你没想到的类型。官方文档C Core Guidelines · SL.con.2/SL.con.4 (isocpp.github.io)6. 完整示例erase-remove 惯用法把前面几节合起来。注意这里必须用remove的返回值做erase的起点直接对vector用remove不会删除任何元素它只会把要留的元素往前挪// demo.cpp — 编译: g -stdc17 -Wall -O2 demo.cpp -o demo#includealgorithm#includecstddef#includeiostream#includevectorintmain(){constexprintkTimes10;std::vectorintv;for(inti0;ikTimes;i)v.push_back(i%3);// 0 1 2 0 1 2 0 1 2 0conststd::size_t cap_beforev.capacity();constautonew_endstd::remove(v.begin(),v.end(),1);std::coutremove 之后 sizev.size()元素前移了长度没变\n;v.erase(new_end,v.end());// 真正删除析构 [new_end, end) 这一段std::couterase 之后 sizev.size() capacity 是否变化(v.capacity()!cap_before)\n;std::cout剩余元素: ;for(constintx:v)std::coutx ;std::cout\n;return0;}remove 之后 size10元素前移了长度没变 erase 之后 size7 capacity 是否变化0 剩余元素: 0 2 0 2 0 2 0remove返回的是「新逻辑末尾」的迭代器它把要留的元素依次前移被删元素挤到尾部但没被析构所以size()还是 10erase(new_end, v.end())才把尾部这段析构掉。这也是为什么erase之后size()变成 7 而capacity不变vector只会变长或原地搬家几乎从不主动缩容。7. 延伸阅读std::vector (cppreference)成员函数总表特别注意data/size/capacity三个本节的中心词。std::vector::capacity (cppreference)capacity()的定义与「不保证缩容」的原文。std::vector::reserve (cppreference)reserve只加不减、可能无效请求的说明。std::vector::push_back (cppreference)**迭代器失效iterator invalidation**条款写在每个容器操作的说明里值得逐个看。Compiler Explorer (godbolt.org)把push_back循环贴进去开-O2能直观看到重新分配的调用链。本知识库内的相关篇目《list 与 forward_list链表真的比 vector 快吗》 —— 用计数分配器和实测耗时把 std::list / std::forward_list 的真实开销算清楚《std::string 高频用法与 SSO 小字符串优化》 —— 为什么创建一个小字符串完全不碰堆、string 对象里那 32 个字节都装了什么这篇用内存布局图和「数《std::array 与裸数组、vector 怎么选》 —— std::array 是裸数组的零开销封装——sizeof 完全相等、没有额外间接、size() 还是8. 一句话总结vector就是「三个指针 一段堆内存」size()是已构造元素数、capacity()是已分配容量装不下时按实现定义的因子libstdc 是 2 倍重新分配并搬运全部元素均摊到每个元素是 O(1)所以能用reserve预分配就一定预分配迭代器失效的根因不是「内存搬了」就是「元素挪了」push_back/reserve/clear会让全部失效insert/erase只让操作点之后失效且erase和clear都不会归还内存。