C++基础 [七] - Vector的模拟实现

目录

前言

源码引入

vector的模拟实现 

成员函数的实现

构造函数

拷贝构造函数

运算符赋值重载 

析构函数

与元素访问有关的实现 

operator[ ]

Iterator — 迭代器

 容量大小的实现

size和capacity

reserve

resize

与修改有关的实现

push_back

pop_back  

insert

erase


前言

本模块呢,我将会带大家一起从0~1去模拟实现一个STL库中的 vector类,当然模拟实现的都是一些常用的接口,以便于让大家更好的巩固之前学习过的 缺省参数、封装、类中的6大默认成员函数等

源码引入

然后呢我们就去调出【vector】的一些核心源码,这里我们主要关注的就是这个使用原生指针value_type*所定义出来的迭代器 iterator

然后我们又看到了保护成员:[start]、[finish]、[end_of_stroage]。看到它们你是否有想起我们在 模拟string 的时候写到过的 [a]、[size]、[capacity];没错,它们就存在着一定的对应关系

 但是呢,只看上面这些成员变量还不够,我们要将其带入到具体的场景中,例如下面有两个接口分别为【尾插】和【扩容】,对于push_back()封装得没有那么厉害,读者结合下面的图应该就能看得懂,分别就是 未满追加的逻辑和已满扩容的逻辑

那对于reserve()来说,就是一个扩容的逻辑,【allocate_and_copy】是开辟和拷贝空间,那【deallocate】就是释放空间在扩完容之后不要忘了去对三个成员变量做更新,这一块的模拟实现我在下面马上就会讲到

所以此时我们就能知道[start]、[finish]、[end_of_stroage]都代表什么意思了

对于上面的这些源码呢,读者可以在学习了STL一段时间后,配合侯捷老师的《STL源码剖析》再去展开阅读,会有更新层次的认识噢 

vector的模拟实现 

然后我们就来模拟实现一下【vector】中的各种接口

首先第一点,为了不和库中的vector类发生冲突,我们可以包上一个名称为xas_vector的命名空间,此时因为作用域的不同,就不会产生冲突了

成员函数的实现

构造函数

无参构造

对于vector的无参构造,我们只需要将三个成员变量置为空指针即可

//构造函数 --- 无参构造
vector()
    //初始化成员列表
	:_start(nullptr)
	, _finish(nullptr)
	, _end_of_storage(nullptr)
{}

迭代器区间构造   

当我们想要以某个对象的区间来进行初始化时,就需要用到模板了。它既可以是类模板,也可以是函数模板。

例如:
用一个常量字符串来构造vector

const char* p = "hello";
vector<char>v(p, p + 2);

 用一个数组来构造vector

int a[5] = { 1,2,3,4,5 };
vector<int>v1(a, a + sizeof(a) / sizeof(a[0]));

用一个string类来构造vector 

string s1("hello");
vector<char>v2(s1.begin(), s1.end());
//构造函数 --- 迭代器区间构造
template <class InputIterator>//既是一个类模板的成员函数,又可以是一个函数模板
vector(InputIterator first, InputIterator last)
	:_start(nullptr)
	, _finish(nullptr)
	, _end_of_storage(nullptr)
{
	while (first != last)
	{
		push_back(*first);//尾插
		++first;
	}
}

马上我们就来测试一下 

// 构造函数
void test6()
{
	// 无参构造
	xas_vector::vector<int> v;
	xas_vector::print_vector(v);
	cout << endl;
	v.Push_back(1);
	v.Push_back(2);
	v.Push_back(3);
	v.Push_back(4);
 
	// 迭代器区间构造
	xas_vector::vector<int> v2(v.begin(), v.end());
	xas_vector::print_vector(v2);
	cout << endl;
 
	std::string s("abcdef");
	xas_vector::vector<char> v3(s.begin(), s.end());
	xas_vector::print_vector(v3);
	cout << endl;
 
	int a[] = { 1,2,3,4 };
	xas_vector::vector<int> v4(a, a + 4);
	xas_vector::print_vector(v4);
	cout << endl;
}

拷贝构造函数

首先读者要明确为什么要写拷贝构造,这个我们通过调试来看一下就知道了:很明显可以看到这里只是做了一个浅拷贝,而不是去做了深拷贝

