【C++】vector的模拟实现详解(二)
前言
在上一篇文章中,我们已经认识了 C++ 标准库中的 vector,并学习了构造、遍历、扩容、插入和删除等常用接口。
只会调用这些接口还不够。如果想进一步理解 vector 为什么能够自动扩容、size 和 capacity 是怎样维护的、插入数据时为什么要从后向前移动,以及扩容后原来的迭代器为什么会失效,就可以自己模拟实现一个简化版本。
本文将在 qen 命名空间中实现一个 vector 类,主要包括:
- 使用三个指针管理连续空间;
- 普通迭代器、
const迭代器和下标访问; size、capacity、empty、reserve和resize;push_back、pop_back、insert和erase;- 析构、拷贝构造、
swap和赋值运算符; - 扩容与删除引起的迭代器失效;
- 为什么保存类类型时不能直接使用
memcpy搬移元素。
这个版本的目标是理解 vector 的核心结构,并不是重新实现完整的标准库。为了让空间变化更加直观,本文采用 new[]、delete[] 和原生指针完成模拟,暂时不涉及空间配置器、定位 new、移动语义和完整的异常安全。
一、vector 的整体设计
1. 类模板与命名空间
vector 需要保存不同类型的数据,因此使用类模板实现。为了与标准库中的 std::vector 区分,把自己实现的版本放在 qen 命名空间中:
cpp
namespace qen
{
template<class T>
class vector
{
public:
typedef T* iterator;
typedef const T* const_iterator;
// ...
private:
iterator _start = nullptr;
iterator _finish = nullptr;
iterator _end_of_storage = nullptr;
};
}
模板参数 T 表示元素类型。例如:
cpp
qen::vector<int> v1;
qen::vector<double> v2;
同一份类模板可以根据 T 的不同,生成保存不同元素类型的 vector。
2. 三个核心指针
整个实现中最重要的是下面三个指针:
cpp
iterator _start = nullptr;
iterator _finish = nullptr;
iterator _end_of_storage = nullptr;
它们各自负责的内容如下:
| 成员变量 | 作用 |
|---|---|
_start |
指向连续空间的起始位置 |
_finish |
指向最后一个有效元素的下一个位置 |
_end_of_storage |
指向整块已申请空间的下一个位置 |

