1. 前言
相较于 string ,vector 相当于一个简洁版的 string ,它们两个容器的函数的功能都一致,只是在函数参数上略有差异。尽管如此,vector 还是存在一些自身的特点,这些会在模拟实现的同时一一介绍;vector 的许多函数的实现都与 string 差不多,所以实现起来会更简单。
2. vector 的模拟实现
vector的原型为:template < class T, class Alloc = allocator<T> > class vector,是一个模板参数,我们在实现时也是使用模板参数。需要注意的是模板的声明和定义不能分离。在实现 vector 的接口之前,先实现 vector 的底层结构,vector 的底层结构与 string 的类似,但是我们按照 vector 源码中的底层结构去实现,vector 源码的底层结构如下图所示:
由上图可以提取到很多信息,vector 的底层结构是三个迭代器,而模板参数 T 被重命名为 value_type ,value_type* 被重命名为 iterator , const value_type* 被重命名为 const_iterator,可以认为 iterator 是由 T* 重命名而来,与之前实现 string 时类似。value_type 被重命名为 reference,const value_type& 被重命名为 const_reference,size_t 被重命名为 size_type。以上的参数会在 vector 的函数中出现。vector 容器的底层结构如代码下所示:
namespace AY
{
template<class T>
class vector
{
public:
typedef T* iterator;
private:
iterator m_start;
iterator m_finish;
iterator m_end_of_storage;
};
}
这三个迭代器指向如何呢?如下图所示:
m_start 指向数组的首元素
m_finish 指向最后一个元素的下一个位置(即有效元素的末尾)
m_end_of_storage 指向内存空间末尾的下一个位置
2.1 无参的构造函数,size 函数和 capacity 函数
2.1.1 无参的构造函数
无参的构造函数的函数原型为:explicit vector(const allocator_type& alloc = allocator_type()) ,函数参数 allocator_type 是空间配置器,库中会提供,一般不用传参数,直接使用缺省值。vector 的三个成员变量均为指针,可以将它们初始化为空指针,代码如下所示:
// 无参的构造函数
vector()
: m_start(nullptr)
, m_finish(nullptr)
, m_end_of_storage(nullptr)
{}
2.2.2 size 函数
size 函数的函数原型为:size_type size() const 。size 函数的作用是返回容器的有效数据个数,可以直接让两个成员变量 —— m_start 和 m_finish 相减,m_start 指向数组的首元素,m_finish 指向有效元素的末尾,两个迭代器相减,即为有效数据个数。代码如下所示:
// size 函数
size_t size() const
{
return m_finish - m_start;
}
2.2.3 capacity 函数
capacity 函数的函数原型为:size_type capacity() const ,该函数的作用为返回当前容器的空间大小(单位是字节),可以直接让两个成员变量 —— m_start 和 m_end_of_storage 相减,m_start 指向数组的首元素,m_end_of_storage 指向内存空间末尾的下个位置,两个迭代器相减,即为当前容器的空间大小。代码如下所示:
// capacity 函数
size_t capacity() const
{
return m_end_of_storage - m_start;
}
2.2 reserve 函数,push_back函数和operator[ ] 函数
2.2.1 reserve 函数
reserve 函数的函数原型为:void reserve(size_type n) ,该函数的作用为扩容,具体操作为开辟新空间,拷贝旧数据,释放旧空间,更新数据。扩容逻辑与 string 中的 reserve 函数的一致,代码如下所示:
// reserve 函数
void reserve(size_t n)
{
if (n > capacity())
{
// 开辟新空间
T* tmp = new T[n];
// 判断数组中的数据是否为0
if (m_start != nullptr)
{
// 拷贝旧数据
memcpy(tmp, m_start, sizeof(T) * size());
// 释放旧空间
delete[] m_start;
}
// 更新数据
m_start = tmp;
m_finish = m_start + size();
m_end_of_storage = m_start + n;
}
}
在拷贝数据之前判断 vector 容器中的有效数据个数是否为0。
2.2.2 push_back函数
push_back函数的函数原型为:void push_back(const value_type& val),该函数的作用为尾插数据。既然要插入数据就需要判断是否要扩容,当有效数据个数等于当前容器的空间大小时,就需要扩容,代码如下所示:
// push_back 函数
void push_back(const T& val)
{
// 判断是否需要扩容
if (m_finish == m_end_of_storage)
{
size_t newcapacity = capacity() == 0 ? 4 : 2 * capacity();
reserve(newcapacity);
}
// 扩容完毕后,插入数据
*m_finish = val;
// 有效数据个数增加 1
++m_finish;
}
2.2.3 operator[ ] 函数
operator[ ] 函数由两个版本,一个是普通版本,一个是 const 版本,这两个版本的函数原型为:reference operator[ ] (size_type n) 和 const_reference operator[ ] (size_type n) const ,该函数的作用为返回对vector容器中位置n的元素的引用,涉及位置需要判断位置是否合法。两个版本的 operator[ ] 函数的实现都是一样的,只是普通容器会调用普通版本的 operator[ ] 函数,const 容器会调用 const 版本的 operator[ ] 函数。代码如下所示:
// 普通版本的 operator[ ] 函数
T& operator[](size_t n)
{
// 判断位置 n 是否合法
assert(n < size());
return m_start[n];
}
// const 版本的 operator[ ] 函数
const T& operator[](size_t n) const
{
// 判断位置 n 是否合法
assert(n < size());
return m_start[n];
}
2.2.4 检验实现的函数是否正确
检验代码如下所示:
// 调用无参的构造函数
vector<int> v1;
// 尾插 5 个数据
v1.push_back(1);
v1.push_back(2);
v1.push_back(3);
v1.push_back(4);
v1.push_back(5);
// 打印
for (size_t i = 0; i < v1.size(); i++)
{
cout << v1[i] << " ";
}
运行上述代码可以发现程序会崩溃,通过调试可以发现是 reserve 函数出现了问题,如下图所示:
从上图可以发现,reserve 函数执行完毕后,成员变量 m_finish 的数据并没有改变,而是 0。如此一来,在执行 push_back 函数的中的 *m_finish 代码时,m_finish 是空指针,对空指针解引用程序会崩溃。明白了问题所在之处后,思考为什么 m_finish 的值会是 0 ?调试可以发现是 size 函数的返回值出现了问题,开辟新空间后,m_start 的值已经改变了,运行到 m_finish(新) = m_start(新) + size() 这行代码,调用 size 函数,返回 m_finish(旧) – m_start(新),加上 m_start(新)之后,得到的结果为:m_finish(新) = m_finish(旧),而 m_finish(旧) 为 0,所以 m_finish(新) 为 0 。
要想解决这个问题,首先得想明白为什么会出现的这个问题,问题出现的原因是 size 函数的返回值有问题,本来是要求得 m_finish(旧) – m_start(旧) 的结果,但是 m_start 的数据更新了。既然如此,可以先将 size 函数的计算结果在开辟新空间前/更新数据前就提前用变量 old_size 存储起来,这样在后续 m_start 更新数据后,直接让 m_start(新) + old_size 就是 m_finish(新) 。代码如下所示:
// reserve 函数
void reserve(size_t n)
{
if (n > capacity())
{
size_t old_size = size();
// 开辟新空间
T* tmp = new T[n];
// 判断数组中的数据是否为0
if (m_start != nullptr)
{
// 拷贝旧数据
memcpy(tmp, m_start, sizeof(T) * old_size);
// 释放旧空间
delete[] m_start;
}
// 更新数据
m_start = tmp;
m_finish = m_start + old_size;
m_end_of_storage = m_start + n;
}
}
2.3 迭代器
这里实现的迭代器是正向迭代器的两个版本 —— 普通迭代器和 const 迭代器。先来实现普通迭代器,要想实现普通迭代器,需要先实现普通版本的 begin 和 end 函数,这两个函数的原型分为: iterator begin() 和 iterator end() ,这两个函数的作用分别为:返回指向 vector 容器的第一个元素的迭代器 和 返回指向容器末尾的迭代器。在 vector 中,vector 容器的第一个元素的迭代器就是 m_start , 指向容器末尾的迭代器就是 m_finish 。代码如下所示:
// 普通版本的 begin 函数
iterator begin()
{
return m_start;
}
// 普通版本的 end 函数
iterator end()
{
return m_finish;
}
接下来实现 const 迭代器,要想实现 const 迭代器,需要先实现 const 版本的 begin 和 end 函数,这两个函数的原型分别为: const_iterator begin() const 和 const_iterator end() const 。这两个函数的实现与普通版本的并无二异,只是返回值的类型有些许不同。代码如下所示:
// const 版本的 begin 函数
const_iterator begin() const
{
return m_start;
}
// const 版本的 end 函数
const_iterator end() const
{
return m_finish;
}
2.4 empty 函数和 pop_back 函数
2.4.1 empty 函数
empty 函数的原型为:bool empty() const ,该函数的作用为:判断当前容器中的有效数据个数是否为0。什么情况下容器的有效数据个数为 0 呢?当然是 m_start 和 m_finish 相等,两个迭代器指向的位置相同时,即可说明当前容器的有效数据个数为0。代码如下所示:
// empty 函数
bool empty() const
{
return m_start == m_finish;
}
2.4.2 pop_back 函数
pop_back 函数的原型为:void pop_back() ,该函数的作用为尾删数据。既然要删除数据就需要判断当前容器的有效数据个数是否为0,可以直接复用 empty 函数;删除一个数据不需要释放空间,只需要有效数据个数减1即可。代码如下所示:
// pop_back 函数
void pop_back()
{
// 判断有效数据个数是否为 0
assert(!empty());
// 有效数据个数减少 1
--m_finish;
}
2.5 insert 函数和erase 函数
2.5.1 insert 函数
insert 函数的函数原型为:iterator insert(iterator position, const value_type & val) ,作用为在指定位置 pos 处插入数据 val 值 。涉及位置需要判断位置的合法性;既然是插入数据,自然需要判断是否需要扩容(扩容逻辑与 push_back 函数的一致);判断是否需要扩容之后,就需要挪动数据(具体的逻辑与 string 中的一致),需要注意的是 vector 的三个成员变量都是指针,需要解引用访问;数据挪动完毕之后,便是插入数据,有效数据个数加 1 ;在这里尤其需要注意的是 insert 函数的返回值为:指向第一个新插入元素的迭代器,新插入的元素的迭代器就是 pos。 代码如下所示:
// insert 函数
iterator insert(iterator pos, const T& val)
{
// 判断 pos 迭代器的合法性
assert(pos >= m_start && pos <= m_finish);
// 判断是否需要扩容
if (m_finish == m_end_of_storage)
{
size_t newcapacity = capacity() == 0 ? 4 : 2 * capacity();
reserve(newcapacity);
}
// 挪动数据
iterator it = m_finish;
while (it > pos)
{
*it = *(it - 1);
it--;
}
// 插入数据
*pos = val;
// 有效数据个数加 1
++m_finish;
// 返回值为 pos
return pos;
}
运行以下代码:
// 调用无参的构造函数
vector<int> v1;
// 尾插 4 个数据
v1.push_back(1);
v1.push_back(2);
v1.push_back(3);
v1.push_back(4);
v1.insert(v1.begin() + 2, 100);
for (auto& e : v1)
{
cout << e << " ";
}
运行上述代码会发现程序会崩溃或者输出错误数据,为什么程序会输出错误的数据?调试发现程序根本没有进入 while 循环,为什么没有进入 while 循环呢?pos 迭代器指向的位置不就是 vector 容器中有效数据下标为2的位置吗?而迭代器 it 指向的位置是有效数据的末尾,很明显 it 就是大于 pos 呀?经过调试可以发现,pos 指向的位置并不是 vector 容器中有效数据下标为2的位置,而是空间中的一个随机位置,这个随机位置的地址大于 it ,所以没有进入 while 循环。
既然 pos 大于 it ,也就是 pos 大于 m_finish,不是会执行断言操作吗?难道断言失效了?并不是,断言之所以没有检查到,是因为 pos 出现问题是发生在断言后的。为什么 pos 会出现这种问题,经过调试可以发现,在执行完扩容操作之后,迭代器 pos 指向的位置的数据变成了随机值,开辟新空间,拷贝旧数据,释放旧空间,而迭代器 pos 仍然指向旧空间的位置,此时 pos 相当于是野指针,而这种问题用官方的语言回答就是迭代器失效。怎么解决这个问题呢?既然是 pos 的指向有问题,更新迭代器 pos 的指向即可,让它指向新空间中与旧空间相同的位置处。所以需要求得旧空间中迭代器 pos 指向的位置,用 pos – m_start 即可。改进代码如下所示:
// insert 函数
iterator insert(iterator pos, const T& val)
{
// 判断 pos 迭代器的合法性
assert(pos >= m_start && pos <= m_finish);
// 判断是否需要扩容
if (m_finish == m_end_of_storage)
{
// 求得 pos 指向的位置的相对距离
size_t offset = pos - m_start;
size_t newcapacity = capacity() == 0 ? 4 : 2 * capacity();
reserve(newcapacity);
// 更新迭代器 pos 的指向
pos = m_start + offset;
}
// 挪动数据
iterator it = m_finish;
while (it > pos)
{
*it = *(it - 1);
it--;
}
// 插入数据
*pos = val;
// 有效数据个数加 1
++m_finish;
// 返回值为 pos
return pos;
}
接下来实现在某个值的位置处插入数据,其代码如下所示:
vector<int> v2;
v2.push_back(1);
v2.push_back(2);
v2.push_back(4);
auto it = find(v2.begin(), v2.end(), 4);
if (it != v2.end())
{
v2.insert(it, 100);
}
find 函数调用的是算法库中的函数,需要引用头文件 algorithm 。it 的类型为iterator(find 函数的返回值类型为 iterator),那么 it 在后续的操作中是否可以再次使用?不可以(如果发现可以使用,那只是巧合),因为 it 失效了,为什么失效了?在 reserve 函数中,不是更新了 pos 迭代器的指向了吗?为什么还会失效呢?因为这里是传值传参,形参的改变不影响实参,it 传给 pos ,pos 的改变不影响 it,既然如此在 insert 函数参数的参数列表中 pos 处加上引用(iterator& pos)问题不就解决了吗?仅仅加上引用,问题还是无法解决,只是解决了这里的问题,但是会出现其它的问题,如下代码所示:
v.insert(v.begin(), 10);
v.insert(v.begin() + 2, 30);
这里 v 调用 begin 函数,返回类型是传值返回,凡是传值返回,返回的都不是对象的本身,而是它的临时对象,临时对象具有常性,不能传给普通的引用。有人可能会想,在 iterator& pos 的前面加上 const ,可以吗?不可以,这样 pos 就不能被修改了,更新 pos 的操作就无法实现。所以,无论怎么操作都不能在 iterator pos 处加 &。既然如此,什么情况下迭代器会失效呢?对于容器 vector 和 string 来说,当向容器添加元素之后,且存储空间被重新分配,则指向容器的迭代器,指针和引用都会失效;如果存储空间未被重新分配,则指向插入位置之前的元素的迭代器,指针和范围 for 仍然有效,但是在插入位置之后元素的迭代器,指针和引用都会失效。如何解决迭代器失效呢?更新迭代器的指向即可,insert 函数返回的是指向新插入数据的迭代器,可以让待更新的迭代器接收 insert 函数的返回值。
2.5.2 erase 函数
erase 函数的函数原型为:iterator erase(iterator position) ,该函数的作用为删除迭代器 pos 位置处的数据。涉及位置需要判断位置的合法性;既然是删除数据自然要挪动数据,挪动数据的逻辑与 insert 函数的逻辑差不多,数据挪动完毕后,有效数据个数在减 1 即可, erase 函数返回的是被删除的元素的后面元素的迭代器。代码如下所示:
// erase 函数
iterator erase(iterator pos)
{
// 检验 pos 迭代器位置的合法性
assert(pos >= m_start && pos <= m_finish);
// 挪动数据
iterator it = pos;
while (it != m_finish)
{
*it = *(it + 1);
it++;
}
// 有效数据个数减 1
--m_finish;
return pos;
}
erase 函数实现完毕后,接下来实现删除具体值数据,其代码如下所示:
vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
v.push_back(4);
v.push_back(5);
auto it = find(v.begin(), v.end(), 4);
if (it != v.end())
{
v.erase(it);
}
那么问题来了,这里的迭代器 it 是否会像 insert 中的 it 那样,会失效?在回答这个问题之前,实现一个程序,删除对象 v 中的所有偶数。听起来很简单,用迭代器遍历 v 中的数,若是偶数,便调用 erase 函数,将偶数删除,然后在继续向后找偶数,代码如下所示:
vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(2);
v.push_back(4);
v.push_back(5);
auto it = v.begin();
while (it != v.end())
{
if (*it % 2 == 0)
{
v.erase(it);
}
it++;
}
for (auto& e : v)
{
cout << e << " ";
}
在 VS 编译器下运行上述的程序,可能会正常运行,但是这只是巧合;但是在 Linux 上,这段代码能正常运行,但是运行的结果却不能达到目的,即没有将偶数删全。之所以会产生这种结果是因为它们对于迭代器失效的处理方式有所差异(偶数没有删全的原因是迭代器失效),VS针对失效的迭代器会进行强制的检查,失效的迭代器就不能去访问了。为什么不能将偶数删全呢?下面来画图分析:
由上图的分析可以知道删除完数据之后,数据会自行移动,删除完毕后,执行++程序。当连续出现偶数时,会跳过部分偶数,导致偶数没有删全。既然如此,就需要将 it++ 与删除操作分开,删除数据之后,就不必再执行 it++ 操作;未执行删除操作才执行 it++ 操作。现在可以回答 erase 函数那里的问题了 —— 是否会像 insert 一样 it 失效?会!erase 也会使迭代器失效。解决方案为更新迭代器 it 的指向,erase 函数返回被删除的元素的后面元素的迭代器。改进后的代码如下所示:
vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(2);
v.push_back(4);
v.push_back(5);
auto it = v.begin();
while (it != v.end())
{
if (*it % 2 == 0)
{
it = v.erase(it);
}
else
{
it++;
}
}
for (auto& e : v)
{
cout << e << " ";
}
总的来说,向容器中添加元素和从容器中删除元素的操作可能会使指向容器元素的迭代器失效,但是这里我们实现的是原生指针版本,所以认为这里的迭代器不是可能失效,而是一定失效。切记不要使用失效的迭代器,这是一种非常严重的程序设计错误,由于每次插入或删除数据之后都可能使迭代器失效,因此必须保证每次使用迭代器时都更新迭代器的指向。
2.6 clear 函数和析构函数
2.6.1 clear 函数
clear 函数的函数原型为:void clear() ,该函数的作用为清除当前容器的所有数据,即有效数据个数为0,但是不影响当前容器的空间大小。将有效数据个数置为0,直接让 m_start 与 m_finish 的指向相同即可。代码如下所示:
// clear 函数
void clear()
{
m_finish = m_start;
}
2.6.2 析构函数
析构函数的函数原型为:~vector() ,该函数的作用为释放容器申请的资源。与 string 中的析构函数的逻辑一致,代码如下所示:
// 析构函数
~vector()
{
if (m_start != nullptr)
{
delete[] m_start;
m_start = m_finish = m_end_of_storage = nullptr;
}
}
尤其要注意当 vector 容器不为空时,才释放资源。
2.7 resize 函数
resize 函数的函数原型为:void resize(size_type n, value_type val = value_type()) ,该函数的作用为调整容器的大小,使其包含n个元素;当 n 小于有效数据个数的大小时,那么 resize 会保留容器中前 n 个数据,删除 n 之后的数据;当 n 大于有效数据个数的大小时,有效数据个数会增加至n,多出来的有效数据的值为val 。由 resize 的函数的作用可以知道,需要分两种情况 —— n 大于有效数据个数和 n 小于有效数据个数。第一种情况需要考虑扩容,之后再将多出来的有效数据值赋值为 val ;第二种情况直接将有效数据的个数减少至 n 即可,逻辑与 pop_back 函数的逻辑一致。怎么给模板缺省值呢?由函数原型可以知道 val 的缺省值为 T(),这里T() 是匿名对象,调用容器的默认构造函数,生成一个匿名对象那么那些内置类型,也会调用相对应的默认构造函数吗?在C++中针对内置类型进行了升级,让这些内置类型也有构造函数。分析完毕,代码如下所示:
// resize 函数
void resize(size_t n, T val = T())
{
// 当 n 大于有效数据个数
if (n > size())
{
// 扩容
reserve(n);
// 将多出来的有效数据赋值为 val
while (m_finish != m_start + n)
{
*m_finish = val;
++m_finish;
}
}
// 当 n 小于有效数据个数
else
{
// 有效数据个数减至 n
m_finish = m_start + n;
}
}
2.8 构造函数
vector 容器中提供的构造函数有很多:拷贝构造函数,用迭代器区间初始化,用 n 个 val 值初始化,支持初始化列表初始化(花括号初始化)。接下来就一一实现这些构造函数。
2.8.1 拷贝构造函数
拷贝构造函数的函数原型为:vector(const vector& x) ,作用为将对象 x 中的数据拷贝到 *this 对象中,不影响对象 x 。涉及拷贝操作,无疑是以下几个操作:开辟新空间,拷贝旧数据,更新数据,逻辑与 string 中的拷贝构造函数的逻辑一致,只是这些操作可以让 reserve 函数来实现。代码如下所示:
// 拷贝构造函数
vector(const vector<int>& x)
: m_start(nullptr)
, m_finish(nullptr)
, m_end_of_storage(nullptr)
{
// 开辟新空间,更新数据
reserve(x.capacity());
// 拷贝数据
for (auto& e : x)
{
push_back(e);
}
}
由于三个成员变量的初始值均为 nullptr ,所以进入 reserve 函数中,并不会执行拷贝数据的操作,需要自己来完成,由此可以借助 push_back 函数来实现数据的拷贝。
2.8.2 用迭代器区间初始化
与迭代器区间初始化的构造函数的函数原型为:template <class InputIterator>
vector(InputIterator first, InputIterator last, const allocator_type& alloc = allocator_type()) ,是一个函数模板。为什么库要将这个函数设计为函数模板呢?设计成普通的函数不好吗?比如这样:vector(iterator first, iterator last) 。这两种设计有很大的差异,设计成普通函数只能让一种容器去使用,iterator 只能传 vector 容器的 iterator ,不能传其它容器的 iterator ;而实现InputIterator 可以实例化为任意容器的 iterator 。既然支持迭代器区间初始化,那么函数体的实现直接使用迭代器,代码如下所示:
// 使用迭代器区间初始化
template <class InputIterator>
vector(InputIterator first, InputIterator last)
{
// 使用迭代器
while (first != last)
{
// 将数据尾插至 *this 对象中
push_back(*first);
++first;
}
}
调用该函数,检验是否与库中的函数的作用一致,检验结果如下所示:
支持其它容器的迭代器区间初始化。
2.8.3 使用 n 个 val 值初始化
使用 n 个 val 值初始化的函数的函数原型为:explicit vector (size_type n, const value_type& val = value_type(), const allocator_type& alloc = allocator_type()) ,既然这 n 个有效数据的值都是一样的,且它的函数原型与 resize 的函数原型一致,那么可以复用 resize 函数。代码如下所示:
// 使用 n 个 val 值初始化
vector(size_t n, const T& val = T())
{
resize(n, val);
}
调用该函数,检验该函数的作用是否与库中的函数的作用一致,检验代码如下所示:
vector<int> v2(10, 1);
for (auto& e : v2)
{
cout << e << " ";
}
运行结果如下图所示:
由上图可以发现,v2 初始化时调用了迭代器区间构造函数函数。若将迭代器区间初始化函数注释掉,会发现 v2 调用了用 n 个 val 值初始化函数。为什么当两个构造函数同时存在时,v2 会调用迭代器区间初始化函数呢?因为该函数的函数参数与 v1 传递参数更匹配,有现成的构造函数会去调用现成的,没有现成的函数会去调用函数模板。编译器会将 10 和 1 识别为 int 类型,使用 n 个 val 值初始化函数中的 T 就会实例化为 int ,那么它的两个参数的类型分别是 size_t 和 int ,与 v1 传递的参数的类型不匹配(两个参数类型都是 int ),将 size_t 改为 int 就能解决这个问题。为什么它会报错呢?因为对 int 类型的数据解引用了(*first)。
但是库里面使用 n 个 val 值初始化函数中参数 n 为 size_t 类型,仅仅将 size_t 类型改为 int ,只是解决了这里的问题罢了。当出现如下情况时,程序仍会报错:
vector<size_t> v2(10, 2);
for (auto& e : v2)
{
cout << e << " ";
}
运行结果如下图所示:
所以将参数 n 的类型由 size_t 改为 int 并不能解决所有的问题,需要使用特殊的方法 —— 在迭代器区间初始化函数的前面加上模板参数:enable_if_t<_Is_iterator_v<InputIterator>, int> = 0。改进后的迭代器区间初始化函数如下所示:
// 使用迭代器区间初始化
template <class InputIterator,
enable_if_t<_Is_iterator_v<InputIterator>, int> = 0>
vector(InputIterator first, InputIterator last)
{
// 使用迭代器
while (first != last)
{
// 将数据尾插至 *this 对象中
push_back(*first);
++first;
}
}
多加的参数表示:传递的参数是迭代器的时候,就会执行迭代器区间初始化函数,模板实例化;反之,不会执行该函数,模板不实例化(了解即可)。
2.8.4 使用初始化列表初始化(花括号初始化)
花括号初始化函数的函数原型为:vector (initializer_list<value_type> il, const allocator_type& alloc = allocator_type()),它是 C++11 的 vector 构造函数的一个特殊的函数,其中的 initializer_list 是 C++11 中单独增加的一个类,这个类是一个类模板 —— template<class T> class initializer_list。支持用大括号括起来的逗号分隔的元素列表来初始化 vector 对象,该类实现的接口有 begin ,end 和 size,所以它的内部支持迭代器。initializer_list 是用来实现以下特殊的初始化的,如下图所示:
但是实际使用时更喜欢将小括号省略不写,如下所示:
vector<int> v1 = { 1, 2, 3, 4, 5 };
vector<char> v2 = { 'w', 'o', 'r', 'l', 'd' };
也可以将等号省略不写,只是这样看着有些奇怪(个人认为),如下所示:
vector<int> v1{ 1, 2, 3, 4, 5 };
vector<char> v2{ 'w', 'o', 'r', 'l', 'd' };
这么初始化的好处不言而喻 —— 可以用任意个数据来初始化 vector 对象。
initializer_list 的原理:针对花括号中的数据开辟足够大(恰好能将这些数据存储起来)内存空间的数组,该空间位于栈上。对于第一种写法,将 { } 中的数作为参数传递给 initializer_list ;对于第二种和第三种写法,则是使用隐式类型转换,利用构造函数和拷贝构造函数。初始化列表初始化函数的底层实际上是用范围 for 遍历传递过来的参数,然后逐个的尾插到初始化对象中。代码实现如下所示:
// 初始化列表初始化(花括号初始化)
vector(initializer_list<T> il)
{
for (auto& e : il)
{
// 这里实际上是 this->push_back(e)
push_back(e);
}
}
2.9 赋值重载函数
赋值重载函数的函数原型为:vector& operator= (const vector& x),与 string 类中的赋值重载函数的思路一致,这里可以使用简便的实现方法。由此,在实现该函数之前先实现 swap 函数,swap 函数的函数原型为:void swap(vector& x),与 string 中的 swap 函数的实现的逻辑一致,借助 std 库中的 swap 函数,代码如下所示:
// swap 函数
void swap(vector<T>& x)
{
std::swap(m_start, x.m_start);
std::swap(m_finish, x.m_finish);
std::swap(m_end_of_storage, x.m_end_of_storage);
}
实现完毕,接下来实现赋值重载函数,代码如下所示:
// 赋值重载函数
vector& operator= (vector<T>& x)
{
swap(x);
return *this;
}
3. 结言
在上述的函数实现中,有个函数存在一些错误,运行以下代码 :
vector<string> v;
v.push_back("1234567890");
v.push_back("2345678900");
v.push_back("3456789000");
v.push_back("4567890000");
v.push_back("5678900000");
for (auto& e : v)
{
cout << e << endl;
}
运行后可以发现,程序崩溃(VS2022编译器下),当将某行 push_back 代码注释掉之后,程序正常运行,由此可以知道是 reserve 扩容函数出现了问题,为什么扩容出现问题了呢?画图来理解:
memcpy 函数是逐字节拷贝,memcpy 会直接复制 string 对象的指针,导致新旧 string 对象共享同一块内存(浅拷贝);旧 string 对象被 delete[ ] 后,新 string 对象指向的内存已被释放;程序后续访问这些 string 时,可能发生崩溃或数据错误.
这里的memcpy应该改为逐个赋值,为什么这里改为逐个赋值就可以了呢?因为会调用赋值重载函数(深拷贝),改进后的reserve函数如下所示:
// reserve 函数
void reserve(size_t n)
{
if (n > capacity())
{
size_t old_size = size();
// 开辟新空间
T* tmp = new T[n];
// 判断数组中的数据是否为0
if (m_start != nullptr)
{
// 拷贝旧数据
//memcpy(tmp, m_start, sizeof(T) * old_size);
for (size_t i = 0; i < old_size; i++)
{
tmp[i] = m_start[i];
}
// 释放旧空间
delete[] m_start;
}
// 更新数据
m_start = tmp;
m_finish = m_start + old_size;
m_end_of_storage = m_start + n;
}
}
对于内置类型,赋值可以完成浅拷贝;对于 string 这样的类型,赋值会调用 operator= 函数完成深拷贝。