所以我们要自己去实现一个深拷贝,逻辑很简单,就不赘述

// 拷贝构造
vector(const vector<int>& v)
{
    _start = new T[v.capacity()]; //开辟一块和容器v大小相同的空间
	memcpy(tmp, v._start, sizeof(T) * v.size());//将容器v当中的数据一个个拷贝过来
	_finish = _start + v.size(); //容器有效数据的尾
	_end_of_storage = _start + v.capacity(); //整个容器的尾
}

但是看到上面这个memcpy(),你是否会有一种警惕的心理呢,因为我们上面讲到过 vector 对象中存放的是 string数组,在拷贝/扩容的过程中会产生浅拷贝的问题,无论是拷贝还是扩容,只要遇到memcpy(),那就不可以使用这个memcpy(),具体问题间下图

注意: 将容器当中的数据一个个拷贝过来时不能使用memcpy函数,当vector存储的数据是内置类型或无需进行深拷贝的自定义类型时,使用memcpy函数是没什么问题的但当vector存储的数据是需要进行深拷贝的自定义类型时,使用memcpy函数的弊端就体现出来了。例如,当vector存储的数据是string类的时候。

 并且vector当中存储的每一个string都指向自己所存储的字符串。

为什么 memcpy() 会产生问题?
memcpy() 是一个低级的内存操作函数,它会按字节复制内存内容。对于 vector<string> 这样的结构,string 对象内部可能包含指向堆内存的指针(用于存储实际的字符数据)。如果直接使用 memcpy(),它仅复制了string对象的指针,而没有处理指针指向的实际数据,这样就会出现浅拷贝问题,即两个 string 对象指向同一内存区域,修改其中一个会影响另一个。 

  • 如果此时我们使用的是memcpy函数进行拷贝构造的话,那么拷贝构造出来的vector当中存储的每个string的成员变量的值,将与被拷贝的vector当中存储的每个string的成员变量的值相同,即两个vector当中的每个对应的string成员都指向同一个字符串空间。

  • 这显然不是我们得到的结果,那么所给代码是如何解决这个问题的呢?

代码中看似是使用普通的“=”将容器当中的数据一个个拷贝过来,实际上是调用了所存元素的赋值运算符重载函数,而string类的赋值运算符重载函数就是深拷贝,所以拷贝结果是这样的:

 

//拷贝构造
vector(const vector<T>& v)
{
	_start = new T[v.capacity()]; //开辟一块和容器v大小相同的空间
	for (size_t i = 0; i < v.size(); i++) //将容器v当中的数据一个个拷贝过来
	{
		_start[i] = v[i];
	}
	_finish = _start + v.size(); //容器有效数据的尾
	_endofstorage = _start + v.capacity(); //整个容器的尾
}

总结一下: 如果vector当中存储的元素类型是内置类型(int)或浅拷贝的自定义类型(Date)使用memcpy函数进行进行拷贝构造是没问题的,但如果vector当中存储的元素类型是深拷贝的自定义类型(string),则使用memcpy函数将不能达到我们想要的效果

运算符赋值重载 

vector的赋值运算符重载当然也涉及深拷贝问题,我们这里也提供两种深拷贝的写法:

传统写法:

  • 首先判断是否是给自己赋值,若是给自己赋值则无需进行操作。若不是给自己赋值,则先开辟一块和容器v大小相同的空间,然后将容器v当中的数据一个个拷贝过来,最后更新_finish和_end_of_storage的值即可。
//传统写法
vector<T>& operator=(const vector<T>& v)
{
	if (this != &v) //防止自己给自己赋值
	{
		delete[] _start; //释放原来的空间
		_start = new T[v.capacity()]; //开辟一块和容器v大小相同的空间
		for (size_t i = 0; i < v.size(); i++) //将容器v当中的数据一个个拷贝过来
		{
			_start[i] = v[i];
		}
		_finish = _start + v.size(); //容器有效数据的尾
		_end_of_storage = _start + v.capacity(); //整个容器的尾
	}
	return *this; //支持连续赋值
}

注意: 这里和拷贝构造函数的传统写法类似,也不能使用memcpy函数进行拷贝。 

现代写法:

  • 赋值运算符重载的现代写法非常精辟,首先在右值传参时并没有使用引用传参,因为这样可以间接调用vector的拷贝构造函数,然后将这个拷贝构造出来的容器v与左值进行交换,此时就相当于完成了赋值操作,而容器v会在该函数调用结束时自动析构。
