【C++】STL | vector (顺序表)详解及重要函数的实现

目录

前言

总代码

vector类框架建立(模板与成员变量)

构造、析构、swap 与 赋值重载

构造

析构

swap

赋值重载

reserve 扩容(重要!!)、size、capacity

operator[ ]重载

insert 插入

逻辑讲解

insert中的迭代器失效

erase 删除

删除主逻辑

迭代器失效

push_back、pop_back

begin 与 end

拷贝构造、n个值构造

一段迭代器区间构造

initializer_list

结语


前言

vector在STL中属于较为简单的知识点,学习难度相对较低,但是相比于string,本篇博客的底层实现会添加一些很有意思且很实用的东西,比如:initializer_list、一段迭代器区间初始化等等

如果有对库中的vector有了解需求的话,可以看一下C++中较为官方的网站,这里面有详细的讲解和用法介绍,网址如下:

https://legacy.cplusplus.com/reference/vector/vector/?kw=vector

总代码

如果有友友只是复习需要,只想看完整代码的话,可以直接点下面的gitee链接

当然对于看完了整篇文章的友友也可以照着这里的代码敲一篇进一步理解喔...(* ̄0 ̄)ノ

gitee - vector - blog - 2024-08-06

vector类框架建立(模板与成员变量)

首先我们可以将我们的类包在一个命名空间里面这样就不会在写完之后因为名字与库中的相同而报错

接着,我们可以写一个类模板,我们vector的本质就是数组,而我们的数组里面可以存任何类型,可以存一个int,可以存char,甚至可以存一个vector<int>类型实现二维数组

所以我们需要写一个模板T,表示类型

接着就是成员变量,这个地方有多种选择,我们可以和上一篇的string一样使用一个指针,一个size一个capacity的策略

但由于源码中的成员是三个迭代器,一个指向开始位置,一个指向有效数据的尾(有效数据个数),一个指向整个空间的尾(总的空间大小),虽然都可以,但我们还是尽量与源码保持一致

由于条件的需要,我们先来谈谈vector中的迭代器是什么:

由于我们的vector是一块连续开辟的空间,所以我们的迭代器和string的一样,都是原生指针,并不需要单独写一个类来实现迭代器的功能,如下:

//迭代器
typedef T* iterator;
typedef const T* const_iterator;

成员如下:

namespace hjx
{
	template<class T>
	class vector
	{
    // 此处的迭代器就是T*

	private:
		iterator _start = nullptr;
		iterator _finish = nullptr;
		iterator _end_of_storage = nullptr;
	};
}
		

构造、析构、swap 与 赋值重载

构造

由于我们的三个成员本质上都是iterator,也就是原生指针,所以我们并不需要专门写一个构造函数来初始化这三个指针

至于一段迭代器区间初始化,initializer_list初始化,n个值的初始化,拷贝构造

这些需要我们后面讲完了插入之后才能进行讲解,因为其主逻辑都是一个一个插入

这里既然不需要显示写,但又需要生成,不然就没法写拷贝构造

所以我们可以使用下面的写法:

//编译器强制生成构造函数
vector() = default;

析构

这里的析构主要就干两件事:

  1. 将空间销毁
  2. 将指针全部置为空指针

当然这一切都得在空间存在的情况下才需要执行,所以我们还可以添加一个判断条件

代码如下:

//析构
~vector()
{
	if (_start)
	{
		delete[] _start;
		_start = _finish = _end_of_storage = nullptr;
	}
}

swap

由于vector的交换函数只需要交换三个指针,库中的swap默认会直接交换空间,所以我们就需要自己显示写

但是主要的逻辑还是借助库中的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);
}

赋值重载

这里我们可以使用”现代写法“:

我们在接收参数的时候,如果不加引用,那么默认接收的是这个参数的拷贝

既然如此,我们vector的拷贝也有空间,我们就只需要交换一下两边的指针,将参数的空间交换过来,销毁的时候调用不到这块空间,就不会销毁

