文章目录
- vector的使用和模拟实现
- vector的使用
- vector介绍
- 重点接口的讲解
- 迭代器部分
- 默认成员函数
- 空间操作
- 增删查改操作
- 迭代器失效问题(重要)
- 调整迭代器
- vector的模拟实现
- 实现的版本
- 模拟实现
- 结构
- 预先处理的函数
- 尾插函数push_back
- swap函数
- 赋值重载
- size函数
- reserve函数
- 迭代器
- 默认成员函数
- 默认构造
- 普通构造
- 拷贝构造
- 析构函数
- 容量操作
- 容量 、判空
- resize函数
- 修改操作
- 尾删
- insert函数
- clear函数
- erase函数
- 打印函数(针对不同容器)
vector的使用和模拟实现
vector的使用
vector介绍
对于STL中各类容器的学习其实是很相似的,因为c++的封装性。虽然是不同的容器,但是c++标准库在实现的时候是对各类的容易实现了一些一样的接口,我们只需要关注其封装的接口的使用即可。所以各类容器的操作是很类似的。
而对于vector其实是一个类模板,其底层的实现本质还是顺序表。只不过与string的底层实现是略有区别。更大的不同是vector中存储的元素不仅仅是一些内置类型,也可以是类,如string,甚至是vector类。
当然学习STL容易是先学会如何使用其对应接口,我们得学会查阅文档:https://cplusplus.com/reference/vector/vector/
重点接口的讲解
迭代器部分
vector的接口其实没有string实现的那么多。因为string是更早写进标准库中的,这是历史遗留问题。
对于string,我们在增删的时候,更多的是传入对应的位置,也就是下标。而当我们查阅vector使用的文档的时候,我们发现参数竟然是使用迭代器的:
如erase函数,可以传一个迭代器的位置,也可以传迭代器指向的一段区间(左闭右开)。
其实迭代器的使用和string是一样的的,有八种。end型的迭代器都是指向最后一个有效元素的后一个位置。
使用的话重点掌握begin、rbegin、end、rend这四个就可以了。
默认成员函数
函数 | 使用 |
---|---|
vector()(重点) | 无参构造 |
vector(size_type n, const value_type& val =value_type()) | 构造并初始化n个val |
vector (const vector& x); (重点) | 拷贝构造 |
vector (InputIterator first, InputIterator last); | 使用迭代器进行初始化构造 |
~vector() | 析构 编译器会自行调用 |
vector& operator= (const vector& x); | 赋值重载 |
这里面有很多不认识的符号,下面给出这些符号的对应表:
我们直到,vector其实是一个类模板,内部的数据类型其实都是由模板参数T来替代的。但是为了代码的可读性更好,所以对一些常用的类型取别名。
我们只需要知道常用的那几个就可以了。
而还有一个很奇怪的构造函数:vector (InputIterator first, InputIterator last),使用迭代器进行初始化构造。这个InputIterator是什么呢?
其实这是一个模板参数的声名,template< class InputIterator >,声名这一个模板参数是因为在构造一个vector的时候,我们很可能需要用别的迭代器进行构造。
举一个很常见的例子:
有时候我们想对链表(STL中的list)中的数据进行排序。但是链表排序其实是效率较低的。所以我们会经常的使用链表的迭代器区间来构造一个vector,vector的本质是顺序表,使用顺序表排序是比较高效率的。然后排好序后再依次将数据覆盖回链表中。
当然这个迭代器也可以是指向数组的:
在这里我们可以认为是数组也有自己的迭代器。
空间操作
函数 | 使用 |
---|---|
size | 获取数据个数 |
capacity | 获取容量大小 |
empty | 判断是否为空 |
resize(重点) | 改变vector的size |
reserve (重点) | 改变vector的capacity |
重点我们来看看resize和reserve的使用。
对于resize:
这是调整数据个数的函数。如果传入的n是小于当前数据个数,那么就会删除数据。
如果n大于当前数据个数,会往后续新加入的位置插入数据,空间不够的时候会扩容。
这个插入的数据是带有缺省参数的,即value_type val = value_type();。这个其实是调用了匿名类的默认构造函数。
很多人会疑惑,如果是int等内置类型也能这样使用吗?答案是当然可以。在以往我们会认为,只有自定义类型才会有默认构造函数。但其实在c++内,对于内置类型也是可以有默认构造函数的:
如果我们进行初始化了,那么值当然就是初始化的值。但是如果我们像上面参数显示的那样去调用默认构造,我们发现不传参的时候默认值是0,传参的时候就是将参数的值给变量。这个用法和自定义类型是一样的。所以我们不用担心自定义类型会使用不了的问题。
对于reserve:
reserve函数就是预留空间,因为c++标准没有明确规定一些细节,导致不同平台对于其实现是有差异的。
vs编译器下坚决不缩容,只会扩容,且扩容大致是1.5倍。
而g++编译器是会缩容的,扩容的方式是很标准的2倍扩容。
增删查改操作
函数 | 说明 |
---|---|
push_back(重点) | 尾插 |
pop_back (重点) | 尾删 |
find | 查找(注意这个是算法模块实现,不是vector的成员接口) |
insert | 在position之前插入val |
erase | 删除position位置的数据 |
swap | 交换两个vector的数据空间 |
operator[] | (重点) 像数组一样访问 |
只需要注意的是insert和erase的操作是要传入对应位置的迭代器。还有就是vector内并没有像string内实现find接口,我们要使用的话就得在算法库algorithm中去调用。
而其余的用法是很简单的,自行查阅文档即可。
迭代器失效问题(重要)
string中也有迭代器,但是我们并没有说迭代器失效的问题。这是因为对于string我们更偏向于用下标来访问内部数据。就连删除插入等操作也是用下标进行访问元素的。而vector却是用迭代器实现的,而在实现的时候发现了一些问题,这些问题统称为迭代器失效,下面让我们一起来看看:
在这里我们先说明一个事情:vector的正向迭代器其实就是数据类型的原生指针。
1.迭代器变为野指针,原来指向的空间被释放
这种问题通常出现在vector容量被修改的时候。我们在模拟实现string的时候就对容量进行修改的时候,是需要新开辟一个空间,然后再将原来空间进行释放。是没有像c语言中realloc一样功能的函数的。
问题就出现在这里:
当然如果要进行缩容也是一样的。所以插入和删除函数在实现的时候,就考虑到这个问题,进行了修正。使得这两个功能能够正常的使用并且达到想要的效果。
2.非法使用迭代器和非法访问元素
这个点也是需要非常注意的,下面我们举一个例子看看:
现有vector v1,指向内容是{1,2,2,3,4,4,5}
我们来看一下下面这个代码:
int main(){vector<int>:: iterator it = v1.begin();while(it != v1.end()){if(*it % 2){erase(it);*it = -1;}++it;}return 0;
}
乍一看没啥问题,但其实问题很大。
首先这个代码在不同平台下的结果是不一样的。在vs2022上是会断言报错的。而在g++编译器上能够正常运行,但是达不到想要的效果。
我们先来说g++下的情况:
运行结果为 1 -1 3 -1 5,这是为什么呢?
这是因为我们非法使用迭代器了:
当遍历到第一个2的时候,就会进行删除操作,那后续的数据会被移动到前面来,数据变成{1,2,3,4,4,5}。原来的2的位置被后面一个2顶上来了。但此时原来的空间并没有被销毁,而正向迭代器的本质是原生指针,所以指向的仍然是原来的那个位置,也就是后顶上来的2的位置,然后对此时位置进行修改,数组变成{1,-1,3,4,4,5}。然后++it会走到3的那个位置。
然后以此类推,最后变成了输出的结果。变成-1的位置就是为了告诉我们,如果使用这个代码去删除偶数项,会有被遗漏的偶数。这其实也是迭代器失效的一个方面。就是会导致访问元素出现问题。
如果在vs2022下:
编译器会直接断言报错。这是因为vs编译器做了严格的检查,如果再执行了删除和插入操作后,迭代器会失效,一般是不能访问的。所以编译器内部自动检查是否有修正迭代器的情况。如果没有就会报错。因为编译器认为这样子是非法访问。
当然对于上面那段代码,如果被删除的元素在末尾也是会出现问题,因为删除后数量减一,但是又要执行++it操作,那么it会越界。也是会触发断言报错的。
调整迭代器
这些都是迭代器失效的问题。为了 防止这种现象发生,我们就得调整迭代器的值。实际上vs编译器也是这么做的。
对于插入操作,编译器会返回新插入的元素的第一个的迭代器。插入操作可能会插入一个怨怒是,有可能插入多个元素。对此返回的是插入元素的第一个位置的迭代器。
对于删除操作,返回的是删除元素的最后一个的后一个元素的新位置的迭代器:
如1 3 5 6 7,删除3 和 5 ,变成 1 6 7,返回的就是指向6的迭代器。如果删除的最后一个元素正好是原本空间中的最后一个元素,那么返回的迭代器其实是数组结束位置。
所以要想真正的能把上面例子种数组的偶数全部删除,需要改进代码:
int main(){vector<int>:: iterator it = v1.begin();while(it != v1.end()){if(*it % 2){it = v1.erase(it);}else ++it;}return 0;
}
这样子就能在vs的编译器上跑起来了。
vector的模拟实现
当然要想更好的学会使用vector,我们也是需要了解如何对vector中的一些重要功能进行实现的。
实现的版本
c++只是规定了容器对应的功能应该完成什么样的效果,但是并没有明确要求应该如何实现。所以不同的版本实现是有一些区别的。
vs2022中的实现其实是非常复杂的,涉及到内存池等内容。由于当前还未学习内存池等相关技术,所以并不适合模仿。而我们可以查看一下g++编译器的底层是如何实现的:
这个是g++编译器下实现版本的比较早期的源代码,我们发现protected成员里面有三个迭代器,分别是start、finish、end_of_storage。
我们再翻看一下迭代器是怎么实现的:
正向迭代器其实就是value_type*这个指针,也就是数据类型的指针。所以对于vector来讲,其正向迭代器就是原生指针。
而以往我们在实现顺序表的时候,都是一个指针配合整形数据空间、容量进行管理内容。但是在vector的源代码中我们发现是通过指针管理的。
start就是指向开头数据的指针,finish其实是有效数据的后一个位置,end_of_storage指示容量,就是当前已有空间的后一个位置。
我们实现的版本主要是这个版本。
模拟实现
源码放在我的码云上了:vector imitate achievement
既然是使用指针实现的,那我们就特别需要注意指针的一些问题,特别是野指针。需要我们能够正确的操作这几个指针变量。
结构
vector是一个类模板,和string不太一样。所以我们声名的是一个类模板,需要定义模板参数。使用模板的话就尽量不要将函数的声名和定义进行分离了,因为会导致链接错误。
所以我们采取以下策略:
在MyVector这个命名空间内定义类模板,将短小多次调用的函数放在类里面进行定义。因为默认内联,方便多次调用。而代码量比较长的就放在类外进行定义。
预先处理的函数
还是一样的,有一些函数由于会被很多次的调用,所以我们需要先处理一下。
尾插函数push_back
尾插函数是非常重要的。特别是在写构造函数的时候,我们可以提前开好空间,然后将需要的数据依次插入到vector指向的空间中。
所以我们可以实现一下尾插函数:
void push_back(const T& x){if (_end_of_storage == _finish) {reserve((size() == 0) ? 4 : size() * 2);}*_finish = x;++_finish;
}
这里的reserve函数虽然还没写,但是当前符合逻辑就可以。只要能在调用方尾插函数前写完就好。
当然目前先不写的原因是会有特殊情况,这一点我们等下会说。
swap函数
这个交换函数最大的目的就是为了方便进行深拷贝,其实现也是非常简单:
void swap(vector<T>& v) {std::swap(_start, v._start); std::swap(_finish, v._finish);std::swap(_end_of_storage, v._end_of_storage);
}
我们直接调用标准库内的交换函数就可以了。
赋值重载
这个是非常重要的,在等一下的reserve函数中也是需要调用。
这个方法很简单的,在上一节string实现深拷贝的优化中我们就已经实现过了,所以就不再过多赘述。
T& operator=(vector<T> v) {swap(v);return *this;
}
其实就是调用拷贝构造,构造一个v,使得v就是需要赋值的数据。当然当前我们也没有实现拷贝构造。但是不要急,我们当前主要还是得厘清逻辑。保证当前接口主逻辑不会出错即可。
使用这个方法最大的有点就是不用担心自己给自己赋值。如果要自己开空间来操作赋值,就得先删掉空间,再来赋值。但是当自己给自己赋值的时候,就会导致原有数据被销毁。所以需要判断这个特殊情况。
size函数
这个没太多好说的,返回容量就好。
size_t size() const{return _finish - _start;
}
reserve函数
因为空间的浪费不会太大,所以为了效率更高,防止频繁扩容,所以我们采用vs底层一样的实现方法,reserve坚决不缩容。
template<class T>
void vector<T>::reserve(size_t n) {if (n <= size()) return; size_t old_size = size(); T* tmp = new T[n]; //复制for (int i = 0; i < old_size; ++i) { tmp[i] = _start[i]; }delete[] _start; _start = tmp; _finish = _start + old_size; _end_of_storage = _start + n;
}
由于代码量还是比较长的,我们放在类外面进行定义。
这里会有几个很容易出错的点:
1.管理内容的三个指针失效
此时我们不再是使用整形变量管理空间。而是使用指针。使用指针最怕的就是野指针。当我们扩容的时候,又需要将旧空间进行释放。那就会导致_finish和end_of_storage两个指针变成野指针。所以需要调整这两个指针的位置。
但是很多人这么写的:
_start = tmp; _finish = _start + size(); _end_of_storage = _start + n;
这样子会出问题。因为size返回的是当前_start和_finish的位置之差。但是我们发现一个事情,就是当我们先让_start指向新空间的时候,_finish会指向被释放的空间。那么这样算出来的size肯定不对。
还有就是从代入表达式的角度看,size()返回的是_start - _finish,代入表达式,
_finish = _start + _start - _finish = _finish,这根本没有改变啊。
所以我们得先记录一下_start和_finish原本的差值,也就是有效数据个数old_size,然后再以此进行调整。源码中也是这么干的:
所以我们就学习这个方法进行调整这三个管理指针。
还有一个问题就是复制,在之前模拟实现string的时候我们是使用memcpy函数进行操作的。但是再vector中万万不能。
假设我们现在声名的是一个vector< string >,如果使用的是memcpy函数,就是将里面内容一个字节一个字节拷贝过去,这个方式是浅拷贝。那一旦碰到有指向资源的数据类型如string那就糟了,那资源是没有办法复制过去的。又或者是vector,也是有指向资源。这是万万不能的。具体内容可以看类和对象章节。
所以复制部分是要调用赋值重载的。这也就是为什么我们要先写赋值重载函数,就是怕内部存储的也是vector(自己写的),那就需要调用其赋值重载函数,那我们就得提供。
迭代器
//实现正向迭代器用的
typedef T* iterator;
typedef const T* const_iterator;//iterator
iterator begin() {return _start;
}
iterator end() {return _finish;
}
const_iterator cbegin() const { return _start;
}
const_iterator cend() const {return _finish;
}
迭代器就是原生指针,实现非常简单。
默认成员函数
默认构造
vector()
{}
我们会在定义三个管理指针的时候给定缺省参数nullptr,所以不需要存储任何东西的时候就不需要进行任何操作,所以无参构造函数这样写即可。
普通构造
vector(size_t n, const T& val = T()) {reserve(n);while (_finish != _end_of_storage){push_back(val); ++_finish; }
}vector(int n, const T& val = T()) {reserve(n);while (_finish != _end_of_storage) {push_back(val);}
}template<class InputIterator>
vector(InputIterator first, InputIterator last) {while (first != last) {push_back(*first);++first;}
}
这里的const T& val = T()是调用默认构造函数,前面部分已经讲过了。
这里有三个函数,最后一个是迭代器区间构造。
有人看到前面两个仅仅是n参数类型不同,为什么要多次一举写多一个呢?
这是因为当我们想要这样写的时候vector(5, 4);,会导致一个问题,因为不写第二个的那个版本,5在编译器中默认为int,4也为int,那么第一个就不是那么的匹配。而传给迭代器区间的时候会更加匹配一些,所以编译器会调用迭代器区间构造的那个。所以我们需要多写一个。而使用其他类型的时候就不会有这个问题。
拷贝构造
vector(const vector<T>& v) {reserve(v.size());const_iterator it = v.cbegin();while (it != v.cend()) {push_back(*it);++it;}
}
这里没有采用以往的那个深拷贝的优化方法。因为在string实现中,我们可以把传入的string的指向字符串的指针拿去构造出一个一样的string。而我们在这里并没有实现这么一个函数,因为只有字符出啊怒这样做是比较方便。所以我们直接自己开空间进行尾插即可。
析构函数
~vector() {delete[] _start;_start = _finish = _end_of_storage = nullptr;
}
析构函数比较简单,就不再赘述。
容量操作
现在来对容量的操作进行实现
容量 、判空
size_t capacity() const {return _end_of_storage - _start;
}bool empty() const {return (_start == _finish);
}
数据个数已经在预处理部分处理过了,所以只需要实现一下容量和判空即可。
resize函数
template<class T>
void vector<T>::resize(size_t n, const T& val) { //声名与定义分离时 定义中不能有缺省参数if (n <= size()) {_finish = _start + n; }else {if (_finish + n > _end_of_storage) { size_t old_size = size();size_t old_capacity = capacity();reserve( old_size + n > old_capacity * 2 ? (old_size + n) : old_capacity * 2);}while (_finish != _end_of_storage) { push_back(val); } }
}
根据文档的要求进行实现即可。注意是否需要删除数据以及扩容即可。
修改操作
尾删
void pop_back() {assert(!empty()); --_finish;
}
需要注意是否为空,否则无法删除。
insert函数
template<class T>
typename vector<T>::iterator vector<T>::insert
(typename vector<T>::iterator pos, const T& val) { assert(pos <= _finish);assert(pos >= _start);size_t old_size = size();size_t posdiff = pos - _start;//必须写这个 要不然迭代器失效了if (_finish == _end_of_storage) {reserve((old_size == 0) ? 4 : 2 * old_size);pos = _start + posdiff;}//挪动数据typename vector<T>::iterator it = end() - 1;while (it >= pos) {*(it + 1) = *it;--it;}*pos = val;++_finish;return pos;
}
很多人会疑问,为什么要加typename这个关键字呢?这是因为我们是在类外面定义这个函数。而这个类此时还没有实例化,那要从里面取东西是需要特别注意的。对于iterator,编译器会不知道这是一个变量还是类型名称。所以加上typename就是告诉编译器这是一个类型。
注意一下之前讲过的迭代器失效的问题即可。
clear函数
这个函数只对数据进行清空,但是不进行缩容:
void clear() {_finish = _start;
}
erase函数
这个函数实现了两个版本:
template<class T>
typename vector<T>::iterator vector<T>::erase
(typename vector<T>::iterator pos) {assert(pos >= _start);assert(pos <= _finish);//不考虑缩容typename vector<T>::iterator it = pos;while (it != end()) {*it = *(it + 1);++it;}--_finish;return pos;
}template<class T>
typename vector<T>::iterator vector<T>::erase
(typename vector<T>::iterator first, typename vector<T>::iterator last) {assert(first <= last);assert(first >= _start);assert(last <= _finish);assert(!(first == end() && last == end()));vector<T>::iterator prev = first; vector<T>::iterator rear = last + 1;while (rear != end()) {*prev = *rear;++prev;++rear;}_finish = prev;return first;
}
当然insert函数也是可以实现迭代器区间插入的(我忘记了哈哈哈哈哈),逻辑都不难,最主要的就是注意一下删除的位置是否合法(通过断言报错),然后实现数据挪动逻辑。然后需要注意迭代器失效得问题,要通过返回值来修正。
最好是通过画图来赋值写代码,然后考虑一下一些特殊位置即可。
打印函数(针对不同容器)
template<class Container>
void Print_container(Container& con) { for (auto& x : con) { cout << x << " "; }cout << endl;
}
通过传入容器以及范围for得使用就可以实现了,这是十分简单的。
到此所有的操作就完成了,想要更详细代码的可以进入我的码云空间获取。