//现代写法
// v1 = v3
// v1是v3 的拷贝, 注意这里不能用& 这样v3就变成v1了,而我们的目的是让 v1 = v3
vector<T>& operator=(vector<T> v) //编译器接收右值的时候自动调用其拷贝构造函数
{
	swap(v); //交换这两个对象
	return *this; //支持连续赋值
}

析构函数

对容器进行析构时,首先判断该容器是否为空容器,若为空容器,则无需进行析构操作,若不为空,则先释放容器存储数据的空间,然后将容器的各个成员变量设置为空指针即可。

//析构函数
~vector()
{
	if (_start) //避免对空指针进行释放
	{
		delete[] _start; //释放容器存储数据的空间
		_start = nullptr; //_start置空
		_finish = nullptr; //_finish置空
		_end_of_storage = nullptr; //_endofstorage置空
	}
}

与元素访问有关的实现 

基本的成员函数我们已经讲完了,string对象也构造出来了,接下去我们来访问一下对象里面的内容吧 

operator[ ]

对于元素访问的话我们最常用的就是下标 + []的形式,这里给出两种,一个是const版本非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];
}}

Iterator — 迭代器

经过上面的学习我们可以知道,要去遍历访问一个vector对象的时候,除了【下标 + []】的形式,我们还可以使用迭代器的形式去做一个遍历

  • 而对于迭代器而言我们也是要去实现两种,一个是非const的,一个则是const的
  •  vector类中的迭代器实际上就是字符指针,只是给字符指针起了一个别名叫iterator而已。
typedef T* iterator;                     // 迭代器某种意义上就是指针
typedef const T* const_iterator;
  •  vector的begin直接返回容器的_start,end返回容器的_finish。
//begin
iterator begin()
{
	return _start; //返回容器的起始位置
}
//end
iterator end()
{
	return _finish; //返回有效数据下一个的地址
}

这里为什么最后不加const呢?

因为我们还可能要做修改操作,不加const这意味着容器的元素可以通过该迭代器进行修改。

iterator it = container.end();
--it;  // 获取指向最后一个元素的迭代器
*it = 10;  // 修改容器中的元素

const版本的迭代器 

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

 容量大小的实现

size和capacity

因为指针相减的结果就是这两个指针之间对应类型的数据个数,所以获取 size 只需 _finish-_start。获取 capacity 只需 _end_of_stoage-_start。 

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

问题:为啥这里不是 const size_t

因为size() 函数的返回类型是 size_t,size_t 本身是一个基本数据类型(通常是 unsigned 类型)。通常,基本数据类型的值是不可修改的,因此不需要将其声明为 const。

reserve

  • reserve增容:

            1.当 n > capacity 时,将capacity扩大到n;

            2.当 n < capacity 时,不进行任何操作;

看着逻辑很清晰,但是呢下面的代码存在着非常多的漏洞

void reserve(size_t n)
{
	if (n > capacity())//判断是否需要扩容
	{
		//扩容
		T* tmp = new T[n];  //开辟n个空间
		if (_start)
		{
            //数据拷贝,也不能去使用memcpy函数
			for (size_t i = 0; i < sz; i++)
			{
				tmp[i] = _start[i];
			}
			delete[]_start; //释放旧空间
		}
		_start = tmp; //指向新空间
		_finish = _start + size();
		_end_of_storage = _start + n;
	}
}

马上我们就来测试一下 

  • 但是呢在运行起来后却发现程序出现了崩溃,这是为什么呢?

 按下【F5】以调试的方式运行起来就可以发现有地方出现了 空指针异常

进一步,我们通过【调试窗口】再来看看,很明显得就看到这个_finish的值为【0x00000000】

其实真正的问题还是出在 reserve 这个扩容的逻辑中,随着我们一步一步地去看,可以看到_start_end_of_storage这两个都没什么问题,但是_finish就是没有什么变化,所以呢我们可以锁定到下面这句话

_finish = _start + size();

