前言
不知何时开始产生了不更新博客的习惯,开始编程学习也过了两年多了。恍惚了一个阶段,我觉得是时候恢复博客产出了,我认为写博客是一种好的学习的方式,不仅可以让你对已经学习过的知识又一遍回顾,还记录了你编程学习的每一步,同时也是正向激励自己的方式。话不多说,本篇文章主要介绍STL中的一个高频使用的容器vector。我会从vector的使用入手,然后简单模拟实现一下vector,以更好的了解这种容器。下面进入正文
使用vector
什么是vector?
首先,vector 这个单词有向量的意思。它是STL中的一个容器,它的本质是一个动态数组。然后,由于它是一个模板类,所以它是支持存储任意类型数据的,它也提供了许多操作它的接口,如push_back,insert等等。vector提供了类似于数组类似的访问方式,它可以支持对容器任意位置数据进行随机访问的。
为什么需要使用vector
- vector提供了一种方便且高效的动态数组的实现,开发者在使用这种容器时,它会根据实际需求开辟好堆区的空间来存储数据。
- 它适用于频繁尾插尾删的场景,在尾插尾删操作的时间复杂度为o(1)。但是,头插头删的效率比较差,时间复杂度为O(N),因为数据挪动是有代价的。所以在实际使用vector的过程中需要注意,尽量避免频繁地头插头删。
- vector支持像数组一样的随机访问方式。所以,在一些需要频繁随机访问的场景下,它的效率是极好的。时间复杂度为O(1)。
- 由于c++支持运算符重载,使得vector的使用语法可以像原生数组一样,所以它的学习成本其实不是很高。它的能力又要比原生数组强,vector还是非常好用的。
如何使用vecotr
首先,在正式使用vector前都是需要包含头文件的。下面我们就通过在线文档加上一些代码演示的方式,来进行vector使用的学习。
简单定义一个vector对象
在简答定义一个vector对象前,我们需要知道vector其实是有两个模板参数的,一个是类型模板参数,另一个是空间配置器模板参数(这里不做介绍)。
使用库提供的方法操作vector对象
这里我就以常用的接口来进行演示。
void test_vector1()
{
vector<int> v;//默认构造
vector<int> v1;
v1.reserve(10); //直接扩容至可以存储10个元素的大小
//reserve可以避免频繁扩容带来的性能损耗
v.push_back(1);//在vector的尾部插入数据
v.push_back(2);
v.push_back(3);
v.push_back(4);
for(int i = 0; i < v.size(); i++)//size()获取有效长度
{
cout << v[i] << " ";//支持[下标]的方式随机访问
}
cout << endl;
v.pop_back();//尾删数据
v[0] += 10; //支持类似于数组的操作方式
v[1]++;
}
由于STL的容器都是支持迭代器进行访问数据的,下面就简单给大家介绍一下vector迭代器的使用。以及常用相关操作的接口
void test_vector2()
{
int arr[] = {1,2,3,4,5,6};
//vector也支持迭代器区间初始化
vector<int> v(arr,arr+(sizeof(arr) / sizeof(int)));
//迭代器的简单使用
vector<int>::iterator vit = v.begin();//将vector其实位置
while(vit != v.end())
{
cout << (*vit) << " ";
++vit;
}
cout << endl;
//支持迭代器就支持范围for
for(auto& e : v)
{
cout << e << " ";
}
cout << endl;
int tmp[] = {3,1,6,8,9,10};
//用inset插入一段迭代器区间
v.insert(v.end(),tmp,tmp+((sizeof(tmp)/ sizeof(int))));
for(auto& e : v)
{
cout << e << " ";
}
cout << endl;
//使用erase删除一段区间
v.erase(v.begin(), v.begin() + 3);
for(auto& e : v)
{
cout << e << " ";
}
cout << endl;
}
下面简单介绍一下反向迭代器的使用,以及resize接口。
void test_vector3()
{
vector<int> v1(10);//初始化10个元素,默认为0
vector<int> v2(10,2);//初始化10个2,
vector<int> v3;
v3.resize(10,4);//使用resize接口,调整vector的有效长度,resize会先调用reserve扩容,第二个参数不给默认值 为0。
auto rit = v1.rbegin();
while(rit != v1.rend())
{
cout << (*rit) << " ";
++rit;
}
cout << endl;
for(auto& e : v2)
{
cout << e << " ";
}
cout << endl;
for(auto& e : v3)
{
cout << e << " ";
}
cout << endl;
}
迭代器失效的问题
迭代器失效是指在对容器进行插入或删除等操作后,原本指向容器元素的迭代器可能变得无效,不能再用于安全访问容器元素。这是因为这些操作可能导致容器的内存重新分配,元素的位置发生变化,从而使先前获得的迭代器指向的位置不再有效。
下面请看样例
#include <iostream>
#include <vector>
int main() {
vector<int> myVector = {1, 2, 3, 4, 5};
// 迭代器指向第三个元素
vector<int>::iterator it = myVector.begin() + 2;
// 在第三个元素之后插入一个新元素
myVector.insert(it + 1, 99);
// 此时,原来的迭代器 it 可能已经失效
// 尝试使用失效的迭代器
cout << "Value at original iterator position: " << *it << endl;
// 这可能导致未定义的行为
return 0;
}
下面看看在不同环境下这段代码的结果是什么?
在这个例子中,我们插入一个新元素后,原来指向第三个元素的迭代器 it 可能已经失效,因为插入元素可能导致容器重新分配内存,原来的元素位置发生变化。因此,尝试使用失效的迭代器可能导致程序行为不可预测,甚至崩溃。
避免迭代器失效问题的常见方法
避免迭代器失效问题的关键在于在可能导致迭代器失效的操作后,重新获取迭代器。以下是一些常见的方法:
-
使用返回值: 在执行可能导致迭代器失效的操作(如插入或删除元素)后,重新获取迭代器,而不是继续使用原来的迭代器。
std::vector<int>::iterator it = myVector.begin() + 2; it = myVector.insert(it + 1, 99); // 重新获取迭代器
-
使用下标访问而非迭代器: 使用下标访问而不是迭代器进行元素的访问和操作。下标是相对稳定的,而迭代器可能在插入或删除操作后失效。
std::vector<int> myVector = {1, 2, 3, 4, 5}; size_t index = 2; myVector.insert(myVector.begin() + index + 1, 99); // 使用下标而非迭代器 std::cout << "Value at position " << index << ": " << myVector[index] << std::endl;
-
避免在循环中修改容器: 如果在循环中对容器进行修改,确保重新获取迭代器或使用下标,以防止迭代器失效。
for (std::vector<int>::iterator it = myVector.begin(); it != myVector.end(); ++it) { // 避免在此处插入或删除元素,或者确保重新获取迭代器 }
-
使用反向迭代器: 如果你知道要在容器尾部进行插入或删除操作,可以考虑使用反向迭代器。这样,在插入或删除元素后,迭代器仍然指向相同的位置。
std::vector<int>::reverse_iterator rit = myVector.rbegin(); myVector.insert(rit.base(), 99); // 使用 base() 获取插入位置
通过谨慎使用迭代器和在可能的地方重新获取迭代器,可以有效地避免迭代器失效问题。
模拟实现vector
简单描述vector的结构
这里通过模拟实现一份简易版本的vector来学习它,以加深对它的了解。我们以SGI版本为原型进行模拟实现。通过上图可以了解到vector的成员有三个,分别是start,它用于标识vector容器的起始地址空间。以及finish用于标识最后一个数据的下一个地址空间。而end_of_storage用于标识当前容器申请到有效空间的最后一个地址空间,用于标识当前容器的容量。根据对这三个成员变量的了解,我们可以推断出size(),capacity()等成员函数的实现。size()其实就是finish - start。而capacity()等于end_of_storage - start。
下面就搭一个简单的vector
push_back()的实现
为了验证上面写的功能没有问题,需要再实现一个插入接口,插入数据验证一下。就实现一个push_back()接口来验证。push_back()实现思路如下,首先我们需要判断一下当前容器的空间是否还足够,不够的话我们就要扩容。然后再finish的位置上插入数据,让finish往后移一步即可。
如何扩容呢?我们需要调用函数reserve()进行扩容。
reserve(size_t n)函数实现思路如下,我们首先需要判断一下参数是否是有效参数。因为n如果小于当前的容量,处于对性能的考虑,reserve()不提供缩小容量。所以我们只考虑扩容的情况。
接着需要开辟空间,用于这里实现的是一份简单的vector,我们就先不考虑使用空间配置器,而是使用new开辟新空间。若start指向非空地址(默认构造出来的指向空地址)说明需要将旧数据挪到新空间上。最后更新一下成员变量即可。
下面分析一份经典的错误代码
这里的问题在于最后修改成员变量finish的时候。因为size()接口的实现是通过finish - start。而在这里修改成员变量finish的时候,恰巧是第一次调用,由于finish一开始值是nullptr。这里调用size()就会造成nullptr - tmp的情况导致程序崩溃。解决方案可以是提前保存size到局部变量中。另一个解决方案是先修改finish这个成员变量再修改start,并配上相应的注释会比较好。
关于memcpy导致深拷贝类型出错的问题。由于memcpy是浅拷贝,下一步的delete会去依次调用深拷贝对象函数的析构函数,在释放整体空间,进而导致出错。所以为了避免T为深拷贝类型而导致的问题,应该去调用深拷贝类型的复制重载。这样才能避免memcpy带来的问题。
operator[]实现
[]操作符使得对vector的操作就像操作原生数组那样。这里需要对下标做一个处理,避免越界情况。当然,还需要实现一个const版本的[]以供const对象使用
insert接口的实现
先看一下insert接口
整体的实现思路如下,首先,判断pos位置合法性。然后,对容量进行判断,需要注意的是扩容可能引发迭代器失效问题。为了解决迭代器失效问题,我们需要先保存pos位置相较于start的偏移量,扩容后,修改新的pos即可。最后,就是从最后一个位置开始挪动数据,完成插入数据。
下面就以插入单个数据为例实现一份insert接口
在insert()内部迭代器失效的问题解决了,并不代表用户在使用的时候会规范。下面演示一个经典的迭代器失效的场景。
避免insert后迭代器可能因为扩容而失效,我们可以在调用insert插入数据时,接受它的返回值已更新迭代器。这样可以有效避免迭代器失效问题。
erase接口的实现
以删除某个位置的值为例,实现erase接口的思路如下,先判断pos位置的合法性。然后将pos位置之后的数据从后向前覆盖。最后返回pos位置的迭代器。
下面聊一聊关于erase()使用后需要注意的细节。当迭代器传入erase函数后,无论如何都不应该再去使用它。因为这样的行为是高危险的。在vs平台下,这样的行为一律都会被强制报错。而在g++平台下,特定场景也会导致程序崩溃。所以,只要是erase失效的迭代器都不应该再去使用它。
由于vs平台只要访问失效迭代器一律报错,这里我就以g++平台为例,带大家看一个在g++平台下,访问失效迭代器导致程序段错误的场景。
上图是一个删除容器中所有偶数的算法。为什么会导致崩溃呢?因为在调用erase后,it已经失效,如果两个偶数连着势必导致漏删其中一个。最致命的问题是当最后一个数为偶数时,由于迭代器失效,会导致finish和it永远不会相遇,进而导致程序崩溃错误。
规范使用insert和erase
在写纯c++代码时,我们需要考虑代码的可移植性和健壮性。但是,不可避免的是不同平台的差异,如vs平台对于迭代器失效问题是我认为比价好的,它并不会让你去访问已经使用过的迭代器。这样能避免你访问失效迭代器而导致未知的错误(标准为定义)。而使用g++这种比较“自由”的平台时,更应该规范使用迭代器,因为时错时不错的情况有时候反而更折磨人。
resize的实现
resize用于调整当前vector的有效元素个数。先谈一谈第二个参数为什么是这样设计的。value_type是一个模板类型,为了使用的时候便捷,第二个参数是提供了默认参数,即当调用resize函数编译器会调用这个参数提供的默认构造。如我们初始化的是string类,就会去调用string类的默认构造。如果value_type类型是内置类型呢?由于模板的出现,编译器其实也为内置类型提供了默认构造函数。所以像int、char等内置类型也完全不用担心兼容性问题。
拷贝构造的实现
先介绍一下现代写法的实现思路。用reserve()函数开辟被拷贝的对象的容量大小的空间,然后依次插入被拷贝对象的数据即可。
传统写法实现思路如下,先开辟新空间,然后用memcpy拷贝一下被拷贝对象的数据,最后修改一下成员变量即可。
现代写法需要依赖reserve和push_back()接口,相较于传统写法比较清爽。传统写法则不需要依赖自己实现reserve()和push_back(),。实现逻辑其实都是开辟空间,然后拷贝数据,只是实现方式不同。个人认为两者掌握其一即可。
operator=重载的实现
我们采用现代写法实现
用于调用operator=时,形参会拷贝一份实参。我们实现一份swap调用库里面的swap交换成员变量后,返回*this即可。这是一种比较通用的写法,不仅vector可以这么写,像list、string等容器都可以这样实现。
迭代器区间初始化构造函数
实现思路就是使用循环不停push_back()元素即可。唯一需要注意的是,我们需要提供一个vector(int n, const T& val = T())的重载,这样可以避免调用构造函数时,指向不够明确导致程序编译错误的问题。
总结
vector是一个能够自动增长容量的线性表,它比原生数组的功能强大,但是又可以像原生数组一样去对它进行操作。这也意味着像二维数组等场景下,原生数组操作比较繁琐的问题。相应的vector也有迭代器失效的问题需要我们注意。操作迭代器去insert和erase后,如果还需要用该迭代器就需要获取返回值更新迭代器,以避免迭代器失效问题。