当对象已经拥有一块有效空间时,三个指针需要满足:
text
_start <= _finish <= _end_of_storage
有效元素保存在左闭右开的区间:
text
[_start, _finish)
整个容量对应:
text
[_start, _end_of_storage)
因此:
text
size = _finish - _start
capacity = _end_of_storage - _start
_finish 既是有效数据的末尾,也是下一次尾插应该写入的位置。当 _finish == _end_of_storage 时,说明当前空间已经没有空余位置,再插入元素就需要扩容。
二、默认构造与析构
1. 默认构造
三个指针已经通过类内成员初始化设为 nullptr,因此默认构造函数可以直接让编译器生成:
cpp
// C++11 引入的可以强制生成默认构造函数
vector() = default;
默认构造完成后,对象处于空状态:
text
_start = nullptr
_finish = nullptr
_end_of_storage = nullptr
= default 表示显式要求编译器生成默认构造函数。这里不需要在函数体中重复给三个指针赋空值。
2. 析构函数
vector 管理的是动态申请的数组空间,因此对象销毁时需要释放它:
cpp
~vector()
{
if (_start)
{
delete[] _start;
_start = _finish = _end_of_storage = nullptr;
}
}
这里使用 delete[],是因为扩容时通过 new T[n] 申请了一组对象。new[] 必须与 delete[] 配对,不能写成普通的 delete。
析构函数执行结束后对象本来就不会继续使用,把三个指针重新置空并不是必需步骤,但能让资源状态更加清楚。
三、迭代器与遍历
1. 普通迭代器与 const 迭代器
这个简化版本的底层空间是连续的,因此可以直接使用原生指针充当迭代器:
cpp
typedef T* iterator;
typedef const T* const_iterator;
普通迭代器可以读取和修改元素,const_iterator 只能读取元素,不能通过它修改元素内容。
对应的 begin 和 end 如下:
cpp
iterator begin()
{
return _start;
}
iterator end()
{
return _finish;
}
const_iterator begin() const
{
return _start;
}
const_iterator end() const
{
return _finish;
}
当对象本身是普通对象时,调用非 const 版本;当对象是 const 对象或 const 引用时,调用带有 const 修饰的版本。
end() 返回的是最后一个有效元素的下一个位置,因此不能对 end() 直接解引用。
2. 使用 const_iterator 遍历
代码中使用一个独立函数输出 vector 中的元素:
cpp
template<class T>
void print_vecotr(const vector<T>& v)
{
// 规定,没有实例化的类模板里面取东西,编译器不能区分这里const_iterator
// 是类型还是静态成员变量
typename vector<T>::const_iterator it = v.begin();
while (it != v.end())
{
std::cout << *it << ' ';
++it;
}
std::cout << std::endl;
}
函数名在当前实现中就是 print_vecotr,虽然单词顺序写成了 vecotr,但所有调用保持一致,所以这里不改变原来的命名。
vector<T>::const_iterator 依赖模板参数 T。编译器在模板实例化前无法确定它表示类型还是静态成员,因此前面需要添加 typename:
cpp
typename vector<T>::const_iterator
这个遍历函数接收 const vector<T>&,不会复制容器,也不会修改其中的元素。
3. const 对象的遍历测试
cpp
void test_const()
{
std::cout << "===== const迭代器测试 =====" << std::endl;
qen::vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
const qen::vector<int>& cv = v;
qen::print_vecotr(cv);
}
输出为:
text
1 2 3
这里传入的是 const 引用,因此 print_vecotr 中调用的是 const 版本的 begin() 和 end()。
四、size、capacity、empty 与 operator\[\]
1. size 与 capacity
三个指针确定之后,size 和 capacity 可以通过指针相减得到:
cpp
size_t size() const
{
return _finish - _start;
}
size_t capacity() const
{
return _end_of_storage - _start;
}
二者的区别为:
| 接口 | 计算方式 | 含义 |
|---|---|---|
size() |
_finish - _start |
当前有效元素个数 |
capacity() |
_end_of_storage - _start |
当前空间最多可以容纳的元素个数 |
这里有一个严格标准层面需要注意的细节:默认构造后三个指针都是 nullptr,而空指针并不属于同一个数组,直接对两个空指针做减法没有标准保证。常见环境通常会得到 0,所以当前测试能够运行;如果继续完善实现,应该在 _start == nullptr 时单独返回 0。
本文保留原来的代码。如果希望用最小改动避开空指针相减,可以把两个接口补充为:
cpp
size_t size() const
{
return _start == nullptr ? 0 : _finish - _start;
}
size_t capacity() const
{
return _start == nullptr ? 0 : _end_of_storage - _start;
}
后面再把这类教学版实现与标准库的区别统一整理。
2. empty
当 _finish 与 _start 相等时,有效区间为空:
cpp
bool empty() const
{
return _finish == _start;
}
测试代码如下:
cpp
void test_empty()
{
std::cout << "===== empty测试 =====" << std::endl;
qen::vector<int> v;
std::cout << "空vector:"
<< v.empty()
<< std::endl;
v.push_back(1);
std::cout << "插入数据后:"
<< v.empty()
<< std::endl;
}
默认情况下,bool 会以 0 和 1 输出:
text
空vector:1
插入数据后:0
3. operator\[\]
连续空间可以直接通过 _start[pos] 访问指定下标:
cpp
T& operator[](size_t pos)
{
assert(pos < size());
return _start[pos];
}
const T& operator[](size_t pos) const
{
assert(pos < size());
return _start[pos];
}
两个版本的区别在返回值:
- 普通对象返回
T&,可以修改元素; const对象返回const T&,只能读取元素。
对应的修改测试为:
cpp
void test_operator()
{
std::cout << "===== operator[]测试 =====" << std::endl;
qen::vector<int> v;
for (int i = 0; i < 5; i++)
{
v.push_back(i);
}
std::cout << "修改前:" << std::endl;
qen::print_vecotr(v);
v[2] = 100;
std::cout << "修改后:" << std::endl;
qen::print_vecotr(v);
}
输出为:
text
修改前:
0 1 2 3 4
修改后:
0 1 100 3 4
assert(pos < size()) 用于在调试阶段检查越界。需要注意,定义 NDEBUG 后断言可能被关闭,因此它不能代替所有正式的错误处理。
五、reserve:扩容并转移数据
1. reserve 的完整实现
reserve 是整个模拟实现中最核心的接口之一。后面的 resize、push_back 和 insert 都会复用它完成扩容:
cpp
// 扩容
void reserve(size_t n)
{
if (n > capacity())
{
//开新空间,拷贝旧空间数据
size_t old_size = size();
T* tmp = new T[n];
// memcpy(tmp, _start, old_size * sizeof(T));
// 不能使用memcpy来直接拷贝数据,否则会有浅拷贝的问题
// 前面我们模拟实现string是因为里面的char是内置类型
// 但对于vector中还要支持内置类型的深拷贝
for (size_t i = 0; i < old_size; i++)
{
tmp[i] = _start[i];
}
delete[] _start;
_start = tmp;
_finish = _start + old_size;
_end_of_storage = _start + n;
}
}
整个扩容过程可以分成五步:
- 只有
n > capacity()时才扩容; - 先保存原来的
size; - 通过
new T[n]申请更大的连续空间; - 把旧空间中的有效元素逐个复制到新空间;
- 释放旧空间,并重新设置三个指针。