此时就需要去看看这个【size】了,之前我们使用的是_finish - _start来计算的 size(),在执行这句话时_start已经发生了改变,因为我们去开出了一块新的空间,但是这时_finish的值还是一开始的【nullptr】,那么这个 size() 最后计算出来的大小即为 - _start,此时再和_startt去做一个结合的话即为 0

 所以,上述就是为什么这个_finish的值为【0x00000000】原因,那我们要如何去修改呢?

我们可以在每次没开始扩容之前我们都可以去事先保存一下这个 size(),后面的更新顺序就不需要发生变动了,在加的时候加上sz即可

void reserve(size_t n)
{
	if (n > capacity())//判断是否需要扩容
	{
		//扩容
		size_t sz = size(); //提前算好增容前的数据个数
		T* tmp = new T[n];  //开辟n个空间
		if (_start)
		{
            //数据拷贝,也不能去使用memcpy函数
			for (size_t i = 0; i < sz; i++)
			{
				tmp[i] = _start[i];
			}
			delete[]_start; //释放旧空间
		}
		_start = tmp; //指向新空间
		_finish = _start + sz;
		_end_of_storage = _start + n;
	}
}

看到这里我们发现好像vector没有什么优势呀,别着急下面就是我们劲爆的了

resize

如果需要扩容,那我们这个初始值该怎么给呢,可以给0吗?答案是不可以的,因为这个是模板,T不一定是int,还有可能是string等,0不能初始化所有值,所以要给一个T类型的匿名对象 T(),T是谁就调谁的默认构造 。 如果是int,能跑吗?理论上是不可能的。因为内置类型没有构造函数。但是有了模板就可以认为内置类型也有默认构造函数了。

与修改有关的实现

接下去的话我们来讲讲有关修改操作的一些接口

push_back

首先第一个的话就是push_back,这个我在上面讲【reserve】的时候给出过,现在仔细地再来讲一讲:首先的话我们要考虑的就是扩容的逻辑,上面我们有讲到在VS下是呈现 1.5倍 的增长趋势,但是在g++下呈现的则是 2倍 的扩容逻辑,这里的扩容的话我们就交给【reserve】来实现

void push_back(const T& x)
{
	if (_finish == _end_of_storage)
	{
		size_t newCapacity = capacity() == 0 ? 4 : capacity() * 2;
		reserve(newCapacity);
	}
	*_finish = x;
	_finish++;
}

pop_back  

 在进行尾删时,需要判断容器是否为空,我们这里并没有实现vector的判空操作,可以利用_finish和_start之间的关系进行判断。

void pop_back(const T& x)
{
	assert(_finish > _start);
	--_finish;
}

insert

这一块有个好处就是,可以让挪动数据变得简单。之前如果pos在下标0位置的时候,如果是无符号会出现最大值。因为pos不可能是0,为什么呢

因为这是一段有效空间的地址,不可能是0

void insert(iterator pos, const T& x)

这一块的话我们已经讲过很多遍了,要在某一个位置插入数据的话就需要先去挪动部分的数据,这里我们从后往前挪,防止造成覆盖的情况,当数据挪动完毕后,再在pos这个位置插入指定的数据即可 

  • 在一进入函数的时候大家可以去做一个断言的操作,不过很多同学可能会好奇这边的pos >= _start,为什么可以位于首部
assert(pos >= _start && pos <= _finish);
  • 不过呢,既然是插入数据的话就一定会存在容量不足的情况,此时就需要一个扩容逻辑,这里我们直接用上面在push_back()接口中所写的即可
// 1.首先考虑扩容逻辑
if (_finish == _end_of_storage)
{
	size_t newCapacity = capacity() == 0 ? 4 : capacity() * 2;
	reserve(newCapacity);
}

整体代码如下

void insert(iterator pos, const T& x)
{
	assert(pos >= _start && pos <= _finish);
	// 1.首先考虑扩容逻辑
	if (_finish == _end_of_storage)
	{
		size_t newCapacity = capacity() == 0 ? 4 : capacity() * 2;
		reserve(newCapacity);
	}
 
	// 2.挪动数据
	iterator end = _finish - 1;
	while (end >= pos)
	{
		*(end + 1) = *end;
		--end;
	}
	*pos = x;
	++_finish;
}

 好,在写完【insert】接口后,我们再来做一个测试。可以发现程序崩溃了

