欢迎各位大佬光临本文章!!!
还请各位大佬提出宝贵的意见,如发现文章错误请联系冰冰,冰冰一定会虚心接受,及时改正。
本系列文章为冰冰学习编程的学习笔记,如果对您也有帮助,还请各位大佬、帅哥、美女点点支持,您的每一分关心都是我坚持的动力。
我的博客地址:bingbing~bang的博客_CSDN博客https://blog.csdn.net/bingbing_bang?type=blog
我的gitee:冰冰棒 (bingbingsupercool) - Gitee.comhttps://gitee.com/bingbingsurercool
冰冰学习笔记:《string类的简单模拟》
冰冰学习笔记:《一步一步带你实现《顺序表》》
系列文章推荐
前言
1.vector类的基本结构
2.vector类中的默认成员函数
2.1构造函数与析构函数
2.2拷贝构造与赋值运算符重载
3.元素访问与容量函数
3.1size与capacity
3.2[ ]运算符的重载
3.3reserve与resize
4.数据管理函数
4.1push_back与pop_back
4.2insert与erase
5.迭代器
5.1迭代器的实现
5.2迭代器的失效
6.memcpy的深浅拷贝
总结
vector与string一样,都是C++中的一种容器,vector是表示可变大小数组的容器。vector与我们之前学习数据结构时实现的顺序表一样,采用连续的内存空间存储数据,可以根据传入的数据类型来存放不同的数据。C++库中的vector提供了众多的接口函数,用来管理vector中的数据。学习vector与学习string一样,需要多加练习,多使用。
vector类与顺序表的实现逻辑虽然相同,但是底层实现方式不一样,vector并没有采用_size,_capacity这种容量的描述方式,而是采用了三个指针来进行描述。
与string类一样,我们的模拟实现也并非达到面面俱到,我们的目的还是了解STL的底层逻辑,以便更好的使用vector。vector类中提供的大量函数我们也是模拟实现最常用的几个函数。
vector中的部分函数接口:
我们模拟实现的vector也可以使用三个指针了进行描述,_start指针指向的是数据存储的头位置,_finish指针指向的数据最后一个位置的下一个位置,_end_of_storage指针指向的则是容量的最大值。
namespace lb
{
template
class vector
{
public:
typedef T* iterator;//迭代器
private:
iterator _start;//头指针
iterator _finish;//尾指针
iterator _end_of_storage;//容量标记指针
};
}
基本结构了解清楚后,剩下的模拟实现就比较简单,与我们实现的顺序表没有什么区别。实现自己的vector时一定要将自己的vector放到自己的命名空间中,避免与库中的出现冲突,方便测试代码。
vector的构造函数没有string类中那么多,每个构造函数都有对应的使用方式。我们还是不关注内存池的实现,直接在堆上开辟空间即可。
(1)普通构造函数
vector的构造函数无非是将三个指针进行初始化,我们直接使用初始化列表将其置为空指针。
vector()
:_start(nullptr)
,_finish(nullptr)
,_end_of_storage(nullptr)
{}
但是vector不仅这一个构造函数,例如我们采用vector
(2)带参构造函数
基于这种情况,我们就需要在实现一个构造函数,这个构造函数应该支持一次性开辟n个空间,当我们传入第二个参数时,就需要将开辟出来的空间初始化指定的数值。
我们查看文档,发现文档中的构造函数是这样写的:
那这个参数是什么意思呢? 其实这个参数是个缺省参数,第二个参数应该传入类型模板中类型的数据,当我们不传时会提供一个匿名对象作为缺省值,对于自定义类型会去调用默认构造函数来创建这个匿名对象。
可内置类型没有构造函数呀?这怎么调用呢?
这里我们需要注意一点,C++在引入类和对象后,为了兼容类和对象,对内置类型进行了升级,使得内置类型也有对应的默认构造函数。当我们采用内置类型来构建对象时,数据类型的默认构造函数会初始化为0,字符类型会初始化为'\0',指针类型会初始化为空指针。
因此,我们的构造函数就可写成下面的样子:
在将vector的三个指针创建并初始化为空指针后,使用reserve函数将空间开辟到n,然后利用尾插,将初始化参数T()插入到空间中。
vector(size_t n, const T& val = T())//T()匿名对象,会去调用相应的默认构造
:_start(nullptr)
, _finish(nullptr)
, _end_of_storage(nullptr)
{
reserve(n);
for (size_t i = 0; i < n; i++)
{
push_back(val);
}
}
当我们构建时可以得到下面的结果:
(3)迭代器区间构造函数
除此之外,vector还提供了利用迭代器区间进行创建的构造函数,在使用时我们只需要将迭代器的区间传入,就可以生成一个新的vector。
注意,这里使用vector存储string类中的字符串时,我们使用的是char类型的vector,因为string类中存储的是字符构成的字符串,而不是单个的string类型。
为了兼容各种版本的迭代器区间参数,我们应该使用类模板来创建构造函数,对迭代器采用解引用操作才会获取迭代器所指向的元素,然后我们依次将这个区间内的数据进行尾插操作即可,注意,迭代器指向的区间是左闭右开 [first,last) 。
template //迭代器区间创造vector
vector(InputIterator first, InputIterator last)
:_start(nullptr)
, _finish(nullptr)
, _end_of_storage(nullptr)
{
while (first != last)
{
push_back(*first);
++first;
}
}
但是,我们在写完这个构造函数后,发现采用 vector
并且,int类型的vector创建会出错,字符类型的并不会。
原因很简单,这是模板匹配类型出错了,因为我们在创建int类型的vector时,传入的参数,1,2都是整型,编译器在匹配类型进行调用构造函数时发现迭代器区间的模板两个都可以推导成int,int类型,而另一个构造函数是 size_t 和int,因此,构造函数调用了迭代器区间的构造函数。调用该函数在进行数据插入操作时将对常数进行解引用操作,所以出现了错误。
为了解决这个问题,STL中是增加了int类型参数的构造函数,我们也可以增加一个,只需要将size_t类型的参数改为int类型即可。当然我们写的vector在构建short类型或者unsigned int等类型时,参数也不要直接写int类型,应该强制类型转换成对应的数据类型,避免出错.
正确书写方式:vector
(4)析构函数
析构函数比较简单,我们只需要将开辟的空间使用delete进行释放即可,然后将三个指针置为nullptr。注意,delete会自己辨别是否为空指针,我们不需要进行检测。
~vector()
{
delete[] _start;
_start = _finish = _end_of_storage = nullptr;
}
拷贝构造与赋值运算符的重载我们使用的都应该是深拷贝而并非是浅拷贝,深浅拷贝的问题博主在string类的模拟实现中已经讲过,这里不再赘述。vector的深拷贝与string一样,我们应该在拷贝构造时重新开辟一份空间,并且将原来空间存放的内容复制到新空间完成拷贝构造。
拷贝构造函数的实现也有传统方式与现代方式。传统方式的实现代码如下:
//拷贝构造
//1
vector(const vector& v)
{
_start = new T[v.size()];
//memcpy(_start, v._start, sizeof(T) * v.size());
for(size_t i=0;i& v)
:_start(nullptr)
, _finish(nullptr)
, _end_of_storage(nullptr)
{
reserve(v.size());
for (const auto& e : v)
{
push_back(e);
}
}
这里我们使用了两种方式来进行拷贝构造, 第一种是直接开辟新空间,然后将参数指向的内容使用memcpy或者采用for循环的方式将内容拷贝过去。另一种是直接使用初始化列表创建一个vector,然后使用reserve开辟空间,再将参数指向的内容进行尾插操作完成拷贝构造。至于我们为什么没有使用memcpy来拷贝,后文会进行详细的介绍。
至于拷贝构造的现代写法,我们就需要使用迭代器区间的构造函数,将参数指向的vector使用其迭代器来创建新的vector,然后使用swap进行交换。
void swap(vector& v)
{
std::swap(_start, v._start);
std::swap(_finish, v._finish);
std::swap(_end_of_storage, v._end_of_storage);
}
vector(const vector& v)
:_start(nullptr)
, _finish(nullptr)
, _end_of_storage(nullptr)
{
vector tmp(v.begin(), v.end());
swap(tmp);
}
当我们实现了拷贝构造,赋值运算符的重载也就实现了,同样采用的也是现代写法。
/赋值
vector& operator =(const vector v)
{
swap(v);
return *this;
}
在前面我们对vector的三个指针进行了分析,利用三个指针之间的差值关系就可以实现size()函数和capacity()函数,并且可以顺手实现front、back函数以及判空函数。
注意:front和back采用的引用返回!
size_t capacity()
{
return _end_of_storage - _start;
}
size_t size()
{
return _finish - _start;
}
T& front()
{
assert(size() > 0);
return *_start;
}
T& back()
{
assert(size() > 0);
return *(_finish-1);
}
bool empty()
{
return size()==0;
}
[ ]操作符在string类中我们介绍过,vector中的[ ]可以让vector向数组一样使用,[ ]运算符的重载也包含const和非const两种形式,我们在使用前应该先对pos位置进行断言判断,确保pos在正常的范围内。
T& operator[](size_t pos)
{
assert(pos < size());
return _start[pos];
}
const T& operator[](size_t pos) const
{
assert(pos < size());
return _start[pos];
}
这两个函数的区别在于resize函数不仅会开辟空间还会对空间进行初始化设置。
reserve函数非常重要,贯穿整个vector的管理操作。reserve函数在实现的时候我们需要注意_finish和_end_of_storage的失效问题。
我们在使用reserve开辟空间后,_start有可能会变更,当_start变为新空间后,_finish和_end_of_storage必然需要更新,_finish应该变为_start+size(),_end_of_storage变为_start+capacity()。但是我们不能在调用size和capacity函数了,因为这两个函数的实现是利用指针相减操作的,而此时的_finish和_end_of_storage已经失效了。
所以这就需要我们提前将size进行保存,然后在reserve最后将_finish和end_of_storage更新。
注意:这里reserve扩容之后并没有直接采用memcpy进行复制。
void reserve(size_t n)
{
if (n > capacity())
{
T* tmp = new T[n];
size_t len = size();//提前保存长度,避免finish失效
if (_start)
{
//memcpy(tmp, _start, sizeof(T) * len);
for (size_t i = 0; i < len; i++)
{
tmp[i] = _start[i] ;
}
delete[]_start;
}
_start = tmp;
_finish = _start + len;
_end_of_storage = _start + n;
}
}
resize函数的实现需要对三种情况进行判别,当n小于当前size时,我们只需要缩容即可,将_finish更改为_start+n,然后返回调用。当n大于capacity时,我们需要扩容,并且需要将新空间初始化为参数T()。当n处于size与capacity之间的时候,我们需要先将size扩大到n,然后将原_finish到n之间的内容进行初始化,并更新finish。
void resize(size_t n, const T& value = T())
{
// 1.如果n小于当前的size,则数据个数缩小到n
if (n <= size())
{
_finish = _start + n;
return;
}
// 2.空间不够则增容
if (n > capacity())
reserve(n);
// 3.将size扩大到n
iterator it = _finish;
_finish = _start + n;
while (it != _finish)
{
*it = value;
++it;
}
}
push_back与pop_back这两个函数的实现更不用多说,push_back函数只需要验证空间是否够用,不够用则扩容,然后将数据放在_finish指向的空间中,并且更新_finish即可。
void push_back(const T& val)
{
if (_finish==_end_of_storage)
{
reserve(capacity()==0?4:2*capacity());
}
*_finish=val;
_finish++;
}
void pop_back()
{
assert(_finish > _start);
--_finish;
}
库中vector的insert函数有三种接口,分别是在pos之前插入指定数据,在pos之前插入n个指定数值,在pos位置之前插入迭代器区间的值。其中pos都是迭代器位置。
(1)指定位置之前插入一个数据
该函数是具备返回值的,返回类型是迭代器,指向的是新插入的数据的位置。那我们实现的时候就需要将传入的pos的值进行更新,注意,我们并不能更改函数外面pos的值,因为是传值调用,我们是需要返回新的pos位置。
由于插入数据可能需要扩容,扩容后_start将指向新的空间,那么我们再对pos指向的空间进行操作就会出现问题,所以需要使用len提前保存_start到pos之间的距离,然后再扩容后将pos更新为_start+len。
挪动数据采用从后向前挪动,挪动完毕后将pos指向的位置放入新数据,并返回pos。
iterator insert(iterator pos, const T& x)
{
assert(pos >= _start);
assert(pos <= _finish);
if (_finish == _end_of_storage)
{
size_t len = pos - _start;
reserve(capacity() == 0 ? 4 : capacity() * 2);
pos = _start + len;
}
//挪动数据
iterator end = _finish - 1;
while (end >= pos)
{
*(end + 1) = *end;
--end;
}
//插入数据
*pos = x;
++_finish;
return pos;
}
(2)指定位置之前插入n个数据
该函数没有返回值,我们不需要返回pos位置, 我们可以直接复用前面实现的insert,只不过需要调用n次,由于insert返回pos值,因此我们需要不断更新pos。
void insert(iterator pos, int n, const T& x)
{
while (n--)
{
pos=insert(pos, x);
}
}
(3)迭代器区间插入数据
我们也可直接复用之前的insert进行插入,但是需要注意一点,库中的迭代器区间插入后是将first到last这个左闭右开的区间元素整个放入pos之前,因此我们的功能应该如下图所示这样:
但是我们之前实现的insert函数实在pos位置之前进行插入,如果直接复用,将first指向的数据进行插入将会得到逆置的结果。
template //迭代器区间插入数据
void insert(iterator pos, InputIterator first, InputIterator last)
{
assert(pos >= _start);
assert(pos <= _finish);
while (first != last)
{
pos = insert(pos, *first);
++first;
}
}
让我们一步一步分析原因:
所以我们需要从后向前调用insert进行插入:
template //迭代器区间插入数据
void insert(iterator pos, InputIterator first, InputIterator last)
{
assert(pos >= _start);
assert(pos <= _finish);
while (first != last)
{
pos = insert(pos, *(last-1));
--last;
}
}
erase在库中提供了两种接口,一种是删除指定位置的元素,一种则是删除迭代器区间的元素。erase函数都具备返回值,返回的是删除后的元素的新位置。
实际上我们在实现的时候采用的是挪动覆盖的逻辑操作。
iterator erase(iterator pos)
{
assert(pos >= _start);
assert(pos < _finish);
iterator begin = pos + 1;
while (begin < _finish)
{
*(begin - 1) = *(begin);
++begin;
}
--_finish;
return pos;
}
迭代器区间的删除采用的是调用区间长度次的erase函数,然后每次更新first。
iterator erase(iterator first,iterator last)
{
assert((first >= _start && first < _finish)
&&(first <= last )
&&(last >= _start && last < _finish));
size_t len = last - first;
while (len)
{
first=erase(first);
len--;
}
return first;
}
vector的迭代器和string类一样,都是原生的指针进行的重命名,原因在于vector是一个顺序表,内存空间连续,因此可以直接使用指针的加减操作。这里我们要特别注意的点是迭代器失效问题。
反向迭代器我们后续介绍,这里还是先提供两种迭代器:
iterator begin()
{
return _start;
}
iterator end()
{
return _finish;
}
const_iterator begin()const
{
return _start;
}
const_iterator end()const
{
return _finish;
}
什么是迭代器的失效呢?之所以设计迭代器在于让用户并不用关心底层的数据结构,迭代器在底层实际上就是指针或者指针的封装。迭代器失效基本就是指针的使用出现了问题,出现了野指针,因此继续使用必然会导致程序崩溃。
在前面我们实现reserve的时候,如果没有提前保存size的值,那么在扩容后,原有空间被释放,就意味着_finish和_end_of_storage已经失效,成为野指针。所以在调用size和capacity的时候会出现问题。
还有insert函数和erase函数会返回插入或者删除后新的位置的pos,因此我们在使用之前的pos继续操作时必然会引发各种问题。所以在使用erase或者insert函数后,原有的pos不能直接访问,应该进行更新在访问。
insert导致迭代器失效的原因在于扩容引发的原有空间地址被释放,导致pos原本指向的空间被回收,成为野指针。而erase导致迭代器失效的原因在于有些erase的实现可能会采用缩容的模式,这必然导致原有空间减小,指针失效。
这里我们来解决为什么不使用memcpy函数来进行拷贝,而是采用循环赋值操作。
memcpy函数只是简单的将原本空间上的数据逐字节的方式进行拷贝。虽然我们开辟了新的空间来存储这些数据,但是如果原有空间存储的并非内置类型,也是自定义类型时,memcpy函数将再次导致浅拷贝的产生。
vector类本质就是顺序表的实现,实现过程中我们应该额外注意迭代器失效以及memcpy所涉及的深浅拷贝问题。博主的模拟实现是以学习理解为主,并不是写来使用。博主实现代码链接如下:增加博客笔记,更改部分代码,可根据博客进行观看 · 1720349 · 冰冰棒/C++ - Gitee.com