目录
- list介绍
- list结构介绍
- 节点类的实现
- 迭代器的实现
- 构造函数
- ++运算符重载
- --运算符重载
- ==运算符重载
- !=运算符重载
- *运算符重载
- ->运算符重载
- const迭代器的实现
- 多参数模板迭代器
- list函数接口总览
- 默认成员函数
- 构造函数1
- 构造函数2
- 构造函数3
- 析构函数
- 拷贝构造函数
- 赋值重载函数
- 迭代器
- begin()和end()
- 修改相关函数
- insert
- push_back和push_front
- erase
- pop_back和pop_front
- empty
- size
- swap
- clear
- 访问相关函数
- front和back
- 模板的拓展应用
list介绍
- list是可以在常数范围内在任意位置进行插入和删除的序列式容器,并且该容器可以前后双向迭代。
- list的底层是双向链表结构,双向链表中每个元素存储在互不相关的独立节点中,在节点中通过指针指向
其前一个元素和后一个元素。- list与forward_list非常相似:最主要的不同在于forward_list是单链表,只能朝前迭代,已让其更简单高
效。- 与其他的序列式容器相比(array,vector,deque),list通常在任意位置进行插入、移除元素的执行效率
更好。- 与其他序列式容器相比,list和forward_list最大的缺陷是不支持任意位置的随机访问,比如:要访问list
的第6个元素,必须从已知的位置(比如头部或者尾部)迭代到该位置,在这段位置上迭代需要线性的时间
开销;list还需要一些额外的空间,以保存每个节点的相关联信息(对于存储类型较小元素的大list来说这
可能是一个重要的因素)
list学习参考文档
list的底层是带头双向循环链表,在学习数据结构时也使用C语言对带头双向循环链表进行过模拟实现——
双向链表的模拟实现;得益于其优秀的结构设计,在数据的插入,删除方面有着极高的效率。
但在STL中的list更让人叫绝的是其迭代器是设计。
- 迭代器的主要作用就是让算法能够不用关心底层数据结构,对外提供一个统一的访问方式,即使不了解容器的底层是什么,也能通过迭代器对容器进行访问。
- 迭代器其底层实际就是一个指针,或者是对指针进行了封装,比如:vector的迭代器就是原生态指针T* ,而今天学习的list的迭代器则是一个封装过的类。
list结构介绍
list得益于其结构,实现增删等功能比前面实现的vector,string更加简单。list更值得我们学习的是其迭代器的的实现。而且迭代期也是我们访问list的主要手段,所以优先实现迭代器。
list的实现需要有三个类:节点类,迭代器类,list类。
节点类的实现
节点作为链表组成的基本单位,其成员有:
- 存储数据的_data
- 连接前一节点的前驱指针_prev
- 连接后一节点的后继指针_next
节点类只需要实现默认构造函数即可。负责将成员初始化。
template<class T>struct list_node{T _data;//数据list_node<T>* _prev;//前驱指针list_node<T>* _next;//后继指针list_node(const T& x = T()):_data(x),_prev(nullptr),_next(nullptr){}};
迭代器的实现
先实现非const版的迭代器;
迭代器的意义就是:
让使用者可以不必关心容器的底层实现,可以用简单统一的方式对容器内的数据进行访问。
而list的底层已不再是连续的空间,而是分散的节点,无法再使用[]加下标访问,也不能仅通过结点指针的自增、自减以及解引用等操作对相应结点的数据进行操作。究其原因就是vector,string的底层空间是连续的,其迭代器就是对应的指针,天然就支持++,–这样的操作。
list的结点指针的行为不满足迭代器定义,那么我们可以对这个结点指针进行封装,对结点指针的各种operator运算符操作进行重载,使其符合迭代器的行为。如:当你使用list当中的迭代器进行自增操作时,实际上执行了p = p->next语句,只是你不知道而已,我们通过类的封装屏蔽了其细节,符合迭代器方法的使用。
总结:
用一个类封装迭代器去模拟指针的行为,该类的成员函数为运算符重载函数(模拟指针行为)。
template<class T>struct __list_iterator//用一个类封装迭代器去模拟指针的行为,该类的成员函数为运算符重载函数(模拟指针行为){typedef list_node<T> Node;typedef __list_iterator<T> self;Node* _node;//节点__list_iterator(Node* node):_node(node){}self& operator++();self& operator--();self operator++(int);self operator--(int);bool operator==(const self& it);//it为迭代器,_node为节点bool operator!=(const self& it);T& operator*();T* operator->();};
该类中只有一个成员,那就是节点。类名太长了,使用typedef对 __list_const_iterator 重命名为self
构造函数
对于封装的迭代器类,我们只需要实现一个构造函数即可,只需要将获取的链表的节点用来构造迭代器中的节点即可。
__list_const_iterator(Node* node):_node(node){}
对于我们模拟实现的迭代器,需要明确我们的目的:我们实现的迭代器是为了帮我们去遍历,访问我们的链表。这也是为什么我们不需要实现拷贝构造和赋值重载函数进行深拷贝的原因,我们使用编译器默认生成的浅拷贝的拷贝构造和赋值重载函数就能达到我们的目的,使用深拷贝反而达不到我们但目的。
++运算符重载
对于前置++,使用原则是:先++,再使用;所以直接将当前节点往后走一位,再返回当前节点即可。引用返回效率更好。
self& operator++(){_node = _node->_next;return *this;}
后置++使用原则为:先使用,再++,所以需要返回++之前的值,所以先用一个临时对象保存该值,再让节点往后走,最后返回临时对象。不能引用返回,tmp为临时对象,函数结束就销毁,不能使用引用返回。
self operator++(int){Node* tmp(_node);_node = _node->_next;return tmp;}
–运算符重载
前置–:先–,再使用:让节点往前走一位,再返回。
self& operator--(){_node = _node->_prev;return *this;}
后置–:先使用,再–;逻辑与后置++一致。
self operator--(int){Node* tmp(_node);//_node = _node->_prev;return tmp;}
==运算符重载
重载节点与迭代期是否相等。分清_node为节点,而it为迭代期,类型不同;对比的是节点_node和迭代器的成员:节点_node。
bool operator==(const self& it)//it为迭代器,_node为节点{return _node == it._node;}
!=运算符重载
原理与上述==相同,只不过现在是!=。
bool operator!=(const self& it){return _node != it._node;}
*运算符重载
使用解引用操作符时,是想得到该位置的数据内容。因此,我们直接返回当前结点指针所存储数据即可,但是这里使用引用返回,因为解引用是支持对数据进行修改的。
T& operator*(){return _node->_data;//返回引用}
->运算符重载
在某些场景下,我们需要使用->来访问数据如指针,或对象类型为自定义类型时。
->操作符会先对指针进行解引用,然后访问其指向对象的成员。所以我们只需要返回数据的地址即可完成任务。
T* operator->(){return &_node->_data;//返回其地址}
注意:
对于->的使用:当数据的类型为自定义类型时,使用一个->就能取到成员对象实际是编译器优化的结果,完整的应为it1.operator->()->对象:先调用operator->()获取对象的地址,再使用->获取对象。
好了,以上就是非const版迭代器所需的重载运算符。那么const迭代器如何实现呢,在现有基础上加个const吗?
const迭代器的实现
如果你认为const迭代器是在非const迭代器上加const,那么我们先回顾一下const对指针的相关限制
int a = 10;int b = 20;const int* pa1 = &a;//对指针所指向的内容进行限制*pa1 = 30;//err,指向的内容不能变。pa1 = &b;//可以改变指针变量的指向。int* const pa2 = &a;//对指针变量进行限制*pa2 = 30;//所指向内容可以改变。pa2 = &b;//err,指针变量不能修改。
const迭代器的目的:
list的const迭代器是允许++,–这样的访问操作的,其需要保证的是const对象所指向的内容不能被修改,也就是不能通过运算符重载operator* ()和operator-> ()来改变数据。这么一看不就是类似const int* pa1 = &a;
在*pa1前加上const修饰。但是你别忘了,这些都是对原生指针的而言的,而list的迭代器是我们封装的一个类,无论你在哪里加const,都会导致迭代器被const修饰而无法修改,从而连最基本的访问也无法执行了。
所以list的const迭代器是不可能在非const迭代器变来的,它必须是一个重新的类。
const迭代器:
template<class T>struct __list_const_iterator//用一个类封装迭代器去模拟指针的行为,该类的成员函数为运算符重载函数(模拟指针行为){typedef list_node<T> Node;typedef __list_const_iterator<T> self;Node* _node;__list_const_iterator(Node* node):_node(node){}self& operator++();self& operator--();self operator++(int);self operator--(int);bool operator==(const self& it);//it为迭代器,_node为节点bool operator!=(const self& it);const T& operator*();const T* operator->();};
仔细一看两个迭代器,虽说是两个不同的类,但是内容高度重合,因为const迭代器不能通过运算符重载operator*()和operator->()来改变数据,所以我们只需要对这两个函数的返回值加const限制即可。逻辑也是一致的。
const T& operator*(){return _node->_data;//返回引用}const T* operator->(){return &_node->_data;//返回其地址}
多参数模板迭代器
对比const迭代器和非const迭代器,你会发现只有operator*()和operator->()处多了一个const,就因为这两个const而重写了一份一模一样的代码,这不是为了吃醋而包饺子了嘛。为了解决这种重复代码的问题,C++拿出了模板来应对。
const迭代器和非const迭代器的代码中有不同的只有两处:T&与const T&,T与const T;再加上原本的T,我们将模板参数设为三个。template<class T,class Ref,class Ptr>
,T为数据类型,Ref为T&或const T&,Ptr为T*或const T*
,再将operator*(),operator->()的类型更为Ref和Ptr即可在使用时根据所需要的迭代器类型来实例化所需的代码。
template<class T,class Ref,class Ptr>//用一个类封装迭代器去模拟指针的行为,该类的成员函数为运算符重载函数(模拟指针行为)
struct __list_iterator
{typedef list_node<T> Node;typedef __list_iterator<T,Ref,Ptr> self;Node* _node;__list_iterator(Node* node):_node(node){}self& operator++();self& operator--();self operator++(int);self operator--(int);bool operator==(const self& it);bool operator!=(const self& it);Ref operator*()const;//返回数据的引用Ptr operator->()const;//返回数据的地址};
在list类当中,只需要使用对应的迭代器,模板参数的Ref就会替换成const T&
或者T&
,Ptr就会替换为const T*
或者T*
,这样就能使用同一份类模板实例化出两种迭代器。
template<class T>class list{typedef list_node<T> Node;//构造时以下操作多,设为私有不公开void list_init()//开好头节点{_head = new Node;_head->_next = _head;_head->_prev = _head;}public:typedef __list_iterator<T,const T&,const T*> const_iterator;//使用那个迭代器就实例化哪一个迭代器typedef __list_iterator<T,T&,T*> iterator;};
好了,这就是迭代器的模拟实现,接下来进入list的模拟实现。
list函数接口总览
list类的结构十分简单,只有一个节点类;还记得我们list的底层是带头双向循环链表吗,所以这里的成员_head为头节点,不存储有效数据。
template<class T>class list{typedef list_node<T> Node;Node* _head;//头节点//构造时以下操作多,设为私有不公开void list_init()//开好头节点{_head = new Node;_head->_next = _head;_head->_prev = _head;}public:typedef __list_iterator<T,const T&,const T*> const_iterator;//使用那个迭代器就实例化哪一个迭代器typedef __list_iterator<T,T&,T*> iterator;//typedef __list_iterator<T> iterator;//typedef __list_const_iterator<T> const_iterator;list();list(initializer_list<T> il);template <class InputIterator>list(InputIterator first, InputIterator last);list(const list& x);list& operator= (const list& x);~list();iterator begin();iterator end();const_iterator begin()const;const_iterator end()const;iterator insert(iterator position, const T& val);void push_back(const T& val);void push_front(const T& val);iterator erase(iterator position);void pop_back();void pop_front();bool empty() const;size_t size() const;void swap(list& lt);T& front();const T& front() const;T& back();const T& back() const;};
- 注意,模拟实现的节点类,迭代器类,lsit类为了不和库的发生冲突,全都放在自己的命名空间里。
默认成员函数
我们实现了三个构造函数:
- 第一个为默认构造
- 第二个为迭代器区间构造
- 第三个为使用initializer_list来构造。
构造函数开头节点的操作都一致,所以将这份代码装一起,并设为私有。
//构造时以下操作多,设为私有不公开void list_init()//开好头节点{_head = new Node;_head->_next = _head;_head->_prev = _head;}
构造函数1
默认构造,开好头结点即可。
list(){list_init();//开好头节点}
构造函数2
使用迭代器区间构造,开好头节点后,将传入区间的元素一个一个push_back
进容器当中。
template <class InputIterator>list(InputIterator first, InputIterator last){list_init();while (first != last){push_back(*first);first++;}}
构造函数3
使用initializer_list来构造,这种方法是在C++11提出的,使用方法如下:
直接将所要用添加的元素放在花括号里。
list<int>lt6 = { 1,2,3,4,5,6 };list<int>lt7({ 1,2,3,4,5,6 });
这里的initializer_list是一个类模板,支持迭代器的使用。
initializer_list支持迭代器,所以使用范围for直接将initializer_list中的数据push_back
进容器
list(initializer_list<T> il){list_init();for (const auto& e : il){push_back(e);}}
析构函数
我们一共实现了三个类,但前两个都没有实现析构函数,那是因为前两个都没有实现深拷贝的拷贝构造和赋值重载函数;而且就像迭代器类,你总不能访问完就把我的节点给释放了吧。所以我们只需要在进行了深拷贝的list中写析构函数。
实现也简单,使用clear先将存储有效数据的节点都释放,最后再将头节点释放。
- clear是不清除头结点的。
~list(){clear();delete _head;_head->_next = _head->_prev = nullptr;}
拷贝构造函数
有了之前vector,string的经验,现在实现拷贝构造也应该信手拈来了:开好头节点,然后使用范围for直接将数据push_back
进容器即可。
list(const list<T>& lt){list_init();for (const auto& e : lt){push_back(e);}}
赋值重载函数
使用现代写法,利用不用引用传参的形参会在传参时调用拷贝构造,构造一个一模一样的list对象,然后调用swap函数将原容器与该list对象进行交换即可。
list& operator= (list<T> lt){swap(lt);return *this;}
迭代器
迭代器的使用区间为:左闭右开,所以开始位置应该是第一位有效数据——即头结点的下一位;而结尾则应该是不存储有效数据的头节点。
- 返回类型为迭代器,但返回的是节点,这里其实发生了隐式类型转化,编译器在返回时会用该节点构造一个迭代器。
- 也可以使用匿名构造,直接构造一个迭代器类型。
begin()和end()
iterator begin(){return _head->_next;//return iterator(_head->_next);}iterator end(){return _head;}
非const迭代器为const对象所调用,防止对容器内容进行修改
const_iterator begin()const{return _head->_next;}const_iterator end()const{return _head;}
修改相关函数
insert
使用迭代器在指定位置插入值val:首先使用val创建一个新的节点,紧接着让pos前后位置的前驱或后继指针建立新的双向关系。具体如下:
iterator insert(iterator position, const T& val){assert(position._node);//防止空指针Node* node = new Node(val);Node* pos = position._node;//position为迭代器类型,需要获其节点node->_prev = pos->_prev;node->_next = pos;pos->_prev->_next = node;pos->_prev = node;return node;}
- list中进行插入时是不会导致list的迭代器失效的
push_back和push_front
调用insert,在头或尾插入val;
void push_back(const T& val){insert(end(), val);}void push_front(const T& val){insert(begin(), val);}
erase
删除指定位置的数据:需要对传入的迭代器进行合法性审查,防止空指针,而且不能删除头节点;然后删除位置前后的节点重新建立双向关系;删除指定位置节点时记得先保存下一节点,用作返回。
iterator erase(iterator position){assert(position._node);//防止空指针assert(position != end());//不能释放头节点assert(!empty());//非空才能删Node* pos = position._node;//节点pos->_next->_prev = pos->_prev;pos->_prev->_next = pos->_next;iterator ret = pos->_next;delete pos;//new的时节点,所以delete节点return ret;}
erase会有迭代器失效问题,使用前记得更新获取迭代器位置。
pop_back和pop_front
同样调用erase完成任务,pop_front删除头节点的下一位,pop_back删除头节点的前一位——即最后一位。
- 尾删是删除存储有效数据的节点,end返回的是头节点。
void pop_back(){erase(--end());}void pop_front(){erase(begin());}
empty
检查链表是否为空:对于空的链表而言,只剩下一个头节点,此时_head的_prev和_next都指向_head,因此可以利用这一点来判断链表是否为空。
bool empty() const{return _head->_next == _head;}
size
获取容器中数据个数:用size来统计个数,再利用范围for遍历链表。
size_t size() const{size_t size = 0;for (auto e : *this){size++;}return size;}
swap
交换链表:交换两个链表的头节点即可。
void swap(list& lt){std::swap(_head, lt._head);}
clear
清空链表:除头节点外,清空剩余全部节点;借助erase,删除节点。
- erase会返回删除节点的下一个节点,所以不需要++,只需要接受其返回值即可。
void clear(){auto it = begin();while (it != end()){it = erase(it);//erase返回下一个节点}}
访问相关函数
front和back
访问一头一尾的数据,直接使用*迭代器的操作访问一头一尾的操作。
T& front(){return *begin();}T& back(){return *(--end());}
当然少不了const版,因为const对象调用front和back函数后所得到的数据不能被修改。
const T& front() const{return *begin();}const T& back() const{return *(--end());}
至此,list的模拟实现基本完成了。
模板的拓展应用
如果我们想要在类外实现一个list的打印函数:利用迭代器可以轻松完成。
void print_list(list<int>& lt){list<int>::iterator it = lt.begin();while (it != lt.end()){cout << *it << " ";it++;}cout << endl;}
但这样写只是针对list的类型为int,换做其他类型就不行了,所以我们可以套上模板,支持所有list的打印。
注意:
由于我们套上了模板,这样的话在编译时list的类型是不确定的,所以迭代器也不能确认,所以在list<T>::iterator it = lt.begin();
处会报错,无法通过编译。此时可以加上前缀typename
作为标记,让编译器先通过编译,等模板实例化后再取。
template<class T>void print_list(list<T>& lt){ //由于list<T>还未实例化,所以T的类型不确定,编译不通过。//此时在迭代器前加上typename,作为标记,让编译器编译成功,等实例化后再来确定迭代器具体类型typename list<T>::iterator it = lt.begin();while (it != lt.end()){cout << *it << " ";it++;}cout << endl;}
除此之外,还能实现一个针对所有容器都能打印的函数吗?
我们此时直接将具体的类作为模板参数,传入的是哪种容器就去取该容器的迭代器,从而进行打印。
template<class Container>//格局打开,直接将具体的类作为模板参数void print_containers(Container& con){typename Container::iterator it = con.begin();while (it != con.end()){cout << *it << " ";it++;}cout << endl;}
本篇篇幅有点长,主要对list
的迭代器进行介绍及模拟实现,对于不是原生指针的迭代器,其const的迭代器使用了多参数模板进行实现,一份代码实例化两种迭代器,这也是本篇博客的难点及精华,对于模板的学习,需要在日后的实践中不断学习。出错的地方还请指出。Thank~