vector的模拟实现

vector的模拟实现

vector是C++下的顺序表,不同于我们在之前"数据结构"中学习的顺序表。

1、成员变量

vector的私有成员变量有三个迭代器:

cpp 复制代码
private:
	iterator _start;
	iterator _finish;
	iterator _end_of_storage;

分别对应资源的起始位置、有效元素末位的下一位、空间末位的下一位:

2、迭代器

这里,我们依旧直接将指针当作迭代器。只不过重命名的方式有变化:

cpp 复制代码
using iterator = T*;

对于重命名,我们不仅可以用typedef,还可以用using。这是C++14规定的语法。

有了迭代器的同时,我们也需要const迭代器:

cpp 复制代码
using const_iterator = const T*;

迭代器的常用接口:

cpp 复制代码
//迭代器,const迭代器
iterator begin()
{
	return _start;
}
iterator end()
{
	return _finish;
}
const_iterator begin() const
{
	return _start;
}
const_iterator end() const
{
	return _finish;
}

3、常用的接口

下标访问:

cpp 复制代码
T& operator[](size_t n)
{
	assert(n < size());
	return _start[n];
}
T& operator[](size_t n) const
{
	assert(n < size());
	return _start[n];
}

元素个数和容量:

cpp 复制代码
size_t size() const
{
	return _finish - _start;
}
size_t capacity() const
{
	return _end_of_storage - _start;
}

4、reserve()

有了前面学习string的知识,我们可以写出vector的reserve()接口:

cpp 复制代码
void reserve(size_t n)
{
	if (n > capacity())// 不缩容
	{
		T* tmp = new T[n];// 若只用new而不用其它内存池,则不需要另外初始化
		if (_start)// 为空,就直接修改参数;不为空,就拷贝、销毁
		{
			memmove(tmp, _start, size()*sizeof(T));
			delete[] _start;
		}// 修改参数
		_start = tmp;
		_finish = _start + size();
		_end_of_storage = _start + n;
	}
}

接着,我们写出尾插函数:

cpp 复制代码
void push_back(const T& val)
{
	if (_finish == _end_of_storage)
	{// 扩容
		reserve(size() == 0 ? 4 : 2*capacity());
	}
	*_finish = val;
	++_finish;
}

但是,我们尾插的过程中,遇到vector顺序表需要扩容时,由于_start在reserve()函数体内已被修改,我们再结合接口size(),就可以发现_finish并未被改变。

所以,对于reserve()扩容,我们要算好距离,另外处理_finish:

cpp 复制代码
void reserve(size_t n)
{// 存下size(),防止其调用新的_start
	size_t sz = size();
	if (n > capacity())// 不缩容
	{
		T* tmp = new T[n];// 若只用new而不用其它内存池,则不需要另外初始化
		if (_start)// 为空,就直接修改参数;不为空,就拷贝、销毁
		{
			memmove(tmp, _start, size()*sizeof(T));
			delete[] _start;
		}// 修改参数
		_start = tmp;
		_finish = _start + sz;// 另外处理_finish
		_end_of_storage = _start + n;
	}
}

5、insert()

我们不难写出这样的insert():

cpp 复制代码
void insert(iterator pos, const T& val)
{
	// 断言检查
	assert(pos >= _start && pos < _finish);
	// 扩容
	if (_finish == _end_of_storage)
	{
		reserve(size() == 0 ? 4 : 2*capacity());
	}
	// 移动
	iterator end = _finish - 1;
	while (end >= pos)
	{
		*(end + 1) = *end;
		--end;
	}
	*pos = val;
	++_finish;
}

5.1、第一个迭代器失效的地方

使用上面的insert()函数,有时输出正常,有时却输出了随机值。

可以发现,当前出错的情况,正好发生在顺序表扩容的时候。

调试发现,是pos出了问题:

顺序表扩容结束后,旧空间释放,数据拷贝入新空间。而pos指向的还是旧空间上的数据,此时pos成了野指针,pos迭代器失效

改进的方法是:及时更新pos。

cpp 复制代码
void insert(iterator pos, const T& val)
{
	// 断言检查
	assert(pos >= _start && pos < _finish);
	// 扩容
	if (_finish == _end_of_storage)
	{
		size_t len = pos - _start;
		reserve(size() == 0 ? 4 : 2*capacity());
		// 此时pos要更新,否则pos发生:迭代器失效
		pos = _start + len;
	}
	// 移动
	iterator end = _finish - 1;
	while (end >= pos)
	{
		*(end + 1) = *end;
		--end;
	}
	*pos = val;
	++_finish;
}

5.2、第二个迭代器失效的地方

