STL(2)——vector的使用与模拟实现

一、vector的介绍与使用

  vector是一个可以动态增长的用数组实现的顺序容器。

  vector是一个模板类,声明如下:

  其第二个参数的含义是一个空间分配器(即内存池),是为了降低频繁向系统申请空间影响效率,为提升效率而设计的。

1 构造函数

  支持四种构造函数:无参数构造、n个val来初始化、支持一段迭代器区间初始化、支持拷贝构造初始化。

int main()
{
	vector<int> v1;
	int n = 8;
	vector<int> v2(n, 4);
	vector<int> v3(++v2.begin(), --v2.end());
	vector<int> v4(v2);
	
	string s("hello");
	vector<char> v5(s.begin(), s.end());
}

  string不能够被vector<char>替代,因为string支持了很多字符串的有关比较和操作,而vector因为支持多种类型,它没有比较操作啥的,并且它也不提供子串查找。

  这里的value_type(),对于自定义类型这就是默认构造函数,对于内置类型,C++也提供了默认构造函数,即可以使用int a = int();int a = int(10);

2 赋值运算符重载

  它实现的是深拷贝。

3 vector的遍历操作

opeartor[]系列访问

  与string类似,operator[]是使用assert判断越界,at是通过抛异常来判断越界

迭代器

  支持迭代器则支持范围for,范围for会被替换成迭代器。

4 容量操作

  它的max_size是string的max_size去除以对象大小获得的。

int main()
{
	vector<int> a;
	cout << a.max_size() << endl;
}

  reserveresizestring的比较类似,reserve是申请容量,resize如果把空间扩大时你没有给参数那么就会填上对应类型的缺省值,对于自定义类型的缺省值,C++规定整形的缺省值都是0,浮点型的缺省值也是0,char类型的缺省值是\0,指针类型的缺省值是空指针。

  resize缩容器元素个数时,一般不会把空间干掉,不过C++标准对此并没有规定。

5 修改操作

assign:

  可以用迭代器范围或用n个val来给容器赋值,如果范围大于当前容量capacity会扩容。

6 查找操作

  在cppreference网页中按ctrl + f:搜索find,发现vector容器的成员函数中没有查找:

  这并不意味着vector不能查找,vector可以通过C++提供的泛型算法find进行查找,理由也很简单,vector和其他顺序容器都会使用迭代器来实现查找功能,所以直接把使用迭代器实现查找的功能放在<algorithm>中了。

  find的使用要注意的是如果找不到,find会返回指向end()的迭代器,若找到了,则会返回指向目标元素的迭代器,每次find完了记得看看找没找到再进行对应操作

  在C++中,迭代器区间对应的实际范围都是[a,b).

int main()
{
	vector<int> a;
	for (int i = 1; i <= 5; ++i) a.push_back(i);
	vector<int>::iterator ret = find(a.begin(), a.end(), 3);
	if (ret != a.end())
	{
		cout << "I find it!" << endl;
	}
}

  string之所以提供find的另一个原因是stringfind还要提供查找子串的功能,这个需求明显与泛型算法实现的功能不同。

insert:在迭代器指向的位置之前插入元素,效率比较低。

erase:删除某个迭代器指向的值,或删除迭代器区间的值。

  在删除之前一定要判断迭代器是否是合法位置,这样删除才有意义,否则可能会导致崩溃。

int main()
{
	vector<int> a;
	for (int i = 1; i <= 5; ++i) a.push_back(i);
	vector<int>::iterator ret = find(a.begin(), a.end(), 30);
	a.erase(ret);
	for (auto e : a)
	{
		cout << e << ' ';
	}
}

  并且最好不要在使用迭代器插入后立刻删除,插入操作可能会造成迭代器失效进而导致删除错误。

clear:清掉元素,但是空间不一定回收。

7 vector的增容方式

  通过以下代码测试,发现VS下是1.5倍左右的增容,Linux下g++是两倍的增容,这点和string一样。

