【C++】200行代码实现一个简单的vector类(包含迭代器失效等相关知识点)

vector简介

  1. vector是表示可变大小数组的序列容器。
  2. 就像数组一样,vector也采用的连续存储空间来存储元素。也就是意味着可以采用下标对vector的元素
    进行访问,和数组一样高效。但是又不像数组,它的大小是可以动态改变的,而且它的大小会被容器自
    动处理。
  3. 本质讲,vector使用动态分配数组来存储它的元素。当新元素插入时候,这个数组需要被重新分配大小
    为了增加存储空间。其做法是,分配一个新的数组,然后将全部元素移到这个数组。就时间而言,这是
    一个相对代价高的任务,因为每当一个新的元素加入到容器的时候,vector并不会每次都重新分配大
    小。
  4. vector分配空间策略:vector会分配一些额外的空间以适应可能的增长,因为存储空间比实际需要的存
    储空间更大。不同的库采用不同的策略权衡空间的使用和重新分配。但是无论如何,重新分配都应该是
    对数增长的间隔大小,以至于在末尾插入一个元素的时候是在常数时间的复杂度完成的。
  5. 因此,vector占用了更多的存储空间,为了获得管理存储空间的能力,并且以一种有效的方式动态增
    长。
  6. 与其它动态序列容器相比(deque, list and forward_list), vector在访问元素的时候更加高效,在末
    尾添加和删除元素相对高效。对于其它不在末尾的删除和插入操作,效率更低。比起list和forward_list
    统一的迭代器和引用更好。

vector的使用

对于这个类的使用我们就不过多赘述了,和string类绝大多数的操作都是类似的,这里可以自己进行参考。

vector的模拟实现

私有的类成员设置

代码如下:

template<class T>
class vector 
{
private:
	iterator _start;
	iterator _finish;
	iterator _end_of_storage;
};

这里我们仿照了PJ版STL的实现方式,和我们模拟实现的string类选择指针指向容器内容、然后两个变量存储有效长度和总容量不同,这里我们用三个迭代器实现了原来三种成员的存储内容分别是指向第一个元素的迭代器,指向最后一个有效元素下一个位置的迭代器和指向最后一个有效空间位置的下一个的迭代器。

迭代器和const迭代器实现

既然我们用迭代器来实现了私有成员,那么就先把迭代器及其const版本实现一下,代码如下:

typedef T* iterator;
typedef const T* const_iterator;

iterator begin()
{
	return _start;
}
iterator end()
{
	return _finish;
}
const_iterator begin() const
{
	return _start;
}
const_iterator end() const
{
	return _finish;
}

实现很简单,返回对应位置迭代器即可。

size() & capacity()

代码如下:

size_t size()
{
	return _finish - _start;
}
size_t capacity()
{
	return _end_of_storage - _start;
}

因为_finish和_start分别指向第一个有效位置和最后一个有效位置的下一个,所以二者之差就是当前的有效长度,_capacity同理。

empty()

判空函数代码如下:

bool empty() const
{
	return _finish == _start;
}

如果_finish和_start指向同一个位置,说明容器为空。(有人会说start指向第一个有效元素,finish指向最后一个有效元素的下一个,空的时候他俩不应该相等啊?但这个其实是看默认构造是如何实现的,我们后面会写出我们的默认构造,生成时是将两个迭代器都置为nullptr了,所以容器为空时二者相等,这里主要看代码的实现方法)

operator[]及其const版本

还是熟悉的方括号,分为只读和可读可写两种,代码如下:

T& operator[](size_t pos)
{
	assert(pos < size());
	return _start[pos];
}
const T& operator[](size_t pos) const
{
	assert(pos < size());
	return _start[pos];
}

在里面我们加入了assert来强制判断访问是否发生了越界。

诸多构造函数—— vector()

构造函数依然是类中非常重要的板块,这里我们提供了四种不同的重载。

①默认构造函数

