1. 前言
List也是STL容器的一种,是C++提供的链表结构的容器。C++中所提供的list是双向带头循环链表,我们这篇文章通过自己模拟实现来学习list的使用。
为了避免和库中的命名冲突,也为了封装的考虑,我们将我们的list放入一个命名空间之中以和外界隔离。
namespace m_list //为自己实现的list定义命名空间
{//代码
}
2. 链表的结点
因为链表的各个元素是由一个个结点串起来的,所以我们首先需要定义出结点。
//结点template <class T>struct ListNode //使用类模板来定义结点的信息{//双向链表,节点内存前驱指针和后继指针,以及值ListNode<T>* _prev;ListNode<T>* _next;T _val;//构造函数,使用初始化列表初始化ListNode(const T& val = T()) //参数注意使用const引用,因为实参可能是常性的:_prev(nullptr),_next(nullptr),_val(val){}};
对于链表节点的定义,有几点需要解释:
①我们采用泛型编程的思想,使用类模板来定义结点的结构体以保证满足各种数据与类型的结点。
②由于我们的链表是双向带头循环链表,所以结点的成员变量应该包括前驱指针、后继指针和结点的值。
③构造函数缺省值为我们已经使用了多次的T(),且为了保证变量和常量值均可被传递,需要使用const引用修饰参数。
3. 链表
3.1 成员变量
同样是为了满足泛型编程的需求采取类模板定义链表类。链表类管理着链表,所以其成员变量中存储着链表的头结点的指针,类型就是结点类模板实例化的指针。另外一个参数则是表示链表的size。
//链表template <class T>class list{private:typedef ListNode<T> Node; //使用结点时需要对结构体模板参数实例化,因此使用typedef简化代码Node* _head; //链表的头size_t _size; //链表的规模};
3.2 构造函数
3.2.1 无参构造
无参构造为我们给出了最基本的构造要求,由于是带头链表,所以应该在构造函数中将链表初始化完成,也就是创建好头结点并且指针指向对应的位置。
//无参构造//双向带头链表,所以需要先new一个头结点,并且前后指针都指向自己list():_head(new Node), _size(0){_head->_prev = _head;_head->_next = _head;}
3.2.2 初始化列表构造
初始化列表在vector的文章中接触过,这里不再解释。初始化列表构造也需要完成无参构造中的操作,创建出头结点,然后再进行元素的逐个插入。所以此处会用到尾插函数以及迭代器,这部分会在后续介绍。
list(std::initializer_list<T> il):_head(new Node), _size(0){_head->_prev = _head;_head->_next = _head;for (auto& e : il){push_back(e);}}
3.2.3 拷贝构造
拷贝构造也需要通过同样的方式创建出头结点。然后将元素进行尾插,此处则是需要尾插函数,由于传参考虑到const和非const的链表对象,因此使用const修饰的链表作为参数,因此遍历这个链表取出数据就需要const迭代器。
//拷贝构造//拷贝构造初始化出头结点,然后将结点逐个插入list(const list<T>& lt):_head(new Node),_size(0){_head->_prev = _head;_head->_next = _head;for (auto& e : lt){push_back(e);}}
3.3 析构函数(以及clear函数)
clear函数通过迭代器的遍历将链表结点进行意义释放删除。析构函数z则是复用clear函数,将链表结点全部释放后,再释放头结点,完成析构操作。
//析构函数//析构需要释放所有的结点,可以通过逐个结点删除的方式来复用函数,完成释放~list(){clear();delete _head;_head = nullptr;}void clear(){iterator it = begin();//使用迭代器对链表结点进行删除时,注意无需it++while (it != end()){it = erase(it);}}
3.4 赋值运算符重载(以及swap函数)
赋值运算符函数的实现我们已经很熟悉了,就是传值传参产生一次拷贝,生成临时对象,然后交换二者,即可得到“借尸还魂”的效果。swap函数则是将待交换对象之间的成员变量值进行交换即可。
void swap(list<T>& lt){std::swap(_head, lt._head);std::swap(_size, lt._size);}//赋值运算符重载list<T> operator=(list<T> lt){swap(lt);return *this;}
3.5 链表属性函数
主要使用到size和empty函数,实现十分简单。
size_t size(){return _size;}bool empty(){return _size == 0;}
3.6 插入删除函数
3.6.1 插入函数
3.6.1.1 随机位置插入insert
我们主要使用下图中的第一种重载形式,insert函数在pos位置前插入val的值。
插入数据的逻辑就是通过迭代器变量pos找到对应的位置,然后创建新的结点,然后进行链接就好了。
//插入//对于list而言,不存在扩容的行为,所以list的插入不会导致迭代器失效//insert在pos位置前插入val,为满足临时变量的插入需要使用const引用void insert(iterator pos, const T& val){Node* next = pos._node;Node* prev = next->_prev;Node* newnode = new Node(val);prev->_next = newnode;newnode->_prev = prev;newnode->_next = next;next->_prev = newnode;++_size;}
3.6.1.2 尾部插入
复用insert函数。
void push_back(const T& val)
{insert(end(), val);
}
3.6.1.3 头部插入
复用insert函数。
void push_back(const T& val)
{insert(end(), val);
}
3.6.1.4 迭代器失效(否)
和vector不同,由于链表的特殊结构,所以在插入值的过程中并不会发生让结点地址产生改变的情况,所以list的插入不会让迭代器失效。
3.6.2 删除数据
3.6.2.1 随机位置删除
erase删除pos位置处的值,并且返回新的迭代器变量,因为迭代器存在失效的可能。
删除pos位置的值,首先是通过pos找到对应的结点,然后将链表链接起来后释放pos位置的结点。在这里细致梳理一下(没什么强调的,就是梳理一下流程),释放对应结点时,由于结点是一个自定义的结构体类型,所以此时会调用结点结构体的析构函数,由于我们没有写,所以编译器默认生成,对于内置类型(如_prev、_next和内置类型实例化的value)不做处理,而对于自定义类型(自定义类型实例化的val)则会调用其析构函数。
在最后返回更新后迭代器的值,由于迭代器类的构造函数是单参数,所以支持隐式类型转换。
//删除//erase由于释放结点,所以有迭代器失效的可能性,所以删除操作需要返回迭代器提供更新iterator erase(iterator pos){Node* cur = pos._node;Node* prev = cur->_prev;Node* next = cur->_next;prev->_next = next;next->_prev = prev;delete cur;//cur是ListNode<T>类型,delete时会调用对应的析构函数//由于ListNode<T>的成员变量都是内置类型,所以无需手动释放资源,使用编译器生成的析构函数即可--_size;//return next; //也可以,单参数构造函数支持隐式类型转换return iterator(next);}
3.6.2.2 尾部删除
复用erase函数。
iterator pop_back(){erase(--end());}
3.6.2.3 头部删除
复用erase函数。
iterator pop_front(){erase(begin());}
3.6.2.4 迭代器失效
因为删除节点会使得原空间被释放,所以存在迭代器失效的可能,迭代器失效的原因和vector一样,由于存在删除,后续的元素会向前补齐,迭代器指向的内容发生变化,所以在删除造作后,记得使用返回值更新迭代器。
4. 迭代器
迭代器可谓是list中最值得学习的部分了,我们将通过list的迭代器实现明白如何包装一个迭代器让其符合使用逻辑,以及如何通过类模板的方式提高代码复用率。
迭代器最重要的一点就是不论内部实现和底层如何花里胡哨,最后使用的方式一定是一致的。
4.1 迭代器类
对于链表而言,由于其物理存储空间不连续,所以迭代器不可以使用原生的指针,因为自增等行为有很大不同。因此需要我们自己根据需要实现迭代器,因此封装一个迭代器类,来支持链表的特性。
那么我们首先要明确这个迭代器需要哪些参数。
首先,因为实例化的参数不同,所以迭代器也必然不同,因此第一个模板参数应该可以表示迭代器之下的数据的数据类型。
其次,对于迭代器,为了支持const和非const的迭代器,同时为了代码复用,所以在迭代器模板中增加模板参数,来选择const和非const,因此我们就需要考虑清楚const和非const的迭代器之间存在哪些不同。是否是const的区别只在于能否对值进行修改,所以返回值是与存储的元素直接关联的函数,即两个解引用操作。因此考虑他们的返回值不同,对模板参数增加两个,分别代表*解引用和->解引用的返回值。
4.1.1 迭代器模板与成员变量
因为*解引用操作返回的是元素的引用,->解引用操作返回的是元素的指针,所以使用T表示真正索要存储数据的数据类型,增加Ref表示其const或非const的引用,Ptr表示其const或非const的指针。
为了可以很方便地进行链表遍历,所以使用结点指针的方式作为迭代器,这样方便进行遍历,同时是一个指针所以节省空间。
//迭代器
//对于链表而言,由于其物理存储空间不连续,所以迭代器不可以使用原生的指针,因为自增等行为有很大不同
//因此需要我们自己根据需要实现迭代器,因此封装一个迭代器类,来支持链表的特性
//对于迭代器,为了支持const和非const的迭代器,同时为了代码复用,所以在迭代器模板中增加模板参数,来选择const和非const
//由于const版本迭代器只在解引用的时候有不同,所以增加两个模板参数对解引用的返回值进行控制
template <class T, class Ref, class Ptr>
struct ListIterator
{//类比学习,迭代器的作用相当于元素的替身。//在string中,string是由一个个字符组成的,迭代器变量表示字符串中的字符,通过解引用操作即可访问其值//在vector中,vector是由一个个元素组成的,迭代器变量表示数组中的元素,通过解引用操作可以访问该元素//同理,对于list是由一个个结点组成的,迭代器应该表示链表的结点,因此采用结点指针的方式定义迭代器typedef ListNode<T> Node;Node* _node;};
4.1.2 构造函数
对于iterator迭代器,其存在一定是指向某个结点的,所以使用带一个参数的构造函数为结点指针成员赋值,即迭代器此时就是这个结点的高层次的代替。
//构造函数ListIterator(Node* node):_node(node){}
4.1.3 解引用
4.1.3.1 *操作符重载
再次提及这句话:迭代器最重要的一点就是不论内部实现和底层如何花里胡哨,最后使用的方式一定是一致的。所以回忆曾经的迭代器使用,当对一个迭代器遍历it进行*it操作后,表达式结果就是迭代器指向元素的值的引用,可以进行访问修改。
对于链表也不例外,我们索要返回的正是这样一个迭代器所指向元素的引用。对于this指针,其成员变量_node就是对应元素所在结点的指针,所以返回_node->_val,就相当于返回了引用,达到目的。
至于返回值,由Ref模板参数决定,当为T类型的const时,Ref就会是const T,否则就是T。所以返回值类型为Ref&即可。
//解引用//*it//迭代器追求的是不考虑组织形式,采取一致的格式进行遍历//因此,在list中,要保证*it可以像其他容器一样拿到对应的数据,所以*it应当返回T//对于*it,非const对象拿到的是非const返回值,const对象拿到的是const返回值Ref& operator*(){return _node->_val;}
4.1.3.2 ->操作符重载
为什么需要->操作符呢,在我们的印象中似乎*解引用已经足够了。那么这时候就需要考虑当T被实例化为一个结构体,我们此时拿到了迭代器变量it,我们知道*it是取得其元素,那么对于结构体我们想一步到位访问结构体对象中的成员,就会用到it->a类似的情景。
void Test2(){A a{ 'a',{1,1} };list<A> lt1;lt1.push_back(a);lt1.push_back({ 'b', { 2,2 } });list<A>::iterator it1 = lt1.begin();while (it1 != lt1.end()){std::cout << it1->c1 << ' ';++it1;}std::cout << std::endl;it1 = lt1.begin();std::cout << it1->a[0] << ' ' << it1->a[1] << std::endl;++it1;std::cout << it1->a[0] << ' ' << it1->a[1] << std::endl;}
为了应付这种情况所以出现了->重载。->重载函数的返回值是this指针的_node->_val的指针,返回地址后再->即可找到需要的成员。仔细思考,发现似乎少了一个->操作符,因为一个->是迭代器操作符重载,返回了自定义类型(实例化T)的指针,应该再来一个->指向T的成员。但是这样写会让语法变得奇怪,不易读,所以规定省略一个->来保证可读性。所以实质上,原本的it->->a,可以写作it->a,实际上是it.operator->()->a。
返回类型Ptr和Ref一样,根据需求传递const T*或者T*即可。
4.1.4 自增自减与判断
需要强调的是为了区分前置++/--和后置++/--,会给后置操作符一个int参数。
//对于list,迭代器自增自减的行为即是找到next和prev结点//前置++ListIterator<T, Ref, Ptr>& operator++(){_node = _node->_next;return *this;}//后置++ListIterator<T, Ref, Ptr> operator++(int){ListIterator<T, Ref, Ptr> tmp(_node);_node = _node->_next;return tmp;}//前置--ListIterator<T, Ref, Ptr>& operator--(){_node = _node->_prev;return *this;}//后置--ListIterator<T, Ref, Ptr> operator--(int){ListIterator<T> tmp(_node);_node = _node->_prev;return tmp;}//==和!=bool operator==(const ListIterator<T, Ref, Ptr>& it){return _node == it._node;}bool operator!=(const ListIterator<T, Ref, Ptr>& it){return _node != it._node;}
4.2 迭代器在list类中
在解决了迭代器形式和操作的问题后就好办了,在list类中实现const和非const的begin和end函数即可。
模板参数已经做过介绍,iterator的构造函数是单参数的结点指针,所以支持单参数隐式类型转换。
//迭代器//对于*it,非const对象拿到的是非const返回值,const对象拿到的是const返回值//对于it->,非const对象拿到的是非const指针,const对象拿到的是const指针typedef ListIterator<T, T&, T*> iterator;typedef ListIterator<T, const T&, const T*> const_iterator;//begin和end返回值都是迭代器类的对象,单参数构造函数所以支持隐式类型转换//非constiterator begin(){return _head->_next;}iterator end(){return _head;}//constconst_iterator begin() const{return _head->_next;}const_iterator end() const{return _head;}
4.3 迭代器总结
为了实现迭代器正常功能所以将迭代器用类模板进行了封装,又为了const考虑增加两个模板参数来控制。因为模板和结构体相互交错,所以此处对于->和*给出两个分析示例,帮助理清逻辑:
void Test3(){list<A> lt1;lt1.push_back({ 'a',{1,1} });list<A>::iterator it1 = lt1.begin();it1->c1;//it1实际的类型是ListIterator<A,A&,A*>,成员是ListNode<A>的结构体指针,这个结构体是链表的结点,包含成员前序、后继指针和值//it1的构造函数将ListNode<A>*类型的参数赋值给it1下的成员(链表结点指针),使用的是lt1调用的begin,返回值是ListIterator<A,A&,A*>类型,其中的结点是头结点的下一个结点//ListIterator<A,A&,A*>类型的参数通过隐式类型转换传参给构造函数,赋值给it1对象//it1->c1调用了Ptr operator->()函数,Ptr被实例化为了A*,返回了_node->_val的地址,也就是返回了lt1链表中的值的地址,即结构体{ 'a',{1,1} }的地址//省略了一个->操作符,找到了c1成员,实际上是it1.operator->()->c1list<int> lt2;lt2.push_back(1);list<int>::iterator it2 = lt2.begin();*it2;//it2实际的类型是ListIterator<int,int&,int*>,成员是ListNode<int>的结构体指针,这个结构体是链表的结点,包含成员前序、后继指针和值//it2的构造函数将ListNode<int>*类型的参数赋值给it1下的成员(链表结点指针),使用的是lt1调用的begin,返回值是ListIterator<int,int&,int*>类型,其中的结点是头结点的下一个结点//ListIterator<int,int&,int*>类型的参数通过隐式类型转换传参给构造函数,赋值给it2对象//*it2调用了Ref operator*()函数,Ref被实例化为了int&,返回了_node->_val的引用,也就是返回了lt2链表中的值的引用,即结点内容 1 的引用}