有个词很符合这中做法:”剥削“(bushi

代码如下:

//赋值重载
vector<T>& operator=(vector<T> v)
{
	swap(v);
	return *this;
}

reserve 扩容(重要!!)、size、capacity

在该函数中,我们的参数是一个 size_t 的类型,意味要将空间扩容为多大,也就是将扩容后的总空间大小传过来了

而我们实现扩容时,有一个需要注意的点,就是迭代器失效

因为我们C++的扩容需要自己开空间,拷贝数据,销毁久空间,所以有可能出现原来的指针还指向已销毁空间但我们还需要用的情况,这是迭代器失效的其中一种情况

而我们后面要重新另指针指向新空间时,需要根据每个指针之间的距离作为考量来初始化,如下:

_start = tmp;
_finish = _start + len1;
_end_of_storage = _start + len2;

但如果 len1   len2 就是通过指针本身计算的呢?

假设 len1 = _finish - _start,但是此时_finish 还指向旧空间,这么计算就错了

所以我们需要提前存储一份oldsize

而扩容的主逻辑无非就是

  1. 开新空间
  2. 拷贝数据
  3. 销毁旧空间

但是第二第三步是要在旧空间存在的情况下才执行的,如果旧空间本身就为空,那么我们自然不需要拷贝数据与销毁

代码如下:

void reserve(size_t n)
{
    如果原空间不够了才需要扩容
	if (n > capacity())
	{
		//提前记录,后面会出现迭代器失效的情况
		size_t oldsize = size();
		T* tmp = new T[n];

        //如果旧空间本身就为空,则不需要拷贝数据与销毁
		if (_start)
		{
			for (size_t i = 0; i < oldsize; i++)
			{
				tmp[i] = _start[i];
			}
			delete[]_start;
		}

		_start = tmp;
		_finish = _start + oldsize;
		_end_of_storage = _start + n;
	}
}

size_t capacity()const
{
	return _end_of_storage - _start;
}

size_t size()const
{
	return _finish - _start;
}

如上我们还添加了size和capacity函数的实现,两个的主逻辑就是指针的相减

operator[ ]重载

我们执行operator[ ]时想要的效果就是返回那个位置的值,所以我们返回就是了

代码如下:

//opertaor[]
T& operator[](size_t i)
{
	return _start[i];
}

const T& operator[](size_t i)const
{
	return _start[i];
}

insert 插入

逻辑讲解

我们能看到,库中的第一个参数都是迭代器类型的,我们自己实现的版本也需要为位置为迭代器的类型(假设该参数名为pos

至于第二个参数自然是传入的要插入的参数了

插入的大逻辑是:

  1. 先判断是否需要扩容,如果需要就将扩容后的总大小作为参数传给reserve
  2. 将pos位置后的所有数据全部往后移一位,将数据插入
  3. 处理成员指针

前两点和我们上一篇讲解string的处理方法极为相似,代码如下:

//判断扩容
if (_finish == _end_of_storage)
{
	size_t len = pos - _start;

	size_t newcapacity = capacity() == 0 ? 4 : 2 * capacity();
	reserve(newcapacity);

	pos = _start + len;
}
//插入数据
iterator end = _finish;
while (end > pos)
{
	*end = *(end - 1);
	--end;
}

扩容时需要注意的点就是:扩完容之后我们的空间就是新空间了,但是我们的pos还指向原来的空间,所以我们需要提前将数据存储一下,扩容完成后再更新pos

而我们的所有数据向后移,直接使用一个while循环即可,就像我们使用迭代器遍历的那样

insert中的迭代器失效

由于我们的插入需要扩容,如果我们在外面使用迭代器访问的话,就有可能出现迭代器还指向原空间的情况,进而导致随机值的出现,如下:

auto it = v1.begin();
v1.insert(v1.begin()+2, 1000);

如果我们的insert此时插入之后刚好扩容了,那么我们的迭代器 it 还指向原空间,如果再去使用的话,就是越界访问了,也就是迭代器失效的一种情况

为了解决这种情况,C++源码中的解决方案是:将insert后的地址返回,然后我们在外面使用的时候再进一步的赋值即可,如下:

auto it = v1.begin();
it = v1.insert(v1.begin()+2, 1000);

综上,我们的insert需要一个iterator类型的返回值

这才是参数需要iterator类型的意义

总代码如下:

//insert
iterator insert(iterator pos, const T& x)
{
	assert(pos <= _finish);
	assert(pos >= _start);

	//判断扩容
	if (_finish == _end_of_storage)
	{
		size_t len = pos - _start;

		size_t newcapacity = capacity() == 0 ? 4 : 2 * capacity();
		reserve(newcapacity);

		pos = _start + len;
	}
	//插入数据
	iterator end = _finish;
	while (end > pos)
	{
		*end = *(end - 1);
		--end;
	}

    //插入数据
	*pos = x;
	_finish++;

	return pos;
}

erase 删除

删除主逻辑

删除其实就只干两件事:

  1. 移动数据向前覆盖一格
  2. --_finish(_finish指向的是有效数据的尾部,减减之后就不会访问到最后一个位置,后面要再插入也会将其覆盖)

到这里,大体上和insert也差不多,接下来我们就来详细讲讲erase的迭代器失效

迭代器失效

我们来看,如果我们要删除的是最后一个位置,那么删除了之后,指针(也就是迭代器)没有更新,就会造成越界访问

另外在有一些平台下面,当数据减少到一个程度之后,会执行缩容操作,此时我们的迭代器也会指向错误的位置造成越界访问

所以这么看,erase也是会造成迭代器失效的,而处理的方法和insert一样,将删除后位置的迭代器返回即可

综上,代码如下:

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

	iterator it = pos + 1;
	while (it != _finish)
	{
		*(it - 1) = *it;
		it++;
	}
	--_finish;

	return pos;
}

push_back、pop_back

上文中我们已经实现了insert了,所以push_back也可以复用insert,因为其本质就是在尾部插入一个数据

代码如下:

void push_back(const T& x)
{
	insert(end(), x);
}

而我们的pop_back相对简单,倒也不需要复用erase,只需要将Z_finish--即可,还是相当简单的

代码如下:

void pop_back()
{
	assert(size() > 0);
	--_finish;
}

begin 与 end

//迭代器
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;
}