#include <iostream>
#include <vector>
using namespace std;
int main()
{
	vector<int> a;
	cout << a.capacity() << endl;
	size_t sz = a.capacity();
	for (int i = 1; i <= 100; ++i)
	{
		a.push_back(i);
		if (a.capacity() != sz)
		{
			cout << "capacity changed :" << a.capacity() << endl;
			sz = a.capacity();
		}
	}
	return 0;
}

8 vector的嵌套结构

  两层的vector其实实现的是一个表装结构,外层vector容器其容器内元素类型是一个一个动态增容的顺序表vector<value_type>,内层的vector<value_type>容器就是一个元素类型是value_tpye的动态增容的顺序表。

  其余多层嵌套的结构是类似的。

二、vector的模拟实现

1 vector的主要架构

  vectorstring不同,它是一个可以指定元素类型的模板类,元素类型可以是普通类型也可以是自定义类型,并不像string一样拘泥于字符类型,因此我们要使用template技术来模拟vector,首先我们先参考STLvector的源码来看看vector的具体架构.

  VS中的vector源码可以通过直接头文件转到定义看,不过VS写的有点乱,所以我们如果要阅读STL源码一般不参考它。

  一般建议参考STL3.0版本的源码,因为它和侯捷老师的那本书的版本是契合的。

  我们可以看到框架是一个开始位置的指针start,一个finish指向结束位置后一个位置的指针,一个end_of_storage是指向空间结束的位置的指针,我们暂时不考虑空间分配器的问题,我们当前使用new申请内存。

  这与我们使用一个指针加上sizecapacity是一样的,因为我们可以通过指针的运算互相转化。

  因此我们这里采用和它一样的架构,即_start指向起始地址的指针,_finish是指向当前数组结尾位置的后一个位置的指针,end_of_storage是指向空间结尾位置的指针,并且注意到指针就是迭代器,为了隔离std中的vector,我们使用一个命名空间与其隔离,因此我们的架构如下:

namespace lyq
{
	template<class T>
	class vector
	{
	public:
		typedef T* iterator;
		typedef const T* const_iterator;
	private:
		iterator _start;
		iterator _finish;
		iterator _end_of_storage;
	};
}

2 默认构造函数

  空的vector中不含任何对象,也不用像string那样设计成含有一个'\0',因此我们可以直接给它赋空指针。

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

3 容器大小和容器容量

  正如我们在架构里头画的那样,我们可以利用指针相减来获得size和capacity。

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

4 增容的实现

reserve

  增容的逻辑与string中类似,如果申请的容量n大于当前capacity,那么就先用tmp接受一个new出来的n块空间的指针,然后如果_start本来指向一块地方,即_start != nullptr,就先把原来的空间拷贝过来,然后释放_start的空间,然后_start等于tmp,对应修改_finish_end_of_storage即可。

  需要注意的是,这里不要用memcpy这种字节序拷贝,因为如果T的成员中有一个数据成员是指向一块空间的地址,那么使用memcpy又会造成浅拷贝的问题,我们这里可以调用operator=,这样只要你自己的对象的operator=实现了深拷贝,我也就实现了深拷贝。

  另外要注意的点是当_start = tmp后,后续的size()capacity()由于实现的问题已经会不能得出正确的结果,所以要么先把size()存起来,要么需要先处理_finish = tmp + size();后再处理_start = tmp;.

void reserve(int n)
{
    if (n > capacity())
    {
        iterator tmp = new T[n];
		size_t sz = size();
        if (_start != nullptr)
        {
            for (int i = 0; i < sz; ++i)
			{
                tmp[i] = _start[i];
			}
			delete[] _start;
        }
		_start = tmp;
        _finish = tmp + sz;
		_end_of_storage = tmp + n;
    }
}

resize

void resize(size_t n, const T& x = T());
// 虽然匿名对象的声明周期只有这一行 但是当我们加了const&它的声明周期就会延长到引用变量销毁时销毁。
// VS要求你的类型要有自己写的构造函数 这是个BUG

  这里的实现与string中的resize类似,都是考虑三种情况:size() < n;size() < n < capacity(); n > capacity()

  第一种情况,直接把finish移动到_start + n的位置即可;

  第二种情况,我们把_start + size()_start + n都置成那个x即可,注意这里我们用移动_finish来完成,这样同时可以把size()给改变了。

  第三种情况,我们需要扩容至n,然后把_start + size()_start + n的位置都置成x即可。

  那个缺省值是使用了一个匿名对象的技术,这样各种类型就可以使用它们的默认构造函数生成缺省值(C++对内置类型也有自己的默认构造函数,即a = int();是合法的,整形的缺省值是0,浮点型的缺省值是0.0,指针类型的缺省值是nullptr)。