扩容时一定要提前保存 old_size。旧空间释放以后,原来的三个指针都不能再参与新空间中的位置计算。
新空间中的三个指针分别为:
cpp
_start = tmp;
_finish = _start + old_size;
_end_of_storage = _start + n;
reserve 只改变容量,不增加有效元素,因此 _finish 仍然根据原来的 old_size 计算。
2. reserve 与迭代器失效
如果 reserve 真正申请了新空间,旧空间就会被释放。原来指向旧空间的迭代器、指针和引用都会失效:
text
旧迭代器 -> 已经释放的旧空间
所以扩容后不能继续访问旧迭代器,应重新调用 begin()、end(),或者根据扩容前保存的相对下标恢复位置。
如果 n <= capacity(),这个实现不会重新分配空间,已有迭代器不会因为这次 reserve 调用而失效。
3. 原注释中需要更正的一个表述
为了保持代码一致,上面的注释没有改动。其中这一句:
cpp
// 但对于vector中还要支持内置类型的深拷贝
更准确的意思应该是:vector 还要支持 string 等自定义类型,尤其是内部管理动态资源的类类型。
int、char 等内置类型直接复制即可,一般不存在多个对象重复释放同一资源的问题。真正需要避免 memcpy 浅拷贝问题的是管理资源的类类型,后面会单独展开。
六、resize:改变有效元素个数
resize 改变的是 size,而不是只改变 capacity:
cpp
// 其实对于内置类型而言也是有构造函数的
// 也是为了更好支持 T val = T() 这种写法
// 比如: int i = int(); int j = int(2);
void resize(size_t n, T val = T())
{
// 小于说明要删除数据到n
if (n < size())
{
_finish = _start + n;
}
else
{
reserve(n);
while (size() < n)
{
*_finish = val;
++_finish;
}
}
}

