【C++】简单学——vector类(模拟实现)
模拟实现的准备工作
看源码,了解这个类的大概组成
1.先看成员变量
成员变量的组成是三个迭代器
问:这个iterator内嵌类型究竟是什么?即这个迭代器是什么
迭代器实际就是T*
问:这三个迭代器代表什么意思?
连蒙带猜:元素的开始,元素的结束,容量的结束
不过因为只是猜测,所以还是需要去自己试验一下的
2.看构造函数
要看对象要怎么初始化
无参的时候是全部迭代器初始化成空
3.看pushback(增加数据)
如果经验丰富的话,就可以知道这个是尾插
问:这个construct是什么意思?
内部其实就是一个定位new
定位new是对已有的空间进行初始化
construct是在finish的位置那里进行尾插,然后finish++
STL的空间都是从空间配置器(内存池)来的,内存池只负责开辟空间,没有初始化,如果说要对已有的空间进行初始化,就要用定位new
问:insert_aux(end(),x)又是什么意思?
如果说finish==end_of_storage,就扩容,可以从这里看出来,vs的扩容是2倍扩容
总结
由上面的信息可以得出下面的图和信息
size就是finish-start
capacity就是end_of_storage-start
开始尝试能不能实现
因为我们是模拟实现,所以会有很多没有考虑到的地方,所以如果我们要写一些方法,强烈推荐写一个画一个图,这样还可以来查漏补缺,看看那些隐藏的bug
模拟实现的意义
1、让我们更加理解底层,能够更好地使用
2、可以跟那些大佬学习他们是怎么实现的(尽管因为大佬在搞的时候会因为考虑全局方面而被迫搞得很复杂,但是仍然有参考意义)
尝试把刚刚前面了解的东西写一下
问:既然我们都已经写了缺省参数,并且我们也没有必要搞有参构造函数,那能不能把那个无参拷贝构造给删掉
答:如果说之后写了拷贝构造,编译器就不会生成默认构造函数,就会导致当你不传参构造对象的时候没有默认构造函数可以调用
析构函数
为了方便测试,所以先写一个pushback尝试跑通一下
对于类模板,函数如果要用到模板,就要特别小心了
除了考虑各种值,还要考虑各种值是否合适
例如:假如这个地方push _back的是一个string或者是vector等等,就会导致消耗很大;如果加了引用,还要小心这个传入的值在里面被修改了
(解决办法:加引用,加const)
push_back的逻辑:
1.先判断够不够容量
如果要扩容,就记录一下新开的空间要多大,至于size和capacity怎么算,我们就顺便把size和capacity给实现了吧
扩容逻辑:根据原本的容量进行二倍扩容,然后把原有的数据拷贝下来,然后原本的start指向的空间就销毁,然后让start指向新的空间,把那些新的成员变量都更新一遍
插入逻辑:在finish的位置的值设置为val,然后finish++往后移一格
问:如果说vector实现了析构函数,tmp开辟了空间,tmp的生命周期结束的时候难道不会把开辟的那个空间给delete掉吗?
答:我们必须得区分好,只有对象才会在销毁的时候自动调用析构函数,对象是包括了_start、_finish、_endofstorage的对象,而我们现在的开辟空间只不过是利用一个T的指针去在堆里面开辟空间,并不是对象,不会自动销毁堆的空间
为了方便测试,我们就做一个遍历吧,为了遍历方便,顺便做一个operator[ ]
(记得包assert的头文件)
测试用例:
但是结果出问题了
调试之后,发现finish在进行更新的时候出了问题
问:为什么这个地方的_finish变成了空指针
答:直到_start更新都是没问题的,但是这个地方的finish = tmp + size();而size是等于finish - start;
也就是说,此时的start已经更新了,而finish还没有更新,所以size = (旧的)finish - (新的)start
所以公式就变成了这样:(新的)finish = (新的)start + (旧的)finish - (新的)start = (旧的)finish
因为旧空间在前面就已经被销毁了,所以新的finish仍然指向这个被销毁的空间,所以_finish就变成了空指针
解决办法:提前记录size(记录start和finish的相对位置)
size和capacity
迭代器
测试用例:对比三种
const的也要实现
晚点把size和capacity也实现const
为了方便我们进行测试,我们直接把刚刚遍历的内容搞成一个print函数来调用吧
当然,也可以实现成模板
但是这里有一个潜藏的问题:因为这个print用了auto所以才避免了
报错的原因:
语法上有冲突,编译器不认识这个const_iterator是什么东西
因为这个函数是函数模板,所以里面的一切模板都是没有实例化的(实例化是在后面调用的时候)
编译器是不敢去没有实例化的模板里面去取东西的
编译器会根据这种(通过指定类域的方式来取数据)情况有两种猜测:
- 这是一个内嵌类型(typedef定义的类型,内部类等等在类里面定义的类型)
- 这是一个类里面的静态变量,如果说是静态变量,那后面肯定就是要去跟分号( ;)的了
为了解决这个问题,编译器规定这种情况要在前面加上typename
pop_back和empty
尾删直接让finish往前移动一格就好,为了防止一直往后删,所以要写一个判断为空的方法
insert
我们就实现第一个就好
插入之前先检查空间够不够(顺便把reserve给实现了)
然后把数据插入到pos位置
把前一个数据挪到后一个数据处,直到把pos位置的数据给挪走
问:string的时候,pos是size_t,导致头插的时候,判断条件让size_t it 永远不会比pos小而死循环,为什么现在不会有这个问题了?
答:我们现在的pos是迭代器,而地址永远都不会为0(地址本质上是每个内存单位的编号,最小的指针就是空指针),所以不存在string的那种问题
出问题了
这个地方我们选择不断头插11.11
当超过4个值,要进行扩容的时候就出错了
调试后发现,pos的位置不对劲,我们原本是打算pos指向头元素的,但是扩容之后,原来的那个空间被销毁了,但是pos还是在旧空间上(迭代器失效)
问:为什么会出现-6.2777等的数字?
答:因为pos没有更新,所以当进行it>=pos的时候,此时的pos是0x0163f068,it是0x01635250,此时的pos要比it大,所以就不会进到循环判断中,也就是啥都没做,但是finish却凭空++了,然后就会看到尾部多了一个非法的数据;(这种情况是因为迭代器失效导致的,所以正常情况下不扩容是看不到这个错误的)
解决办法:提前记录pos和start的相对位置
reserve
直接把前面push_ back时临时搞的扩容直接拷贝过来就好
最终实现
push别忘了改回去
erase
依次把pos+1的位置往前覆盖,直到pos+1到了结尾
由此可以复用pop_back
resize
根据库里面实现的resize,我们可以得到以下的函数
const T& val = T()是一个调用了默认构造的无参的匿名对象
好处:可以根据不同的类型来给初始值(因为string和其他更多类型不能简单的用0来初始化,就可以用匿名对象来进行初始化)
问:如果说是vector<int>,int是内置类型,没有默认构造怎么办?
因为模板出来之后,为了解决这个问题,祖师爷让内置类型也有了默认构造(被迫升级),就是为了应对这个地方
整形就是0(char也是0,只不过在ascll中就是'\0'),浮点数就是0.0,指针就是空指针;
resize的逻辑:无非就是插入和删除的问题
因为我们reserve有做元素个数和容量大小的检查,所以这个地方我们可以直接分成两种情况(而不是三种情况)
插入:n比size大(无所谓capacity),我不管三七二十一,反正我就先扩容,反正实际扩不扩容还是得看reserve
删除:n比size小
总结就是以下
拷贝构造
因为编译器自动生成的拷贝构造走的是浅拷贝,所以当他们指向同一块空间时,在销毁时就会析构两次,就会报错,所以我们需要自己实现拷贝构造
(销毁的时候直接崩掉)
另一种拷贝构造的方法
push_back后会自己开空间和初始化,利用这个原理
(此处是this.push_back,不要疑惑)
可以利用把v1的值一次头插给v2来实现拷贝构造
但是这个拷贝构造还是不够好,还可以优化
(如果是string的话,范围for的时候会有很大消耗,所以说用引用;如果说要拷贝1000个数据,可以提前开空间)
赋值拷贝
现代写法
为了实现我们的现代写法赋值拷贝,我们提前搞一个swap来实现
但是还是报错了
我们这个swap期望是去调用库里面的,但是因为编译器在查找的时候会就近原则,他就会以为是递归那种自己调用自己,调自己的话就是一个参数,所以才报错说不接受两个参数
解决办法:
加上类域指定
构造(补充)
用迭代器构造
科普:如果说类的模板参数不够用,我还可以自己给自己加模板参数
可以利用其它对象的迭代器来初始化
调用的例子:
那我这样调用呢?想要从指向第二个迭代器和倒数第二个迭代器来进行初始化
会出问题:
begin和end是传值返回,所以是临时拷贝;不能加引用,防止会修改自己那个底层的成员
++和--的特点就是会去修改自身,所以编译器因为没办法修改临时拷贝而报错
解决办法:不改变就好了
也可以这样调用
迭代器构造写成模板的原因就是为了支持前面的写法
n个值的构造
但是会出错
原本希望调用n个值的构造,结果却调用到了迭代器构造;
划红圈的位置是报错的位置
找bug技巧:编译错误如果实在不好看,我们可以利用排除法(屏蔽掉部分代码进行确认)
当屏蔽迭代器构造函数之后,可以发现,我们的n个值的构造函数是没有问题的
但是一旦两个同时出现的时候就会报错
以下的问题跟模板有关:
如果说10不用某些标识符来区分的话,默认就是int类型而不知sizt_t
此时第一个调用,10跟size_t n不是特别匹配,但也能用;但是调用那个迭代器构造就非常合适了,所以这个构造就会调用迭代器的构造
在这个函数里面,*first的时候相当于(*10),想把地址编号为10的值进行尾插,所以就会报错说非法的间接寻址
第二个调用和第三个调用都是n个值构造比较适配,所以就不会出问题
解决办法:
库里面的解决办法是写一个重载,让特殊情况方便调用
{}构造(initializer_list)(C++11)
这部分内容只是提前看一下
原理:灵感来源于初始化数组
花括号类型:initializer_list<class>类
只有这几个成员方法
本质:这个类里面有两个指针,而{1, 2, 3, 4, 5, 6, 7, 8, 9}就是一个常量数组,在常量区开了一个空间,一个指针指向这个常量数组的开始lbegin,一个指向结束end
通过sizeof计算这个花括号常量数组大小,得出来的大小是8byte(两个指针)也可以来证明这一点
有点像是vector,但是数据是在常量区,不能动态扩容
所以下面的代码其实就是在用initializer_list对象来初始化vector(隐式类型转换)
所以说如果要实现用大括号来进行构造的话,就要有一个单参数构造函数,且 参数为initializer_list
initializer_list单参数构造函数
有迭代器就可以范围for
具体使用:
特殊bug
复杂vector情况
这样子会挂,关键在于开空间
通过检查,发现是析构的时候崩了
memcpy导致string浅拷贝了(所有的嵌套开辟空间的类型都会有这个问题)
当需要扩容的时候,会把原本的空间给delete[],同时也会调用每个string的析构函数,尽管mencpy对于vector是深拷贝,但是对于string仍然是浅拷贝,还是析构两次的问题
所以说必须要实现这样
问:解决方案可以是用swap吗?
不可以,因为不知道T是什么,所以也不知道T有没有swap,万一没有,调用了库里的浅拷贝一样完蛋
解决方案:不用mencpy了,直接for循环把原本空间的元素一个个赋值到新空间里
迭代器失效
情况1
这里的insert插入之后,导致扩容,然后原本的空间就失效了,it还是指向原本的空间,所以这个就是迭代器失效
问:之前我在解决这个东西的时候,不是遇到了这个问题,然后更新了pos吗?为什么还会失效?
因为形参是实参的拷贝,形参的修改不影响实参,it在外面不变
问:那为了解决这个问题,我传引用不就好了?
也不行,因为会导致以下的传参方式不行
begin是传值返回,具有常性,普通引用不能引用具有常性的值
解决办法:失效之后就不要再使用了,如果还想要找刚刚那个位置,那就自己找,自己去重新更新这个迭代器
情况2
erase的时候,会把后面的值覆盖前面的值,相当于把整体往后移了,相对的,自己就++了,所以会多移动一次
第一个对了是巧合
当最后一个数据是偶数就会出大问题(错过了判断条件)
就会让it一直往后越界,直到访问到没有开辟的空间就会崩了
问:那如果我选择删除的时候不动不就好了?
也不可以
- STL不强制要求具体实现,其他的vector具体的实现有可能缩容,缩容就意味着delete原空间之后开新空间,然后迭代器就失效了
- 因为vs规定,只要你前面进行了erase的话,后面就没办法用之前的迭代器了(因为vs用的不是原生指针,内部会有特殊处理和检查)
在erase之后,it就不能再用了,除非你新搞一个出来
解决办法:
既然有了这个问题,那么库里面自然会给出解决办法
会返回删除位置的下一个位置的迭代器
修改方案:
我们自己的erase也要修改
总结:只要进行了插入删除就要默认前面的迭代器已经失效了,因为STL没有规定具体实现,也就不知道什么时候会扩容然后换新空间