STL 序列式容器是 C++ 开发中最高频使用的工具,其中 vector 和 list 最具代表性:一个底层是动态数组,主打随机访问;一个底层是双向链表,主打灵活插入删除。本文通过手动模拟实现 vector,结合源码细节与常见坑点,系统梳理两大容器的核心机制。
一、vector 的底层结构与核心接口
1. 底层数据结构
vector 本质是动态开辟的连续数组,底层通过三个指针维护整个空间:
iterator _start = nullptr; // 数组起始位置
iterator _finish = nullptr; // 有效元素的末尾位置
iterator _end_of_storage = nullptr; // 整个存储空间的末尾
基于这三个指针,可以直接计算出容器的关键属性:
size():有效元素个数 =_finish - _startcapacity():总容量 =_end_of_storage - _startempty():判空 =_start == _finish
2. 扩容机制:reserve
当插入元素导致 size() == capacity() 时,vector 会触发自动扩容:
- 开辟新的更大空间(默认策略:空容器从 4 开始,之后每次扩容为原来的 2 倍)
- 将旧空间的元素迁移到新空间
- 释放旧空间,更新三个指针指向新空间
代码中一个很有价值的细节:元素迁移使用 std::swap 而非直接赋值。对于 string 这类需要深拷贝的类型,swap 只交换内部指针,避免了大规模深拷贝,大幅提升扩容效率。
3. resize 与 reserve 的区别
reserve(n):只调整容量,不改变有效元素个数;只扩不缩,n 小于当前容量时什么也不做。resize(n, val):调整有效元素个数 :- n <size ():直接截断,把
_finish前移到_start + n - n > size ():用 val 填充尾部,容量不足时自动触发 reserve
- n <size ():直接截断,把
4. 插入与删除
push_back / pop_back:尾插尾删,时间复杂度 O (1),满了自动扩容。insert(pos, val):任意位置插入,需要将 pos 之后的元素全部后移一位,时间复杂度 O (n)。erase(pos):删除指定位置元素,需要将后续元素全部前移一位,时间复杂度 O (n)。
注意:insert 触发扩容后,原有的 pos 迭代器会直接失效,必须通过偏移量重新计算位置:
size_t len = pos - _start; // 保存相对偏移 reserve(...); // 扩容后_start改变 pos = _start + len; // 重新定位pos
二、拷贝控制的现代写法:Copy-and-Swap
传统写法的痛点
传统的拷贝构造和赋值运算符,需要手动开空间、逐个拷贝元素,不仅代码冗余,还要额外处理自赋值判断、内存泄漏和异常安全问题。
现代写法:利用 swap 简化逻辑
核心思想是复用构造函数 + 交换资源,代码更简洁且天然异常安全:
// 拷贝构造
vector(const vector<T>& v)
{
vector<T> tmp(v.begin(), v.end()); // 用迭代器区间构造临时对象
swap(tmp); // 交换临时对象和当前对象的资源
}
// 赋值运算符(值传递参数,自动生成拷贝)
vector<T>& operator=(vector<T> tmp)
{
swap(tmp);
return *this;
}
- 拷贝构造:构造临时对象后交换,临时对象析构时自动释放旧资源
- 赋值运算符:参数值传递自动完成拷贝,交换后 tmp 带走旧资源,无需自赋值判断
三、迭代器失效:最容易踩的坑
迭代器失效的本质是:容器底层结构发生变化,导致原迭代器指向的内存不再合法,继续访问会产生未定义行为。
1. vector 的迭代器失效场景
(1)插入操作
- 如果触发扩容:整个数组被迁移到新地址,所有迭代器全部失效
- 如果未触发扩容:插入位置之后的所有迭代器失效(元素后移,指向位置不再对应原元素)
(2)删除操作
被删除元素及其之后的所有迭代器全部失效(元素前移,后续位置被覆盖)。
典型错误:遍历 vector 时边遍历边删除
// 错误写法:erase后it失效,++it会越界或跳过元素 auto it = v.begin(); while (it != v.end()) { if (*it % 2 == 0) v.erase(it); ++it; } // 正确写法:用erase返回值更新迭代器 auto it = v.begin(); while (it != v.end()) { if (*it % 2 == 0) it = v.erase(it); else ++it; }
2. list 的迭代器特性
list 底层是双向链表,节点地址独立、不连续,因此:
- 插入操作:不会导致任何迭代器失效(节点不移动,只是指针指向变化)
- 删除操作:只有被删除节点的迭代器失效,其他迭代器不受影响
这也是链表相比数组的核心优势之一。
四、vector 与 list 的核心对比
表格
| 维度 | vector | list |
|---|---|---|
| 底层结构 | 动态连续数组 | 双向循环链表 |
| 随机访问 | 支持,O (1) | 不支持,O (n) |
| 尾插尾删 | O (1)(扩容除外) | O(1) |
| 中间插入删除 | O (n)(需挪动数据) | O (1)(只需改指针) |
| 内存分布 | 连续,缓存命中率高 | 不连续,缓存不友好 |
| 迭代器类型 | 随机访问迭代器 | 双向迭代器 |
| 排序方式 | std::sort(v.begin(), v.end()) |
自带 lt.sort() 成员函数 |
list 的特有操作
因为不支持随机访问,list 提供了很多专属成员函数:
sort():链表专属排序,效率略低于通用 sort 但无需随机访问unique():去重,使用前必须先排序splice():链表拼接,直接将另一个链表的节点转移过来,无需拷贝
五、补充细节
1. emplace_back 与 push_back
push_back(val):先构造临时对象,再拷贝 / 移动到容器尾部emplace_back(args...):直接在容器尾部调用构造函数生成对象,省去一次拷贝,自定义类型下效率更高
2. initializer_list 列表初始化
C++11 引入 std::initializer_list,让 vector 支持 {1,2,3,4,5} 这种直观的列表初始化,本质是编译器自动构造 initializer_list 对象传入构造函数。
3. const 迭代器
begin() / end() 提供 const 重载:const 对象返回 const_iterator,只能读不能写,保证常量语义。
总结
vector适合频繁随机访问、以尾插尾删为主的场景,内存连续、缓存友好,是大多数场景的首选。list适合频繁在中间插入删除、不需要随机访问的场景,节点不移动、迭代器相对更安全。- 理解底层扩容、拷贝控制和迭代器失效,是写出高效、安全 STL 代码的核心。