vector模拟实现
- 前言
- 正式开始
- 三个成员变量
- 无参构造
- 析构
- push_back
- [ ]重载
- pop_back
- insert
- erase
- 迭代器失效问题
- insert迭代器失效
- erase迭代器失效
- 深浅拷贝
- 拷贝构造函数
- 赋值运算符重载
- n个val构造
- resize
- front和back
- front
- back
前言
这篇写的是vector的模拟实现。
如果对于vector不熟悉的话,可以看前一篇博客:vector基本用法介绍
本篇不会讲太多实现上的细节,会偏重迭代器方面的讲解,如果想要更好理解构造函数、[]运算符重载等函数的实现的话,可以看这篇string的模拟实现,因为string的用法和vector是差不多的:手把手教你模拟实现string类
正式开始
模拟实现,首先要搞清楚其主要的成员变量有啥。
其实就3个:start、finish、end_of_storage。
这三个都是指针,start指向所开辟的空间的首地址,finish指向实际存储元素的下一个位置,end_of_storage指向开辟空间的末尾的下一个位置。
前一篇介绍vector的博客也说了,vector是一个模板类。
模板参数T,还有一个内存池。
但是这里实现的话,就不搞内存池了,先把一些基本的搞懂,再说那内存池的东西,后面自然会说的。
然后库中的实现,有type_name什么我们未接触到STL时不懂的类型,但是上一篇也说了,这篇就讲了,直接用,但是把库中的实现拿出来看一下:
库中对于T*,搞了两个类型,一个是pointer,一个时iterator。
我们就用后面那个就行。这一个iterator就是一个T而已,前面也是说过了, vector和string这种空间是连续的数据结构,其迭代器底层就是原生的指针。我们用iterator就用的是T,如果T是int的话,就是int*。
废话不多说,开搞。
三个成员变量
先把迭代器搞出来,非常简单,不要觉得听起来很高大上,就长这样:
然后在搞三个成员变量:
前面那张图也提到了,这里再放出来理解一下:
然后就是构造。
无参构造
先看一下库中是怎么实现的:
给的是0,就是空指针nullptr。
我们也写一下:
不多赘述,接着看析构:
析构
因为三个成员变量都是指针,且指向同一块空间,所以释放最前面的那个指针就行了。
想要简单使用的话,在搞一下尾插尾删就可以了。
push_back
尾插的话,是增加元素,只要增加,第一时间想到的就是扩容。
扩容的话,我们需要知道size和capacity的确切值。
也很简单,指针减去指针就可以了:
size: _finish - _start 就是size。
capacity: _end_of_storage - _start 就是capacity。
然后只要size和capacity相等的时候就要扩容,初始情况下,二者都为nullptr也成立。
然后光是上面的这些话,就要写三个函数:size()、capacity()、reserve()。
分别是size、capacity和扩容。
代码具体怎么实现的就不说了,前面C语言写顺序表的时候已经说过了,这里就不把时间放在这种基本功上面的了。
先实现一下:
其实上面的扩容是有bug的,不知道细心的同学发现了没有。
当我们扩完了容之后,如果_start不是是nullptr就需要将原来的_start中的数据转到tmp中,再改变_finish,但是改finish的时候调用size(),进入到size函数中_finish - _start,此时_start已经变了,但是finish并没有变,所以把size()替换为_finish - _start后,整个式子就变成了_finish = _statr + _finish - _start = _finish。故当我们扩了容之后finish并没有改变,从头到尾一直是nullptr。所以就出问题了。
我们把push_back写完后来试试:
可以看到,出了问题,就是finish一直是那个nullptr,变不了,那么怎么改呢?
两种方法:
-
将_start的赋值语句和_finish的赋值语句调换下位置,并将_finish赋值语句中的_start变为tmp。
此时再调试,就可以了:
-
在前面将未改变的size记录下来,并将_finish赋值语句中的size()改变为记录下来的原始size。
只要在_start改变之前记录下来就可以了。
再调试,也可以:
更推荐第二种写法。
[ ]重载
再实现下[]操作符重载。
前面库中vector的成员类型有个reference,就是引用,我们也写一个。用来实现[]重载。
用一下:
但是如果是const对象的话,就不能用了。
所以也要写一个const的[]重载:
想要遍历顺序表中的元素的话,前一篇也说了,三种方法。
- 前面的for循环
- 迭代器
迭代器前面也定义了。就是T*。
然后搞一下begin和end:
很简单,就是原生指针。没有太大的含金量。
然后我们用这个遍历试一下:
和库中的用法是一样的。
- 范围for
有了迭代器就可以用范围for了,因为范围for底层就是迭代器的“傻瓜式”替换。
看:
但是如果我们把begin和end函数名改一下,范围for就用不了了。
比如说第一个字母给大写:
此时范围for报错:
所以说范围for底层就是迭代器的傻瓜式的替换。只认识begin和end,字母变一下就不认识了。
然后库中也有const对象对应的迭代器,这里也实现一下:
调用一下:
这里const对象也是变一下begin和end就会范围for就会失效。
正常情况下:
变了begin/end后:
又不能用了。
这个讲到这。
下面说pop_back。
pop_back
很简单,还是要先考虑是否为空,然后_finish减一下就行。
然后说insert和erase。
insert
很简单,string中也是讲过的,不说细节了,直接给代码:
这里的insert要配合着find来使用。
erase
库中的erase两个函数返回值都是Iterator,但是insert有的是返回Iterator,有的是void,所以说这里我就只实现了一个返回Iterator。
也是直接给实现:
再调用一下:
但是上面的有些要注意的地方。
就是迭代器失效。
迭代器失效问题
insert迭代器失效
先说insert的迭代器失效问题:
看代码:
这里直接崩掉了。
原因很简单,就是我们在pos位置插入时,pos位置已经失效了。
为什么pos位置会失效呢?
注意看,顺序表中本来就只有1、2、3、4四个元素,所以说当我们插入数据时会扩容,如果扩容,那么原空间地址就会失效,换成新的地址,此时pos位置仍然指的是原空间的地址,所以说,扩完容后再插入,就会非法访问源地址空间,此时就会出错,这就是所谓的迭代器失效。
那么怎么避免呢?
也很简单,扩了容之后将pos位置进行修正就可以了。
看:
再测试:
此时就没事了,但只是这里没事了,我们再来搞一个测试。
pos位置处插入数据后,再到pos位置处插入数据:
断言崩掉了。
这里还是因为pos,我们是传值传的pos,insert函数中的pos改了,但是外面的pos不会改变,第一次传过去,pos还是在那个合法范围中(start ~ finish)的,第二次传过去pos还是原来的那个pos,就完全不在合法范围了。
有的同学可能就说为什么不将参数改为引用呢?
那么我只能说,当我传参是begin()或者end()时,你又该如何应对?
传参为普通的指针时,可以通过:
如果改为引用:
虽然pos可以传:
但是如果我传begin的话:
就传不过去了。
因为begin返回时不是返回_start本身,而是返回一个临时数据,临时数据具有常属性,不能被修改,如果传给引用成功的话,就会导致权限被放大,导致临时数据能被修改,所以就不能传引用了。
那又有同学说能不能将参数改为加const的呢?
还是不能,因为加了const后,你insert里面的那个pos怎么修改呢?
所以说是不行的。
库中的实现,参数也是Iterator就完了,没有加引用或者const。
说一点最重要的:当使用pos插入了元素之后,就尽量不要再使用pos访问那个位置了。
我上面模拟实现的insert是有点小瑕疵的。返回值要改为返回一下插入位置的迭代器。
代码如下:
那么insert就讲到这,下面说erase的迭代器失效。
erase迭代器失效
仔细看我的erase模拟实现。是不会出现迭代器失效的问题的。
但是不能说库中的erase不会失效。
这里的模拟实现并没有进行缩容的操作,缩容就是指当实际元素个数小于容量的
一半或者多少倍时,就将顺序表的容量进行缩减,一般不会这样做,但是STL并没有规定说不能这样做,可能有的库中会这样进行实现,如果进行缩容了的话,原空间丢失,就又会导致insert中的迭代器pos位置失效。
这里就不演示缩容的了,这种情况很少见,大家只要懂了上面insert中的迭代器失效,相信这里也是能懂erase失效的。
那就展示个别的,删除所有偶数:
给出如下代码:
auto it = v1.begin();
// 删除所有偶数
while (it != v1.end())
{
if (*it % 2 == 0)
{
v1.erase(it);
}
++it;
}
上面的代码中给出v1三种情况:
- 1、2、3、4、5
- 1、2、3、4
- 1、2、4、3、4、5
三种情况,结果各不相同。
因为上面的代码有问题。
先把挨个的结果给出来:
第一种:结果正确。
第二种:程序崩溃。
第三种:结果错误。
为什么?
我只能说大家画一下图,自己模拟一下删除的过程。
我这里也不好演示,能力有限,不会搞那种动图。
第一种情况是凑巧,歪打正着。
第二种情况是越界,程序崩掉了。
第三种情况是,将偶数直接跳过了。
那么把上面的代码改改就行。
库中的erase是有返回值的,都是返回删除位置的下一个数据的迭代器的位置。
那么就好说了,我们每次删除一个数据之后就更新一下当前迭代器的位置就解决了。
代码如下:
auto it = v1.begin();
while (it != v1.end())
{
if (*it % 2 == 0)
{
it = v1.erase(it);
}
else
{
++it;
}
}
这样就好了。
上面的三个场景都是正确的。
总结一下:
insert / erase pos位置,不要直接访问,一定要更新,直接访问可能出现各种各样的结果,就是所谓的迭代器失效。
STL只是一个规范,只规定了要实现什么东西,具体的实现细节没有做什么要求,不同的平台下的实现是不同的。
上面的三个示例,g++和vs2019下结果一样,但是vs2013下三种情况都会报错。就是因为库的实现不同。
下面说一说深浅拷贝的问题。
深浅拷贝
深浅拷贝对于自定义类型来说已经是家常便饭了。
老问题。浅拷贝的结果是:1. 析构两次, 2. 一个对象修改同时影响多个对象。
深浅拷贝存在于拷贝构造函数和赋值运算符重载中。挨个说。
拷贝构造函数
这里给三种实现方法。
- 正常实现
直接给代码:
注意上面const对象不能调用非const成员函数,所以还要再写一个const的size()。
测试一下:
- 复用reserve和push_back
测试一下:
但其实,代码是稍微有点问题的。
vs2019会自动初始化成员变量,如果别的平台没有初始化的话,就会有问题。
没有初始化的话,_start就会变为野指针,当reserve的时候就会先拷贝脏数据然后再释放未开辟的空间,就会导致程序崩溃。
所以说得加上初始化:
这样才比较完善。
- 复用构造再交换
我前面在模拟实现string那篇中也用到了这个方法,但是string有个构造函数是可以多个元素初始化成不同数据的,但vector中只有个迭代器区间初始化时可以实现这样的初始化,所以我们要先实现以下这个迭代器区间的构造函数。
迭代器区间我们可以直接用我们自己写的那个iterator,但是也可以用一个函数模板来实现,后者的好处在于能够用不同类的迭代器区间来构造类对象。可能你没有听懂,没关系,等会给示例就懂了。
像下面这样的迭代器区间:
实现出来就是这样:
测试一下:
也是有点问题,没有初始化。但是vs2019会默认初始化。
加上:
然后我们试一下刚才说的不同类型初始化。
就是上面的这个。
然后就是拷贝构造函数:
上面拷贝构造函数中swap没有用库中的,因为直接用库中的话是深拷贝开销会很大,没必要,自己实现一个就好。
测试一下:
再来说一下赋值运算符重载。
赋值运算符重载
还是先给出普通的实现:
测试下:
再用一个比较简便的方式:
测试一下:
可能基础不牢的同学这个方法已经看懵了,不要懵,听我讲。
v3 = v2。v2以传值的形式传给了v,所以说v是v2的一份拷贝,将这个拷贝的数据与v3进行交换,就能把v中与v2相同的值全部交换给v3,这样就能达成赋值的目的,然后重载函数运行完毕,栈帧销毁,v被释放,不会造成任何影响还能给v3赋值。
然后再来看个构造函数。
n个val构造
直接给代码:
测试一下:
细心的同学可能已经发现了v1初始化的时候用的是8u,也就是无符号整型8。
但是我为什么要这么做呢?
看一下我不加u的结果:
出错了,编译器说我非法的间接寻址。
为什么?
直接说,就是因为前面我写的那个迭代器的构造。
这里参数是两个int,也就是(int, int)。
编译器在匹配重载的函数的时候,是按照最匹配的函数来进行匹配的。
我们的迭代器区间构造函数,参数是(InputIterator, InputIterator)这两个一样的参数,也就是说,我们没有参数为 (int, int) 的构造函数,所以说当我们传参为int,int最匹配的就是(InputIterator, InputIterator),而不是(size_t n, const T& val = T()),所以此时调用的就是迭代器区间的构造函数。
那么如何避免这个问题呢?
我看库里面的实现是再专门搞一个参数为(int, const T&)的,甚至还有个(long, const T&):
所以我们模拟的话,就也专门搞一个参数为(int, const T&)的构造函数就行。
测试一下:
完全ok。
然后看一下resize()这个函数。
resize
就考虑三种情况就行。
-
n > capacity
就是扩容加初始化 -
size < n < capacity
就是初始化 -
n < size
就是缩容
测试一下:
代码如下:
vector<int> v1(8, 5);
v1.resize(10);
v1.resize(5);
v1.resize(8);
上面的情况分别打印出来就是:
我们再看一下标准库中的和我们的一样不:
一样。
然后再看一下front和back。
front和back
很简单,返回值就是首尾元素。而且是引用。
front
测试一下:
back
测试一下:
然后再讲点关于深拷贝的问题。
不知道各位看我前一篇vector简介没,那篇最后我给了一道题,就是杨辉三角,这里需要用一下这个。
就是二维数组,用vector实现的二维数组。
借用一下那道题的代码:
class Solution {
public:
vector<vector<int>> generate(int numRows) {
vector<vector<int>> res;
res.resize(numRows);
for (int i = 0; i < res.size(); ++i)
{
res[i].resize(i + 1);
res[i].front() = 1;
res[i].back() = 1;
}
for (int i = 1; i < res.size(); ++i)
{
for (int j = 1; j < res[i].size() - 1; ++j)
{
if (res[i][j] == 0)
{
res[i][j] = res[i - 1][j] + res[i - 1][j - 1];
}
}
}
return res;
}
};
这里运行起来的话,会直接崩掉。
调试起来发现是最后析构的时候错了。
看一下返回值:
如果我们把返回值改为void会怎么样:
运行起来了。
然后我们再来搞一个顺序表来接收一下最后的二维数组。
调试起来最终还是在析构处崩掉了。
不卖关子了,讲:
我们调试起来发现:
虽然外层的vector是深拷贝。
但是内层的那个vector是浅拷贝:
画出来图的话,就是这样:
蓝色区域的部分,在析构的时候会被释放两次。
此时就会导致程序崩溃。
怎么避免呢?
看一下我们的拷贝构造函数:
memcpy不能用,内部的成员会发生浅拷贝。因为memcpy是逐字节的拷贝。拷贝完后的数据是完全一样的。_start[i] = v._start[i]就不是了,当_start是vector的时候就会去先调用拷贝构造来对_start内部的成员赋值,然后再调用赋值重载,给_start整体赋值。
所以得要换成for循环来逐个赋值。
这样的话就能实现深拷贝。
也就是这样:
上面的拷贝构造用的是普通版本的拷贝构造。
我们的reserve也是有问题的。也要改成for循环的。
我们其他的写法就是用了reserve,如果没有改reserve的话就会崩掉。
再看一下二维的顺序表的图:
就讲到这吧,这个模拟实现讲的挺多的了。
到此结束。。。