根据 n 的大小,可以分成三种情况。
1. n 小于当前 size
cpp
_finish = _start + n;
只需要让 _finish 向前移动,逻辑上的有效元素个数就会缩小,capacity 不变。
2. n 大于 size,但不超过 capacity
reserve(n) 不会申请新空间,循环直接在已有空余位置写入 val:
cpp
while (size() < n)
{
*_finish = val;
++_finish;
}
3. n 大于 capacity
先通过 reserve(n) 扩容,再把新增位置填成 val。
默认参数写成:
cpp
T val = T()
对于 int,T() 得到 0;对于类类型,会调用对应的默认构造逻辑来产生默认值。
当前实现使用 new T[n] 一次性构造整块数组,所以缩小 size 时只是移动 _finish,不会立即析构被移出有效区间的类对象。这与真正的 std::vector 不完全相同,属于简化对象生命周期带来的差别。
七、push_back 与 pop_back
1. push_back
尾插时,_finish 正好指向新元素应该写入的位置:
cpp
void push_back(const T& x)
{
if (_finish == _end_of_storage)
{
reserve(capacity() == 0 ? 4 : capacity() * 2);
}
*_finish = x;
++_finish;
}
如果空间已满,就按照下面的策略扩容:
cpp
capacity() == 0 ? 4 : capacity() * 2
- 第一次插入时申请
4个元素的空间; - 后面容量不足时扩成原来的
2倍。
这只是当前模拟实现选择的增长策略,不代表所有标准库实现都必须按照相同倍数扩容。
扩容完成后,在 _finish 位置写入新元素,再让 _finish 向后移动一个位置。
测试代码如下:
cpp
void test_push_back()
{
std::cout << "===== push_back测试 =====" << std::endl;
qen::vector<int> v;
for (int i = 1; i <= 10; i++)
{
v.push_back(i);
std::cout << "插入:" << i
<< " size=" << v.size()
<< " capacity=" << v.capacity()
<< std::endl;
}
qen::print_vecotr(v);
}
按照当前增长策略,容量变化为:
text
插入:1 size=1 capacity=4
插入:2 size=2 capacity=4
插入:3 size=3 capacity=4
插入:4 size=4 capacity=4
插入:5 size=5 capacity=8
插入:6 size=6 capacity=8
插入:7 size=7 capacity=8
插入:8 size=8 capacity=8
插入:9 size=9 capacity=16
插入:10 size=10 capacity=16
2. pop_back
尾删不需要搬移前面的元素,只要让 _finish 向前移动即可:
cpp
void pop_back()
{
assert(!empty());
--_finish;
}
调用前必须保证 vector 不为空,否则 _finish 会越过 _start。这里使用断言检查:
cpp
assert(!empty());
对应的测试为:
cpp
void test_pop_back()
{
std::cout << "===== pop_back测试 =====" << std::endl;
qen::vector<int> v;
for (int i = 1; i <= 5; i++)
{
v.push_back(i);
}
std::cout << "删除前:" << std::endl;
qen::print_vecotr(v);
v.pop_back();
v.pop_back();
std::cout << "删除后:" << std::endl;
qen::print_vecotr(v);
}
输出为:
text
删除前:
1 2 3 4 5
删除后:
1 2 3
和 resize 缩小时一样,当前版本只是移动 _finish,没有立即析构被尾删的类对象。
八、insert:指定位置插入
1. insert 的实现
cpp
iterator insert(iterator pos, const T& x)
{
assert(pos >= begin() && pos <= end());
if (_finish == _end_of_storage)
{
size_t len = pos - _start;
reserve(capacity() == 0 ? 4 : capacity() * 2);
pos = _start + len;
}
iterator end = _finish;
while (end > pos)
{
*end = *(end - 1);
--end;
}
*pos = x;
++_finish;
return pos;
}
插入位置允许位于:
text
[begin(), end()]
pos == end() 表示尾插,因此断言中需要使用 <= end()。
2. 扩容后为什么要重新计算 pos
pos 的底层类型是 T*。如果插入引起扩容,旧空间被释放以后,原来的 pos 就指向无效空间。
因此扩容前先保存 pos 相对于 _start 的距离:
cpp
size_t len = pos - _start;
扩容完成后再根据新 _start 恢复位置:
cpp
pos = _start + len;

这种方法保存的是相对下标,而不是旧空间中的绝对地址,所以空间发生变化后仍然能够找到对应位置。
3. 为什么从后向前移动
插入会让 pos 位置及其后面的元素整体向后移动一个位置:
cpp
iterator end = _finish;
while (end > pos)
{
*end = *(end - 1);
--end;
}
必须从后向前移动。如果从前向后覆盖,前面的元素写入下一个位置后,会把后面还没有搬走的数据提前覆盖掉。
移动完成后,再写入新值并更新 _finish:
cpp
*pos = x;
++_finish;
最后返回指向新插入元素的迭代器:
cpp
return pos;
4. insert 的迭代器失效规则
需要区分是否发生扩容:
- 如果发生扩容,原有全部迭代器、指针和引用失效;
- 如果没有扩容,插入位置及其后面的迭代器、指针和引用失效;
insert的返回值指向新插入的元素,可以接收返回值继续使用。
即使某个旧指针在当前简化实现中看起来仍然指向同一个地址,也不应该继续依赖它表示插入前的元素位置。
5. 中间插入测试
cpp
void test_insert_middle()
{
std::cout << "===== insert中间测试 =====" << std::endl;
qen::vector<int> v;
for (int i = 1; i <= 5; i++)
{
v.push_back(i);
}
v.insert(v.begin() + 2, 100);
qen::print_vecotr(v);
}
输出为:
text
1 2 100 3 4 5
头部插入、中间插入都可能移动后面的元素,因此时间复杂度通常为 O(N);在 end() 位置插入且不扩容时,不需要搬移已有元素。
九、erase:删除指定位置元素
1. erase 的实现
cpp
iterator erase(iterator pos)
{
assert(pos >= begin() && pos < end());
iterator it = pos + 1;
while (it != end())
{
*(it - 1) = *(it);
++it;
}
--_finish;
return pos;
}
删除位置必须是真正存在的元素,因此范围为:
text
[begin(), end())
end() 指向有效区间之后,不能作为单个元素传给这个 erase。

