文章目录
前言
注意本篇难度偏高,其主要体现在迭代器类的实现 !
什么,list类的迭代器还要单独封装成类!?
还真是,毕竟它的元素存储在物理意义上不是连续的
正文开始!
一、结点类的实现
但是首先我们得先来实现一下节点类,因为我们说list底层是个链表,其实更准确地说是双向链表
而实现一个结点类。而一个结点需要存储的信息有:数据、前一个结点的地址、后一个结点的地址,于是该结点类的成员变量也就出来了(数据、前驱指针、后继指针)
而对于该结点类的成员函数来说,我们只需实现一个构造函数 即可。因为该结点类只需要根据数据来构造一个结点即可,而结点的释放则由list的析构函数来完成
cpp
// List的节点类
// 直接设置为公开访问即可,后面很明显有访问成员变量的必要
template<class T>
struct ListNode
{
// 若构造结点时未传入数据,则默认以list容器所存储类型的默认构造函数所构造出来的值为传入数据
// 对内置类型和自定义类型都是如此
ListNode(const T& val = T())
: _prev(nullptr)
, _next(nullptr)
, _val(val)
{}
ListNode<T>* _prev;
ListNode<T>* _next;
T _val;
};
二、迭代器类的实现
来了来了
这是我们要实现的三个类,而迭代器类的实现基础就是我们刚才实现的节点类
你可能注意到我们是把迭代器类给公开的,数据公开意味着,内部数据可以被任意修改。但是在这里没人会去跳过封装,使用内部的数据,没有意义。因为不同编译器中底层实现是不一样的(实现逻辑、名称),这本身就是一种隐式设置为私有的作用
迭代器类的存在意义
之前模拟实现string和vector时都没有说要实现一个迭代器类,为什么实现list的时候就需要实现一个迭代器类了呢?其实就像前言说的,因为string和vector对象都将其数据存储在了一块连续的内存空间 ,我们通过指针进行自增、自减以及解引用等操作,就可以对相应位置的数据进行一系列操作,因此string和vector当中的迭代器就是原生指针,typedef一下就行
但是对于list来说,其各个结点在内存当中的位置是随机的,并不是连续的,我们不能仅通过结点指针的自增、自减以及解引用等操作对相应结点的数据进行操作,因为链表的各个元素只在逻辑上连续
而迭代器的意义就是,让使用者可以不必关心容器的底层实现,可以用简单统一的方式对容器内的数据进行访问,我们也想有个list迭代器,可以简单的++、- -、*,理解成本低,所以,封装节点指针,重载运算符就是我们要做的工作
比如说++,我们重载的时候,其实就是在内部让p = p -> _next;只是运用的时候没必要知道而已
迭代器类的模板参数
cpp
template<class T, class Ref, class Ptr>
你可能会感到诧异,为什么迭代器类会有三个模板参数?其实,迭代器分为两种,普通迭代器和const迭代器,我们在list类的模拟实现中会运用到这两个
cpp
typedef ListIterator<T, T&, T*> iterator;
typedef ListIterator<T, const T&, const T&> const_iterator;
这体现了一种封装智慧,因为两种迭代器的自增自减等等几乎没区别,区别就仅仅在于元素的访问和获取指向元素的指针这两个成员函数的返回值上(比如说元素的访问,一个是T&,一个是const T&),如果是仅仅因为这点小区别,我们就写两个类,那这代码就不算好,于是我们选择丢给编译器,让它实例化的时候帮我们自动生成,尽管这也是实现两个类,但是我们少写了一个
好风凭借力,请说谢谢编译器先生
还是那句话,没有什么岁月静好,都是有人在帮你负重前行
所以Ref和Ptr分别代表的其实是引用类型和指针类型
构造函数
迭代器类实际上就是对结点指针进行了封装,其成员变量就只有一个,那就是结点指针,其构造函数直接根据所给结点指针构造一个迭代器对象即可
cpp
ListIterator(Node* node = nullptr)
: _node(node)
{}
++运算符的重载
前置++原本的作用是将数据自增,然后返回自增后的数据。我们的目的是让结点指针的行为看起来更像普通指针,那么对于结点指针的前置++,我们就应该先让结点指针指向后一个结点,然后再返回"自增"后的结点指针即可
而对于后置++,我们则应该先记录当前结点指针的指向,然后让结点指针指向后一个结点,最后返回"自增"前的结点指针即可
cpp
Self& operator++()
{
_node = _node->_next;
return *this;
}
//有拷贝构造就需要考虑深浅拷贝的问题。
//这里需要使用到浅拷贝,指向同一块空间,并且不需要考虑重复析构的问题,也说明了浅拷贝并都是坏处。
//临时对象tmp同指向一块空间,调用完临时对象被销毁,指向空间资源保留
//这也导致了返回类型是指针还是引用
Self operator++(int)
{
Self temp(*this);
_node = _node->_next;
return temp;
}
typedef其实可以将一个较长的类型变短,这里的Self其实就是迭代器类自己
typedef ListIterator< class T, class Ref, class Ptr> Self;
--运算符的重载
对于前置- -,我们应该先让结点指针指向前一个结点,然后再返回"自减"后的结点指针即可
而对于后置- -,我们则应该先记录当前结点指针的指向,然后让结点指针指向前一个结点,最后返回"自减"前的结点指针即可
cpp
Self& operator--()
{
_node = _node->_prev;
return *this;
}
Self operator--(int)
{
Self temp(*this);
_node = _node->_prev;
return temp;
}
==、!=运算符的重载
当使用==、!=运算符比较两个迭代器时,我们实际上想知道的是这两个迭代器是否是同一个位置的迭代器,也就是说,我们判断这两个迭代器当中的结点指针的指向是否相同即可
cpp
// 迭代器间的比较并不是比较指向数据,而是比较迭代器指向的位置
bool operator!=(const Self& it) const
{
return _node == it._node;
}
bool operator==(const Self& it) const
{
return _node != it._node;
}
*运算符的重载
当我们使用解引用操作符时,是想得到该位置的数据内容。因此,我们直接返回当前结点指针所指结点的数据即可,但是这里需要使用引用返回,因为解引用后可能需要对数据进行修改
cpp
Ref operator*()
{
return _node->_val;
}
->运算符的重载
我们使用迭代器的时候可能会用到->运算符,一般发生在T也是自定义类型的时候
cpp
int main()
{
list<Date> lt;
Date d1(2021, 8, 10);
Date d2(1980, 4, 3);
Date d3(1931, 6, 29);
lt.push_back(d1);
lt.push_back(d2);
lt.push_back(d3);
list<Date>::iterator pos = lt.begin();
cout << pos->_year << endl; //输出第一个日期的年份,此时Date的成员变量为公有
return 0;
}
这个时候我们只需要返回节点所存储数据的指针即可:
cpp
Ptr operator->()
{
return &(operator*()); // 复用,毕竟*就是拿元素,&又是取地址符
}
可是你可能会感觉不对,按道理来说,这种情况不是应该两个->吗?怎么pos->_year就拿到年份这一变量了?
其实这还是编译器弄的鬼,因为假如一个地方出现两个箭头,程序的可读性太差了,所以编译器做了特殊识别处理,为了增加程序的可读性,省略了一个箭头
所以说pos->_year,其实本质上是pos->->_year ,更准确的说是pos.operator->()->_year
第一个箭头是pos ->去调用重载的operator->返回Date* 的指针,第二个箭头是Date* 的指针去访问对象当中的成员变量_year
那能不能自己显式写出两个->呢?你自己试试,pos->->_year不行;pos.operator->()->_year可以
总结
本篇暂时就先介绍两个类,剩下最后一个list类我们下篇再介绍
铠甲要合体了!