我们在顺序表的0下标处插入数字0,顺序表是没有问题的。

问题是,迭代器it失效了。

原因也是it指向了旧空间。

此时的it,如果顺序表不需要扩容,不会失效;如果需要扩容,就会失效。

6、erase()

cpp 复制代码
void erase(iterator pos)
{
	// 检查
	assert(pos >= _start && pos < _finish);
	// 移动数据
	iterator it = pos + 1;
	while (it != _finish)
	{
		*(it - 1) = *it;
		++it;
	}

	--_finish;
}

6.1、第一个迭代器失效的地方

我们定义迭代器it在顺序表的末尾:

经过erase(),顺序表得到正确的尾删,但是it此时指向了_finish,失效了

我们自主实现的erase(),由于没有真正清理掉末尾元素,所以没有出现问题:

但是,如果我们使用vs2022下标准库的vector,就会断言报错:


然而,利用4.8.5版本的Linux环境下的标准库,执行的结果同自主实现:

所以,当前迭代器失效与否,是未知的,不同环境,不同版本编译器的输出结果不同。保险起见,我们都当作此时迭代器已失效。

6.2、第二个迭代器失效的地方

我们对上面顺序表做一个操作:移除所有偶数。

我们当前自主实现的vector,没有出现问题:

然而使用vs2022下标准库的vector,断言报错:

4.8.5版本的Linux环境下,执行的结果同自主实现:

7、编译器失效问题的解决

对于insert()和erase(),我们只需返回函数体内处理完的pos即可:

cpp 复制代码
iterator insert(iterator pos, const T& val)
{
	// 断言检查
	assert(pos >= _start && pos < _finish);
	// 扩容
	if (_finish == _end_of_storage)
	{
		size_t len = pos - _start;
		reserve(size() == 0 ? 4 : 2*capacity());
		// 此时pos要更新,否则pos发生:迭代器失效
		pos = _start + len;
	}
	// 移动
	iterator end = _finish - 1;
	while (end >= pos)
	{
		*(end + 1) = *end;
		--end;
	}
	*pos = val;
	++_finish;

	return pos;
}
cpp 复制代码
iterator erase(iterator pos)
{
	// 检查
	assert(pos >= _start && pos < _finish);
	// 移动数据
	iterator it = pos + 1;
	while (it != _finish)
	{
		*(it - 1) = *it;
		++it;
	}

	--_finish;
	return pos;
}

删除偶数的操作,也只需更新一下迭代器it即可:

cpp 复制代码
void test6()
{
	// 这里以自主实现的vector为例
	vector<int> v;
	v.push_back(1);
	v.push_back(2);
	v.push_back(3);
	v.push_back(4);
	v.push_back(5);

	auto it = v.begin();
	while (it != v.end())// 删除所有偶数
	{
		if (*it % 2 == 0)
		{
			it = v.erase(it);// 更新it
		}
		else
		{
			++it;
		}
	}
	for (auto e : v)
	{
		cout << e << " ";
	}cout << endl;
}

但是以下代码的迭代器失效就无法避免,因为it始终未动,而_finish经erase()操作后,移动到it位置处。

cpp 复制代码
void test5()
{
	vector<int> v;
	v.push_back(1);
	v.push_back(2);
	v.push_back(3);
	v.push_back(4);

	auto it = v.end() - 1;
	it = v.erase(it);//这里防不了
	v.Print();
	cout << *it << endl;
}

所以我们要尽量避免这样使用迭代器,万一使用了也要及时标记。

8、resize()

resize()的两点功能:

  1. n < size(),缩容
  2. n > size(), 扩容,用固定值填补
cpp 复制代码
void resize(size_t n, T val = T())// 匿名对象
{
	if (n < size())
	{
		_finish = _start + n;
	}
	else
	{// 避免没必要的扩容
		reserve(n > capacity() ? n : capacity());
		while (_finish < _start + n)
		{
			*_finish = val;
			++_finish;
		}
	}
}

我们看到填补值参数给了一个默认值T(),T()就是类型T的一个匿名对象

可以做缺省参数的量:

  • 字面量常量(常量10、nullptr)
  • 全局变量 / 类的静态成员
  • 匿名对象

匿名对象创建时,会调用默认构造完成初始化,从而做缺省参数。

代码中的T来自模板,这个T可能是内置的类型,也可能是自定义的类型,所以使用匿名对象会使代码的设计更灵活。


vs2022下,匿名对象调用默认构造时,进行了初始化,并没有给上随机值:

cpp 复制代码
int i = 0;
int j = int();//  j = 0
int k = int(1);// k = 0