从 pos + 1 开始遍历,把后面的元素依次向前覆盖:
cpp
*(it - 1) = *it;
数据移动完成后,让 _finish 向前移动,并返回原来的 pos。此时 pos 已经指向被删除元素后面的新位置。
如果删除的是最后一个元素,没有元素需要搬移,--_finish 后返回的 pos 正好等于新的 end()。
2. 遍历过程中连续删除偶数
代码中已经给出了正确的更新方式:
cpp
void test_vector02()
{
vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
v.push_back(4);
v.push_back(4);
v.push_back(4);
v.push_back(5);
auto it = v.begin();
while (it != v.end())
{
if ((*it) % 2 == 0)
{
// 迭代器在删除数据后我们认为它失效了,一般不建议使用
// 要用的话需要更新一下,它会指向下一个数据
// 这是对于我写的vector而言,如果是std中的在vs下会直接报错
// 同理对于insert也一样的
it = v.erase(it);
}
else
{
++it;
}
}
print_vecotr(v);
}
输出为:
text
1 3 5
原注释中"在 VS 下会直接报错"描述的是某些调试模式下的实现表现。调试迭代器可能主动检测失效并给出错误,但 C++ 标准只规定继续使用失效迭代器没有正确性保证,并不承诺所有编译器、所有运行模式都会立即报错。
删除成功后不能无条件再执行一次 ++it,否则会跳过刚刚移动到当前位置的元素。正确逻辑是:
- 删除时使用
it = v.erase(it)接收下一个位置; - 不删除时才执行
++it。
erase 会使删除位置及其后面的迭代器和引用失效,删除位置之前的迭代器仍然有效。这个实现不会因为 erase 缩小容量,所以 _end_of_storage 保持不变。
中间删除需要移动后面的元素,时间复杂度通常为 O(N)。
十、为什么不能默认使用 memcpy 搬移任意元素
1. memcpy 只复制二进制内容
如果 T 是 int、char 等简单类型,直接复制对象的字节通常能够得到相同的值。但是 vector 还要能够保存 string 等类对象:
cpp
#include <string>
qen::vector<std::string> v;
v.push_back("1111");
v.push_back("2222");
v.push_back("3333");
string 对象内部通常会管理自己的资源。如果直接使用:
cpp
memcpy(tmp, _start, old_size * sizeof(T));
复制的只是对象的二进制表示。假设对象内部保存一个指向动态空间的指针,那么复制后两个对象可能得到相同的地址。旧空间释放时,其中一个对象会释放该资源,新对象中的指针就可能变成悬空指针,后面还可能发生重复释放。

2. 逐个复制会调用元素类型自己的逻辑
当前 reserve 使用循环:
cpp
for (size_t i = 0; i < old_size; i++)
{
tmp[i] = _start[i];
}
对于类类型,这会调用 T 的赋值运算符。只要 T 自己正确实现了资源管理,复制后的对象就能够拥有独立、有效的状态。
这里更准确的说法不是"vector 自动完成深拷贝",而是:vector 通过正常的元素赋值操作,让元素类型自己的复制语义得到执行。
因此,只有当类型满足适合按字节复制的条件时,才能考虑底层二进制搬移。通用容器不能默认把任意 T 都交给 memcpy。
十一、拷贝构造、swap 与赋值
vector 拥有动态资源,如果直接使用编译器生成的浅拷贝,多个对象会保存相同的 _start,最终导致同一块空间被重复释放。因此需要自己处理拷贝控制。
1. 拷贝构造
cpp
vector(const vector<T>& v)
{
reserve(v.capacity());
for (const auto& e : v)
{
push_back(e);
}
}
先按照源对象的容量申请自己的空间,再遍历源对象,把每个有效元素依次尾插到新对象中。
因为参数 v 是 const vector<T>&,范围 for 会调用 const 版本的 begin() 和 end()。
拷贝完成后,两个 vector 的三个指针分别指向各自的空间,修改或销毁其中一个对象不会影响另一个对象。
当前实现选择保留源对象的 capacity。真正的 std::vector 进行拷贝后具体得到多大容量并不是这里需要依赖的固定规则。
2. swap
cpp
void swap(vector<T>& v)
{
std::swap(_start, v._start);
std::swap(_finish, v._finish);
std::swap(_end_of_storage, v._end_of_storage);
}
交换两个 vector 时,不需要逐个交换元素。只要交换三个指针,两个对象管理的整块空间就完成了互换,时间复杂度为 O(1)。
3. 赋值运算符
cpp
vector<T>& operator=(vector<T> v)
{
swap(v);
return *this;
}
这里的参数不是引用,而是按值接收:
cpp
vector<T> v
调用赋值运算符时,形参 v 会先通过拷贝构造得到一份独立副本。进入函数以后,把当前对象与副本交换:
cpp
swap(v);
交换后:
- 当前对象接管副本中的新数据;
- 形参
v接管当前对象原来的旧空间; - 函数结束时,
v析构并释放旧空间。

