上一篇文章我们讲了vector的使用和基本介绍,这一篇我们来通过模拟实现vector的方式来逐步深入了解vector的底层。
1.关于vector的模拟实现

因为标准命名空间里的vector类是一个模板,所以我们也在头文件里面声明定义一个模板。这边需要注意的是,对于模板来说,声明和定义必须放在一个文件下,不然会引发链接错误。并且为了和标准命名空间里面的vector区分开来,我们需要自己定义一个命名空间,然后把我们的模拟实现的类模板放进去。
像我们之前定义的类里面,对于容量大小和存储内容的数量用的都是size_t类型,这里使用迭代器是为了抽象出迭代器的逻辑概念、这种写法更符合 C++ 标准容器接口规范、适配泛型算法,同时隐藏底层实现细节,便于后续灵活修改迭代器实现,而 size_t 仅能表示数量 / 索引,无法满足遍历、访问元素及泛型编程的需求。
1.1 默认构造函数

因为对于我们模拟实现的这个vector来说,三个成员变量实际上都是指针,那初始化的话,只需要通过初始化列表,将成员函数都置为nullptr即可。同时我们还需要知道我们所定义的vector的大小和容量,所以需要显示定义函数。只需要返回两个位置的差值即可。
1.2 插入、删除和扩容函数

因为既然要插入内容,那就有可能会遇到扩容的问题。首先我们要先检查一下传值过来的n是不是大于我当前的容量,如果大于的话我才要开辟空间。因为start这个成员变量在初始化的时候是空指针,所以还需要检查一下原来的内容有没有开辟空间大小。如果原来的内容有空间大小的话,还需要把原来空间里的内容都拷贝到新开辟的空间,再把原空间给释放掉。然后再把存储内容结束的位置和容量结束的位置进行一下修改。
把扩容问题解决之后,就可以直接来插入数据了。
但是实际上,这段代码里面有一个坑,就是在 *finish = x 的部分。
这里会引发一个异常:finish是nullptr。到这里大家可能会很疑惑:finish怎么会是nullptr呢?我们在扩容阶段不是把它修改过了吗:

我们来重新捋一下思路:

在扩容阶段开辟的这段空间是没有存储内容的,那么finish和start应该指向的是同一个地方,就如图中所示,而不是原来被初始化成的nullptr。但是我们来看一下对于finish = start + size();这行代码,按照我们的逻辑,应该是 finish = start + 0 ; 才能使得finish和start是指向同一位置。但是,对于size()函数的返回值中的finish - start,此时finish还是nullptr,start已经是tmp指向的位置,所以返回值就不可能是0。而只有当finish和start都是nullptr的时候,size()函数的返回值才是0。也就意味着,我们需要的是start在被赋值成tmp之前的size()的返回值。

所以我们需要先定义一个变量来存储原来的旧的返回值。这样的话问题就得到了解决。
最后来测试一下代码:

这里插入五个数据是因为:在插入的时候第一次扩容是扩容了四个字节的空间大小,而当插入第五个内容的时候,就会涉及到二次扩容。这样也检查确认了我们的扩容函数的代码写的是没有问题的。
同时,因为这里还涉及到打印数据,我们除了用基础的for循环来实现,还可以调用范围for,而使用范围for同时又需要迭代器,所以我们需要添加一下:
这里的迭代器有const版本和非const版本,这样就能使用范围for了。
下一个插入函数是insert:在指定位置插入。大家阅读官网文档会发现,其实vector的insert函数给的参数类型比string的insert的参数类型简单很多,只有三种。并且vector的参数类型都是迭代器。

同样的,既然是插入,首先,肯定要检查一下是否需要扩容。并且因为是在指定位置插入,所以还需要判断一下选择的位置是不是一个合法位置。然后我们让第pos个位置往后的内容,都依次向后移动,再让第pos个位置等于我们要插入的内容,最后再把finish的位置向后移动一个。
但是对于这段代码还有一个问题,是由扩容导致的++迭代器失效++。

比如说我的vector类对象里面原来已经有了4个数据,现在我想在下标为2的位置插入一个数字0,但是因为容量就只有4,插入一个数据之后,容量需求是5,所以就需要扩容。而扩容操作中的new会新开辟一块空间,把原空间的内容拷贝到新空间之后,再清理掉原空间的内容并释放空间,但是此时,迭代器pos还是指向原空间的地址,此时,就会引发像野指针一样的问题,但是对于迭代器来说,更官方的术语就叫做:迭代器失效。
所以我们刚刚写的代码中的while循环就失效了,因为它的判断条件是end >= pos。那么我们需要做的,就是再扩容操作之后,更新pos的位置:

我们在扩容操作之前先记录一下pos到start位置的距离,在扩容结束之后,再让start加上这段距离,就表示扩容之后的新的pos位置。这样就解决了迭代器失效的问题。
实现了插入函数,我们接着来实现尾删除的函数:pop_back。