void resize(size_t n, const T& x = T())
{
    size_t sz = size();
    size_t cp = capacity();
    if (n < sz)
    {
        _finish = _start + n;
	}
    else if (n < cp)
    {
        while (_finish != _start + n)
		{
            *_finish = x;
			++_finish;
        }
	}
	else if (n > cp)
    {
        reserve(n);
		while (_finish != _start + n)
        {
            *_finish = x;
			++_finish;
        }
	}
}

5 提供遍历有关函数

  仿照string的设计,提供const版本和普通版本即可,二者构成重载,这样对const对象和普通对象都能遍历且普通对象能通过[]修改值了。

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

  为了能够支持范围for,我们提供begin()end()迭代器。

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

6 容器的尾插

push_back

  我们只要在_finish位置插入一个元素,然后_finish往后移动一下即可,注意若_finish == _end_of_storage则需要扩容。

  由于我们使用的是模板,所以对于T类型,它可能很大,我们为了减少拷贝可以传其const引用。

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

  现在就可以做一个小规模测试一下了:

void test_vector1()
{
    vector<int> a;
    for (int i = 1; i <= 10; ++i)
	{
        a.push_back(i);
	}
	int sz = a.size();
    for (int i = 0; i < sz; ++i)
	{
        cout << a[i] << ' ';
	}
	cout << endl;
    a.resize(15);
	for (auto p : a)
    {
		cout << p << ' ';
    }
}

7 析构函数和拷贝构造函数和赋值运算符重载

析构函数

~vector()
{
    if (_start != nullptr)
    {
        delete[] _start;
    }
    _start = _finish = _end_of_capacity = nullptr;
}

拷贝构造函数

  这里不要用memmove,因为T类型中可能涉及深浅拷贝的问题,所以用operator=让T类型的赋值运算符重载自己解决这个问题。

  现代写法的核心思想就是“找我已经写过的东西来替我办事“,string中我们可以利用构造函数完成了拷贝构造的现代写法,但是在vector中,我们目前没有有参数的构造函数,所以我们先实现这个构造函数:

  因此现代写法可以复用这个迭代器区间的拷贝构造:

赋值运算符重载

  利用传值会调用拷贝构造函数,就可以复用拷贝构造函数完成赋值运算符重载。

  同理,这三个swap也可以封装到一起。

  进而简化拷贝构造和赋值:

  我们迭代器区间构造的模板函数的名字是有意义的,其意义和迭代器分类有关系。

  迭代器分类(与容器类型有关):

  • input_iterator无实际类型。
  • output_iterator无实际类型。
  • forward_iterator单向迭代器:forward_list单链表,unordered_map哈希图,unordered_set哈希集合,只支持++
  • bidirectional_iterator双向迭代器:list,map,set,支持++,–。
  • randomaccess_iterator随机迭代器:dequevector,支持++,–,还支持+, -.

  这里头是有继承关系的,之间有类似“权限”的限制,如果模板库的某个函数中需要随机迭代器,你传一个双向迭代器虽然在语法上因为都是可以的,但是里头可能要用+-,就会出错。

  拷贝构造和赋值运算符重载这里模板参数<T>都可以不写。

8 容器的尾删

  注意检查容器非空即可,提供一个empty,另外模仿STL的风格,直接提供暴力的assert检查容器非空即可。