这种写法通常称为 copy-and-swap。它把复制、资源替换和旧资源释放组合在一起,自赋值也能够自然处理。
4. 拷贝与赋值测试
cpp
void test_vector03()
{
vector<int> v1;
v1.push_back(1);
v1.push_back(2);
v1.push_back(3);
v1.push_back(4);
print_vecotr(v1);
vector<int> v2 = v1;
print_vecotr(v2);
vector<int> v3;
v3.push_back(10);
v3.push_back(20);
v3.push_back(30);
v1 = v3;
print_vecotr(v1);
print_vecotr(v3);
}
输出为:
text
1 2 3 4
1 2 3 4
10 20 30
10 20 30
前两行验证拷贝构造,后两行验证赋值运算符。赋值后 v1 与 v3 内容相同,但底层空间彼此独立。
十二、当前测试的调用关系
Main.cpp 中已经准备了 push_back、operator[]、头部插入、中间插入、尾部插入、尾删、判空和 const 遍历等测试。
不过当前 main 实际执行的是:
cpp
void test2()
{
//qen::test_vector02();
qen::test_vector03();
}
int main()
{
//test1()
test2();
return 0;
}
因此当前一次运行只会执行 test_vector03(),也就是拷贝构造和赋值测试。其他测试函数虽然已经写好,但在 test1() 中处于注释状态;连续删除偶数的 test_vector02() 也处于注释状态。
如果需要逐项观察,可以一次放开一个测试,避免多组输出混在一起。已有测试的覆盖关系如下:
| 测试函数 | 主要验证内容 |
|---|---|
test_push_back |
尾插、size 与扩容过程 |
test_operator |
非 const 下标访问与修改 |
test_insert_head |
头部插入 |
test_insert_middle |
中间插入与元素后移 |
test_insert_tail |
在 end() 位置插入 |
test_pop_back |
连续尾删 |
test_empty |
空状态判断 |
test_const |
const_iterator 遍历 |
test_vector02 |
使用 erase 返回值连续删除 |
test_vector03 |
拷贝构造与赋值运算符 |
还可以继续补充下面这些边界测试:
- 显式调用
reserve和resize; - 插入时刚好触发扩容;
- 接收并使用
insert的返回值; - 删除最后一个元素,检查返回值是否等于新的
end(); - 空
vector的拷贝与赋值; - 自赋值
v = v; - 拷贝后修改其中一个对象,验证另一个对象不受影响。
十三、当前实现与 std::vector 的区别
到这里,一个简化的 vector 已经能够完成常见的空间管理、访问、插入、删除和拷贝操作。不过它仍然不能直接等同于标准库中的 std::vector。
1. 当前实现的接口范围
| 类别 | 已实现 | 还未实现 |
|---|---|---|
| 构造与拷贝 | 默认构造、拷贝构造、赋值、析构 | 指定个数构造、区间构造、初始化列表、移动构造 |
| 迭代器 | begin、end |
反向迭代器、cbegin、cend |
| 容量 | size、capacity、empty、reserve、resize |
max_size、shrink_to_fit |
| 元素访问 | operator[] |
at、front、back、data |
| 修改 | push_back、pop_back、insert、erase、swap |
clear、assign、emplace 等 |
2. 对象生命周期是简化模型
当前扩容使用:
cpp
T* tmp = new T[n];
这会直接构造 n 个 T 对象,所以当前实现要求 T 能够默认构造。push_back 和 insert 使用的是赋值,而不是在未构造的原始空间中创建对象。
真正的 std::vector 会把"申请原始空间"和"在指定位置构造元素"分开,只在 [0, size) 范围内维护已经构造的对象。因此标准库可以更精确地控制元素的构造与析构,也能支持更多类型。
这也解释了为什么当前版本的 pop_back、erase 和缩小 resize 只移动 _finish,不会立即析构被移除元素。对于管理资源的类对象,资源可能一直保留到整块数组被 delete[]。
3. 暂时不支持只移动、不可复制的类型
扩容时使用:
cpp
tmp[i] = _start[i];
这要求元素能够默认构造并进行复制赋值。只支持移动、不允许复制的类型暂时无法正常使用。
4. 参数引用容器内部元素时还有边界问题
例如:
cpp
v.push_back(v[0]);
如果这次尾插刚好引起扩容,参数 x 引用了旧空间中的元素。reserve 释放旧空间以后,x 会变成悬空引用,后面再执行:
cpp
*_finish = x;
就没有正确性保证。insert(pos, x) 在 x 引用容器内部元素时,也要考虑扩容和元素搬移带来的影响。
这属于当前简化实现还可以继续完善的地方,不能把它当成完整标准库直接替换使用。
5. 异常安全还不完整
reserve 申请 tmp 后,如果某个元素的赋值操作抛出异常,当前代码没有自动释放 tmp 的保护措施。拷贝构造过程中如果复制元素失败,也可能留下未释放资源。
对于当前 int 测试没有影响,但通用容器需要进一步使用 RAII 等方式保证异常发生时也不会泄漏资源。
6. 迭代器没有调试检查
这里的迭代器就是 T*,不会记录自己属于哪个容器,也不会主动检测是否已经失效。失效迭代器有时看起来还能得到一个数值,但继续使用仍然属于错误行为,不能根据"这一次没有崩溃"判断它仍然有效。
7. 头文件与工程组织还可以完善
当前代码使用了 std::swap,为了减少对其他头文件间接包含的依赖,更完整的写法应显式包含 <utility>。
另外,test_vector02 和 test_vector03 是定义在头文件中的普通非模板函数。当前只有一个 .cpp 包含该头文件时没有问题;如果多个 .cpp 同时包含它们,链接时可能出现重复定义。测试函数更适合放进单独的 .cpp 文件,或者根据需要使用合适的内联方式。
这些问题不影响当前单文件测试,但能够帮助我们区分"用于理解原理的模拟实现"和"真正可复用的通用容器"。
十四、总结
本文完成了一个简化 vector 类的模拟实现。
首先使用 _start、_finish 和 _end_of_storage 管理连续空间,三个指针分别表示空间起点、有效数据末尾和容量末尾。根据指针距离可以得到 size 与 capacity,而普通指针又可以直接作为 iterator 和 const_iterator。
容量不足时,reserve 申请新空间、逐个复制有效元素、释放旧空间,再重新设置三个指针。真正发生扩容后,所有指向旧空间的迭代器、指针和引用都会失效。resize 在此基础上改变有效元素个数,push_back 则复用 reserve 完成自动扩容与尾插。
中间插入时,需要先保存 pos 相对于 _start 的距离,扩容后再重新计算位置,并从后向前移动元素;删除时则把后面的元素向前覆盖。erase 返回删除位置之后的新迭代器,所以遍历删除时应使用 it = v.erase(it) 更新当前位置。
由于元素类型可能是管理资源的类对象,扩容不能简单地使用 memcpy 进行二进制拷贝,而要让元素自身的复制语义得到执行。容器本身也需要正确管理资源,通过析构函数释放空间,通过拷贝构造建立独立副本,再利用 swap 和传值形参完成 copy-and-swap 赋值。
当前版本仍然采用简化的对象生命周期模型,没有完整实现标准库的全部接口、移动语义和异常安全。即使如此,把这些功能串联起来以后,已经能够看清 vector 最核心的几个问题:连续空间管理、扩容搬移、元素构造与复制、迭代器失效以及资源所有权。
完