默认构造的实现非常简单,就是将三个迭代器均置为空:

vector()
	:_start(nullptr),
	_finish(nullptr),
	_end_of_storage(nullptr)
{}

②传入两个迭代器的构造函数

因为vector是一个可以存放各种类型的顺序容器,所以我们只要传入两个同种类型的迭代器就可以对容器内容进行初始化,既可以指向string类型的,也可以是其他自定义类型的,只要是迭代器就可以,其实现代码如下:

template <class InputIterator>
vector(InputIterator first, InputIterator last)
	: _start(0)
	, _finish(0)
	, _end_of_storage(0)
{
	while (first != last)
	{
		push_back(*first);
		++first;
	}
}

为了保证泛型编程,我们这里再用模板来进行函参类型的指定,然后当两个迭代器不相等时,就循环将first指向的内容进行压入,完成vector的初始化,push_back会在后面进行实现。

③用n个相同对象来进行初始化的构造函数

这种构造也不难理解,比如对于一个vector< int >来说,比如我们相用10个1来进行初始化,就可以用vector< int > v(10, 1)来进行构造,其实现代码如下:

vector(size_t n, const T& val = T())
	:_start(nullptr),
	_finish(nullptr),
	_end_of_storage(nullptr)
{
	reserve(n);
	for (size_t i = 0; i < n; ++i)
	{
		push_back(val);
	}
}

我们可以注意到这个构造对应的参数列表里有一个缺省参数用的是匿名对象,这里有人会问为什么不用0什么的来进行初始化呢?因为对于包含模板的泛型编程来说,这个T可以是任意一种类型,对于内置类型来说你将0设置为缺省无可厚非,但是对于自定义类型来说0可能就毫无意义,所以这里我们调用默认构造来生成一个匿名对象以保证参数缺省时的正常插入。

②和③的冲突

上面提到,比如我们想要用10个1来初始化一个vector< int >容器,那我们就会这样来初始化:

vector<int> v(10, 1);

但是我们知道10这个数字如果不发生隐式类型转换它的类型应该是int类型的,和1是同种类型,所以当我们到诸多的构造函数中去进行匹配的时候,先去找非模板的函数,结果发现第一个形参是size_t的,第二个参数是T类型的,不够匹配,所以转头就去找了包含模板的构造函数,发现这个构造两个形参都是T,和我一样是两个相同类型,然后就选择了这种,然后发现编译器报错了,因为我们不能将10或者1赋值给指针。
为了解决这个问题,我们有两种方式,第一种就是在传入参数的地方动动手脚,将10强制类型转换为size_t的来匹配非模板的构造函数,但这种使用方式多少有点奇怪;那么第二种解决方式就是多提供几种能够进行构造的重载,将这种需要进行隐式类型转换才能匹配的都写出来,以防止自动匹配到模板的构造上去。比如重载出这个版本:

vector(int n, const T& val = T())
	:_start(nullptr),
	_finish(nullptr),
	_end_of_storage(nullptr)
{
	reserve(n);
	for (int i = 0; i < n; ++i)
	{
		push_back(val);
	}
}

这样在遇到用10和1进行初始化的操作时就会自动匹配这个版本,问题也就迎刃而解了。

push_back()

和string相同,pushback也是在尾部插入的一个接口,实现代码如下:

void push_back(const T& element)
{
	if (size() == capacity())
	{
		size_t NewCapacity = capacity() == 0 ? 4 : (capacity() * 2);
		reserve(NewCapacity);
	}
	*_finish = element;
	++_finish;
}

同样的套路,插入前先判断容器是否已满,满了扩容,然后将待插入元素放到_finish指向的位置,然后让 _finish加一。扩容函数细节较多,我们后面实现。

pop_back()

尾删函数,代码如下:

void pop_back()
{
	assert(!empty());
	--_finish;
}

直接将_finish前移即可,记得删前要判断非空哦~

vector(const vector& v)——拷贝构造函数

