1. 从“会用”到“用明白”:为什么要深入拆解 vector
先讲一个我经常在代码评审里看到的场景:很多人把std::vector当成“会自动变大的数组”,push_back 用得飞起,size()和capacity()分不清,程序一崩就怀疑是“内存泄漏”。说实话,vector 这个容器在 C++ 里的地位,几乎相当于“默认选项”——没有特殊理由,你就是在用它。但默认选项不等于简单选项,它的接口设计、动态扩容策略、迭代器失效规则,哪怕写了好几年 C++ 的人,也未必完全讲得清楚。
这篇文章的主角就是 vector。我会从最常用的接口开始,逐步拆到它的底层内存模型:容量是怎么涨的,元素是怎么挪的,为什么reserve能显著提升性能,为什么在遍历时删除元素会崩,以及vector<bool>这个“历史包袱”到底坑在哪里。适合谁看?如果你是 C++ 初学者,正在背“vector 用法清单”,这篇文章能帮你把背后的原理补齐;如果你已经工作几年,想系统地梳理一下底层机制,或者准备面试时被问“vector 扩容是几倍、为什么是这几倍”,这篇文章同样值得读到最后。
先说明一点,我不会只贴 API 文档。每个接口、每个底层策略,我都会尽量解释“为什么这么做”,再配上我实际踩过的一些坑。这样你看完之后,遇到问题能自己推出来原因,而不是靠死记硬背。
2. vector 接口全拆解:那些你“以为会用”的成员函数
2.1 size、capacity、resize、reserve:四兄弟的关系别搞混
新手最容易懵的就是size()和capacity()的区别。打个比方:size()是房间里现在住了几个人,capacity()是这个房间最多能住几个人。你只往房间里加人,当人数超过最大容量时,就得换一个更大的房间,然后把所有人都搬过去——这个过程就是 vector 的扩容。
size():当前元素个数,也就是end() - begin(),时间复杂度 O(1)。capacity():当前已分配内存能容纳的元素个数,不重新分配内存的前提下,你可以继续往里塞元素。resize(n):把size()改成 n。如果 n 大于当前 size,就插入元素(值初始化的元素或你指定的值);如果 n 小于当前 size,就删除尾部多余元素。它会改变 size。reserve(n):把capacity()至少改成 n。它不会改变 size,只负责预留内存。
实操中我经常见到一种写法:一个循环里 push_back 几千个元素,但从来没 reserve。每次扩容都要重新分配内存、移动所有已有元素,均摊下来虽然还是 O(1),但常数很大,而且会引发大量内存分配和释放。如果一开始就知道大概要放多少数据,直接v.reserve(expected_count),能省掉大部分扩容开销。
提示:
reserve只保证 capacity 不小于 n,不保证恰好等于 n。有些实现会直接分配恰好 n 个元素的内存,有些会向上取整到某个对齐值,依赖“reserve(100) 就是 capacity=100”是不可靠的。
再补一个冷门接口:shrink_to_fit()。它请求把 capacity 缩小到 size,释放多余内存。但注意“请求”二字——标准并没有强制要求,是否真正释放取决于实现。你可以在批量处理完数据后调用一次,把峰值内存还给系统,但不要频繁调用,因为缩容通常意味着重新分配内存并把所有元素搬一遍,比扩容还贵。
2.2 访问元素:[]、at()、front、back 之间的安全差异
访问 vector 元素有四条路径:v[i]、v.at(i)、v.front()、v.back()。
v[i]不做越界检查,越界是未定义行为。这也是很多人说的“C++ 快但危险”的典型代表。v.at(i)会做边界检查,越界时抛出std::out_of_range异常,代价是每次访问多一次分支判断。front()和back()分别访问首尾元素,对空容器调用是未定义行为。
我的建议很简单:在性能关键路径上用[],在逻辑边界不确定时用at()做兜底。比如你解析一个格式不完整的网络报文,索引从报文头算出来,很可能越界,这时候用at()并在外层 catch,能快速定位问题;如果是遍历一个已知大小的数组,[]就够了,没必要每次都检查边界。
还有一种更保险的写法,至少对连续索引来说是安全的——用循环遍历时直接基于begin()和end()迭代,或者用范围 for:
for (auto& item : v) { // 处理 item }C++20 之后还有std::span,可以只传“数组视图”而不拷贝数据,函数签名里用std::span<T>代替const std::vector<T>&,更灵活也更安全。如果你的项目已经上了 C++20,建议尝试一下。
2.3 插入与删除:push_back、pop_back、insert、erase 的正确姿势
push_back在尾部追加一个元素,均摊 O(1);pop_back删除尾部元素,O(1)。这两个接口是 vector 的“主场”,但如果要在中间插入或删除,就是另一种故事了。
insert(pos, value)在pos之前插入一个元素,erase(pos)删除一个元素,两者都需要把后续元素挨个挪动,最坏 O(n)。这还没完,插入可能触发扩容,删除不会。实际编码时这里有一个高频坑:在循环中边遍历边删除。
错误的典型写法:
for (auto it = v.begin(); it != v.end(); ++it) { if (*it % 2 == 0) { v.erase(it); // 迭代器 it 已失效,++it 行为未定义 } }正解是利用 C++11 之后erase返回被删除元素的下一个迭代器:
for (auto it = v.begin(); it != v.end(); ) { if (*it % 2 == 0) { it = v.erase(it); } else { ++it; } }如果你只想删除符合某个条件的元素,更推荐“移除-擦除”惯用法:
v.erase(std::remove_if(v.begin(), v.end(), [](int x) { return x % 2 == 0; }), v.end());std::remove_if把不符合条件的元素往前挪,返回新的“逻辑结尾”,然后erase一次性删掉尾巴。这样只做一遍移动,效率比逐个erase高得多。
insert也有个容易被忽略的点:如果你要在中间连续插入多个值,一次性传入迭代器区间,会比循环insert快很多。因为单次insert能算出需要移动多少元素,只做一次搬移;多次 insert 每次都搬移一遍,复杂度直接差一个量级。
2.4 emplace_back 与 push_back:省掉一次拷贝/移动构造
emplace_back(Args&&...)是在容器内直接构造元素,而push_back是先构造临时对象,再拷贝/移动到容器里。C++17 之后有拷贝省略和移动语义的加持,很多时候两者性能差距不大,但emplace_back依然有一个理论优势:它避免了临时对象的构造和析构。
struct Point { int x, y; Point(int a, int b) : x(a), y(b) {} }; std::vector<Point> v; v.push_back(Point(1, 2)); // 构造一个临时 Point,再移动进容器 v.emplace_back(1, 2); // 直接用参数在容器内存上构造 Point对于Point这种小对象,差别微乎其微。但如果元素构造很重(比如字符串、互斥锁、复杂聚合对象),emplace_back的优势就很明显了。我的习惯是:新代码一律用emplace_back,除非你明确就是要先构造一个具名对象然后推入。
不过 emplace_back 有个“隐蔽坑”:禁止用花括号初始化器直接做参数,比如v.emplace_back({1, 2})编译不过,要么写v.emplace_back(1, 2),要么先构造对象再传。
3. 深入底层:vector 的内存模型与动态扩容机制
3.1 三指针结构:start、finish、end_of_storage
vector 的内部实现并非什么玄学,绝大多数标准库实现里,它就是三个指针(或迭代器)在维护一片连续堆内存:
_M_start(或begin):指向内存起始位置。_M_finish(或end):指向当前最后一个元素的下一个位置,即 size 的“指针化”表示。_M_end_of_storage:指向已分配内存的末尾,capacity 的“指针化”表示。
所以size()就是finish - start,capacity()就是end_of_storage - start,两者都是 O(1),就是指针减法。
这片内存在堆上分配(除非使用自定义分配器),所以 vector 能动态增长。它和std::array最大的区别也在这里:std::array的大小在编译期固定,不是放在栈上就是嵌入在其他对象中;vector 则永远持有堆指针,只把三个指针本身放在栈上。
这套“三指针模型”带来的直接推论是:vector 对象本身非常小,通常是 24 字节(64 位系统下三个指针),拷贝一个 vector 变量并不会拷贝底层数据,只有vector整体复制(拷贝构造或拷贝赋值)才会深拷贝底层堆内存。
3.2 扩容策略:为什么是 2 倍(或 1.5 倍)而不是固定增量
vector 的扩容通常是:当 size 达到 capacity 时,分配一块更大的新内存,把旧元素全部搬过去,然后释放旧内存。问题在于“多大才算大”。最常见的策略是倍增:new_capacity = old_capacity * 2(libstdc++ / libc++ 都是 2 倍)。
为什么选倍增,而不是每次多分配 100 个元素的固定增量?这里有个均摊复杂度的经典推导:
假设从 capacity=1 开始,每次扩容到原来的 2 倍,那么扩容到 n 的过程中,各次搬迁的元素总数是:
1 + 2 + 4 + ... + n/2 ≈ n
也就是说,即使经历了 log₂(n) 次扩容,所有元素被搬移的总次数也只有 O(n) 量级。平均到每次 push_back,就是 O(1) 的均摊代价。如果固定增量扩容(每次多分配 K 个),则每个元素平均会被搬移 O(n/K) 次,总复杂度会退化到 O(n²) 级别——这种情况在实践中是不允许出现的。
那 1.5 倍呢?有教科书和面试题会问 2 倍和 1.5 倍孰优孰劣。核心考量是内存碎片与空间浪费的平衡:
- 2 倍扩容:峰值内存大约是当前 size 的 3 倍(旧内存 + 新内存各一份,搬迁过程中两者同时存在),翻倍后最多浪费 50% 空间。
- 1.5 倍扩容:峰值内存约是当前 size 的 2.5 倍,空间浪费更少,扩容更频繁,但每次搬移量更小,内存碎片化程度也更低。某些实现(如早期 MSVC)曾用 1.5 倍,后来也逐渐调整为 2 倍。
注意:C++ 标准只规定
push_back的均摊时间复杂度是 O(1),并没有强制指定扩多少倍。不同编译器和标准库实现可能不同。你可以在自己的环境里跑一个小程序验证倍率:
#include <iostream> #include <vector> int main() { std::vector<int> v; size_t prev_cap = v.capacity(); for (int i = 0; i < 100; ++i) { v.push_back(i); if (v.capacity() != prev_cap) { std::cout << "size=" << v.size() << ", capacity=" << v.capacity() << ", growth ratio=" << (double)v.capacity() / prev_cap << "\n"; prev_cap = v.capacity(); } } }你会发现第一次扩容通常是 1 到 1(从 0 到 1),之后才是固定倍数。这里也暴露了一个冷知识:对空 vector 执行 reserve(0) 或默认构造,capacity 是 0,第一次 push_back 才会真正分配内存。
3.3 扩容过程中的元素搬迁:拷贝还是移动?
扩容时要把旧内存里的元素搬到新内存去,C++11 之前全是拷贝构造,C++11 之后优先移动构造。如果你的元素类型是可移动的,搬迁成本就会低很多;如果不可移动,就只能拷贝。
这个细节影响很大。比如你有一个std::vector<std::mutex>,std::mutex既不可拷贝也不可移动,那么 vector 在扩容时就无法编译通过。遇到这种需求,你得改用std::deque或std::list,或者用std::unique_ptr<std::mutex>的容器。
对于自研对象,如果你的类里面有裸指针管理资源,务必正确实现移动构造和移动赋值,并把拷贝构造删除或显式实现。否则 vector 扩容时会悄悄调用拷贝构造,这可能造成双重释放或资源泄漏。
class Buffer { public: Buffer(size_t size) : data_(new char[size]), size_(size) {} Buffer(const Buffer&) = delete; // 禁止拷贝 Buffer(Buffer&& other) noexcept : data_(other.data_), size_(other.size_) { other.data_ = nullptr; other.size_ = 0; } Buffer& operator=(Buffer&& other) noexcept { if (this != &other) { delete[] data_; data_ = other.data_; size_ = other.size_; other.data_ = nullptr; other.size_ = 0; } return *this; } ~Buffer() { delete[] data_; } private: char* data_; size_t size_; };注意移动构造函数最好标记noexcept。否则标准库在扩容时会担心移动操作抛出异常导致原数据不完整,于是退回使用拷贝构造。这个行为在《Effective Modern C++》Item 14 里有详细解释,这里你只需要记住:能加 noexcept 的移动操作,一定要加。
3.4 为什么 vector 要求元素是连续存储的,以及这带来的“缓存友好”优势
vector 的底层是一片连续的堆内存,元素之间没有间隙。这让它具备了随机访问 O(1) 的能力,也让它在遍历时对 CPU 缓存非常友好——预取器能预测到下一个元素就在附近,绝大多数场景下比std::list遍历快一个数量级。
这也是为什么“默认选 vector”这个原则是有硬件依据的。std::list虽然在中间插入删除是 O(1),但每个节点独立分配,遍历时缓存命中率很差,实际速度往往让你怀疑人生。如果你需要一个“插入删除频繁但遍历较少”的容器,也请先测一下能不能用 vector + 标记删除(tombstone)解决,再考虑 list。
连续存储还有一个推论:你可以把vector<T>的底层数据通过data()接口拿出去,当普通 C 数组用。这在与 C 库交互时几乎是零成本桥接:
std::vector<uint8_t> buffer(4096); read(fd, buffer.data(), buffer.size()); // 直接用 buffer 内存,避免再开一个 C 数组但要注意,data()返回的指针只在 vector 没有被修改容量时有效。执行 push_back 扩容或 insert 后,所有旧的data()指针都会失效。这个坑在并发或多线程环境里特别容易踩。
4. 迭代器失效规则与多线程注意点
4.1 哪些操作会让迭代器失效
vector 的迭代器本质就是指针,所以“迭代器失效规则”其实就是“内存地址何时不再可靠”。逐个场景来列:
push_back或insert触发扩容:所有迭代器、指针、引用全部失效。因为底层内存换了地方。insert中间位置(未扩容):插入点之后的所有迭代器、指针、引用失效,之前的保持有效。erase中间位置:被删除点及之后的所有迭代器、指针、引用失效,之前保持有效。pop_back:被删元素(尾部)的迭代器、指针、引用失效,其他保持有效。
用一个口诀总结:只要涉及元素移动或重分配,后续位置全部失效;之前的可能安全,但也不能依赖。
很多线上崩溃的根因都在这里。比如你在一个函数里保存了auto* p = &v[0],然后在另一个地方 push_back,再回头用p,此时p指向的内存可能已经被释放,这就是典型的悬垂指针。
4.2 遍历中删除元素的正确方案
前面已经给出了循环 erase 的正确姿势,这里再补充一个更底层的思路:很多情况下你根本不需要在遍历时删除,可以先挑出要保留的元素,再整体重建。
比如,你想把 vector 里所有满足条件的元素删掉:
std::vector<int> v = {1, 2, 3, 4, 5, 6}; v.erase(std::remove_if(v.begin(), v.end(), [](int x) { return x % 2 == 0; }), v.end());这个惯用法不仅代码短,而且性能极好:remove_if是前向遍历加移动,不会破坏稳定性(相对顺序保留),也不会频繁触发 erase 导致 O(n²) 的搬移。
4.3 多线程环境下的使用禁忌
vector 本身不是线程安全的。多线程同时对一个 vector 做 push_back,会引发数据竞争,程序行为未定义,轻则丢失数据,重则直接崩溃。
常见的错误是“每个线程往 vector 里 push_back 一部分结果”。这非常诱人,但一旦触发扩容,内存搬迁过程中其他线程还在往旧内存写数据,直接踩踏。两个替代方案:
- 预先
reserve好总大小,然后让每个线程写固定区间(例如线程 i 负责[i * chunk, (i+1) * chunk)),注意写入操作也需要用原子变量做进度同步,否则仍是数据竞争。 - 每个线程维护自己的局部 vector,结束后合并且移动(
std::move拼接),避免共享容器的并发写。
如果一定要共享并并发修改,需要加锁,或者改用无锁队列等专用结构。vector 的设计目标本来就不是无锁并发容器,硬上的代价大于收益。
5. 性能对比与场景选型:什么时候不能选 vector
5.1 vector 与 array、list、deque 的取舍
很多刚入门的朋友喜欢问“vector 和 list 哪个快”,这其实是个伪命题,因为读写模式不同,结论完全不同。我直接给一个基于实际测试和日常经验的参考表:
| 容器 | 随机访问 | 尾部插入删除 | 中间插入删除 | 遍历缓存友好性 | 迭代器失效特点 |
|---|---|---|---|---|---|
std::vector | O(1),极快 | O(1) 均摊 | O(n),要搬移 | 很高 | 扩容后全部失效 |
std::array | O(1),极快 | 不支持 | O(n) | 很高 | 固定栈内存,不失效 |
std::list | O(n),很慢 | O(1) | O(1)(找到位置后) | 很低 | 插入不影响其他迭代器 |
std::deque | O(1),稍慢 | 两端 O(1) | O(n) | 中等 | 插入两端不失效,中部会失效 |
结论很明确:绝大多数场景用 vector 就够了。std::list只有在“你需要长期持有元素的稳定迭代器,并且频繁在中间插入删除,同时不常按索引访问”时才值得考虑。std::deque适合需要两端插入删除的场景,比如实现双端队列。
还有std::vector<bool>这个特化要单独提一嘴:它不是真的存 bool,而是按位压缩存储,因此operator[]返回的是一个代理对象(std::vector<bool>::reference),而不是bool&。它不能用来绑定普通引用,也不满足很多泛型算法的要求。如果你需要真正的bool*数组,改用vector<uint8_t>或vector<char>,性能通常更好,别在vector<bool>上死磕。
5.2 reserve 的正确打开方式
我之前提过一次reserve,这里再往深了说一层。reserve 不是“限制容器大小”,而是“提前分配好空间”。常见的高性能用法:
std::vector<Record> records; records.reserve(100000); // 提前分配足够内存 for (int i = 0; i < 100000; ++i) { records.emplace_back(...); }这能避免 17 次左右的无谓扩容(2 倍扩容从 1 到 131072,大约发生 17 次搬迁)。如果你的程序反复经历“填满一个小 vector 然后清空再填满”,每次填满都可能触发扩容,这时考虑在全过程中只 reserve 一次,别清空后就把容量丢掉了。
clear()只会销毁元素、把 size 置零,不会释放内存,capacity 保持不变。如果你希望在清空后重置容量,可以:
std::vector<int>().swap(v); // 清空并释放内存 // 或者 C++11 后写: v.clear(); v.shrink_to_fit();swap技巧是“把空容器的内存跟 v 交换”,本质是把 v 的内存转给临时对象,临时对象析构时释放。这个方法在shrink_to_fit出现之前被广泛使用,现在你也可以用,只是稍显 hack。
5.3 直接从 vector 数据构造其他结构时的注意事项
有时候你需要把 vector 内容传给 C 接口,或者做序列化。直接拿data()+size()是最快的:
std::vector<char> payload = BuildPayload(); write(fd, payload.data(), payload.size());这里有一个常见误解:data()在空 vector 上返回什么?标准规定返回非空指针或者值同样有效的指针,但你不能解引用它。所以传给 C 接口时要注意,size 为 0 就不要调用 write 了,或者调用 write 时 length 传 0。
另一个痛点是把 vector 转成字符串。C++23 之前没有官方接口,常见做法是:
std::string s(v.begin(), v.end());这个写法依赖迭代器区间构造函数,std::string 会自己处理所有拷贝,性能也还可以。
6. vector 崩溃现场:常见问题排查与调试技巧
6.1 三个高频崩溃场景
我在排查代码问题的时候,vector 相关的崩溃常年是前三名。这里总结三个最典型的现场,以及对应的排查思路。
场景一:越界访问
for (int i = 0; i <= v.size(); ++i) { std::cout << v[i] << std::endl; // i == v.size() 时越界 }这种 bug 在 Debug 模式下可能某个编译器会帮你检测(libstdc++ 的_GLIBCXX_DEBUG宏、MSVC 的迭代器调试),但 Release 下往往是“偶尔崩、偶尔不崩”,因为越界访问到的是堆上残留数据,不一定马上触发段错误。排查手段就是开启 ASan(AddressSanitizer),能让越界在第一时间暴露。
场景二:迭代器失效后的二次使用
auto it = v.begin(); v.push_back(1); v.push_back(2); v.insert(it, 100); // it 已经失效这种问题不好肉眼发现,因为it可能仍然指向旧内存,看起来好像没坏,实际上数据已经被搬走,对这块内存的操作就是未定义行为。复现困难,修起来也费劲。
场景三:返回 vector 内部引用后继续操作
int& ref = v[0]; v.push_back(3); // 如果扩容,ref 悬垂 ref = 42; // 悬垂写入解决思路只有一个:明确是否持有指向 vector 元素的指针/引用,如果持有,就不要修改 vector 的容量。想修改就通过索引重新获取,不要保存长期引用。
6.2 调试利器:AddressSanitizer 与 Debug 模式
排查 vector 问题,与其靠眼睛看代码,不如直接上工具。我最常用的组合是:
- 编译时加
-fsanitize=address -g(GCC/Clang),把 ASan 打开; - 用
-D_GLIBCXX_DEBUG(GCC libstdc++)启用标准库的调试检查,越界访问、迭代器失效都会直接报错而不是“悄悄崩溃”; - MSVC 下开启
/D _ITERATOR_DEBUG_LEVEL=2,效果类似。
这三个选项平时不开,但调试疑难杂症时非常管用。尤其是_GLIBCXX_DEBUG,它会把迭代器实现从裸指针换成带检查的对象,越界和失效都能在出错的第一时间弹出来。
不过注意,_GLIBCXX_DEBUG会极大降低性能,而且改了 ABI,只能用于调试构建,不能直接拿去上线。
6.3 用性能分析工具定位扩容瓶颈
如果你怀疑程序慢是因为 vector 扩容太频繁,最简单的方式是在代码里跟踪 capacity 的变化,打印每次扩容时的 size/capacity。稍微高阶一点的做法是重载全局operator new,统计分配次数和总分配字节数,或者直接用perf命令看memcpy/_M_realloc_insert的调用频次。
一个我在实践中常用的快速办法是:把reserve加进去,看性能提升多少。如果提升巨大,说明扩容和搬移确实是瓶颈;如果几乎没变化,那瓶颈可能不在 vector 上。这种“先改动,再测对比”的做法,比凭空纠结更高效。
6.4 vector 与常见库的“撞名”问题
这里提一个 C++ 圈子外也常遇到的事情:很多人在搜索资料时会碰到 Vector 公司(做 CANoe、CANalyzer、AUTOSAR 工具链的那家),它们和 C++ 的std::vector没有一点关系。如果你在做嵌入式或者汽车电子相关开发,看到“Vector 接口”这个词,多半是指总线工具链里的报文、信号接口定义,而不是 C++ 容器。写代码时别混在一起理解,不然会被绕晕。
回到正题:C++ 的 vector 是标准模板库的一部分,跟任何商业工具都无关,它是免费的、开源的、跨平台的。
7. 从源码看 vector:一个极简的实现框架
如果你有兴趣,可以看看 libstdc++ 或 MSVC 的 vector 源码,其实核心逻辑并不复杂。我在这里写一个极简的框架,帮你理解三指针模型:
template <typename T, typename Alloc = std::allocator<T>> class SimpleVector { public: using iterator = T*; using const_iterator = const T*; size_t size() const noexcept { return finish_ - start_; } size_t capacity() const noexcept { return end_of_storage_ - start_; } void push_back(const T& value) { if (finish_ == end_of_storage_) { grow(); } // placement new 在 finish_ 处构造元素,而不是简单的 *finish_ = value std::allocator_traits<Alloc>::construct(alloc_, finish_, value); ++finish_; } void pop_back() { --finish_; std::allocator_traits<Alloc>::destroy(alloc_, finish_); } T* data() noexcept { return start_; } private: void grow() { size_t old_cap = capacity(); size_t new_cap = old_cap ? old_cap * 2 : 1; T* new_start = alloc_.allocate(new_cap); // 移动旧元素 for (size_t i = 0; i < size(); ++i) { std::allocator_traits<Alloc>::construct(alloc_, new_start + i, std::move(start_[i])); std::allocator_traits<Alloc>::destroy(alloc_, start_ + i); } alloc_.deallocate(start_, old_cap); start_ = new_start; finish_ = start_ + size(); end_of_storage_ = start_ + new_cap; } T* start_ = nullptr; T* finish_ = nullptr; T* end_of_storage_ = nullptr; Alloc alloc_; };注意几个工程细节:真实 vector 不会用realloc去扩容,一是因为 realloc 对于非平凡类型不能安全搬迁,二是标准库通过 allocator 的allocate/deallocate管理内存,再用construct/destroy管理对象生命周期。这也是为什么 vector 能容纳std::string这种带资源管理的类型——它用的是“分配裸内存 + 放置构造”的组合,而不是 C 语言那套“直接按字节拷贝”。
如果你理解了这套机制,很多面试问题都能迎刃而解:为什么 vector 扩容时自引用元素的迭代器会失效?因为start_变了。为什么用 placement new 而不是赋值?因为旧元素可能还没析构,直接赋值会产生临时对象或错误的生命周期管理。
8. 我的一些习惯与最后建议
这篇文章写到这里,我梳理一下我平时写 vector 相关代码的习惯,算是给大家一个参考:
第一,能预先知道元素数量时,我一定先reserve。哪怕是估算的偏大一点都没关系,比反复扩容省下的时间多得多。
第二,能用索引遍历就不要长期保存迭代器。我见过太多因为“稍后还要用同一个迭代器”而把代码搞得极复杂,最后崩溃找不到北的例子。
第三,能移动就不要拷贝。自研类型尽量满足“可移动且 noexcept”,这样 vector 扩容时才能走最快的路径。
第四,需要并发写时,先想清楚每个线程到底负责哪一段数据,尽量不要共享同一个 vector 做 push_back。
最后再分享一个小技巧:如果你在一个函数里构造好了一个大 vector,然后要把它作为返回值传出去,直接return v就行,现代 C++ 的 NRVO 与移动语义会保证几乎零拷贝。不要写std::shared_ptr<std::vector<T>>这种拐弯抹角的代码,vector 本身就是可廉价移动的。