int x = {};//     x = 0
int y = {1};//    y = 1
int z = {2};//    z = 2

9、拷贝构造

拷贝构造需要考虑深拷贝问题。

我们可以通过另外开辟空间的办法完成深拷贝,也可以借助扩容函数rserve()。

但是当前使用reserve()会遇到一个问题:由于我们只在构造函数使用了初始化列表进行初始化而其它地方没有,导致此时的*this的各个成员都是随机值,都未初始化。

拷贝构造的初始化也走初始化列表

当前reserve()是使用new开辟新空间,new会调用构造初始化;而不借助new开辟空间的方式,就可能不会调用构造初始化,就可能出现问题。

所以,我们就可以给成员声明处赋缺省值:

cpp 复制代码
private:
	iterator _start = nullptr;
	iterator _finish = nullptr;
	iterator _end_of_storage = nullptr;

由于走初始化列表时,编译器会先走当前构造函数(拷贝构造)的初始化列表,如果当前构造函数没有,就使用缺省值初始化。也就意味着成员赋缺省值,完成了对所有构造函数的初始化。

cpp 复制代码
		vector(vector<T>& v)
		{
			reserve(v.capacity());
			// 范围for赋值
			for (const auto& e : v)
			{
				*_finish = e;
				++_finish;
			}
		}

范围for赋值,可能会遇到vector顺序表元素很大(如果存的是string串),拷贝的代价很大。

所以我们用 const 引用。

10、initializer_list构造

vector顺序表还可以传入initializer_list参数进行构造:

cpp 复制代码
vector(initializer_list<T> il)
{
	// 扩容
	reserve(il.size());
	// 赋值
	for (const auto& e : il)
	{
		push_back(e);
	}
}

11、赋值运算符重载

赋值运算符重载需注意三点:

  1. 深拷贝
  2. 自己对自己赋值没必要
  3. 支持连续赋值
