简述
在前面我们看了vector的实现之后相信对容器有了一定的认识。容器即为存放物件之所,它代表着一块空间。想要直观的了解一个容器,那么看懂他的空间分配策略是一个非常有效的入手方式。接下来我们就来看看STL中的list又是如何实现的吧。
list的结构
list就是我们常说的链表,说到链表相信大家就很熟悉了。非连续空间、通过指针来连接每一个小空间、插入和删除都是O(1)操作,元素访问效率较低等等。。。
list采用的结构是双端环形链表,用下面一幅图来形象的表达
咳咳,虽然图是丑了点,确也是可以直观的看出它的结构的。图中红色的方框就代表着一块空间,在这块空间中存放了三个东西,第一个东西是下一块空间所在的位置,也就是next指针所指向的位置。第二个是上一块空间所在的位置。第三个就是存放的数据呐。其中有一个特殊的是有一块 head 的区域,有人会想这块区域显然就是标识我大链表的头部了。其实它既是链表头,也是链表尾。看一下下面对链表的遍历操作你很块就能理解了1
2
3
4for (auto i = head->next; i != head; ++it)
{
//...
}
list的空间配置
list的空间配置呢相对vector就要复杂一点点,vector的空间是一整块一整块分配的,当这一块空间不足够存放我的数据的时候,就重新分配一块*2大小的空间,再将原来的数据搬过来。说了这么多就是想找个对比,没有对比就没有伤害嘛
list是一小块一小块的节点空间,那么每次新增一个节点的时候就要分配一块空间供我们使用。分配多大呢?看一下list的节点的结构就知道那
1 | struct _List_node_base |
大家看到这个节点的结构的时候有没有感觉到有一点点奇怪,为什么有两个结构体?
在SGI的STL实现中呢,将list的节点分为了指针域和数据域。为什么要这么划分,当然是有它的好处的。
我们对list的操作中更多的是对节点进行遍历,而访问数据成员总是在我们找到了某个节点的时候。那我们在遍历操作中只存放节点的指针域,而不存放数据是不是很大的节省了空间呢,特别是对于c++来说,这个数据域大多都是我们自定义的类,而一个类所占用的空间可能会很大,不像指针,在32位下一个指针才4bytes
看懂了节点的结构之后,那么它的空间分配也就显而易见了,即每次分配sizeof(_List_node<_Tp>) 大小的空间即可
1 | template<typename _Tp, typename _Alloc> |
代码还是比较简单的,_List_base类主要就负责head节点的初始化工作。需要注意的就是传递给分配器的模板参数是 **_List_node<_Tp>,该类型作为simple_alloc的_Tp参数
list的元素访问
明白了list的内存布局之后,我们在看看list的迭代器,看看迭代器是如何实现对其进行元素访问的。
话不多说,先上代码,从源码中见分晓1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27struct _List_iterator_base
{
_List_node_base* _m_node;
_List_iterator_base(_List_node_base *_x) :_m_node(_x){}
_List_iterator_base():_m_node(nullptr){}
void _M_incr()
{
_m_node = _m_node->_m_next;
}
void _M_decr()
{
_m_node = _m_node->_m_prev;
}
bool operator==(const _List_iterator_base &_x)const
{
return _m_node == _x._m_node;
}
bool operator!=(const _List_iterator_base &_x)const
{
return _m_node != _x._m_node;
}
};
看到第一行的_m_node相信大家应该明白了之前所说的为什么将节点的指针域和数据域分开的原因了吧,在迭代器中只需要指针域,因为迭代器的工作就是访问节点,而不是数据
iteartor的基类做的工作很简单
- 初始化当前节点
- 提供访问当前节点的相邻节点的接口
- 提供节点比较的方法
有了基类提供的接口之后,迭代器的实现也就很简单了
1 | template<typename _Tp, typename _Ref, typename _Ptr> |
在前面定义了一些类型的别名,iterator真正提供的接口就是访问下一个节点(operator++), 访问前一个节点(operator–)以及访问当前节点的数据(operator*)
前期的准备工作都做好了,可以开始看list的具体实现了
list的具体实现
1 | template<typename _Tp, typename _Alloc = alloc > |
这些都是一些别名的定义以及对基类成员和函数的引用,基类的代码在上面有贴出,忘记了的同学可以翻上去看一下
构造函数
1 | list() : _Base(){} |
别看构造函数好像都与insert函数有关,然后跑去看insert,结果被其吓倒了,其实构造函数无非就做了两件事
- 通过基类初始化head节点
- 将传递进来的数据插入到list中
只是我们提供的构造数据的方式多种多样,就显得比较负责而已,等到后面我们在来了解它到底是如何进行花式插入的
成员访问函数
1 | iterator begin() |
这些统一的接口相信大家一看就懂了。我还是在这里多说几句。reverse_iterator是反向迭代器,意思就是将迭代器的访问顺序反着来,本来我们是从头访问到尾,而它正好相反,这个将在后面来实现,在size()中的distance()函数大家可以这样理解1
2for(auto it = begin(); it != end(); ++it)
++result;
它只是为了提供一个统一的接口供不同的迭代器调用。对于list这样的不能随机访问的迭代器来说就要通过遍历来知道它的size有多大,而对于像vector这样的随机访问迭代器来说,直接end()-begin()就知道了它的size,它的作用就是统一接口,针对不同的迭代器采取不同的策略
insert
insert是list中比较重要的一部分了,list就是为了插入删除操作而生的。我们来好好了解一下到底是如何进行花式insert的吧
1 | // 在pos处插入值x |
这个很关键,还是画一幅图好好理解一下,因为后面的插入操作无非也就是在pos出插入多个值而已,咳,又到了展现画工的时候了,还真是激动。
好了,这就是初始状态,为了方便,就不画成环形了,大家知道就好
接下来分两步走
1.改变tmp指针域的指向
2.改变pos指针域的指向
红色代表被删除了,蓝色代表新加入的。这样新节点就成功加入到了pos前
1 | iterator insert(iterator _pos) |
可以看到,剩下的插入操作都是调用的第一个接口。
erase
1 | iterator erase(iterator _pos) |
删除操作看代码应该很好理解,将pos的前一个节点的next指针指向pos的下一个节点,pos的后一个节点的prev指针指向pos的前一个节点
push && pop
1 | void push_front(const_reference _x) |
有了insert和erase做接口,push和pop直接调用即可