前言
STL 容器是 C++ 开发中绕不开的'神兵利器',而 vector 作为最常用的动态数组容器,更是新手入门 STL 的核心内容。但多数时候,我们只是'会用'vector,却对它的底层逻辑一知半解——比如它如何动态扩容?push_back 的内存管理是怎样的?构造函数的匹配规则为何如此复杂?
与其停留在'黑盒调用'的层面,不如亲手模拟实现一个 vector:从底层的指针管理(_start/_finish/_endofstorage),到核心接口(push_back/resize/operator[]),再到构造、拷贝等特殊函数的实现,一步步揭开 STL 容器的面纱。
本文不会纠结过于晦涩的标准细节,而是以'实用、易懂'为核心,带你用 C++ 手动实现一个具备基础功能的 vector——既能加深对容器原理的理解,也能锻炼 C++ 的底层编程能力。
一、vector 的介绍
简单说,vector 是 C++ 里可以动态变大小的数组——它既保留了普通数组'连续存储、下标访问高效'的优点,又解决了普通数组'大小固定、不能自动扩容'的痛点。
它的底层逻辑是动态分配的数组:
当你往 vector 里加新元素时,它会自动管理内存——如果当前数组存满了,就会重新分配一块更大的空间,把旧元素全搬过去,再继续存新元素(不过这个'搬家'操作开销不低,所以 vector 会提前多分配一些额外空间,避免频繁搬家)。
它的特点可以总结成'优劣势分明':
**优势:**访问元素(用下标)、在末尾增删元素,效率都很高;
**劣势:**在中间 / 开头增删元素,效率很低(因为要挪动大量元素);
对比其他容器(比如 list):vector 的访问更快,但灵活增删不如 list。
1. vector 的定义
namespace ayj
template <class T>
class vector {
public:
typedef T* iterator;
typedef const T* const_iterator;
private:
iterator _start = nullptr;
iterator _finish = nullptr;
iterator _endofstorage = nullptr;
};
}
这就是 vector 的核心底层结构——通过三个指针(_start/_finish/_endofstorage)管理连续内存,既实现了数组的高效访问,又能动态调整容量。
二、vector 的默认成员函数
1. 构造函数
vector() : _start(nullptr), _finish(nullptr), _endofstorage(nullptr) {}
vector(size_t n, const T& val = T()) { resize(n, val); }
template<class InputIterator>
vector(InputIterator first, InputIterator last) {
while (first != last) {
push_back(*first);
++first;
}
}
讲一个特殊的场景:
按道理来说我们是想用 10 个 1 构造一个 vector 这没啥问题吧,那为啥会编译不通过那,而且报的错误还是和迭代器范围构造相关的。
这是因为 vector<int> v(10, 1) 会误匹配此版本(10/1 被当作 InputIterator),此时 *first 会把 10 当作指针解引用,触发未定义行为(崩溃)。
这个时候有两种办法解决这个问题:
方法 1:
将 10 显式指定为 size_t 类型(写成 10u),这样编译器会优先匹配填充构造函数(而非范围构造函数),从而正常创建包含 10 个值为 1 的元素的 vector。
方法 2:
新增一个以 int 类型为参数的填充构造函数重载版本,让 vector<int> v(10, 1) 这类传入 int 型数值的场景,能直接匹配该重载函数,避免编译器误匹配范围构造函数。
vector(int n, const T& val = T()) { resize(n, val); }
C++ 函数匹配规则:
2. 拷贝构造函数
vector(const vector<T>& v) :
_start(nullptr), _finish(nullptr), _endofstorage(nullptr) {
_start = new T[v.capacity()];
for (size_t i = 0; i < v.size(); i++) {
_start[i] = v._start[i];
}
_finish = _start + v.size();
_endofstorage = _start + v.capacity();
}
vector(const vector<T>& v) :
_start(nullptr), _finish(nullptr), _endofstorage(nullptr) {
reserve(v.capacity());
for (size_t i = 0; i < v.size(); i++) {
push_back(v[i]);
}
}
先来说传统写法:先给数组开辟对应空间,然后进行数据拷贝,最后更新 _finish 和 _endofstorage 指针。
这里需要注意:拷贝数据不能用 memcpy。因为如果数据类型是带有动态资源的自定义类型(比如 string),用 memcpy 拷贝会引发浅拷贝问题——它只会复制指针的值,不会复制指针指向的动态资源,最终导致多个对象共用同一块内存空间。
这个解决方案其实很简单:直接通过赋值操作完成拷贝即可。因为对于 string 这类自定义类型,赋值时会自动调用其赋值运算符重载函数,从而实现内部动态资源的深拷贝;而对于 int、double 等内置类型,赋值就是简单的数值拷贝,不会有任何额外影响。
3. 赋值运算符重载
void swap(vector<T>& v) {
std::swap(_start, v._start);
std::swap(_finish, v._finish);
std::swap(_endofstorage, v._endofstorage);
}
vector<T>& operator=(vector<T> v) {
swap(v);
return *this;
}
operator= 的参数是值传递(vector<T> v),会自动调用拷贝构造函数生成实参的临时副本;
借助 std::swap 交换当前对象和临时对象的内部指针,临时对象出作用域时会自动释放原当前对象的旧资源,避免内存泄漏;
返回 *this 以支持连续赋值(如 v1 = v2 = v3),符合赋值运算符重载的规范。
vector<T>& operator=(vector<T>& v) {
T* tmp = new T[v.capacity()];
for (size_t i = 0; i < v.size(); i++) {
tmp[i] = v._start[i];
}
delete[] _start;
_start = tmp;
_finish = _start + v.size();
_endofstorage = _start + v.capacity();
return *this;
}
这段赋值重载传统写法,先开辟临时空间深拷贝数据,再释放当前对象旧空间,最后更新指针并返回当前对象引用,实现安全且正确的深拷贝赋值。
4. 析构函数
~vector() {
if (_start) {
delete[] _start;
_start = _endofstorage = _finish = nullptr;
}
}
三、vector iterator
iterator begin() {
return _start;
}
iterator end() {
return _finish;
}
const_iterator begin() const {
return _start;
}
const_iterator end() const {
return _finish;
}
迭代区间始终遵循 [begin, end) 的规则,以下是迭代器 begin() 与 end() 的边界定位示意图:
begin() 指向容器的第一个有效元素,end() 指向最后一个有效元素的下一个位置。
反向迭代器我们先暂时放一放,等后面学到 list 的反向迭代器时,vector 的反向迭代器写法也就自然掌握了。
四、vector 容量相关接口
1. size
size_t size() const {
return _finish - _start;
}
2. capacity
size_t capacity() const {
return _endofstorage - _start;
}
3. empty
bool empty() const {
return size() == 0;
}
4. reserve
void reserve(size_t n) {
if (n > capacity()) {
size_t sz = size();
T* tmp = new T[n];
if (_start) {
for (size_t i = 0; i < sz; i++) {
tmp[i] = _start[i];
}
delete[] _start;
}
_start = tmp;
_finish = _start + sz;
_endofstorage = _start + n;
}
}
该接口用于为 vector 预留至少 n 个元素的存储空间,仅当 n 大于当前容器容量时才执行扩容操作;扩容时先记录当前有效元素个数,开辟 n 个大小的临时空间,通过循环调用元素赋值重载完成深拷贝(解决自定义类型动态资源的浅拷贝问题),随后释放原空间,更新容器的 _start、_finish、_endofstorage 指针,使容器容量更新为 n,且保留原有全部有效元素,有效避免频繁扩容带来的性能损耗。
扩容接口有两个特殊问题:
- 自定义类型动态资源的浅拷贝问题:当容器存储的是带动态资源的自定义类型时,直接用 memcpy 拷贝会导致浅拷贝,这一问题的解决思路在赋值运算符重载和拷贝构造的内容中已有说明,此处不再赘述;
- 必须提前用 sz 记录当前有效元素个数:因为扩容后原空间会被释放,
_start 指针会指向新空间,若此时再调用 size() 获取元素个数,会因 _start 和 _finish 不再指向原空间而计算错误,所以需要在扩容前先把当前元素个数存到 sz 变量中。
5. resize
void resize(size_t n, const T& val = T()) {
if (n < size()) {
_finish = _start + n;
}
else {
reserve(n);
while (_finish != _start + n) {
*_finish = val;
_finish++;
}
}
}
这段 resize 接口用于调整 vector 的有效元素个数:
第二个参数的缺省值是调用默认构造,内置类型由于加入了模板,也有了自己的默认构造。
当 n 小于当前元素数时,直接截断(仅调整 _finish 指针,保留前 n 个元素);
当 n 更大时,先调用 reserve 预留空间,再将新增位置填充为指定值,最终更新 _finish 指针,既支持缩小容器,也能安全扩容并填充元素。
五、vector 增删查改
1. push_back
void push_back(const T& x) {
if (_finish == _endofstorage) {
size_t newcapacity = capacity() == 0 ? 4 : capacity() * 2;
reserve(newcapacity);
}
*_finish = x;
++_finish;
}
2. insert
iterator insert(iterator pos, const T& x) {
assert(pos >= _start && pos <= _finish);
if (_finish == _endofstorage) {
size_t len = pos - _start;
size_t newcapacity = capacity() == 0 ? 4 : capacity() * 2;
reserve(newcapacity);
pos = _start + len;
}
iterator end = _finish - 1;
while (end >= pos) {
*(end + 1) = *end;
end--;
}
*pos = x;
++_finish;
return pos;
}
迭代器失效场景 1:扩容引发的野指针问题
insert 的参数 pos 在扩容时,原内存空间会被销毁回收,此时 pos 指向的旧地址会失效并变为野指针;而 (*pos)++ 对这个野指针进行了解引用操作,最终导致程序崩溃。
那我们该如何解决这个问题呢?
我标记的这两步是为了解决 pos 迭代器在函数内部失效的问题,通过记录扩容前 pos 和 _start 的相对位置以便于在扩容后更新 pos 的位置,让其指向新空间的当前位置。
返回更新后的 pos,解决外部迭代器失效问题。
pos 为啥不能传引用解决外部失效问题?
第一个调用 v.insert(pos, 5):传的是 pos 变量(左值迭代器),引用传参能直接修改外部 pos,所以没问题;
第二个调用 v.insert(v.begin(), 5):传的是临时迭代器(右值),临时数据自带'常性'(不可修改),而引用传参要求修改它,这属于'权限放大',因此会编译失败。
迭代器失效场景 2:非扩容下的逻辑错位失效
若未触发扩容:插入点之后的迭代器全部失效(元素后移导致指向错位,属于逻辑错位);
3. erase
iterator erase(iterator pos) {
assert(pos >= _start && pos < _finish && size() > 0);
iterator it = pos + 1;
while (it != _finish) {
*(it - 1) = *it;
it++;
}
--_finish;
return pos;
}
迭代器失效场景 3:erase 导致的逻辑错位失效
情况 1:删除单个偶数(巧合没报错)
情况 2:删除连续偶数(迭代器失效导致跳过元素)
这个没删干净哦~,这是因为在删除第一个 2 后,这时候 it 指向第二个 2,但是 it++ 跳过了。
情况 3:删除末尾偶数(迭代器失效导致崩溃)
删除末尾偶数会触发死循环,当遍历到末尾偶数 6 并执行 erase(it) 后,it 迭代器失效且与更新后的 vector 尾指针重合,后续的 it++ 无法正常推进迭代器,导致循环条件 it != v.end() 永远成立,循环无法退出而陷入死循环,部分运行环境中也可能因失效迭代器操作触发内存越界崩溃。
综上所述,上述所有问题(巧合未报错、跳过元素、死循环 / 崩溃),本质都是错误的遍历删除逻辑导致的迭代器失效:代码直接在 erase 后执行 it++,没有利用 erase 返回的有效迭代器更新 it,使得失效的迭代器参与后续操作,进而引发未定义行为(结果随机、逻辑错乱、程序异常)。
正确的写法应是用 erase 的返回值更新迭代器(删除元素时不执行 it++,仅在未删除时推进),避免使用失效的旧迭代器,才能保证代码的正确性与稳定性。
迭代器失效解决方案
核心结论:对 vector 执行 erase 或 insert 操作后,原迭代器必然失效(可能因内存销毁成野指针,或因元素移位逻辑错位),此时再访问该迭代器的结果是未定义的(可能巧合运行、跳过元素、死循环甚至崩溃)。
解决方案:若需继续操作容器,必须用 erase/insert 返回的新有效迭代器替代旧迭代器——这是 C++ 标准规定的唯一安全方式,确保每次操作都基于容器当前的有效状态。
简言之:旧迭代器作废,用返回的新迭代器续接操作。
不同编译器对失效迭代器的访问处理存在差异:比如在 VS 编译器中,一旦使用了失效的迭代器,程序会直接报错。
4. pop_back
void pop_back() {
erase(--end());
}
5. operator[]
T& operator[](size_t pos) {
assert(pos < size());
return _start[pos];
}
const T& operator[](size_t pos) const {
assert(pos < size());
return _start[pos];
}
容器的 [] 访问比普通数组多了什么约束?
容器的 [] 访问多了逻辑 size 约束;
要求访问下标必须小于容器的 size(已初始化的有效元素数量),而普通数组仅受物理内存范围限制,无逻辑有效性检查。
问题:下面代码为啥会崩溃?
vector<int> v;
v.reserve(10);
for (int i = 0; i < 10; i++) {
v[i] = i;
}
reserve(10) 仅预留内存,vector 的 size 仍为 0,直接用 v[i] = i([] 下标访问运算符重载规定,i < size)属于越界访问未初始化内存,会导致未定义行为(如崩溃)。
正确做法:用 resize(10) 初始化 10 个元素(size=10),再下标赋值;或 reserve 后用 push_back 添加元素。
6. find(算法模块的接口)
vector 自身并没有提供 find 接口,因此如果需要查找元素,要调用算法库中的 find 接口来实现。
template <class InputIterator, class T>
InputIterator find(InputIterator first, InputIterator last, const T& val) {
while (first != last) {
if (*first == val) {
return first;
}
first++;
}
return last;
}