本篇目标:
掌握vector部分接口的简单实现
一.vector类的底层实现
1.三个指针
首先通过对前面vector的使用,我们就已经知道了vector可以实例化出int,vector<int>,string等各种类型,无论是内置类型,还是自定义类型,都可以实例化,那么我们自然就想到了vector的底层实现必然是有模板的参与,但是有了模板以后,我们就无法将类中接口的声明与定义放在两个不同的文件里了 ,而通过对string的底层的探索,我们知道了string底层是通过指针来管理一片连续的空间的,那么vector 如何管理一块连续空间?
首先,vector 需要记录三个信息:
- 空间从哪里开始:才能找到存储的元素。
- 已经存了多少个元素 :对应
size()。 - 总共能存多少个元素 :对应
capacity(),用于判断是否需要扩容。
这三个信息,可以通过三个边界位置来表示:
| 指针 | 含义 |
|---|---|
_start |
存储空间的起始位置 |
_finish |
最后一个有效元素的后一个位置 |
_end_of_storage |
整块存储空间的结束位置,即容量边界 |
简化后,成员可以这样写:
cpp
T* _start;
T* _finish;
T* _end_of_storage;
由于元素连续存储,指针相减就能得到元素个数:
cpp
size = _finish - _start;
capacity = _end_of_storage - _start;
例如,已经存了 3 个元素 ,但分配了 5 个元素的空间:
_start位于起点。_finish位于起点后第 3 个元素的位置。_endofstorage位于起点后第 5 个元素的位置。
当 _finish == _end_of_storage 时,说明空间已经用满,再插入就需要扩容。
所以vector的大致框架 为:
在vector.h中,
cpp
#pragma once
#include <iostream>
#include <assert.h>
using namespace std;
namespace kong
{
template<class T>
class vector
{
public:
vector()
:_start(nullptr),
_finish(nullptr),
_end_of_storage(nullptr)
{}
private:
T* _start;
T* _finish;
T* _end_of_storage;
};
}
注意:其实底层也是通过三个指针来实现的,但是目前vector的底层过于复杂,就不带大家来验证了。
2.迭代器
前面我们已经知道,vector 通过起始指针和有效元素的结束指针,确定了元素所在的范围。那么,当我们想在容器外部遍历这些元素时,应该如何访问它们呢?
这些指针属于 vector 的内部成员,我们可以通过 begin() 和 end() 向外提供有效元素范围的起点和终点。遍历时,我们需要一种对象:能够通过 * 访问当前元素 ,通过 ++ 移动到下一个元素,并通过比较判断是否到达终点。这样的对象,就是迭代器。
由于 vector 的元素连续存储,普通指针本身就支持这些操作,因此在模拟实现 vector 时,可以直接使用 T* 作为迭代器类型 。begin() 返回指向第一个元素的迭代器,end() 返回指向最后一个元素后一个位置的迭代器。于是,从 begin() 开始不断向后移动,直到到达 end(),就能遍历所有元素。注意,end() 表示遍历的终点,不能对它进行解引用。
示意图:

