【C++篇】揭秘STL vector:高效动态数组的深度解析(从使用到模拟实现)
💬 欢迎讨论:在阅读过程中有任何疑问,欢迎在评论区留言,我们一起交流学习!
👍 点赞、收藏与分享:如果你觉得这篇文章对你有帮助,记得点赞、收藏,并分享给更多对C++感兴趣的朋友
文章目录
- 前言
- 一、vector的介绍
- 二、vector的使用
- 1. constructor
- 2. iterator
- 3. capacity
- 4. Modify 增删查改
- 三、深度剖析vector底层(含模拟实现)(重点)
- 1. 成员变量设置
- 2. reserve扩容器
- 使用memcpy导致的隐藏浅拷贝问题
- 3. insert插入元素
- 迭代器失效问题1
- 4. erase删除元素
- 迭代器失效问题2
- 5. resize控制大小
- 6.构造函数
- 因参数匹配导致的调用构造函数错乱问题
- 7. 拷贝构造
- 8. 赋值重载
- 源码
- 四、vector实现动态二维数组
前言
本文核心内容为vector的介绍及使用和对vector的模拟实现。本文包含很多易错点,都是我在学习vector过程中踩过的坑。因为自己淋过雨,希望可以为你们撑一把伞!共勉。
一、vector的介绍
📜vector的文档介绍(cplusplus)
vector使用了模板,以满足任意类型数据的适用。
另外还使用到了内存池,以提高效率。
- vector是表示可变大小数组的序列容器。
- 就像数组一样,vector也采用的连续存储空间来存储元素。也就是意味着可以采用下标对vector的元素进行访问,和数组一样高效。但是又不像数组,它的大小是可以动态改变的,而且它的大小会被容器自动处理。
- 本质讲,vector使用动态分配数组来存储它的元素。当新元素插入时候,这个数组需要被重新分配大小为了增加存储空间。其做法是,分配一个新的数组,然后将全部元素移到这个数组。就时间而言,这是一个相对代价高的任务,因为每当一个新的元素加入到容器的时候,vector并不会每次都重新分配大小。
- vector分配空间策略:vector会分配一些额外的空间以适应可能的增长,因为存储空间比实际需要的存储空间更大。不同的库采用不同的策略权衡空间的使用和重新分配。但是无论如何,重新分配都应该是对数增长的间隔大小,以至于在末尾插入一个元素的时候是在常数时间的复杂度完成的。
- 因此,vector占用了更多的存储空间,为了获得管理存储空间的能力,并且以一种有效的方式动态增长。
- 与其它动态序列容器相比(deque, list and forward_list), vector在访问元素的时候更加高效,在末尾添加和删除元素相对高效。对于其它不在末尾的删除和插入操作,效率更低。比起list和forward_list统一的迭代器和引用更好。
二、vector的使用
这里主要以介绍vector常用接口的方式进行讲解,由于vector的接口与过去所学的string的接口雷同,许多接口直接平移使用即可。
使用vector需包头文件:#include<vector>
使用格式:vector<T> + [对象名]
1. constructor
构造函数声明 | 接口说明 |
---|---|
vector()(重点) | 无参构造 |
vector(size_type n, const value_type& val = value_type()) | 构造并初始化n个val |
vector (const vector& x)(重点) | 拷贝构造 |
vector (InputIterator first, InputIterator last) | 使用迭代器进行初始化构造 |
vector& operator=(vector v) | 赋值运算符重载 |
调用演示:
//无参构造
vector<int> v;//构造并初始化n个val
vector<int> v1(10, 6);
vector<string> v2(10, "1111111");//使用迭代器进行初始化构造
string str("qwertyuiop");
vector<char> v3(str.begin(), str.end())//拷贝构造
vector<int> v4(v1);//赋值运算符重载
v = v1;
疑点:
这里你可能看不懂构造并初始化n个val的构造函数的第二个参数:const value_type& val = value_type()
。
缺省值是一个类型后面跟个()
,这是啥?为什么不用0?
这里是为了解决val是自定义类型时的情况。
当val是string类型是,val还能为0吗?当然不能!
其实,这里这样用在语法上看是不合理的,但在C++引入模板的时候,包容了这个写法,以兼容模板的功能。
2. iterator
vector的迭代器是一个原生指针
iterator的使用 | 接口说明 |
---|---|
begin + end(重点) | 获取第一个数据位置的iterator/const_iterator; 获取最后一个数据的下一个位置的iterator/const_iterator |
rbegin + rend | 获取最后一个数据位置的reverse_iterator;获取第一个数据前一个位置的reverse_iterator |
3. capacity
容量空间 | 接口说明 |
---|---|
size | 获取数据个数 |
capacity | 获取容量大小 |
empty | 判断是否为空 |
resize(重点) | 改变vector的size |
reserve (重点) | 改变vector的capacity |
- capacity的代码在vs和g++下分别运行会发现,vs下capacity是按1.5倍增长的,g++是按2倍增长的。因此不要固化的认为,vector增容都是2倍,具体增长多少是根据具体的需求定义的。vs是PJ版本STL,g++是SGI版本STL。
- reserve只负责开辟空间,如果确定知道需要用多少空间,reserve可以缓解vector增容的代价缺陷问题。
- resize在开空间的同时还会进行初始化,影响size。
4. Modify 增删查改
vector增删查改 | 接口说明 |
---|---|
push_back(重点) | 尾插 |
pop_back (重点) | 尾删 |
find | 查找。(注意这个是算法模块实现,不是vector的成员接口) |
insert | 在pos之前插入val |
erase | 删除pos位置的数据 |
swap | 交换两个vector的数据空间 |
operator[] | (重点) 像数组一样访问 |
这里在使用insert和erase时,可能会发生迭代器失效的问题,导致出错。在后文模拟实现这两个接口时会进行讲解。
三、深度剖析vector底层(含模拟实现)(重点)
当我们在模拟实现vector的过程中会出现很多问题,这些问题都十分重要!都是重点!
罗列一下:
- 迭代器失效
- 使用memcpy导致的隐藏浅拷贝
- 因参数匹配导致的调用构造函数错乱
所有接口我就不一一实现了,我会讲解实现:构造、拷贝构造、赋值重载、reserve、resize、insert、erase。
1. 成员变量设置
对标stl30学习,成员变量包括:
typedef T* iterator;
typedef const T* const_iterator;iterator _start = nullptr; // 指向数据块的开始
iterator _finish = nullptr; // 指向有效数据的尾
iterator _endOfStorage = nullptr; // 指向存储容量的尾
2. reserve扩容器
功能:当n>capacity()时,进行扩容;反之,不作处理。
STL中的做法是:分配一个容量更大的新的数组,然后将全部元素迁移到这个数组中,再清理原数组的空间,完成扩容。
实现(bug):
void reserve(size_t n)
{if (n > capacity()){size_t sz = size();//创建一个容量更大的新的数组T* tmp = new T[n];if (_start){//元素迁移memcpy(tmp, _start, sizeof(T) * size());//清理原数组delete[] _start;}_start = tmp;_finish = _start + sz;_endOfStorage = _start + n;}
}
使用memcpy导致的隐藏浅拷贝问题
在进行元素迁移时,T为内置类型的时候,使用memcpy是没有问题的。
但T为自定义类型时,就不行了。
以vector<string>
为例:
memcpy会将原数组的元素一一拷贝到新的扩容数组中,并没有调用拷贝构造,因此发生了浅拷贝。
所以在接下来调用delete时,原数组和新数组的元素都会被清理,后序再进行扩容操作时,会导致一个对象被析构两次,最终程序崩溃
总结问题:
vector需要的是深拷贝,但是当存储的数据为自定义类型时,使用memcpy会导致元素对象浅拷贝。
解决方案:
调用元素对象的赋值重载来进行对象的深拷贝。
for (int i = 0; i < size(); ++i)
{tmp[i] = _start[i];
}
reserve正确代码:
void reserve(size_t n)
{if (n > capacity()){size_t sz = size();T* tmp = new T[n];if (_start){//自定义类型会发生浅拷贝//memcpy(tmp, _start, sizeof(T) * size());//用赋值重载来避免自定义类型发生浅拷贝问题for (int i = 0; i < size(); ++i){tmp[i] = _start[i];}delete[] _start;}_start = tmp;_finish = _start + sz;_endOfStorage = _start + n;}
}
3. insert插入元素
功能:在pos位置插入一个元素,并返回pos位置(pos是一个迭代器)
实现思路:
- 检查容量,不足就扩
- 挪动数据:
- 将pos位及后面的元素向后挪动一个位置
- 挪动方法:从后向前依次向后一位覆盖数据。
- 插入数据
实现(bug):
iterator insert(iterator pos, const T& x)
{//检查pos是否合法assert(pos <= _finish && pos >= _start);//检查容量,不足就扩if (_finish == _endOfStorage){int newcapacity = capacity() == 0 ? 1 : capacity() * 2;reserve(newcapacity);}//挪数据iterator end = _finish - 1;while (end >= pos){*(end + 1) = *end;--end;}// 插入数据*pos = x;++_finish;return pos;
}
迭代器失效问题1
我们仔细走读一下代码(包括reserve),可以发现:pos成野指针了!
我们每次在扩容时,会使得数据更换空间,此时pos迭代器指向的空间地址是无效地址(pos成了野指针(pos迭代器失效))。一旦我们再使用这个迭代器,就会出错。
在这里:扩容使得迭代器失效,后移使用了失效迭代器
解决问题:
更新迭代器:每次扩容时,将pos也同时指向新数组的对应位置。
正确代码:
iterator insert(iterator pos, const T& x)
{assert(pos <= _finish && pos >= _start);if (_finish == _endOfStorage){int len = pos - _start;int newcapacity = capacity() == 0 ? 1 : capacity() * 2;reserve(newcapacity);//解决迭代器失效问题pos = _start + len;//如果发生扩容,会使得数据更换空间,此时迭代器指向的空间地址是无效地址(pos变为野指针)//因此,要更新迭代器pos。}iterator end = _finish - 1;while (end >= pos){*(end + 1) = *end;--end;}*pos = x;++_finish;return pos;
}
4. erase删除元素
功能:删除pos位置的元素,并返回pos(pos是迭代器)
实现思路:
- 判断数组是否为空,删除空数组元素会出错
- 挪动数据:从pos+1位置开始,数据向前覆盖一位。
代码实现:
iterator erase(iterator pos){assert(pos < _finish && pos >= _start);assert(size() > 0);iterator end = pos;while (end < _finish - 1){*end = *(end + 1);++end;}--_finish;return pos;}
迭代器失效问题2
都没有发生扩容呀?难道pos也能失效???
在我们删除pos位置的元素后,pos位会由其他元素代替。
如果pos位及其之后的所有元素都被删除了,pos就会指向一个无效的位置,导致迭代器失效。
例如:2,3,5,7,9
,pos指向第三个位置,我们要对第3个位置删除3次:
这里我们可以发现:pos迭代器是有失效的风险的,也不是一定会失效。
但是:在vs上,编译器认为vector的insert和erase后的迭代器就是失效的,不能再去访问这个迭代器,否则报错
然而,g++就有所不同,它就是我们刚刚的结论:不失效不报错。但是我们也不能这样去做,否则代码你的代码没有可移植性。
5. resize控制大小
void resize(size_t n, const T& value = T());
功能:改变数组大小
实现思路:
n <= size()
:将数组大小缩小至nn > size()
:n < capacity
:将数组大小扩大至n,增加的元素设置为valuen > capacity
:扩容,并将数组大小扩大至n,增加的元素设置为value
代码实现:
void resize(size_t n, const T& value = T()){if (n > size()){reserve(n);while (_finish != _start + n){*_finish = value;++_finish;}}else{_finish = _start + n;}}
6.构造函数
我们来实现常用的构造:
实现(bug):
//构造并初始化n个val
vector(size_t n, const T& val = T())
{//直接复用resize的功能可以完美解决resize(n, val);
}// 迭代器构造
// [first, last)
template<class InputIterator>
vector(InputIterator first, InputIterator last)
{while (first != last){push_back(*first);++first;}
}//无参构造
vector()
{}
注意:要在定义成员变量处设置缺省值,否则就需要用初始化列表来进行初始化。
private:iterator _start = nullptr; // 指向数据块的开始iterator _finish = nullptr; // 指向有效数据的尾iterator _endOfStorage = nullptr; // 指向存储容量的尾
因参数匹配导致的调用构造函数错乱问题
我们试试这样实例化对象:vector<int> v(10, 10)
运行代码,发现报错了,为什么呢?
调试一下,可以发现并没有去调用vector(size_t n, const T& val = T())
,而是去调用了迭代器构造函数。
原来,我们的两个参数都是int型,恰好符合迭代器的两个相同参数,而vector(size_t n, const T& val = T())
第一个参数类型是size_t
,符合度没有迭代器构造函数高才导致这个结果的。
解决问题:
再重载一个vector(int n, const T& val = T())
构造函数,以防止调用迭代器构造。
//构造1
vector(size_t n, const T& val = T())
{resize(n, val);
}//构造2
//避免vector<int> v(10, 1) 调用3
//亦或者用vector<int> v(10u, 1),使其完美匹配1
vector(int n, const T& val = T())
{resize(n, val);
}// 构造3
// 迭代器构造
// [first, last)
template<class InputIterator>
vector(InputIterator first, InputIterator last)
{while (first != last){push_back(*first);++first;}
}vector()
{}
7. 拷贝构造
有两个实现思路:
- 常规思路:开辟一个新的空间,将所有元素深拷贝至整个空间即可
- 创建一个空对象,将所有元素依次尾插至目标数组即可
代码实现:
//拷贝构造v1
vector(const vector<T>& v)
{_start = new T[v.capacity()];int sz = v.size();//自定义类型会发生浅拷贝//memcpy(_start, v, sizeof(T) * sz);//用赋值重载来避免自定义类型发生浅拷贝问题for (int i = 0; i < sz; ++i){_start[i] = v[i];}_finish = _start + sz;_endOfStorage = _start + v.capacity();
}//拷贝构造v2
vector(const vector<T>& v):_start(nullptr), _finish(nullptr), _endofstorage(nullptr)
{reserve(v.capacity());for (auto e : v){push_back(e);}
}
8. 赋值重载
实现思路:利用传值会进行拷贝构造的特性,再用swap完成实现。
代码实现:
//赋值重载
vector<T>& operator=(vector<T> v)
{swap(v);return *this;
}
源码
这是我模拟实现vector的源码,仅供参考。
四、vector实现动态二维数组
vector嵌套一个vector就可以实现一个二维数组了。
vector<vector<int>> vv(n);
构造一个vv动态二维数组,vv中总共有n个元素,每个元素都是vector类型的,每行没有包含任何元素,如果n为5时如下所示:
vv中元素填充完成之后,如下图所示:
在使用时和普通二维数组差不多,也是vv[][]
。