在string类中我们实现了两种拷贝构造,我们分别称他们为常规写法和简洁写法,到了vector这里我们只展示简介写法,代码如下:

void swap(vector<T>& v)
{
	std::swap(_start, v._start);
	std::swap(_finish, v._finish);
	std::swap(_end_of_storage, v._end_of_storage);
}
vector(const vector<T>& v)
	:_start(0)
	,_finish(0)
	,_end_of_storage(0)
{
	vector<T> tmp(v.begin(), v.end());
	swap(tmp);
}

简介写法的核心思路就是借助构造函数来抓一个工具人,让他打完工把赚的钱都拿出来交给我们要构造的对象,并且将初始化列表中的内容换给这个工具人来防止它调用析构时发生野指针问题,我们这里选择的构造是两个迭代器版本的构造,因为其他的构造我们无法进行使用。构造建立好tmp后,和调用拷贝构造的对象进行“身份替换”,实现目标对象的构造。

vector& operator=(vector v) —— 赋值重载函数

赋值重载的作用就是用右边已有对象的内容来覆盖等号左侧对象的内容,左侧对象若已经存在则直接复制,否则构造后进行赋值,实现代码如下:

vector<T>& operator=(vector<T> v)
{
	swap(v);
	return *this;
}

是不是也是让人眼前一亮(黑)?因为我们这里赋值重载使用的是传值调用的方法,在调用过程中就会用到拷贝构造函数生成一个临时对象,然后让等号左侧的对象(也就是调用赋值运算符重载的对象)和我们用拷贝构造形成的临时对象进行交换,然后传引用返回这个初始化好的对象(也就是等号左侧的对象)。

析构函数

析构主要就是将申请的空间释放,然后三迭代器(指针)置空,代码如下:

~vector()
{
	delete[] _start;
	_start = _finish = _end_of_storage = nullptr;
}

resize()

和string类相同,resize函数也是要重新规划容器的有效内容的大小,这里面包含三种情况,分别是传入size大于最大容量、传入size大于当前size以及传入size小于等于当前size,实现代码如下:

void resize(size_t n, T val = T())
{
	if (n > capacity())
	{
		reserve(n);
	}

	if (n > size())
	{
		while (_finish < _start + n)
		{
			*_finish = n;
			++_finish;
		}
	}
	else
	{
		_finish = _start + n;
	}
}

首先n大于总容量了,我们肯定要先扩容,然后再判断是否要插入数据,所以会有两个if;然后判断n是否大于当前有效长度:如果是,则在_start + n到当前_finish之间插入目标参数val,然后不断地将_finish后移;如果n是小于当前有效长度的,则重置一下_finish的位置即可。

reserve()

reserve函数的细节就很多了,我们先看看代码:

void reserve(size_t n)
{
	size_t OldSize = size();  //防止迭代器失效
	if (n > capacity())
	{
		T* tmp = new T[n];
		if (_start)
		{
			//memcpy(tmp, _start, sizeof(T) * OldSize);
			for (size_t i = 0; i < OldSize; ++i)
			{
				tmp[i] = _start[i];
			}
			delete[] _start;
		}
		_start = tmp;
		_finish = _start + OldSize;
		_end_of_storage = _start + n;
	}
}

