文章目录
- 前言
- 1. list的介绍及使用
- 1.1 list的介绍
- 1.2 list的使用
- 遍历
- 插入删除数据
- Operations
- 迭代器的功能分类
- list 的sort性能测试
- 2. list的模拟实现
- 2.1 STL_list源码浏览
- 2.2 基本结构实现
- 2.3 思考:list迭代器是否可以用原生指针
- 2.4 list迭代器的实现(重难点)
- list_iterator:结点指针的封装
- `*、前置++ 、!=`的重载
- begin、end
- 思考
- 其它运算符重载
- const迭代器
- 代码优化:增加一个模板参数
- ->的重载
- 第三个模板参数
- 2.5 插入删除操作
- insert
- push_back 和 push_front
- erase、pop_back和pop_front
- 2.6 clear和析构
- 2.7 迭代器区间构造和拷贝构造
- 2.8 赋值重载
前言
这篇文章我们来继续STL的学习,今天我们要学习的是list,也是STL中容器的一员。
和之前一样,我们还是先学习它的使用,然后再对它进行一个深度剖析和模拟实现。
1. list的介绍及使用
1.1 list的介绍
list的文档介绍
list的底层实现其实就是我们之前数据结构学过的带头双向循环链表:
1.2 list的使用
首先我们来学习一下list的使用:
那经过之前string和vector的学习,我们想要学会list的使用,成本就很低了,所以下面我们就带大家快速的过一下,我们的重点还是在于后面的模拟实现。
我们来看一下它的接口:
首先看一下构造:
也是我们熟悉的这几个,默认构造、n个val的构造、迭代器区间的构造以及拷贝构造。
然后看一下它的迭代器:
也是这几个,相信学到现在大家都很熟悉了。
修改操作:
这里面常用的几个接口我们也都比较熟悉。
但是我们看到list这里只有resize,没有reverse了,因为它是链表嘛,就没有扩容这一说了。
那剩余的比较重要的接口我们后面讲到再说。
但是我们会注意到:
list与string和vector最大的区别是啥?
我们会发现list没有重载[]
,也就是说我们要遍历和访问list,就只能用迭代器了(范围for的底层也是迭代器)。
所以说,迭代器才是通用的方式,所有的容器都可以用迭代器,而[]
只是针对特定容器的特殊方式。
遍历
那经过之前的学习,相信大家就可以直接上手使用list的迭代器了:
int main()
{
list<int> l;
l.push_back(1);
l.push_back(2);
l.push_back(3);
l.push_back(3);
l.push_back(5);
for (list<int>::iterator it = l.begin(); it != l.end(); ++it)
cout << *it << " ";
cout << endl;
for (auto e : l)
cout << e << " ";
cout << endl;
for (list<int>::reverse_iterator rit = l.rbegin(); rit != l.rend(); ++rit)
cout << *rit << " ";
cout << endl;
return 0;
}
插入删除数据
然后我们来演示一下insert和erase:
我们发现list也没有提供find,所以我们要获取某个位置的迭代器,可以用算法库里面的find:
也是这几个版本,就不给大家全部演示了。
然后erase:
但是注意list的迭代器是双向迭代器:
只能++或者- -,不能+或-:
Operations
然后来看下这几个接口:
首先这个splice
,用的不多,它可以把一个链表的一部分转移到另一个链表
大家需要的时候自己看一下文档就会用了。
然后remove
就是删除指定的元素,find+erase
:
后面还有个remove_if
,这个涉及仿函数,我们先不用管。
然后unique
就是去重:
merge
可以合并两个有序链表:
然后呢,list也提供了sort
:
就是可以对链表进行排序:
最后reverse就是对链表逆置,就不多说了。
那我们接下来思考一个问题:算法库里面不是已经有sort了吗,为什么链表自己还要提供一个sort?
最主要的原因是算法库里的排序list就用不了。
我们发现报了一堆错怎么,回事呢?
我们看到算法库的sort进行了什么,是不是-
啊?
但是我们上面说了,**list的迭代器是双向迭代器,是不能进行-
操作的。**当然除此之外里面其它操作list也不行。
迭代器的功能分类
所以呢:
虽然库里的sort是一个函数模板,理论而言这里可以传任意类型的参数,但是其内部对使用的迭代器有要求,参数的名字就暗示了我们要传随机迭代器。
当然不同容器对应的迭代器是什么类型跟它的底层结构有关系。
那我们之前文章也提到过:
迭代器我们之前讲的什么正向反向,const迭代器,这些是使用属性;那还有一个特性属性,迭代器严格来说还可以细分为单向迭代器,双向的和随机的,单向的就是只能++不能- -,双向就是可以++也可以- -,那随机就是除了可以++和- -之外还可以+或- 。
那这个单向,双向,随机大家可以认为是迭代器的功能分类:
1、单向迭代器:只能++,不能- -。例如forward_list和unordered_map
;
2、双向迭代器:既能++也能–。例如list
;
3、随机访问迭代器:能++ 和- -,也能+和-。例如vector和string
。
文档里面在Member types
我们能看到当前容器的迭代器类型。
🆗,那除了sort,算法库里的:
我们看到reverse参数名字是不是也有暗示,暗示我们要传双向迭代器。
那你传个单向可以吗?
是不是不行啊,因为双向既要支持++还要支持- -,而单向是不是只能++啊:
但是我们传随机可以吗?
是不是可以啊,因为随机是支持++和- -的。
🆗 ,那相信现在大家就明白为什么list要自己提供一个sort 了。
但是,想告诉大家的是:
list提供了一个sort其实是有点没必要的,大多数情况下我们都不会用list的排序。
为什么,因为效率太慢了。
list 的sort性能测试
现在我这里已经有一段写好的代码用来测试vector和list排序的性能,具体实现大家可以不用关心,看一下结果就行了。
现在产生100000个随机数,分别放到vector和list中,然后list调自己提供的sort,vector调库里面的sort,我们来对比一下它们的运行时间:
多运行几次:
我们看到vector是更快一些在它们排一组相同的数据下。
那我们加到1000000个数据再来看:
然后我们再换一种玩法:
我们现在定义两个list,还是上面那些数据,一个list我直接排,另一个怎么做呢?
我先把它拷贝到一个vector里面去排序,然后排完再拷贝回list,我们来看一下:
🆗,我们看到还是比直接在list里排快很多。
所以说:
虽然list提供了sort,但是我们大多数情况下是不会选择用它的。
🆗:
那list的使用我们差不多就讲到这里,大家用到哪些接口如果没有讲到可以自行查阅文档。
2. list的模拟实现
那接下来我们就来对list进行一个深度剖析和模拟实现,那首先我们还是先来简单的浏览一下STL中list的源码:
2.1 STL_list源码浏览
首先我们可以看到:
它里面有三个模板类:
第一个类是结点;
第二个是迭代器;
最后一个就是链表对应的类模板。
但是我们发现结点和迭代器的类他都是用struct定义的,那用struct就说明他想把类里面的所有成员都对外开放出去,因为struct的默认访问权限为public。
那我们来看一下list类的成员变量:
🆗,我们会发现它只有一个成员变量link_type node
,那它的类型link_type
是啥呢?
我们找一下:
我们看到link_type
其实就是结点的指针。
然后我们在看什么呢?
🆗,是不是可以看一下它的构造啊,看明白构造,我们就可以知道它的一个初始状态是怎么样的,然后我们就可以再去看一下它的一些核心的接口,当然对于链表来说无非也就是头插头删、尾插尾删这些,那这样我们对它就差不多了解一个七七八八了。
那构造函数:
我们看到它里面又调了另一个函数empty_initialize
,就是空初始化的意思嘛。
那empty_initialize
干了什么呢?
我们看到就是创建了一个结点,然后让他的next和prev都指向自己,什么意思呢?
那如果大家看过我之前数据结构的文章,学过里面的带头双向循环链表的话,一看就明白了。
其实就是创建了一个哨兵位的头结点嘛。
然后再来看:
头插push_front
和尾插push_back
,那头插就是在begin的位置插入一个元素,尾插就是在end的位置插入一个元素。
那对于list来说,begin和end一个在哪呢?
🆗,那剩下的接口我们就可以不用看了,本身我们之前也学过,已经比较了解它的结构了,后面有需要的地方我们再来看。
那接下来我们就可以开始模拟实现了。
2.2 基本结构实现
那首先我们写一下结点的结构:
namespace yin
{
template <class T>
struct list_node
{
list_node<T>* _next;
list_node<T>* _prev;
T _data;
//构造
list_node(const T& x)
:_next(nullptr)
, _prev(nullptr)
, _data(x)
{}
};
}
这里还是放到我们自己的命名空间里。
然后写一下list的结构,迭代器我们先放到后面在搞:
然后先来给一个默认构造:
然后我们先写一个push_back:
尾插要怎么写,想一下?
很简单,首先拿要插入的数据创建一个新结点,找尾然后改变指针指向链接就行了嘛
void push_back(const T& x)
{
node* newnode = new node(x);
//找尾
node* tail = _head->_prev;
//链接
_head->_prev = newnode;
newnode->_next = _head;
tail->_next = newnode;
newnode->_prev = tail;
}
测试一下:
🆗,我们的链表就成形了。
2.3 思考:list迭代器是否可以用原生指针
那接下来我们来搞一下list的迭代器:
大家回想一下,我们之前模拟实现string和vector的时候,它们的迭代器我们是不是都使用了原生指针去实现啊,因为使用指针完全是可行的嘛。
那现在list的迭代器我们还可以使用指针来搞吗?
🆗,string和vector的迭代器之所以能使用原生指针去实现,最主要的原因是不是因为它们底层的物理空间是连续的啊,那连续的话,用指针是不是很方便啊,++就往后走正好访问到下一个元素。
那list呢?list的迭代器用原生指针实现可行吗?或者说用原生指针实现有没有什么问题呢?
🆗,list里面是一个一个的结点,如果我们用结点的指针node*
的话,首先它解引用是啥?
是不是对应的结点啊,但是我们访问链表,取的应该是每个结点里面data域的数据吧。
其次,结点的指针++,得到的是下一个结点的指针吗?
是不是大概率不是啊,因为每个结点的空间都不一定是连续的啊。
那怎么办呢?我们可以来看一下库里面怎么实现的:
那其实刚才开始我们在浏览源码的时候也提到了,库里面是不是把迭代器也实现成一个类模板了,那这个类模板是啥呢?
我们发现迭代器这个类模板其实就是对结点的指针进行了一个封装。
但是,迭代器要能++找到下一个结点位置的迭代器,还要能够解引用取到结点里面的值等等一系列操作,怎么办?
我们看到,它在类模板里面对++,–,解引用这些操作进行了重载。
++怎么实现的?
就是让node走到下一个结点。
解引用呢?
就是去取结点里data域的数据。
这样是不是就满足迭代器的需求了。
2.4 list迭代器的实现(重难点)
list_iterator:结点指针的封装
那接下来我们就来实现一下list的迭代器:
就是对结点的指针进行一个封装:
*、前置++ 、!=
的重载
那我们先来重载一下迭代器的++,*和!=,这三个重载完就可以使用迭代器遍历了
首先解引用就是返回当前结点的data:
然后++(先写一下前置),就是让结点的指针走到下一个结点,前置++返回++之后的值。就是他自己嘛
当然这个类型比较长,我们可以typedef一下:
然后!=:
begin、end
然后我们在list里面加一个begin和end就可以用迭代器了:
那begin就是返回第一个元素位置(即头结点后面)的迭代器,end就是返回最后一个元素的下一个位置(即头结点位置)的迭代器:
那现在我们就可以用迭代器遍历list了:
🆗,是不是就可以了。
当然范围for也就支持了:
那学到这个地方,我们其实可以得到一个结论:
迭代器要么就是原生指针,要么就是自定义类型对原生指针进行封装,模拟指针的行为
思考
然后,大家来思考一下问题:
大家看,这个地方把begin的返回值赋值给it,发生了什么?
🆗,这个地方是不是会调用迭代器__list_iterator
这个类的拷贝构造啊。
但是,我们自己并没有实现拷贝构造,所以这个地方回调用默认生成的拷贝构造。
但是默认生成的是浅拷贝,那这个地方浅拷贝有问题吗?
是不是没问题啊,这个地方是不是就应该是浅拷贝啊。begin返回的迭代器里面有一个结点的指针,指向list的第一个元素,然后把它拷贝给it,it里面的结点指针也指向第一个元素,这样后面++是不是才能正确找到后续的元素啊。
所以这个地方就应该是浅拷贝。
那再来思考:
为什么这个地方浅拷贝但是没有报错呢?
浅拷贝的话这里两个对象不是指向同一块空间了,我们之前遇到这种情况不是都报错了嘛,为什么这里没事呢?
🆗,我们之前浅拷贝造成程序崩溃时因为什么,是不是最后对同一块空间析构了两次,所以才会崩。
但是这里我们的迭代器__list_iterator
类我们是不是自己都没写析构函数啊。
那需要写吗?
是不是不需要啊,因为它不需要去释放里面指针指向的结点的空间。
那为什么不需要释放啊?
🆗,它里面虽然有结点的指针,但是它指向的结点属于谁,是不是属于list啊,那结点的释放应该是谁的事情?
是不是list的析构应该干的事情啊。
这里的结点本身就不是你迭代器创建的,也不需要你去释放,你这里拿到结点的指针,只是帮助我去访问和修改链表的。
其它运算符重载
那我们再来重载一下后置++:
后置++和前置++的重载怎么区分,还记得吗?
前置++和后置++都是一元运算符,为了让前置++与后置++形成能正确重载。C++规定:后置++重载时多增加一个int类型的参数,但调用函数时该参数不用传递(它的作用就是为了构成重载),编译器自动传递。(- -也是如此)
那后置++和前置++的区别就是返回值不一样,后置++是先使用,后++,返回++之前的值。
再来实现一个前置- -和后置- -:
很简单,++向后走,–向前走
再来个==:
那就实现的差不多了。
const迭代器
假如现在我们要写一个打印链表的函数:
然后我们调用该函数:
发现报错了,为什么,是不是又是权限放大的问题啊?
怎么解决?
🆗,我们是不是要实现const迭代器,提供const版本的begin和end啊。
那在我们的list中,我们可以怎么实现const迭代器呢?
我们现在已经有了一个普通迭代器的类
__list_iterator
,那我们可以再实现一个const迭代器的类__list_const_iterator
:
和普通的迭代器一样,我也可以进行++ - -等操作,唯一的区别就是你只能访问我指向的数据,而不能修改它。
然后,我们list类里面就可以实现const版本的begin和end了:
那这样的话,我们的print_list
函数里面:
这个地方换成const_iterator就行了。
这下就可以了。
当然const_iterator就不能修改了:
代码优化:增加一个模板参数
但是:
这样写的话:
我们看到这两个类除了名字不同之外,唯一的区别就是operator*
的返回值类型不同,一个返回引用,一个返回const引用。
那这样是不是太冗余了呀,那我们能不能想想办法,只写一个类,就搞定这两种情况呢,其实就是控制一下这里operator*
的返回值,const对象调用就返回const引用,普通对象调用就返回引用。
那可以怎么做呢?
🆗,我们可以这样做:
我们不是要控制operator*
的返回值不同情况下不一样(T&或const T&
)嘛
现在有一个参数T我们是可以拿到T&的,但是我们现在故意增加一个模板参数ref(reference——引用)。
然后我们再做这样一件事情,在list类模板里面,我们就不再像原来那样搞了,这样:
普通迭代器就传引用,const迭代器就传const引用。
是不是可以啊。
这种写法是不是感觉很牛逼啊,而且是不是就很好的避免了我们上面那样写造成的代码冗余的问题。
那其实库里面就是这样写的:
->的重载
但是呢:
我们看到
库里面有三个模板参数,还有一个Ptr
,这个又是干什么的呢?
那我们会发现库里面对于迭代器除了重载*
还重载了->
那为什么还要重载->呢,什么场景下会用到呢?
🆗,我们看这种场景:
现在有一个自定义类型AA,我们定义一个list变量l,让他里面存AA类型的数据。
然后我们使用迭代器打印一下:
我们发现报错了,怎么回事?
🆗,我们这里对it进行解引用并打印,但是*it得到的是什么,是不是当前it对应的结点data域里面的数据,是什么?
是不是一个AA类型的变量啊,但是它是自定义类型,并且我们没有重载流插入,所以这里打印不成。
那如何解决呢?
首先我们可能会想到对AA这个类重载流插入,这当然是一个办法。
但是它是struct定义的,所以它的成员默认是公有的,所以我们可以不重载,这样也可以:
那此时list里面放AA类型数据的话,我们的迭代器是不是就相当于是对AA*
这个类型的结构体指针(或者说类指针)的封装啊,模拟结构体指针的行为。
但是正常情况下,我们拿到一个结构体指针或类对象的指针去访问它的成员,会先解引用,再通过.
去访问吗?
是不是可以直接用->
啊。
所以:
基于这样的原因,我们的迭代器也需要重载一下
->
。
那怎么实现呢?
🆗,去调operator*
,operator*
返回的是啥?
是结点的data域中的数据,然后再取它的地址返回。这样如果是自定义类型数据是不是返回的就是原生的类对象的指针啊,那就可以用->
了。
我们来实现一下:
是不是就这样啊。
就可以用->了。
但是,如果我们仔细观察一下的话,会发现好像有点不对啊,哪里不对呢?
大家看it->_a1
,这样写对吗?
it->
是不是it去调用operator->
这个函数了,那它的返回值是啥?
是不是返回了结点的data域中放的类对象的地址(指针),那我们用这个地址去访问成员变量是不是可以再用->去访问,所以这里正常是不是应该这样写:it->->a1
,等同于it.operator->()->a1
。
没毛病啊,就是这样。
所以呢:
这个地方本来应该是两个->,但是为了增强代码可读性,省略了一个->,大家也可以认为这个地方进行了一个特殊处理。
就像前面我们讲过的前置++后置++重载的区分那种情况。
第三个模板参数
那现在回到我们上面的那个问题:
为什么还有第三个模板参数Ptr
?
再看我们重载的->:
现在它的返回值是T*
,但是如果是const对象调用的话,是不是应该返回const T*
啊,所以呢?
和operator*
,我们增加一个模板参数来控制不同情况下返回不同类型的返回值。
这样const对象也可以使用->了:
反向迭代器我们学到后面一点再讲。
2.5 插入删除操作
那接下来我们来实现一下insert和erase:
insert和erase实现好,头插头删、尾插尾删就可以直接复用了。
那这些东西呢也很简单,没什么新东西,都是我们数据结构阶段玩过的,所以这个部分的重点其实就是迭代器的实现,大家要好好看一看。
insert
那我们先来搞一下insert:
创建新结点链接就行了。
测试 一下:
没问题。
那大家来思考一个问题,list的insert会导致迭代器失效吗?
🆗,是不会的。
list的底层结构为带头结点的双向循环链表,在list中进行插入操作是不会导致list的迭代器失效的。
之前学到vector进行插入会导致迭代器失效是因为vector插入数据可能会扩容,扩容之后原来的迭代器就指向一块被释放的空间了,而且就算没有扩容,由于插入元素要挪动数据,那插入之后pos位置的就不在是原来的数据了。但是对于链表来说不存在这些问题。
插入前后,pos始终指向同一个结点,不会发生改变,因此在list中进行插入操作是不会导致list的迭代器失效的。
push_back 和 push_front
那实现了insert,push_back 和 push_front就可以直接复用了:
试一下:
没问题。
erase、pop_back和pop_front
再来实现一下erase:
然后pop_back和pop_front直接复用:
来测试一下:
那大家思考一下,erase会导致迭代器失效吗?
是不是会啊!
进行erase这些删除操作之后,当前迭代器指向的结点都被释放了,那它肯定失效了。
那失效了我们还想继续用怎么办?
那erase正常情况下是有返回值的:
返回指向被删除元素后面元素的迭代器,如果被删除的是最后一个元素,则返回的是end()。
那我们想继续用的话,接收一下返回值就行了。
2.6 clear和析构
那接下来我们写一下析构:
不过写析构之前我们可以先写一下clear,然后析构可以复用一下clear。
那clear是啥?
是不是清空list里面所有的元素啊,当然头结点不能清除。
来写一下:
这样是不是就行了啊,直接复用erase,但是erase会导致迭代器失效,所以我们接收一下返回值。
来试一下:
除此之外呢,这个地方还可以这样写:
大家看这样写可以吗?
🆗,这样也是可以的,我们看后置++是怎么实现的:
上来先++,迭代器已经更新到下一个位置了,然后这里返回的是++之前的迭代器的拷贝,所以不会导致迭代器失效,这样也是可以的。
但是这样写肯定是错误的:
那然后我们来写析构:
那析构就是释放所有资源,包括头结点。
就完成了。
2.7 迭代器区间构造和拷贝构造
我们再来实现一下迭代器区间的构造:
很简单,我们vector就写过嘛,来写一下,
但是现在这样写有没有什么问题?
🆗,直接尾插的话是不是的有头结点啊。
那我们可以学一下库里:
搞一个这个函数empty_initialize
(空初始化):
这样在好多地方都可以直接复用。
测试一下:
没问题。
不过呢,顺便提一下
在这个地方有的老铁可能会提出这样的疑惑,就是这里
empty_initialize
是非const成员函数,那要是定义const对象是不是调不了啊?
🆗,肯定是可以调的,要是这样认为的话,那我们的构造函数也都是非const的,那我们是不是就定义不了const对象了啊。
那为什么可以呢?
补充一个小知识点:
问大家一个问题,const变量在定义的时候有const属性吗?
是没有的,否则它还怎么初始化呢?
111行这句代码可以通过吗,它可以通过那110行就也没问题。
所以大家也可以认为这是一个特殊处理,const变量在定义的时候是不具有const属性的,定义完成之后才有。
所以是可以调的,没问题。
然后来搞一下拷贝构造:
这样是不是就可以啊。
试一下:
当然也可以用现代写法:
但是呢?
有问题啊,怎么回事?
🆗,是不是忘了初始化了,就直接跟tmp交换了。
好了。
2.8 赋值重载
再来搞一个赋值重载:
那这个用现代写法也很easy:
两句代码就搞定了。
测试一下:
注意:
参数不能传引用,传引用的话就会把给它赋值的对象给改变了。
🆗,那我们关于list的讲解就到这里了,欢迎大家指正!!!