迭代器让我们通过统一的方式访问容器中的元素。对于其他存储结构的容器,也可以提供支持相应操作的迭代器,而不必让使用者直接处理它们的内部结构。
所以为了与库保持一致,我们就需要将T*typedef一下,修改后的框架:
cpp
namespace kong
{
template<class T>
class vector
{
public:
typedef T* iterator;
typedef const T* const_iterator;
vector()
:_start(nullptr),
_finish(nullptr),
_end_of_storage(nullptr)
{}
private:
iterator _start;
iterator _finish;
iterator _end_of_storage;
};
}
知道了上面的意思后,迭代器的实现就简单了,如代码所示:
cpp
iterator begin() { return _start; }
iterator end() { return _finish; }
const_iterator cbegin()const { return _start; }
const_iterator cend()const { return _finish; }
顺便将size(),capacity()和empty()实现一下:
cpp
size_t size() const { return _finish - _start; }
size_t capacity() const { return _end_of_storage - _start; }
bool empty() const { return _start == _finish; }
3.reserve接口
我们发现,当 vector 的容量不足以容纳新元素时,需要先进行扩容。而后续的插入等操作,也可能遇到容量不足的问题。因此,我们可以将扩容逻辑单独封装成一个接口,方便其他操作复用,这就引出了 reserve。
reserve(n) 用于保证 vector 的容量至少为 n。如果 n 不大于当前容量,就不需要进行任何操作;如果 n 大于当前容量,就申请新的空间,将原有元素迁移过去,释放旧空间,并更新三个指针。
注意:reserve 只增加容量,不增加有效元素的个数。
代码块:
cpp
template<class T>
void vector<T>::reserve(size_t n)
{
if (n > capacity())
{
T* tmp = new T[n];
if (_start)
{
memcpy(tmp, _start, sizeof(T) * size());
delete[] _start;
}
_start = tmp;
_finish = _start + size();
_end_of_storage = _start + n;
}
}
注意:这个是将reserve的实现放到了类外的,但是我们还要指明这是那一个类里面的,以及添加模板,后续对应一些较长的代码我都是写在类外的,当然大家也可以写在类里面。
4.push_back接口
前面我们已经了解了 vector 如何通过三个指针管理连续空间,以及如何通过迭代器访问其中的元素。那么,当我们需要向 vector 中添加一个新元素时,又该如何操作呢? 这里先考虑最常用的尾部插入操作 ,也就是 push_back 接口。
由于 _finish 指向最后一个有效元素的后一个位置,如果还有剩余容量,就可以将新元素放到这个位置,然后让 _finish 向后移动一个位置。这样,有效元素的个数就增加了一个。
不过,插入之前还需要判断空间是否已经用满 。当 _finish == _endofstorage 时,说明当前容量不足,不能直接插入,需要先扩容:申请更大的空间,将原有元素迁移过去,释放旧空间,并更新三个指针,然后再完成尾部插入。因此,实现 push_back 时,需要先检查容量,再根据情况完成扩容和插入。
代码块:
cpp
template<class T>
void vector<T>::push_back(const T& x)
{
if (_finish == _end_of_storage)
{
//我们需要先找到旧空间的空间大小
size_t newCapacity = capacity() == 0 ? 4 : 2 * capacity();
reserve(newCapacity);
}
*_finish = x;
_finish++;
}
测试代码:
cpp
void test_vector1()
{
kong::vector<size_t> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
v.push_back(4);
v.push_back(5);
v.push_back(6);
v.push_back(7);
v.push_back(8);
for (auto& x : v)
{
cout << x << " ";
}
cout << endl;
}
可是测试结果:

这个居然显示错误了,那么就让我们调试一下吧,如图:
错误如图:

为啥这个_finish的地址是nullptr啊?我不是已经在reserve里面更新了_finish吗?
答案 :我们确实是在reserve里面更新了_finish,但是我们是先更新_start为tmp ,此时**_start指向的是新的地址** ,而**_finish仍然指向旧空间地址** ,那么我们调用size()即_finish-_start时不就是用旧地址减去新地址,然后再加上_start,那么_finish=_start+_finish-_start,那么_finish不还是旧地址吗?而我们构造时_finish不是nullptr吗? 所以我们***nullptr不就报错了吗?** 那么该咋解决?我们可以先更新_finish,但是这样写不太雅观,但是我们可以先保存一下旧的size,后面更新时用old_size+_start即可,更新后的reserve如代码所示:
cpp
template<class T>
void vector<T>::reserve(size_t n)
{
if (n > capacity())
{
//我们需要先找到旧空间的空间大小
size_t old_size = size();
T* tmp = new T[n];
if (_start)
{
memcpy(tmp, _start, sizeof(T) * old_size);
delete[] _start;
}
_start = tmp;
_finish = _start + old_size;
_end_of_storage = _start + n;
}
}
此时我们再进行测试,就可以得出正确的结果了。
5.pop_back()接口
尾部删除的接口实现就非常简单了,如代码:
cpp
template<class T>
void vector<T>::pop_back()
{
assert(!empty());
_finish--;
}
6.insert接口
前面我们已经实现了 push_back,可以在 vector 的尾部插入数据,那么如果我们想在开头或者中间插入数据,又该怎么办呢?这就需要引入 insert 接口了。我们可以通过迭代器指定插入位置,将新的数据插入到这个位置。
那么该如何实现呢?由于 vector 管理的是一块连续空间,我们不能直接将数据放到指定位置,否则就会覆盖原来的数据。因此,我们需要先将插入位置及其后面的元素,从后往前依次向后移动一位,腾出位置后再放入新的数据,最后让 _finish 向后移动一位 。当然,如果空间已经满了,就需要先通过 reserve 扩容,再进行插入。
然后我们这个insert的移动与string中的insert移动也一样,就不再演示了。
代码块:
cpp
template<class T>
void vector<T>::insert(iterator pos, const T& x)
{
assert(pos >= _start && pos <= _finish);
if (_finish == _end_of_storage)
{
size_t newCapacity = capacity() == 0 ? 4 : 2 * capacity();
reserve(newCapacity);
}
// 将插入位置及其后面的元素整体向后移动一位
std::memmove(pos + 1, pos, sizeof(T) * (_finish - pos));
*pos = x;
_finish++;
}
测试代码:
cpp
void test_vector2()
{
kong::vector<int> v;
v.insert(v.begin(), 1);
v.insert(v.begin()+1, 2);
v.insert(v.begin()+2, 3);
v.insert(v.begin()+1, 4);
v.insert(v.begin()+2, 5);
v.insert(v.begin()+5, 6);
for (auto& x : v)
{
std::cout << x << " ";
}
std::cout << std::endl;
}
运行结果:

