当前位置: 首页 > news >正文

c++STL——vector的使用和模拟实现

文章目录

  • 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型的迭代器都是指向最后一个有效元素的后一个位置。

使用的话重点掌握beginrbeginendrend这四个就可以了。

默认成员函数

函数使用
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)中的数据进行排序。但是链表排序其实是效率较低的。所以我们会经常的使用链表的迭代器区间来构造一个vectorvector的本质是顺序表,使用顺序表排序是比较高效率的。然后排好序后再依次将数据覆盖回链表中。

当然这个迭代器也可以是指向数组的:
在这里插入图片描述
在这里我们可以认为是数组也有自己的迭代器。

空间操作

函数使用
size获取数据个数
capacity获取容量大小
empty判断是否为空
resize(重点)改变vector的size
reserve (重点)改变vector的capacity

重点我们来看看resizereserve的使用。

对于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[](重点) 像数组一样访问

只需要注意的是inserterase的操作是要传入对应位置的迭代器。还有就是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成员里面有三个迭代器,分别是startfinishend_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.管理内容的三个指针失效
此时我们不再是使用整形变量管理空间。而是使用指针。使用指针最怕的就是野指针。当我们扩容的时候,又需要将旧空间进行释放。那就会导致_finishend_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得使用就可以实现了,这是十分简单的。

到此所有的操作就完成了,想要更详细代码的可以进入我的码云空间获取。

相关文章:

  • 【Android 】ContentProvider深度解析
  • ssh用户秘钥登录设置
  • 接口测试:实用指南4.0
  • 医疗设备预测性维护合规架构:从法规遵循到技术实现的深度解析
  • Electricity Market Optimization(VI) - 机组组合模型以及 Gurobi 求解
  • 20250417-vue-条件插槽
  • uview1.0 tabs组件放到u-popup中在微信小程序中滑块样式错乱
  • Java深入
  • Qt常见按钮类控件属性及其使用
  • 微前端架构
  • 盘古-ultra:不用英伟达GPU,华为发布全新大模型
  • SpringBoot高校学生评教系统设计实现
  • MCP 与 A2A 协议:构建复杂 AI 系统的协同基石
  • 【时时三省】(C语言基础)用while语句实现循环
  • 消息队列通信原理与实现
  • 什么是人工智能芯片?
  • 网络协议分析
  • 【kubernetes】pod.spec.containers.ports的介绍
  • MySQL-CASE WHEN条件语句
  • 24-25【动手学深度学习】AlexNet + Vgg
  • 我国首部《人工智能气象应用服务办法》今天发布
  • 外交部官方公众号发布视频:不跪!
  • 杨国荣︱学术上的立此存照——《故旧往事,欲说还休》读后
  • 北京动物园:大熊猫“萌兰”没有参加日本大阪世博会的计划
  • 西湖大学本科招生新增三省两市,首次面向上海招生
  • 借助AI应用,自闭症人群开始有可能真正“读懂他人”