和模拟实现string时的尾删除函数一样,我们只需要使存储内容的最后一位指针向前移动一位即可,对于尾部的指针存储的内容不需要去给置为0。此外,这里还需要进行一次断言,因为如果存储的最后一个内容的指针都要比最开始的指针靠前的话,就会引发报错。
当然这里除了这样进行判断之外,还可以用另一个办法:
bool函数对于返回语句会进行判断,如果返回语句为真,就返回true,反之返回false。这样的话与assert断言结合使用,会更加精细化。
下面一个是指定位置删除:erase。
首先和插入是一样的要先判断选择的位置是否合法,并且对于指定位置删除来说其实更加简单,只需要把第pos个位置删除,然后剩下的内容依次按顺序移动即可,不用涉及到扩容操作。
下面我们来测试一下pop_back和erase:

对于目前的场景下来看我们发现erase是完全没有问题的。但实际上在另一个场景下,比如当我想删除指定数据时,erase也会发生迭代器失效的问题:
对于我们刚刚插入的12345,这五个数据,我现在想要把其中的偶数都删除掉,这个时候就需要用到erase。我们先来测试一下标准命名空间里面的vector容器的erase:

大家会发现标准命名空间里面的vector容器的erase发生了断言终止,可是删除数据并没有涉及到重新开辟空间的问题,那迭代器为什么会失效呢?
这是因为:当调用v.erase(it)删除当前元素时,std::vector<int>的 erase 会释放当前 it 指向的内存,并让后续元素往前移动。此时,原 it 指针会变成 "悬空指针"(指向已释放 / 无效的内存),失去了合法的指向。而代码中在 erase 后直接执行++it ,本质是对失效的迭代器 进行操作,这会导致未定义行为(比如程序崩溃、结果错误)。而我们需要做的是:将 迭代器 it 重新指向被删除元素的下一个有效元素的迭代器。
此时我们再来看一下标准命名空间里面的vector容器的erase:
大家会发现这里的erase还有一个返回值,返回值就是被删除元素的下一个有效元素的迭代器。
所以我们需要让迭代器 it 接收返回值:

因此对于我们自己模拟实现的erase也需要做出一些调整:
我们再用自己模拟实现的erase进行一下测试:

同样的对于insert函数来说,除了我们上面提到的那种方法也可以和erase一样,来设定一个返回值:

在我们原先的写法当中,只能保证这个pos位置正确。但是当insert函数调用结束之后,如果我还想使用这个迭代器就没有办法使用,这是因为在调用的时候是传值调用,pos的位置虽然改变成正确的了,但不会影响外面的 it 迭代器,这样写之后,和erase一样,先让it迭代器接收返回值,然后就能使用it迭代器对值进行修改了。
1.3 clear和析构函数
这边要注意的是对于clear函数来说,我们只需要把原空间里面的内容都给清除而已,并不需要把原空间里面的内容都给置空。因为我们有可能还需要再插入数据,所以只需要让finish和start的位置相同就可以。对于析构函数来说,是需要把原来的空间给释放掉,然后再把三个位置的指针置为空即可。
1.4 resize函数
resize 函数用于调整容器的元素个数,若新长度大于原长度则用指定值(或默认构造值)填充新元素,若小于则截断尾部元素,容器容量可能不变或增大。
既然要使用缺省值,即默认构造值来填充新元素,那这个新元素就要有参数类型,可是我们的vector是封装成模板的,那么对于模板来说这个参数类型就有可能是任意类型,那我们缺省值应该给什么值呢?

这个时候就可以使用匿名对象,这里使用匿名对象的本质其实是调用默认构造函数。同时在C++当中,对于内置类型C++也给它们编写了默认构造函数。所以对于内置类型如int、char等等,你也可以这样写:

这都是没有问题的。
因为代码比较简单和string的resize差不多,在这里就不过多赘述,直接展示代码:
接下来我们测试一下:

实现了resize函数,我们还可以顺带实现另一个构造函数:构造n个val的值。

这个构造函数经常用在实例化对象时使用,比如我们上一篇文章中提到的杨辉三角问题,就使用了这个构造函数。
1.5 拷贝构造函数
既然是拷贝构造函数,那一定要是深拷贝,这里拷贝构造函数先初始化 ,是为了避免这些指针在调用 reserve/push_back 前处于未初始化的随机地址状态,防止后续内存操作(如 reserve 里的 if (start) 判断)出错,保证内存操作的安全性。

并且我们因为是把三个成员变量都设置成为nullptr,那我们也可以在类里面的成员变量直接使用缺省值,这样的话我们就不需要再自己手动进行初始化,同时在默认构造函数的初始化列表也不需要写了,就像这样:
那么这个时候就有同学要问了,对于上面这个构造函数来说,我啥都没给,只有单单的一个函数名,那写它有什么意义呢?是不是就可以直接把它去掉了?当然不是这样的。
这是因为假如说你想实例化一个对象,就必然会调用构造函数。如果你没有显式写构造函数。编译器就会给你自动生成默认构造函数。那么此时假设你把自己写的这个构造函数给去掉,对于这段代码来说编译器会自动生成默认构造函数吗?答案是不会的。因为拷贝构造函数也算构造函数,但是拷贝构造函数没有实例化对象的功能。同时,既然没有构造函数,就不能实例化对象,那就会引发报错。
所以在这里这个构造函数看上去好像什么都没写,但是我们也必须要写它。同时还有另一种写法:
同时还有在C++当中给了一个新的关键字:default。这个关键字的作用就是强制编译器生成默认构造函数。
我们接着来看下一个问题,在前面我们提到的构造函数都是单参数的构造,那能不能进行多参数的构造呢?就像这样:

对于这种多参数的构造就会引发一个报错:没有与参数列表匹配的构造函数。所以说我们也需要自己实现一下:
这里还需要标准命名空间里面的一个initializer_list的类模板,帮助我们实现多参数的构造函数。
1.6 迭代器区间构造

对于同类型的迭代器,我们只需要找到初始位置迭代器和末尾位置迭代器,然后依次插入即可。但如果是想要插入不同类型的内容,比如说想要插入一个string类型的对象:

那迭代器就需要string类型的迭代器,但是对于我们刚刚写的代码来说,只能插入一个vector类型的迭代器,所以这个时候,我们可以将这个迭代器区间构造函数写成模板:

下面来测试一下:

这里存储的几个一百多的数字,就是s1里面字符对应的ASCII码值。
这里还隐含了一个问题:我们在前面讲到resize函数的时候提到了一个构造函数,就是构造n个val的值,我们现在尝试测试一下:
我们会发现这段代码发生了一个报错内容是:无法取消引用类型为inputiterator的操作数。实际上这里的意思是:对于这段代码,它调用的是我们刚刚写的这个模板,而不是第二个。但我们本来的想法就是想让编译器调用第二个函数,帮我们实现构造,这里为什么会调用到模板呢?
这其实是因为对于第二个函数来说,其中第一个参数类型是size-t。这表示:形参n是一个无符号整型也就是unsigned int。第二个参数value,自动识别的类型是int。这就意味着第二个函数的类型是unsigned int 和 int。但是我们传参传的是两个int类型的值。对于模板来说,它能自动识别类型,所以对这段代码来说更加适合,编译器就自动调用了第一个模板。
但是模板中的参数是指针实现的迭代器,而传参传的两个都是int类型,根本不是指针,所以会引发报错。
所以对于出现的这个问题的一个解决办法就是,把第二个函数里面的第一个参数类型改成 int 即可。但这实际上并没有真正的解决问题,因为这里vector的类型是int,那么当下一次vector的类型是其他的类型的时候,又会出现问题。 解决办法在这先不展示出来,大家可以先在评论区探讨一下。
1.7 赋值运算符重载

赋值运算符重载可以利用交换的思想去模拟实现,那我们就需要定义一个交换函数,这里的交换函数可以直接用标准命名空间里面的swap函数封装。
1.8 vector里的深拷贝问题
这里还有一个小小的坑,直接展示代码:

对于这样一段代码我先实例化了一个string类型的vector对象,然后往里面插入了五个字符串,再将其打印。结果打印出来的是一堆代码,我们之前说到出现乱码的情况大多是出现野指针等问题,并且这里还引发了一个异常叫做:写入访问权限冲突。

但是当我插入4个字符串的时候就没有问题。问题就很明显的指向了扩容函数。

我们通过调试,经过监视窗口能发现在reserve函数当中,扩容的时候需要释放掉旧空间,也就是在释放掉旧空间的时候,即delete[ ] start这一行代码上发生了问题。可是我们释放的明明是原来的旧空间start啊,为什么这里的新开辟的tmp空间会受到影响呢?这其实就是深层次的深拷贝问题。原因就在string身上。

在扩容之前vector这个数组里面已经存储了4个string类对象,在扩容之后,容量大小就扩充到了8个。并且扩容之前,string类对象里面的_str都指向堆上存放的字符串,我们在之前的string容器讲解中提到过:当字符串比较少的时候会存放在栈的缓冲区,当字符串长度大于一定限制使就会存放到堆内存空间当中。
我们再来看看扩容操作中的拷贝是用的什么方法?用的是memcpy。而memcpy拷贝后,也就是扩容之后的每个string类对象的_str还是指向的原来的内存空间,这就是memcpy导致的浅拷贝问题。
而此时要执行delete [ ] start。使用delete释放空间,首先会将这个数组上的每个自定义类型对象都调用析构函数,那就会把原来指向的堆内存空间给释放掉。那扩容后的每个string类对象里的_str就都变成野指针了,那编译器就会把这些指针置成随机值,所以才会引发刚刚出现的乱码的问题。
所以这里的解决办法就是摒弃memcpy,进行深拷贝,而进行深拷贝的方法就是依次赋值:
使用依次赋值的方式同样能达到拷贝的效果,并且,对于string类对象来说,这里还涉及到赋值运算符重载,而string类对象的赋值运算符重载走的都是深拷贝。这就解决了问题,而且这样写的话,对于char、int等这种内置类型,通过浅拷贝完成,如果是其他什么自定义类型,只要有深拷贝的赋值运算符重载,都可以满足需求。
本文到此结束,感谢大家的阅读,如果有讲解的不到位或者错误的地方,欢迎各位读者批评或指正。