9 根据迭代器插入insert与扩容导致的迭代器失效

  在pos位置之前插入一个x.

  但当我们容器的元素个数是4个,这调用insert插入20却插入了一个随机值,这就是一种迭代器失效。

  这里的分析比较简单,就是4个元素在插入的时候会扩容,然后旧空间会被释放,但是pos指向的是原来的位置,而那个位置已经扩容时delete[]还给了系统了,导致pos位置失效了,出现了野指针的访问问题。

  解决的思路就是既然扩容会导致pos失效,那么就在扩容之前记录好pos_start的相对距离len,扩容完后更新pos = _start + len.

  但此时仍可能存在迭代器失效的问题,因为我们是传值的pos,你尽管在函数体重修改了pos使其有效,但是回到调用的地方pos的值并不会改变,仍然使得原本的迭代器变成一个野指针了,这仍然是一种迭代器失效。

  STL中是通过返回新的迭代器来预防这种情况。

  这里不能通过加引用来修改迭代器,因为这个引用必然是要修改的值,如果我们传v1.begin()这种临时对象就会失效,如果用const&来弥补,就不能修改这个值,解决不了问题。

  所以最终的代码如下:

10 根据迭代器删除erase与意义改变导致的迭代器失效

  基础逻辑还是比较简单,就是直接往前移动,到_finish为止,然后容器大小减小1,挪动_finish

  不过它也存在迭代器失效的问题,假如我们要求删除所有的偶数:

1 2 3 4 5 正常

1 2 3 4 崩溃

1 2 4 5 出现错误

  VS下上面三个例子都会崩溃,因为它们会被断言检查出来,g++下上面三个例子出现的情况都一样。VS对erase后的迭代器进行了强制检查,当你erase迭代器后,vs不允许你再次访问被删除位置的迭代器。

  情况1和情况2的分析如下:

  其次,某些版本实现的erase在删除后,会使容器缩容,给予新的地址,此时it会失效称为野指针,和insert中类似。

  不过一般SGIHP版本都没有这么做。

  接着分析1 2 3 4崩溃的原因。

  无限往前走就崩了。

  本质原因就是erase后it的意义变了,查看cppreference也是通过返回值来解决这个问题:

  erase返回刚刚删除的元素的下一个元素的迭代器

  所以官方的写法应该是这样的。

11 迭代器失效总讨论

  迭代器失效一般都发生在inserterase,因为这两个接口会修改迭代器并修改底层的数据结构。

  string的inserterase会不会导致迭代器失效呢?

  结论:只要使用迭代器访问的容器都可能涉及迭代器失效,所以string也会发生迭代器失效,并且它失效的情况与vector的失效完全类似,不过string的插入删除主要使用的是下标而非迭代器,所以体现的很少。

12 深拷贝时不要使用memcpy

void reserve(size_t n)
{
    size_t sz = size();
    size_t cap = capacity();
    if (n > cap)
    {
        T* tmp = new T[n];
        if (_start != nullptr)
        {
            /*for (int i = 0; i < sz; ++i)
            {
                tmp[i] = _start[i];
             }*/
             memcpy(tmp, _start, sizeof(T) * sz);
             delete[] _start;
        }
        _start = tmp;
        _finish = tmp + sz;
        _end_of_capacity = tmp + n;
    }
}

  比如如果vector中是string对象,那么就会开除一块tmp指向的空间,把_start指向的空间中string的每个字节拷贝了过来,这样就使得tmp和_start都指向了同一块空间,然后delete[] _start就寄了。

  解决这里浅拷贝的问题,如果tmp的空间是malloc出来的,还未调用构造函数,我们可以使用replacement new来调用拷贝构造函数,不过tmp的空间是new出来的,所以我们只好调用T类的赋值运算符重载了,它会帮我们完成深拷贝。

void reserve(size_t n)
{
    size_t sz = size();
    size_t cap = capacity();
    if (n > cap)
    {
        T* tmp = new T[n];
        if (_start != nullptr)
        {
            for (size i = 0; i < sz; ++i)
            {
                tmp[i] = _start[i];
            }
             delete[] _start;
        }
        _start = tmp;
        _finish = tmp + sz;
        _end_of_capacity = tmp + n;
    }
}

  由于VS下短小的字符串会有栈上的缓冲区保存,所以使用memcpy浅拷贝有时候并不会导致崩溃,但是这仅仅是VS下的特性,不能一概而论。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值