我们首先看第一行,为什么要先记录OldSize?如果不记录会怎么样?在这里我们遇到了第一个迭代器失效的问题。 我们可以再不记录的情况下来进行推演:如果不进行记录,我们下面所有代码中的OldSize都要替换成size(),那么问题就命中在倒数第二行改变_finish的这个地方。因为我们size()的实现方式为_finish - _start,等效替换过来就是_start + _finish - _start,所以_finish是不可能会发生改变的(等同于自己给自己赋值),所以这里我们必须提前记录好原来数据的长度,然后根据新的_start来找到新的_finish。到此为止第一个问题就已经解决了,而代码中还有一行注释,也就是memcpy这行,这行又存在什么问题呢?和修改之后的区别在哪?不难理解,if语句这一段的主要作用就是将原来对象中的数据向新空间进行拷贝,但是memcpy的最大问题就是他只是按照字节序进行拷贝的,我们知道vector的一个最大特点就是泛型,它的尖括号里可以放各种各样的类型,可以放char、int、string,甚至是vector< int >也没问题,对于一些内置类型来说,memcpy无可厚非,因为这些变量他们不在指向其他地方,他们就是我们想要的数据,但如果vector里面存的是vector< int >,也就是说vector里面存的每个对象都还指向了其他的对象,那么我们如果将这些内容(这里我们可以理解为地址)进行字节序拷贝的话,新的空间是不是就也指向了原来要释放掉的对象所指向的内容啊!那把这些地址拷贝给新对象,就必定会出现浅拷贝问题,因为在刚拷贝完之后他们已经被delete掉了,当拷贝出来的对象调用析构的时候,肯定会发生delete野指针的问题。所以这里的解决方法就是将拷贝变为深拷贝即可。我们看看是怎样解决的:delete这个部分肯定是不要修改,我们只是用一个循环来代替memcpy即可,循环条件不用多说,就是一个一个的进行拷贝,最核心的是这个循环体,这个等号不是一个简单的等号,因为我们把容器对象里面装的内容也当作了一个个对象,所以这里的等号其实就是对应类的赋值运算符重载!(当然了,前提是这个类的赋值重载已经实现过了,所以我们这里用的是vector套vector进行实现,vector的赋值重载我们已经实现过了,自然也就通过了)我们刚才讲过,调用的运算符重载在传参的过程中就会调用拷贝构造形成临时对象,然后将等号右侧对象的内容交给临时对象,然后将临时对象的内容交换给调用赋值重载的对象,最后返回这个对象的引用完成拷贝,此时用再套几层vector也不会出现崩溃的问题了。

讲清楚了reserve的两个深坑,我们再回过头来看看整个过程:先记录原来容器的有效长度,然后判断新大小是否大于当前上限容量,如果不大于就不进行操作,否则new出新空间,向新空间中深拷贝原来的数据,释放旧空间,最后修改三个控制内容的迭代器即可完成reserve。

insert()

我们这里实现的insert函数的参数分别是迭代器参数指向位置和T类型的某个固定数据n,其实现方法如下:

void insert(iterator pos, const T& n)
{
	assert(pos >= _start);
	assert(pos < _finish);
	if (_finish == _end_of_storage)
	{
		size_t len = pos - _start; // 防止迭代器失效,要提前记录容器长度
		size_t NewCapacity = capacity() == 0 ? 4 : (capacity() * 2);
		reserve(NewCapacity);
		pos = _start + len; // 修改扩容后的迭代器指向
	}
	iterator end = _finish;
	while (end > pos)
	{
		*(end + 1) = *end;
		--end;
	}
	*pos = n;
	++_finish;
	//return *pos;
}

先判断插入位置是否合法,然后再扩容位置这里出现了本文的第二个迭代器失效,这次又因为什么呢?

我们这个类中共包含两个插入函数,一个pushback、一个insert,为什么在pushback函数中就没有判断和校正迭代器的两行代码呢?不难发现,尾插我们只需要传入数据,而根本不需要传入迭代器,也正是因为我们是尾插,所以即使发生了扩容,整个数据被搬到了其他的位置,我们仍要向最后的位置进行插入,所以扩容之后在pushback中直接使用类内置的已经修改好的_finish进行插入就好了;但对于insert来说,我们必须要定位到整个容器中间的某一个位置,所以我们才传入了迭代器,而在调用了扩容函数之后,储存数据的位置跑到了其他地方,而传入的pos仍然在原来被释放掉的地方傻站着,这时候再插入肯定会发生意料之外的问题。