马上,我们通过调试来观察一下 

  • 此时我们已经往【v】中插入了4个数据,马上使用insert(v.begin(),0)去做一个头插,那么一进到函数中我们就可以知道这个当前对象的_startpos所处的迭代器位置是相同的,也就是同一段空间的地址

  • 那此时我们知道容器中的空间已经满了,所以会去走一个扩容的逻辑,此时可以看到当前对象this的_start已经发生了改变

  • 可以看到,在扩完容之后,当前对象的_start和待插入位置的pos已经发生了变化,那么在此时我们再去挪动数据进行插入的时候就会出现问题了

 我们可以通过下面的图示来看看到底这个扩完容之后是怎样的

 以上所出现的这个问题就被称作是 【迭代器失效的问题】---- 扩容导致野指针

  • 首先大家要明白的一个点是出错的根本原因在于:_start的位置改变了但是pos的位置没有发生改变。 
  • 所以我们所要做的一个点就是:让pos的位置随着_start的变动而一起变动,这样就不会出现问题了。以下我们需要改进的代码部分,在进行扩容之前,我们可以先去计算一下从【_start】到【pos】的位置有多远;

即我们要更新pos的位置,那怎么更新呢,我们可以算pos_start的相对位置,然后等移动到新空间之后_start + 相对位置就是pos的位置了

//在pos位置插入数据
void insert(iterator pos, const T& x)
{
	if (_finish == _end_of_storage) //判断是否需要增容
	{
		size_t len = pos - _start; //记录pos与_start之间的间隔
		size_t newcapacity = capacity() == 0 ? 4 : 2 * capacity(); //将容量扩大为原来的两倍
		reserve(newcapacity); //增容
		pos = _start + len; //通过len找到pos在增容后的容器当中的位置
	}
	//将pos位置及其之后的数据统一向后挪动一位,以留出pos位置进行插入
	iterator end = _finish;
	while (end >= pos + 1)
	{
		*end = *(end - 1);
		end--;
	}
	*pos = x; //将数据插入到pos位置
	_finish++; //数据个数增加一个,_finish后移
}

再次运行 

迭代器指向的位置意义改变

  • 但是呢就上面这样还不够,我们只解决了内部迭代器失效的问题,而外部迭代器失效的问题并没有很好地解决。
  •  外部迭代器,那是什么东西? 我们来看下这段代码
    vector<int> v1;
	v1.Push_back(1);
	v1.Push_back(2);
	v1.Push_back(3);
	v1.Push_back(4);
	for (auto ch : v1)
	{
		cout << ch << " ";
	}
	cout << endl;
	vector<int>::iterator it = v1.begin();
	it = v1.insert(it, 0);
	for (auto ch : v1)
	{
		cout << ch << " ";
	}
	cout << endl;
	cout << *it << endl;
  • 可以看到,在使用完这个这个迭代器之后再去访问就出现了问题 

你不是更改了pos的位置吗?怎么又会有这个错误呢?

因为这是个传值传参,pos的改变不会影响it。

所以insert以后迭代器可能会因为扩容而失效,对于迭代器这一块我们在使用的时候一定要慎重。在使用完之后不要去轻易地使用这个形参迭代器了,因为他可能失效了

如果执意要进行修改的话也不是没有办法,我们只需要在insert】之后去接受一下当前所操作的这个迭代器的位置即可记住这个位置,下次在访问的时候也就不会出问题

erase

对于【erase】来说,我们也是需要先去挪动数据的,但是在这里呢我们需要从前往后挪,也是防止造成覆盖的情况

//删除pos位置的数据
iterator erase(iterator pos)
{
	assert(!empty()); //容器为空则断言
	//将pos位置之后的数据统一向前挪动一位,以覆盖pos位置的数据
	iterator it = pos + 1;
	while (it != _finish)
	{
		*(it - 1) = *it;
		it++;
	}
	_finish--; //数据个数减少一个,_finish前移
	return pos;
}

当删除end的时候,也会出现迭代器失效的问题。反正就是insert或者earse以后,就不能使用了

再看下面的例子

为什么无论里面的内容有多少,它的大小都一直是不变的?

当你声明一个 string s1("hello"); 时,s1 实际上会有一个指针指向存储 "hello" 字符的内存,此外还有记录字符串长度和容量的信息。而 sizeof(s1) 返回的是这个 string 对象的整体大小,包括指针和一些元数据,不会考虑字符数组的实际内容。

评论 46
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值