前言
vector是C++STL四大组件之一容器的一部分。vector属于容器中的序列式容器,之所以被称之为容器,是因为在有了模板之后,vector在显示实例化时可以按照不同的需求实例化出存储不同类型数据的类,就像是一个容器一样,你放入什么,它就是什么。vector的本质就是一个可以动态增长的数组,是利用指针来存储数据。
一、库中vector的简单认识
1.vector类对象的定义
因为模板的存在,在定义库中的vector类型时,必须显示实例化。如下图
2.vector类的大小
通过计算和调试,可以看到,四个模板参数不同的未初始化的vector类的对象大小都是一样的16
vector<int>类型的v1,在未插入数据前,它的大小是16,在插入5个数据之后,它的大小还是16。这是因为sizeof只会计算所占栈空间的大小。对于堆区的空间(vector存储的数据是在堆区开辟),是不会进行计算的。如下图
看到这里聪明的你可能已经想到,vector的成员变量是指针。而关于vector类对象的大小,我们只要明白和插入数据的多少没有关系,而是和平台的规定有关即可。
3.vector的成员变量
在stl里vector的定义中,可以看到,vector的成员变量是三个指针。
也就是 iterator start; iterator finish; iterator end_of_storage;
关于iterator,它是value_type*的类型重定义,而value_type又是对模板参数T的重定义,也就是说iterator就是T*。如下图
4.vector的成员函数
4.1Member functions部分函数
4.2Iterators部分函数
迭代器部分函数,因为vector的本质是动态数组,空间是连续的,所以它的迭代器就是指针,可以直接使用++,--,*,->等操作。
4.3Capacity部分函数
需要注意的是,size和capacity是根据偏移量来计算的,指针之间偏移量的差值就表示数据或者空间的大小。
值得一提的是,要分清reserve和resize的区别,前者不会改变size的大小,而后者会。但最重要的还是关于有资源申请的自定义类型涉及到的深浅拷贝问题,下文会进行详细解释。
4.4Element access部分函数
元素访问的接口函数很简单,因为vector就是一个动态数组,利用指针实现,可以通过指针加下标直接访问数据。
4.5Modifiers部分函数
该部分的重点在于insert和erase,会涉及到迭代器失效的问题,下午会进行详细解释。
二、vector的简单模拟实现
1.成员变量的模拟实现
vector的成员变量是三个指针。
_start指向的是空间的起始位置
_end指向的是空间中存储最后一个数据的下一位置
_end_of_storage指向的是空间的末位置
如下图所示
为了和库中vector的定义保持一致,并且,vector中也实现了迭代器,因此我们对模板参数类型的指针定义为iterator,作为三个指针的类型,本质和T*是一样的。
2.iterator部分函数的实现
vector的迭代器只是指针类型的重定义,这是因为vector的存储空间是连续的,指针本身的++和--和*和->等操作可以直接使用。
关于迭代器部分的函数,begin是返回起始位置,end是返回最后一个数据的下一位置,加了const并且返回类型为const_itrator的函数是为了让const对象使用。
3.默认成员函数的实现
构造函数一和二。如下图
第一个构造函数仅仅能将三个成员变量置为空
第二个构造函数是存放n个val,因为val既可以内置类型也可以是自定义类型。所以用匿名对象来做缺省参数,保证当val为自定义类型时,也可以完成初始化中的赋值。随后更新_finish和_end_of_storage即可。
构造函数三和四。如下图
需要注意的是,第二个构造函数和第三个构造函数的唯一区别就是,前者的第一个形参的类型是size_t,后者第一个形参的类型是int。第三个构造函数的作用也只是为了防止因为有了第四个构造函数而发生的调用歧义。
很明显,第四个构造函数时通过指向另一个vector对象迭代器来初始化自己。
但是当发生如下v3的初始化时,因为5和0都是相同的类型,该初始化的本意是调用第二个构造函数,但是相比第二个构造函数参数的size_t,int,第四个构造函数的int,int很明显更符合,而一旦调用第四个构造函数,其中对指针的解引用操作就会引发错误。所以才有了第三个构造函数,来让编译器择优识别选用。
拷贝构造函数
无非是开空间加赋值即可,但是要记得更新成员变量。拷贝构造的功能是用一个对象初始化另一个对象。如下图
析构函数
释放掉动态申请的空间后,将三个指针置空即可。
赋值运算符重载
参数中没有加&,所以会先经过拷贝构造,再利用swap函数将拷贝构造得来的v和自身交换。
效果如下
4.Capacity部分函数的实现
4.1reserve和resize
reserve和resize的区别:二者都是开辟空间,并且,vector空间的开辟和string一样也是要重新开辟空间,再将旧空间的数据拷贝到新空间。但是resize会在此基础上将剩余的空间全部填充。
也就是说reserve不会改变数据个数,而resize会改变。
因为_start会指向新的空间,而_finish和_end_of_storage是在_start的基础上通过数据个数进行改变,所以无论是reserve还是resize,指向新空间后,成员变量本身都会发生改变。而这一点也会引发vector中的另一个问题:迭代器失效。该问题会在下文提及。
reserve函数
当新空间的大小大于旧空间的大小时,需要重新开辟空间,将旧空间的值拷贝到新空间后,释放掉旧空间,最后更新成员变量。
resize函数
resize函数需要改变数据的个数,如果新空间的大小小于原本的数据个数时,通过改变_finish来改变_finish和_start之间的偏移量来改变数据个数。如果新空间的大小大于旧空间的大小,复用reserve后,填充剩余的空间即可。
val的缺省参数之所以是匿名对象,是因为val的类型既可能是内置类型,也可能是自定义类型。由于,_start和_end_of_storage已经通过reserve改变,所以最后只需要更新_finish。
4.2深浅拷贝问题
无论是reserve还是resize的实现,无非就是开空间加拷贝数据,resize在此基础上还需要填充。
而vector整个拷贝数据的过程,我们是利用=来进行的。
而在string的模拟实现中,是利用memcpy来进行拷贝数据。
简单看看string的结构。如下图
string和vector的存储空间都是动态开辟,string在实现vector时直接复用memcpy就可以轻松完成拷贝,为什么vector在这里没有使用呢?
这是因为,string的数据类型只能是char,属于内置类型,并且该内置类型没有涉及到动态空间的开辟。
再来看看vector,vector是可以实例化自定义类型的,比如说string。如下图
vector本身空间的开辟就是需要动态申请,而它存储的数据类型string,每一个也都要动态申请空间,如下图
string对象的扩容,如下图
如果对数据类型是string的v直接使用memcpy进行拷贝,拷贝的是string内指针变量的值,也就是说,拷贝到的string所指向的空间和旧空间string指向的空间是相同的。
而在使用memcpy拷贝数据后,旧空间就会被释放,此时新空间的每个数据指向的都是被释放的空间,当本身生命周期结束后,又会释放空间。也就是说,一块空间要被析构两次,很明显是错误的。
所以,vector的扩容使用了赋值符号,当数据类型是内置类型时,就是简单的赋值,但数据类型如果是有动态资源申请的自定义类型,那么=调用的其实是赋值运算符重载,该函数可以实现对每个自定义类型的数据先开辟新空间,再拷贝旧空间的数据,使得拷贝的每个数据的空间都是新开辟的。
什么是浅拷贝:像std::vector<string> v;拷贝string类型的数据时,直接使用memcpy,拷贝到的只是指针变量本身的值(所以浅拷贝也叫值拷贝),也就是指向空间的地址,也就是让两个指针指向同一块空间,这就是浅拷贝。
什么是深拷贝:像std::vector<string> v;拷贝string类型的数据时,对于每个string数据的拷贝,都是先开辟新的空间,再将每个string存储的数据拷贝过来,指针指向的是各自的空间,这就是深拷贝。
4.3其余简单函数
5.Element access部分函数实现
重载下标是为了让vector也支持下标随机访问,const版本的函数是为了给const对象使用。front和back分别返回的是第一个数据和最后一个数据。
6.Modifiers部分函数实现
6.1insert和erase函数中的迭代器失效问题
insert函数
在插入函数中,position指向的是vector对象存储数据的空间的某一位置,但是,如果插入过程需要扩容,那么旧空间会被释放,此时position会指向一块被释放的空间并被返回,这明显是错误的,该类型错误被称之为迭代器失效。所以要提前保存position和旧空间_start之间的偏移量,再发生扩容后,即使_start指向了新的空间,也可以通过偏移量使得position重新指向新空间中的正确位置。
erase函数
删除完成position所指向位置的元素后,position虽然要指向下一个数据,但本身的位置不需要发生改变,只需要从前向后挪动数据就可以达到指向下一元素的问题。
所以,当利用erase删除对象中的全部数据时,不需要再对返回的迭代器++,因为删除当前数据后,后面的数据挪动过来,迭代器已经指向下一个数据,如果多次一举,反而会导致数据不能完全删除。如下图
关于迭代器失效问题。假设要删除的位置恰好是最后一个数据,再来看实现的erase代码
删除最后一个数据时,while循环不会执行,只通过--_finish完成。理论上来说,删除最后一个数据后,应该是这样,如下图
也就是说不能再访问的到被删除的最后一个数据,但实际上,可以访问的到。
如下图
本不能访问的数据,此时能被访问,这被称为迭代器失效。
而在vs平台,由于这种情况的出现,所以vs直接规定任意位置的删除都是迭代器失效。所以在删除完任意位置的数据后,再利用失效的迭代器访问数据(解引用)属于非法访问。如下图
解决迭代器失效问题的办法也很简单,既然失效了,那我对迭代器重新赋值即可。如下图
利用循环删除全部数据,每次删除完成后,重新赋值,避免迭代器失效问题。
6.2其余简单函数
尾插和尾删,只要是插入数据,就必须判断是否需要扩容,而对于数组的尾删,改变下标即可。