拷贝构造、n个值构造

我们的拷贝构造本质上也是构造一个类,只不过是拷贝了另一个vector而已

而无论是什么写法,即使是现代写法在参数那里编译器也会复制一遍,其他写法也是一个一个放数据,本质上效率都一样

所以我们这里的写法就是:直接使用范围for遍历另一个vector,再将值一个一个push_back即可

但是在此之前,我们还需要先reserve出一块空间,虽然说push_back内部也有扩容逻辑,但是我们提前开好倒也算是减去了一部分消耗

代码如下:

//拷贝构造
vector(vector<T>& v)
{
	reserve(v.capacity());
	for (auto e : v)
	{
		push_back(e);
	}
}

而n个值初始化也是同理,我们只需要写一个for循环循环n次,再执行push_back的逻辑即可

代码如下:

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

vector(int n, const T& val = T())
{
	reserve(n);
	for (size_t i = 0; i < n; i++)
	{
		push_back(val);
	}
}

这里之所以写两个版本(一个size_t, 一个int),是因为后面我们在实现一段迭代器区间构造的时候会用到函数模板,如果我们n个值构造传的是一个int,一个其他类型的倒还好

但如果我们传两个int的话会匹配到迭代器区间初始化那里去

所以我们在写size_t版本的同时再写一个int的版本,为的就是应对这种情况

一段迭代器区间构造

//一段迭代器区间初始化
template<class InputIterator>
vector(InputIterator first, InputIterator last)
{
	while (first != last)
	{
		push_back(*first);
		++first;
	}
}

我们之所以要搞一个模板,为的就是无论什么结构的数据,都能初始化到vector这来,如果我们单纯给T的话,就只有vector的迭代器区间可以放进来初始化

但如果我们写了模板,那么无论是链表的,栈的,红黑树的等等,都可以放数据进来给vector初始化

而内部逻辑也与上述的拷贝构造、n个值构造一样,用的主要都是push_back的逻辑,这里就不过多讲解了(因为就是将迭代器指向的值一个一个尾插进来,再将迭代器往下移动一个而已)

initializer_list

我们通过查看文档可以发现,initializer_list本质上就是一个类,类里面有几个函数,仅此而已

所以我们可以直接将initializer_list中的值拿来push_back,由于里面有begin和end函数,这也就意味着我们可以使用范围for对initializer_list进行遍历(〃 ̄︶ ̄)人( ̄︶ ̄〃)

代码如下:

vector(initializer_list<T> il)
{
	reserve(il.size());
	for (auto e : il)
	{
		push_back(e);
	}
}

结语

本篇文章关于vector的讲解到这里就结束啦(~ ̄▽ ̄)~

如果觉得对你有帮助的话,希望能多多关注喔(○` 3′○)

  • 18
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值