经过对list的使用的学习,我们模拟STL库中的list,实现自己的list
目录
1.观察库中的链表的实现
与观察库中的vector相同,list依然是只观察最主要的部分即可: stl_list.h
(需要代码压缩包的可以评论区留言或者私信我)
首先一上来就是一个结构体模版 , 此处库中将void_pointer(也就是指向自己的指针)设计成一个void* , 所以在之后的使用中必定涉及到强转来使用,我们更建议直接设计成T*
然后是一个超长的和iterator有关的结构体模版的定义:
(此处没有截图完整,在压缩包中48~94行)
由此我们可以推测,双向迭代器是一个类封装的,不再是vector中简单的原生指针。
最后来到链表,通过观察我们可发现,主要成员变量就是一个node:
而node的类型是一个节点的指针
初始化:无参构造就是实现一个哨兵位,哨兵位的next和prev都指向自己
get_node:通过内存池(为了效率)或得空间
其它插入方法:
push_back 以及 insert
都是将原来的元素往后顺移一个,将希望放入的元素放在position的位置
我们还能观察出,position被当作一个结构体一样,通过.去访问对应的元素。
在学习C语言时期使用的单链表需要单独考虑为空的时候的操作,但是当我们使用带头双向循环链表时就能发现,只有头结点的时候这样操作也是没有问题的。
2.list的实现
2.1 基本结构
在C语言的实现中,链表中的每一个元素都是通过结构体包含的,我们此处也使用struct而非class,具体原因我们之后再解释。
节点:
由头尾指针,一个数据构成。
链表:
ListNode<T>这个名字太长了,我们将其typedef一下(库中也是这么实现的)。
类成员只需要一个哨兵位节点的指针,也就是_head即可。
因为通过一个哨兵位的节点就能找到所有的节点
2.2 基本接口
对于现在的list, 编译器自动生成的构造函数就是生成一个指针_head,
但是list的默认构造一定是需要开出一个确切的空间的,否则_head指向的是什么位置呢?
需要我们自己实现默认构造
因此,默认构造就是生成一个哨兵位。调用此函数一定是该链表刚开始被创建的时候,我们应当让此时在堆上开出的哨兵位的next和prev都指向自己
list() {
_head = new Node();
_head->_next = _head;//_head是Node类型的变量,我们此处访问他的成员变量
_head->_prev = _head;//如果Node(也就是ListNode<T>)是一个class
} //成员变量默认就会变成私有,会被拒绝访问
new Node()相当于new了一个ListNode<T> , 也就是调用了ListNode<T> 的 默认构造
复习如何push_back:
先找到当前的tail , 记录此节点的prev , 然后改变tail的next的指针指向,指向新加入的元素。
void push_back(const T& x) {
Node* tmp = new Node(x);
Node* pretail = _head->_prev;
pretail->_next = tmp;
tmp->_next = _head;
tmp->_prev = pretail;
_head->_prev = tmp;
为什么用struct而不是class:
如果我们之前的节点用的是class,写这样一段代码之后在主程序运行会报错(并且不实例化就不会报错,在运行之前编译器不会发现这个错误),原因如下:
1. 模版不会被编译。只有在实例化以后才会编译模版类(因此不会提前报错)
并且不是所有的成员函数都会实例化,调用哪个才实例化哪个(按需实例化),因此其他没有被使用的函数可能会有语法错误,但是不会被发现。只有当使用这个有错误的语法后,错误才会被报出
2. ListNode没有构造函数
因为我们push_back中写了一个new Node(x);
而此时我们没有写这样的传参构造。
★★★我们此时不写push_back是不会报错的,再来梳理一下我们的构造:
只写上图中43行的默认构造的代码是没问题的,我们执行list<int> li1;时,直接调用list()构造,也就是我们刚刚自己写的将_head的头和尾都指向自己的函数
在new Node()的过程中,因为结构体模版是可以实现自动生成默认构造的,所以我们直接不传参即可。
在new出来但是执行26、27行代码之前,_next和_prev都是走的编译器默认生成的结构体中的构造函数,作为指针被赋值为0x00000000000000000000
然后我们执行26 27行代码,将开出来的Node的地址赋给_next和_prev,完成哨兵位节点的创建。list的初始化是和Node的初始化分不开的,两者相互联系。
注意:new Node()和new Node是不一样的,后者不会调用默认构造函数,只是开空间
3.访问权限问题
原因在于class下默认的都是private,是不能被访问的。
假如我们用class来实现节点:
然后我们又在list的默认构造和push_back下访问了_next和_prev,所以建议用struct,否则:
解决如下:
template<typename T>
struct ListNode {
ListNode<T>* _next;
ListNode<T>* _prev;
T data;
ListNode(const T& x = T())
:_next(nullptr),//一个新创建的节点的next和prev不需要指向任何地方,赋给nullptr即可
_prev(nullptr),
_data(x)
{}
};
或者class加上修饰限定符public也可以:
当一个类需要全部被访问,不希望有private修饰的成员时,cpp多直接用struct
关于哨兵位的初始化:不能用0,因为不确定T到底是int还是string或者vector等。。。。
建议使用匿名构造:
或者全缺省( 反正我们在ListNode<T>里面是加了参数T()的 )
想要观察push_back或者做测试,就需要实现迭代器。
3.迭代器(本篇重点)
3.1 迭代器的构建
之前都是使用的原生指针T*作为iterator
因为其物理空间都是连续的,所以可以直接用指针来完成迭代器的工作。
而我们刚才由源码中观察得到,链表的指针是封装过的自定义类型。
由上一文得,因为我们的iterator是需要能够++和--的,倘若iterator是一个Node* 的话,
原生指针的++--都是加减一个类型的大小,Node*的++--只能跨过一个相同大小的空间去到相邻的一个应该不存在的Node
但是,Node*是有办法找到下一个节点或者上一个节点的,如果我们能让这个iterator的++是去移动到(*iterator)._next对应的那个元素,--是移动到_prev对应的那个元素就好了。
因此,我们的做法是:
自己封装一个iterator,然后重载运算符
为了避免混淆名字,别直接取iterator————因为每一种数据结构都包含iterator
封装Node* , 因为其作为一个类,是可以重载++ 和 --的,这样就达到了我们的目的
然后再在list类内部用typedef规范我们的迭代器即可(将ListIterator的名字改成所有容器统一的iterator)。
typedef ListIterator iterator ;
实现iterator:
先实现框架:
template<typename T>
class ListIterator {//此处是用class还是struct呢?
typedef ListNode<T> Node;
typedef ListIterator<T> _self;//这里是模仿库中的写法,将自己的名字缩短一点
Node* _pnode;
public:
ListIterator(Node* pnode) //之后在list中就可以通过iterator it(li1.begin())等构造
:_pnode(pnode)// iterator it = li1.begin()也可以,这是隐式类型转换
{}
operator++
operator--
};
再来实现++(要在参数处加int才是后置加加):
_self& operator++() {
_pnode = _pnode->_next;
return *this;
}
this是指向ListIterator的指针,我们让iterator指向了下一个节点,并且传引用返回了它自己
小练习:
_self operator++() {
return _self(_pnode->_next);//测试一下能不能跑,并阐述理由
}
答案是不会编译报错,也能运行,但是这样并没有起到++的作用,而是+1的作用。
这样实现的话,必须要再用this对应的iterator接受这个返回值才行。
同理实现-- 以及 解引用访问该iterator对应的节点:
_self& operator--() {
_pnode = _pnode->_prev;
return *this;
}
T& operator*() {
return _pnode->_data;
}
使用迭代器访问各种元素时,经常用到!=判断是否走到了末尾:
bool operator!=(const _self& it) {
return this->_pnode != it._pnode;
}
除了前置++--,还有后置的:
让pnode先往下走一个,再通过prev指向的位置构造一个新的。
3.2 联系list
再回到list中:
begin()需要指向的是head->next
end()需要指向的是最后一个节点的下一个位置,所以就是头结点
iterator begin() {
return iterator(_head->_next);
}
iterator end() {
return iterator(_head);
}
同时,在调用测试时:
因此还需要把 ListIterator中的几个函数变为公有。
(!=在重载的实现过程中需要访问_pnode ,如果按照class实现,就需要改用public修饰,不如直接将iterator写成一个struct)
如果此时只有一个头结点也不要紧,it表示的是begin->next,是自己,end也是自己,就不会进入该循环,逻辑是自洽的
并且,由于for循环就是自动去找iteraotr和begin()与end()三个名字,我们也能使用循环for
我们不能直接改变内置类型的行为,但是可以通过封装的方式来将该内置类型封装成想要的样子。
理解清楚这张图:
this是一个指向self(也就是ListIterator<T>)的指针 ,return *this就相当于把这个iterator传回去了
3.3 ListIterator的完善
3.3.1 不需要写析构函数
Node说到底其实是链表的东西,所以在封装的iterator中不能直接给他释放了
3.3.2 拷贝构造和赋值运算符
都使用浅拷贝即可,系统自动生成的就是浅拷贝
因为我们的类成员只有一个指针,在对iterator使用=时就是希望能够让两个iterator指向同样的Node 。
3.3.3 operator-> (双箭头变单箭头)
就像刚才观察的position一样,除了使用*解引用来获得iterator指向的节点的_data , 还需要能够通过->来访问
先实现一个箭头访问:
(注意,_node的类型是Node* , 箭头优先级也是高于取地址的)
假设我们的T是一个控制坐标的自定义类型Pos
我们再对自己的链表进行测试:
it解引用之后是一个Pos,但是Pos没法直接被留提取<<给输出,因此报错。
我们当然可以“投降”,一个一个的访问元素:
但是我们更应该直接通过iterator去访问pos的成员变量:
关于箭头的理解,此处比较麻烦:
首先,对于(*it)._row 是比较好理解的。 我们重载的*返回的是一个T的引用,这个引用的类再通过.来访问自己的成员_row
其次,对于it->_row 就有点麻烦了。->作为我们重载的一个单目运算符,it->返回的是_data的地址,也就是一个T* , 相当于就变成了 T*_row , 按照我们之前的学习,这样是不能访问的。应当是两个箭头:(指针通过箭头访问内部成员)
显式调用的话应该是:
但是为了代码的可读性,编译器不支持这样使用,而是选择省略了一个箭头。
两个箭头的意义是不一样的,第一个是运算符重载的调用,第二个是原生指针访问内部成员。
(日常的迭代器用operator->的机会不多,之后在学习图(map)的时候会比较多)
特殊现象:
关于为什么begin()的返回值可以++,也就是
++li1.begin();
虽然返回值具有常性,但是编译器会对内部函数的返回值特殊处理。有常性,但不是只有常性。这样的返回值任然可以调用那些非const的成员函数
我们稍加总结:
匿名对象的const和临时对象的const是不一样的,虽然不能直接在返回值给引用,但是可以调用非静态的成员函数
4.const迭代器
在使用场景中,一定有使用const_iterator的时候,比如传引用(传引用时都建议加上const),我们都建议传一个const修饰的链表,该链表的迭代器就需要是const_iterator
不能直接在list<int> :: iterator 前面加const ,
const list<int>::iterator it2 = li1.begin();//错误样例
这样写的话,it2就不能++和改变自己的值了(而不是 不能改变自己对应的值)
这样的iterator是不能修改的,而我们希望的是有一个指向的内容不能修改但是自己是可以修改的const_iterator。
那么到底是什么内容在控制iterator是否能被修改呢?
核心模块:
iterator对应的值是否能修改,就看operator* 返回的是T& 还是 const T&;
operator->返回的是T*还是const T*
因此我们可以实现两个类,一个是刚刚的ListIterator ,另一个是我们再实现一个ListConstIterator
改一改名字,修改一下两个核心模块的函数返回值即可。
template<typename T>
class ListConstIterator {
typedef ListNode<T> Node;//与class list中的命名保持一致性
typedef ListConstIterator<T> _self;
Node* _pnode;
public:
ListConstIterator(Node* pnode)
:_pnode(pnode)
{}
//_self operator++() {
// return _self(_pnode->_next);//测试一下能不能跑
// }
_self& operator++() {
_pnode = _pnode->_next;
return *this;
}
_self operator++(int) {
_pnode = _pnode->_next;
return _self(_pnode->_prev);
}
_self& operator--() {
_pnode = _pnode->_prev;
return *this;
}
_self operator--(int) {
_pnode = _pnode->_prev;
return _self(_pnode->_next);
}
const T& operator*() {
return _pnode->_data;
}
const T* operator->() {
return &_pnode->_data;
}
bool operator!=(const _self& it) {
return this->_pnode != it._pnode;
}
};
这样我们就能达到目的了。
别忘了再去写cons修饰的list对应的begin和end,这样返回的begin()和end()才都是不会被修改的。
注意此处的lt也必须是const list<int> lt ;(也就是链表也必须是被const修饰的)
读者朋友们是否会觉得这样的实现有点过于冗余呢?
因为ListIterator<T>和ListConstIterator<T>两者非常相像,只有少数接口不一样
能否不定义一个新的类呢:
此时编译器会分别拿T和const T 分别去实现两个迭代器
如果T是int
ListIterator中所有的T都变成了const int,但是class list中依然实例化的是int, 若调用:
此时初始化const_iterator就需要一个ListNode<const int>* , 而我们的list和ListNode都是用的int来初始化,上图中return处的构造就没法成功。
_head->_next的类型取决于list处你用什么去生成模版类
按照我们之前的写法就一直都是一个int*
并且也没有人会要求初始化一个const_iterator是需要一个const T*的,T*就足够了。
但是按照之前的办法,实现两个完整的迭代器就不存在这个问题,我们可以在const_iterator的构造函数处将传入的参数设置成 ListNode<T>* 即可。
解决方案:
直接将引用和指针传入,一个不使用const , 一个使用const修饰
iterator:
对照着来看:
5. list中的insert和erase
insert不会发生迭代器失效,但是erase还是有迭代器失效的。
首先看insert :
按照合理的顺序更改各个指针的指向即可
再看返回值:
指向用户插入的第一个元素:
iterator insert(iterator pos, const T& x) {
Node* tmp = new Node(x);
tmp->_next = pos._pnode;
tmp->_prev = pos._pnode->_prev;
pos._pnode->_prev->_next = tmp;
pos._pnode->_prev = tmp;
return iterator(tmp);
}
返回临时变量(我们用tmp构造的临时变量)时就不要传引用返回了,返回一个tmp的拷贝更加合适。
类似于vector , erase返回的也是被删除的下一个位置的迭代器
同时还要注意断言,不要把哨兵位给删掉了
iterator erase(iterator pos) {
assert(pos != end());//别把哨兵位给删了
Node* cur = pos._pnode;
Node* prev = pos._pnode->_prev;
Node* next = pos._pnode->_next;
prev->_next = next;
next->_prev = prev;
delete cur;
return iterator(next);
}