为啥又出错了呢? 让我们再调试检查一下吧!
如图:


我们仔细一看,嗯?为啥又读到 nullptr 了?
此时,_start 和 _finish 都指向同一个地址,说明还没有插入任何数据;而 _end_of_storage 与 _start 的地址相差十六进制的 0x10,也就是 16 字节,对于 int 类型来说,正好可以存放 4 个元素。这说明空间已经开辟成功了啊 ,可是为什么 pos 仍然是 nullptr 呢?
那么我们再仔细想一下:这个 pos 保存的不还是扩容之前的地址吗?扩容后,_start、_finish 和 _end_of_storage 都已经更新 为新空间中的对应位置,但是 pos 并不会跟着它们自动更新,它仍然保留着原来的值。这里原来的值就是 nullptr,那我们继续使用这个旧的 pos,不就会出错了吗?
所以,我们需要在扩容之前,先保存 pos 相对于 _start 的偏移量 ;等扩容完成后,再根据新的 _start 和之前保存的偏移量,重新确定 pos 的位置,这样它才能正确指向新空间中的插入位置。
所以我们还需要更新一下pos,因此我们需要记录一下len,len=pos-_start;
如代码所示:
cpp
template<class T>
void vector<T>::insert(iterator pos, const T& x)
{
assert(pos >= _start && pos <= _finish);
if (_finish == _end_of_storage)
{
size_t len=pos-_start;
size_t newCapacity = capacity() == 0 ? 4 : 2 * capacity();
reserve(newCapacity);
pos=_start+len;
}
// 将插入位置及其后面的元素整体向后移动一位
std::memmove(pos + 1, pos, sizeof(T) * (_finish - pos));
*pos = x;
_finish++;
}
7.erase接口
前面我们已经实现了 insert 接口,可以在指定位置插入数据,那么如果我们想删除指定位置的数据,又该怎么办呢? 这就需要引入 erase 接口了,同样通过迭代器来指定要删除的位置。
那么该如何实现呢?由于 vector 管理的是一块连续空间,删除一个元素后,我们需要将它后面的元素依次向前移动一位,覆盖掉要删除的数据,最后让 _finish 向前移动一位,这样有效元素的个数就减少了一个。这里减少的是 size,而 capacity 不变,因为我们并没有释放原来开辟的空间。
需要注意的是,end() 指向最后一个有效元素的下一个位置,并没有可供删除的元素,所以传入的位置必须满足 pos >= _start && pos < _finish。
代码块:
cpp
template<class T>
voud vector<T>::erase(iterator pos)
{
assert(pos >= _start && pos < _finish);
for (iterator it = pos + 1; it < _finish; it++)
{
*(it - 1) = *it;
}
_finish--;
}
测试代码:
cpp
void test_vector2()
{
kong::vector<int> v;
v.insert(v.begin(), 1);
v.insert(v.begin()+1, 2);
v.insert(v.begin()+2, 3);
v.insert(v.begin()+1, 4);
v.insert(v.begin()+2, 5);
v.insert(v.begin()+5, 6);
v.insert(v.begin()+5, 6);
for (auto& x : v)
{
std::cout << x << " ";
}
std::cout << std::endl;
for (auto it = v.begin(); it < v.end(); )
{
if (*(it) % 2 == 0)
{
v.erase(it);
}
it++;
}
std::cout << std::endl;
for (auto& x : v)
{
std::cout << x << " ";
}
std::cout << std::endl;
}
运行结果:

看这个运行结果是不是非常的奇怪?为啥6没有被删除呢?那么要讲清楚这个就要提到迭代器失效问题了。
8.迭代器失效
通过上面的报错,让我来解释一下吧。
你插入数据后,vector 中的元素为:
cpp
1 4 5 2 3 6 6
当 it 指向 4 时,执行 erase(it),后面的元素依次向前移动,得到:
cpp
1 5 2 3 6 6
在你当前使用指针作为迭代器的实现中,it 保存的地址没有改变,但是这个位置上的数据已经由 4 变成了 5。 如果接着执行 it++,就会走到 2,跳过刚刚移动过来的 5。
跳过奇数暂时看不出问题,但是最后两个连续的 6 就能体现出来:
| 操作 | vector 中的元素 | it 所在位置 |
|---|---|---|
删除前一个 6 之前 |
1 5 3 6 6 |
前一个 6 |
删除后,后一个 6 向前移动 |
1 5 3 6 |
移动过来的 6 |
再执行 it++ |
1 5 3 6 |
新的 end() |
此时循环结束,移动过来的 6 没有被检查,所以最终输出的是:
cpp
1 5 3 6
对于标准 std::vector,erase 会使删除位置及其后面的迭代器失效 ,应该使用它返回的有效迭代器继续遍历 。因此,我们模拟实现的 erase 也可以返回删除位置 :删除后,下一个元素会移动到这里;如果删除的是最后一个元素,这里就是新的 end()。
代码:
cpp
template<class T>
typename vector<T>::iterator vector<T>::erase(iterator pos)
{
assert(pos >= _start && pos < _finish);
for (iterator it = pos + 1; it < _finish; it++)
{
*(it - 1) = *it;
}
_finish--;
return pos;
}
然后我来解释一下这个typename的作用:
在这段代码中:
cpp
template<class T>
typename vector<T>::iterator vector<T>::erase(iterator pos)
typename 的作用是告诉编译器:后面的 vector<T>::iterator 是一个类型名。
我们在类中定义过:
cpp
typedef T* iterator;
所以知道 iterator 是类型 。但是 vector<T> 依赖模板参数 T,这种依赖模板参数的名字称为依赖名 。编译器解析模板时,不能仅凭 vector<T>::iterator 就确定它是类型,还是静态成员变量等其他成员。
因此,写上:
cpp
typename vector<T>::iterator
就是明确告诉编译器:"把它当作类型来解析,它是这个函数的返回类型。"
那么insert其实也会犯同样的问题:如果 insert 触发扩容,it 仍然指向旧空间。虽然 insert 内部修正了 pos,但外面的 it 不会自动更新,所以后面的 *it 和 it++ 都会出问题, 所以我们也应该返回插入后的位置,代码:
cpp
template<class T>
typename vector<T>::iterator vector<T>::insert(iterator pos, const T& x)
{
assert(pos >= _start && pos <= _finish);
if (_finish == _end_of_storage)
{
//我们需要先找到旧空间的空间大小
size_t len = pos - _start;
size_t newCapacity = capacity() == 0 ? 4 : 2 * capacity();
reserve(newCapacity);
pos = _start + len;
}
memmove(pos + 1, pos, sizeof(T) * (_finish - pos));
*pos = x;
_finish++;
return pos;
}
9.resize接口
前面我们已经实现了 reserve 接口,可以调整 vector 的容量,那么如果我们想直接调整有效元素的个数,又该怎么办呢?这就需要引入 resize 接口了。
假设我们要将有效元素的个数调整为 n,那么就需要分情况考虑 :如果 n 小于当前的 size(),就将有效元素的个数缩减到 n;如果 n 大于当前的 size(),就需要补充新的元素,并用指定的值进行初始化。如果没有指定这个值,对于 int 类型,新增元素就初始化为 0。
当然,增加元素时,也要考虑空间是否足够。如果 n 大于当前的 capacity(),就需要先通过 reserve 扩容,再填充新增的元素,最后更新 _finish。所以,reserve 调整的是容量,而 resize 调整的是有效元素的个数;缩小 size 时,容量并不会随之缩小。
代码块:
cpp
template<class T>
void vector<T>::resize(size_t n, const T& x)
{
if (n > capacity())
{
reserve(n);
}
if (n > size())
{
for (size_t i = size(); i < n; i++)
{
push_back(x);
}
}
else
{
_finish = _start + n;
}
}
10.构造函数
我们之在使用vector的构造函数时,是可以用一个vector的迭代器区间构造的,那么这个的实现也是十分的简单的,如代码:
cpp
template <class InputIterator>
vector(InputIterator first, InputIterator last)
{
while (first != last)
{
push_back(*first);
first++;
}
}
之前我们实现的构造是vector的默认构造函数,那么此时我们就需要实现vector的拷贝构造 ,那么就需要用到现代的写法,那么就需要用到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);
}
拷贝构造:
cpp
vector(const vector<T>& v)
{
vector tmp(v.cbegin(), v.cend());
swap(tmp);
}
还有个构造函数,如代码所示:
cpp
vector(size_t n, const T& x = T())
{
reserve(n);
for (size_t i = 0; i < n; i++)
{
push_back(x);
}
}
测试代码:
cpp
void test_vector3()
{
kong::vector<int> v1(10,1);
kong::vector<int> v2(v1);
for(auto&x:ch)
{
cout<<x<<" ";
}
}
运行结果:

这又整啥幺蛾子呢?
答案:
问题主要出在这一句:
cpp
kong::vector<int> v1(10, 1);
你想调用的是"创建 10 个值为 1 的元素"的构造函数,但编译器优先匹配了迭代器构造函数。
看这两个构造函数:
cpp
vector(size_t n, const T& x = T());
template<class InputIterator>
vector(InputIterator first, InputIterator last);
10 和 1 都是 int:
- 第一个构造函数需要将 10 从 int 转成 size_t。
- 第二个构造函数可以直接推导出 InputIterator = int,两个参数都精确匹配。
但是第二个匹配得更好,编译器会尝试实例化:
cpp
vector(int first, int last)
{
while (first != last)
{
push_back(*first);
first++;
}
}
但是first 是 int,不能解引用。
你可以先这样修改调用,让第一个参数明确为 size_t:
cpp
kong::vector<int> v1(10u, 1);
此时两个参数类型分别为 size_t 和 int,迭代器构造函数无法将它们推导为同一个 InputIterator,就会调用你需要的构造函数。
我们也可以再添加一个int的,如代码所示:
cpp
vector(int n, const T& x = T())
{
reserve(n);
for (int i = 0; i < n; i++)
{
push_back(x);
}
}
11.赋值重载函数
首先呢,我们需要重载一下\[\],如代码:
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];
}
然后重载一下=,如代码所示:
cpp
vector<T>& operator=(vector<T> v)
{
swap(v);
return *this;
}
12.析构函数
cpp
~vector()
{
delete[] _start;
_start = _finish = _end_of_storage = nullptr;
}
注意:当我们没有写析构函数时,我们还没有写拷贝构造和关于=的赋值重载,那么此时的拷贝构造和vector和vector间的赋值都是浅拷贝。
13.通用打印
前面我们在测试 vector 时,每次都要写一段遍历代码来打印里面的数据,那么能不能将这部分代码封装起来,写成一个通用的打印函数呢?自然就想到了函数模板,让 Container 表示传入的容器类型。只要容器支持这里使用的迭代器接口,我们就可以通过同一个函数来遍历并打印其中的元素。
由于打印只需要读取数据,不需要修改容器,所以参数使用 const Container&,既避免了拷贝,也限制了对容器的修改。同时,我们通过 cbegin() 和 cend() 获取遍历范围,使用 Container::const_iterator 来访问元素。这里的迭代器类型依赖模板参数 Container,因此在前面加上 typename,明确告诉编译器它是一个类型名。
代码块:
cpp
template<class Container>
void print(const Container& con)
{
typename Container::const_iterator it = con.cbegin();
while (it != con.cend())
{
cout << *it << " ";
it++;
}
cout << endl;
}
14.浅拷贝问题
或许到了这里,有人觉得浅拷贝的问题都已经解决了,倘若我给你个这样的测试用例呢?
cpp
void test_vector3()
{
kong::vector<string> v;
v.push_back("terhsehseheshdhdhhfg");
v.push_back("terhsehseheshdhdhhfg");
v.push_back("terhsehseheshdhdhhfg");
v.push_back("terhsehseheshdhdhhfg");
v.push_back("terhsehseheshdhdhhfg");
v.push_back("terhsehseheshdhdhhfg");
print(v);
}
运行结果:

这个打印结果是乱码而且还报错了吗、,那么这到底是什么问题呢?哪么这就要提到memcpy的浅拷贝问题了。
问题:
这里要分清两层空间:vector 开辟的空间存放的是 string 对象 ,而 string 对象自身还可能管理另一块存放字符的动态空间。
cpp
T* tmp = new T[n];
memcpy(tmp, _start, sizeof(T) * old_size);
我们确实给 vector 开辟了新的空间,但是 memcpy 只是将旧 string 对象的字节原样复制到新对象中。如果旧对象内部保存着指向字符空间的指针,这个指针的地址也会被直接复制,而不会另外开辟字符空间。 这样,新旧两个 string 对象就会指向同一块字符空间,这就是这里所说的浅拷贝问题。
接下来执行:
cpp
delete[] _start;
它不仅释放旧 vector 的空间,还会先调用其中每个 string 对象的析构函数 ,释放它们管理的字符空间 。但是,新对象中复制过来的指针仍然保存着原来的地址,此时这些指针就成了悬空指针。
所以,后续打印时可能访问已经释放的空间,出现乱码或报错;等新对象析构时,还可能再次释放同一块空间,造成重复释放。
示意图 :

所以我们应该将memcpy替换成如下的代码:
cpp
for (size_t i = 0; i < old_size; i++)
{
tmp[i] = _start[i];
}
这时执行的是 T 的赋值操作,如果是自定义类型就调用它的赋值重载函数,没有的话仍然是浅拷贝, 所以当 T 为 std::string 时,会调用它的拷贝赋值运算符,正确复制字符串内容,让新对象拥有独立的字符串数据。这样,销毁旧对象就不会影响新对象。
同理,memmove 也有同样的问题,它和 memcpy 都是直接复制字节 ,不会调用对象的拷贝构造或赋值运算符。两者的区别在于:memmove 支持源区间和目标区间重叠 ,但这并不能解决 string 内部资源的浅拷贝问题。
修改后的insert代码:
cpp
template<class T>
typename vector<T>::iterator vector<T>::insert(iterator pos, const T& x)
{
assert(pos >= _start && pos <= _finish);
if (_finish == _end_of_storage)
{
//我们需要先找到旧空间的空间大小
size_t len = pos - _start;
size_t newCapacity = capacity() == 0 ? 4 : 2 * capacity();
reserve(newCapacity);
pos = _start + len;
}
for (iterator i = _finish; i >pos; i--)
{
*(i) = *(i-1);
}
*pos = x;
_finish++;
return pos;
}
总结:
本篇通过模拟实现 vector 的常用接口,理解了它如何利用三个指针管理连续空间,以及 size 与 capacity 的区别。通过插入、删除和扩容中的问题,我们认识了迭代器失效,并了解了如何通过返回值继续正确遍历。最后,通过 vector<string> 的测试,明白了容器开辟新空间并不意味着其中的对象完成了深拷贝,处理这类对象时,应使用类型自身的拷贝或赋值操作,而不能直接使用 memcpy、memmove 复制字节。