所以在insert函数中,我们想正确的插入必须在扩容后重定位pos以解决迭代器失效的问题,所以我们提前记录好有效内容的长度(和reserve里面解决迭代器失效问题的方法类似),扩容后重新定位pos即可。

扩容并解决了迭代器失效的问题后,我们就可以进行插入操作了,先找到新的末尾,然后将pos位置及其后面的对象全都后移一格,然后修改pos位置的对象,修改_finish,插入结束。

erase()

删除某个位置的对象,实现代码如下:

void erase(iterator pos)
{
	assert(pos >= _start);
	assert(pos < _finish);

	iterator begin = pos + 1;
	while (begin < _finish)
	{
		*(begin - 1) = *begin;
		++begin;
	}
	--_finish;
	//return pos;
}

首先判断删除位置是否合法,然后定位到待删除对象的下一个位置,想前一个一个进行覆盖,最后将_finish减一即可完成删除。

clear()

此函数的功能就是将数据都删掉而不改变最大容量,实现也很简单,代码如下:

void clear()
{
	_finish = _start;
}

谈谈使用过的迭代器是否失效的问题

我在上面实现insert和erase两个函数埋下了伏笔,分别注释掉了返回的代码并将返回类型修改为void。

正常来讲我们定义好迭代器后,传给insert或者erase函数进行操作,然后我们如果对于使用过的迭代器进行读或写操作,是否会发生错误呢?这里我们分别在vs2019和centos上分别尝试一下:
对于以下代码:

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

void test_fun()
{
	vector<int> v;
	v.push_back(1);
	v.push_back(2);
	v.push_back(3);
	v.push_back(4);

	vector<int> ::iterator it = find(v.begin(), v.end(), 3);
	if (it != v.end())
	{
		v.erase(it);
	}
	//读
	cout << *it << endl;
	//写
	//++(*it);

	for (int& e : v)
	{
		cout << e << " ";
	}
	cout << endl;
}

int main()
{
	test_fun();
	return 0;
}

我们先在vs2019上进行测试,测试结果如图所示:
请添加图片描述
可以看到,在vs上,使用过的迭代器即使是只读也会被检查出来,这是因为在vs上vector迭代器的实现并不是简单的指针,而是进一步进行了封装,所以用过之后会进行修改,从而阻止再次使用。我们再看看在centos上的结果:
请添加图片描述
可以看到,在centos上使用过的迭代器不仅可以读,还可以进行写操作。

这是因为centos中的STL实现和我们模拟实现的这种方式类似,他不会对使用过的迭代器进行处理,所以我们可以继续使用。

但我们本着只要在某一种编译器上跑不过去的代码就是有bug的原则,我们统一认为只要使用过的迭代器就已经失效了,所以如果我们要想继续使用这种作为参数的迭代器,我们就要接受他们的返回值,所以我们可以看到std里实现的insert和erase方法都是有返回值的,他们都会返回一个迭代器:在这里插入图片描述
在这里插入图片描述
我们将返回值赋值给传入的迭代器,这些迭代器就都可以再次被使用了。修改后的代码如下:

#define _CRT_SECURE_NO_WARNINGS 1
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

void test_fun()
{
	vector<int> v;
	v.push_back(1);
	v.push_back(2);
	v.push_back(3);
	v.push_back(4);

	vector<int> ::iterator it = find(v.begin(), v.end(), 3);
	if (it != v.end())
	{
		it = v.erase(it);  // 接收返回值
	}
	//读
	cout << *it << endl;
	//写
	++(*it);

	for (int& e : v)
	{
		cout << e << " ";
	}
	cout << endl;
}

int main()
{
	test_fun();
	return 0;
}

修改后,这段代码在vs上也可以顺利地跑起来了:
请添加图片描述

结束语

至此关于vector类的简单实现和部分迭代器失效相关知识的全部内容已呈现完毕,如本文有不足或遗漏之处还请大家指正,笔者感激不尽;同时也欢迎大家在评论区进行讨论,一起学习,共同进步!

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值