cpp 复制代码
//v1 = v3
vector<T>& operator=(vector<T>& v)
{
	// 避免自身赋值
	if (this != &v)
	{
		// 方法1 --> v3, v1空间相近
		// 清理数据
		clear();
		// 扩容
		reserve(v.capacity());
		// 赋值
		for (const auto& e : v)
		{
			*_finish = e;
			++_finish;
		}

		// 方法2 --> v3空间明显大于v1
		// 释放空间 --> 重新申请 --> 赋值
	}

首先,clear()的实现:

cpp 复制代码
void clear()
{// 并不是真的清理
	_finish = _start;
}

其次,对于赋值重载,假设对象v3赋值给v1:

  • 如果v3, v1的有效值空间大小相差不大,可以采用上面代码的方法
  • 如果v3的有效值空间远大于v1,则可以尝试采用以下方法:
    1. 释放v1
    2. 申请空间
    3. 赋值

12、拷贝构造的现代写法

拷贝构造的现代写法的思路是:借助临时对象完成开辟空间、构造、析构。

假设对象v1拷贝给v2,我们就创建一个对象tmp,tmp利用v1初始化,然后v2和tmp交换,最后tmp析构。

cpp 复制代码
// 现代写法
// v2(v1)
vector(vector<T>& v)
{
	vector<T> tmp;
	tmp = v;
	swap(tmp);
}

不能直接vector<T> tmp = v,因为自定义类型的变量传值传参时会调用拷贝构造,导致无穷递归。

除了可以借助赋值重载完成开辟空间、构造,我们还可以使用迭代器进行构造:

cpp 复制代码
// 迭代器构造
template<class InputIterator>
vector(InputIterator first, InputIterator last)
{
	// reserve(last - first);// 有的迭代器不一定支持减操作
	while (first != last)
	{
		push_back(*first);
		++first;
	}
}// 模板自动识别

// 现代写法
// v2(v1)
vector(vector<T>& v)
{
	vector<T> tmp(v.begin(), v.end());
	swap(tmp);
	// 成员函数swap:
	// 调用算法库swap,交换成员(指针)
}

13、赋值运算符重载的现代写法

cpp 复制代码
// 现代写法
// v1 = v3
vector<T>& operator=(vector<T> tmp)
{
	swap(tmp);
	return *this;
}

假设对象v3拷贝给v1

如代码所示,传给tmp的是v3的拷贝,交换后,tmp析构。

14、传入多个相同值构造

我们很容易就能写出来:

cpp 复制代码
vector(size_t n, const T& val = T())
{
	// 扩容
	reserve(n);
	for (size_t i = 0; i < n; ++i)
	{
		*_finish = val;
			++_finish;
	}
}

但是一运行,就会发现编译错误。

仔细一看,我们原本是想调用vector(size_t n, const T& val)构造,却调用了传入迭代器构造的函数模板。

其实这里存在类型匹配的问题。

上面代码中,我们传入参数10, 1,编译器把这两个数都识别成了int类型。

所以应该是这个函数模板的参数更匹配:

因为传入模板的两个参数,类型是一样的;同时,传入模板的参数,其类型并没有固定是迭代器。

而我们设计的多个相同值构造,传入第一个参数n时,要进行类型转换:

就不太匹配了。

C++20以后,可以使用概念 这一语法来限制传入使用迭代器构造的函数模板的参数必须是迭代器。

而我们现在可以使用函数重载进行补救:

cpp 复制代码
vector(size_t n, const T& val = T())
{
	// 扩容
	reserve(n);
	for (size_t i = 0; i < n; ++i)
	{
		*_finish = val;
		++_finish;
	}
}

vector(int n, const T& val = T())
{
	// 扩容
	reserve(n);
	for (int i = 0; i < n; ++i)
	{
		*_finish = val;
		++_finish;
	}
}

15、当前自主实现vector的一个小BUG

定义一个顺序表,里面每一个元素都是string类型;然后传入字符串(传入串的长度都大于16,以避免SBO优化)。

我们传入前4个串,是没有问题的:

而传入第5个串,就发生了问题:

我们会很敏锐地发现,问题可能发生在扩容的时候。

调试:


发现执行delete操作时发生了问题。

根据经验,这里的问题往往不是delete本身的问题。

这里的问题是拷贝的问题。

我们可以把每一个string对象,简化成这样的类:

cpp 复制代码
class string
{
private:
	char* str;
	size_t _size;
	size_t _capacity;
};

而当前v的每一个string对象的指针,指向一块空间。

我们开辟新数组tmp,tmp借助new实现初始化:

而memmove(),只能达到v中存储的指针,拷贝给tmp的效果:

这样实际上tmp每一个string的指针与v每一个string的指针,都分别指向了同一块资源。析构时,这块资源被释放,就会发生意想不到的错误。

第一种补救措施,是利用赋值时的深拷贝

cpp 复制代码
void reserve(size_t n)
{
	size_t sz = size();
	if (n > capacity())
	{
		T* tmp = new T[n];
		if (_start)
		{
			//memmove(tmp, _start, size()*sizeof(T));
			for (size_t i = 0; i < sz; ++i)
			{
				tmp[i] = _start[i];// 赋值时深拷贝
			}
			delete[] _start;
		}
		_start = tmp;
		_finish = _start + sz;
		_end_of_storage = _start + n;
	}
}

第二种补救措施,是利用swap:


cpp 复制代码
void reserve(size_t n)
{
	size_t sz = size();
	if (n > capacity())
	{
		T* tmp = new T[n];
		if (_start)
		{
			//memmove(tmp, _start, size()*sizeof(T));
			//for (size_t i = 0; i < sz; ++i)
			//{
			//	tmp[i] = _start[i];
			//}
			for (size_t i = 0; i < sz; ++i)
				std::swap(tmp[i], _start[i]);// 交换
			delete[] _start;
		}
		_start = tmp;
		_finish = _start + sz;
		_end_of_storage = _start + n;
	}
}

对于第二种方法,如果不考虑string对象直接利用算法库swap(拷贝大资源)的代价,其效率比第一种方法更优秀,因为第二种方法只需tmp中每一个string指向的\0,与v中每一个string指向的串资源交换,即指针的交换。而第一种方法还需要拷贝串资源。

代码演示

代码演示

相关推荐
浅念-2 小时前
分治算法专题|LeetCode高频经典题目详细题解
数据结构·c++·算法·leetcode·职场和发展·排序·分治
H Journey2 小时前
C++ 性能瓶颈分析与优化
c++·性能优化·gprof·perf·valgrind·瓶颈分析
熬夜敲代码的猫3 小时前
C++继承:让你从入门到深入
c++·算法·继承
txz20353 小时前
2,使用功能包组织C++节点
开发语言·c++·ros
谭欣辰3 小时前
C++ 哈希表详解
c++·算法·哈希算法·散列表
blasit3 小时前
Qt C++ http服务器安全登录token生成管理
c++·后端·qt
云栖梦泽3 小时前
Linux内核与驱动:GPIO设备树与SPI设备树的区别
linux·运维·c++·嵌入式硬件
南境十里·墨染春水3 小时前
C++笔记——STL list
c++·笔记·list
彷徨而立3 小时前
【C/C++】在头文件中定义全局变量的方法
c语言·开发语言·c++