目录
最基础的链表结构以及迭代器实现
链表节点结构
构造函数
push_back
list的迭代器
增删查改功能实现
insert
erase
pop_front
pop_back
push_front
clear
默认成员函数
析构函数
拷贝构造函数
赋值操作符重载
list的完善
const迭代器
赋值操作符重载优化
swap
size
类型名问题
->重载问题
list容器的本身其实是一个双向带头循环链表,具体的结构其实并不复杂本人曾在学习数据结构时有所记述,有需要的读者可以跳转至这篇文章数据结构4:双向链表+OJ题_lanload的博客-CSDN博客_双向链表题
那么既然是一个链表,那就少不了节点指针以及节点结构,模拟实现list也需要套入模板,那么我们先实现一个最基本具有数据存放功能的链表来试试看。
最基础的链表结构以及迭代器实现
链表节点结构
一个链表结构,需要下一个节点的指针,存放当前数据的变量,和上一个节点的指针
那么作为一个容器,使用模板必不可少。
template<class T>
struct List_node
{
T _data;
List_node* _prev;
List_node* _next;
//节点结构的构造函数,处理数据的传入
List_node(const T& x)
:_data(x)
, _next(nullptr)
, _prev(nullptr)
{}
};
构造函数
无参构造函数
由于需要设定节点结构内部的变量,不走初始化列表
namespace mylist
{
template<class T>
class mylist
{
public:
typedef List_node<T> Node;
mylist()
{
_head = new Node(T());
//_head->_data = T();匿名构造防止自定义类型偷家.不过这一步NEW已经做过了
_head->_prev = _head;
_head->_next = _head;
}
};
}
Node* == List_node<T>
list的基础结构是一个带头双向循环链表,那么有一个哨兵位非常合理
private:
Node* _head;
push_back
- 双向带头循环链表不需要考虑边边角角,直接尾插
- 需要一个尾部节点,尾节点的next指向新节点,哨兵位头节点的prev指向新节点,新节点的next指向头节点
void push_back(const T& val)
{
Node* newnode = new Node(val);
Node* tail = _head->_prev;
tail->_next = newnode;
newnode->_next = _head;
newnode->_prev = tail;
_head->_prev = newnode;
}
list的迭代器
上文我们已经实现了一个具有最基础插入数据功能的链表,但是还差一个访问方式,那么我们就创建一个迭代器。
但是list的迭代器不能同vector不包装直接实现:
- 由于不同于连续容器支持连续的指针访问,链表的迭代器不能使用原生指针
- 这也就导致了我们实现迭代器的时候需要重新封装一个类,这个类用于获取mylist的指针节点
- 所以需要重写++以及*运算符,使得这个类的运作方式形同原生指针
迭代器的结构类
这个类的作用其实很像一个打包袋,既然我们没法使用原生指针来实现++和解引用,那么在原生指针上面套上一个类,就可以间接的实现这些功能了
template <class T>
struct _list_iterator
{
//节点类型重定义
typedef List_node<T> Node;
//把原生指针装进打包袋之前,需要一个空袋子
Node* _pnode;
};
为了很方便的直接把节点指针放进这个类里面,我们直接使用构造函数,然后把指针装进去,非常简单,用构造函数走初始化列表,不走也成能装进去就行
_list_iterator( Node* p)
:_pnode(p)
{}
那么一个迭代器的标准访问还需要解引用以及++的功能才能迭代访问,这些也实现一下,实现的逻辑已用注释给出
template <class T>
struct _list_iterator
{
typedef List_node<T> Node;
//把原生指针装进打包袋之前,需要一个空袋子
Node* _pnode;
//然后把指针装进去,非常简单,用构造函数走初始化列表,不走也成能装进去就行
_list_iterator( Node* p)
:_pnode(p)
{}
//运算符重载,*解引用,传进来一个节点,返回节点里面的值
T& operator*()
{
return(_pnode->_data);
}
//运算符重载,++指向下一个节点,返回下一个节点
//这里为什么返回_list_iterator<T>?因为我们包装了这个原生指针,但是不需要得到里面的东西
//只需要让它往下走一个节点然后返回就行,并且能实现链式访问
_list_iterator<T>& operator ++()
{
_pnode = _pnode->_next;
return *this;
}
};
重写完了这个类还没有结束,我们还需要给出begin的接口以及end的接口。在这里我们重定义迭代器类的名称以方便使用
typedef _list_iterator<T> iterator;
iterator begin()
{
return iterator(_head->_next);
}
iterator end()
{
return iterator(_head);
}
试试效果,使用迭代器和范围for进行访问
没有问题。
增删查改功能实现
- 链表的增删查改,insert,earse,pop_back,pop_front,push_front,clear,析构,拷贝构造,赋值操作符重载,其中的尾插尾删头插头删都可以借由insert以及erase便捷实现,其余的实现也不算困难
insert
void insert(iterator pos, const T& val)
{
//先创建一个节点
Node* newnode = new Node(val);
//这里需要节点的指针,但是iterator并不能解引用,理解稍微有点不到位,被存放的指针拿去初始化这个类了
Node* cur = pos._pnode;
Node* prev = cur->_prev;
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
}
erase
删除,然后链接被删除的节点,但是需要注意的是别把哨兵位节点给干掉了
iterator erase(iterator pos)
{
assert(pos != end());
Node* cur = pos._pnode;
Node* prev = cur->_prev;
Node* next = cur->_next;
prev->_next = next;
next->_prev = prev;
delete pos._pnode;
return iterator(next);
}
pop_front
//头删
void pop_front()
{
erase(begin());//等价于this->erase( this-> begin())
}
pop_back
//尾删
void pop_back()
{
erase(--end());
}
push_front
//头删
void pop_front()
{
erase(begin());//等价于this->erase( this-> begin())
}
clear
void clear()
{
iterator it = begin();
while (it != end())
{
it = erase(it);
}
}
默认成员函数
析构函数
~mylist()
{
clear();
delete _head;
_head = nullptr;
}
拷贝构造函数
拷贝构造的传参我们暂时不使用const修饰,因为还没有实现const迭代器,我们后文实现
mylist(mylist<T>& l2)
{
//我们先置空
_head = new Node(T());
_head->_next = _head;
_head->_prev = _head;
iterator il2 = l2.begin();
while (il2 != l2.end())
{
push_back(*il2);
++il2;
}
}
赋值操作符重载
//赋值操作符重载
mylist<T>& operator = ( mylist<T>& l2)
{
if (this != &l2)
{
clear();
for (auto& e : l2)
{
push_back(e);
}
}
return *this;
}
list的完善
const迭代器
- 我们前文提到过,之所以不使用const对象做拷贝构造函数的参数是因为没有实现conts对象的迭代器,那么为什么没实现就不能用呢?
在这里,先再次回顾一次const迭代器的作用,当一个类生成一个对象的时候,可以分为普通对象以及conts对象,普通对象可读可写,const对象只能读不能写,那么针对const对象的这个特性,一部分成员函数以及迭代器都需要额外的实现const版本,不然const对象无法调用对应的成员函数。
回到话题上来,在尝试给拷贝构造的参数加上const之后,程序就报错了
那么根据以上的报错,我们很可能会借助前车之鉴也就是vector的const迭代器来尝试改写成如下形式以修复bug
但其实这样子与const迭代器的使用目的不相同,const修饰一个变量的时候有两种形式
const T* p1;//1
T* const p2;//2
- 代码1的const保护的是p1指针所指向的对象,而p1本身则可以被修改
- 代码2的const保护的则是p2这个指针本身,其对象依旧可以被修改。
- 我们希望const迭代器遵从的修饰规则是代码1,也就是保护所指向的对象,但是我们的迭代器是一个被封装好的类,我们对其加上了const只能让迭代器类本身不能被修改。
- const迭代器和普通迭代器都可以解引用以及执行++操作,区别则在于const迭代器并不能对解引用出来的值进行修改
归根结底:使用const做传递参数,需要额外实现针对const对象而编写的const迭代器以支持访问,但其实同普通的迭代器所实现的成员函数相比,const迭代器的不同则体现在解引用时的返回值的不同。普通迭代器返回T& 而const迭代器需要返回const T&
那么很简单我们直接上手,对症下药不就好了吗
T& operator*()
{
return(_pnode->_data);
}
const T& operator*() const
{
return(_pnode->_data);
}
诶,这样不就解决了吗?const对象调用下面的const成员函数,普通对象调用上面的!完美!
但很显然我们忽略了++这个问题,但你可能会想:“那有什么难的?跟上面一样再重载一个不就成了吗?”
但++的逻辑我们回顾一下
这个_pnode可是不能动的!没法重写。
所以综上所述:以上的实现方法,可以解引用,但是不能++,这还是有悖于我们的const迭代器没有实现对应功能的问题。
那么我们其实可以尝试多实现一个类,称之为_mylist_const_itreator,其中与当前的迭代器中唯一的区别就是解引用的返回值不同即可,其他的除去类名都不需要变换,当我们需要使用const迭代器的时候使用_mylist_const_itreator这个类名替代即可,不仅能++还能解引用。
template<class T>
struct _list_const_iterator
{
typedef list_node<T> node;
node* _pnode;
_list_const_iterator(node* p)
:_pnode(p)
{}
const T& operator*()
{
return _pnode->_data;
}
_list_const_iterator<T>& operator++()
{
_pnode = _pnode->_next;
return *this;
}
_list_const_iterator<T>& operator--()
{
_pnode = _pnode->_prev;
return *this;
}
bool operator!=(const _list_const_iterator<T>& it)
{
return _pnode != it._pnode;
}
};
但是这样冗杂的实现方法虽然说可以成立,但是对于STL的实现大佬来说这肯定是不合格的,那么库里是如何实现的?
我们先回顾一个概念:不同的模板参数,会生成不同的类
举例,对于vector而言:
vector<int>
vector<string>
vector<vector<int>>
如上的三种不同的模板参数,生成了3个不同的类。
那么大佬则是利用了这个特点,不同的模板参数,同一个类型,生成不同的对象
template <class T,class Ref>
struct _list_iterator
先额外加一个模板参数,Ref是reference(引用)的英文。你可能会觉得奇怪,多加一个模板参数可以改变现状吗?答案是完全可以,而且非常巧妙,只需要加上以下的语句就可以了。
typedef _list_iterator<T,T&> iterator;
typedef _list_iterator<T, const T&> const_iterator;
- 我们分析这段语句就可以发现其巧妙之处,借助不同的模板参数生成不同的类这个特质,相当于以一个类特化生成了两个不同的类,当我们使用const迭代器的时候,const T&会直接被模板参数套用生成一个const版本的迭代器,我们只需要再完善迭代器类内部的名称就可以实现这种特化了。
在额外的添加了一个模板参数之后,_list_iterator<T>&这个语句段的模板参数里面也需要额外加上一个Ref,因为之后还需要添加一个模板参数,我们直接typedef一下迭代器来方便更改。
template <class T,class Ref>
struct _list_iterator
{
typedef List_node<T> Node;
typedef _list_iterator<T,Ref> Self;
//把原生指针装进打包袋之前,需要一个空袋子
Node* _pnode;
//然后把指针装进去,非常简单,用构造函数走初始化列表,不走也成能装进去就行
_list_iterator( Node* p)
:_pnode(p)
{}
Ref operator*()
{
return(_pnode->_data);
}
Self& operator ++()
{
_pnode = _pnode->_next;
return *this;
}
Self& operator --()
{
_pnode = _pnode->_prev;
return *this;
}
bool operator != (const Self& it)
{
return _pnode != it._pnode;
}
};
赋值操作符重载优化
前文所记述的赋值操作符重载是“传统写法”
//赋值操作符重载
mylist<T>& operator = ( mylist<T>& l2)
{
if (this != &l2)
{
clear();
for (auto& e : l2)
{
push_back(e);
}
}
return *this;
}
为了实现“现代写法”也就是“摇人打工法”我们需要自己先实现list自己的成员函数swap
swap
swap的逻辑非常简单,不必一个个的交换节点,既然我们有哨兵位头节点,我们直接交换他俩即可完成交换
void swap(mylist<T>& tmp)
{
std::swap(_head, tmp._head);
}
为什么不直接使用算法库内部的swap来直接交换两个链表?
因为算法库的算法消耗还是比较大的,毕竟为了适配所有类型的交换,消耗远大于我们自己单纯的交换两个指针。
那么我们的赋值操作符重载就非常简单了,注意需要使用传值传参,触发拷贝构造
mylist<T>& operator = ( mylist<T> tmp)
{
swap(tmp);
return *this;
}
那为什么拷贝构造不使用“现代写法”?
我们在实现vector的现代拷贝构造的时候,为了防止当前的this指针交换过去的时候析构一个没有初始化过的指针,我们会给予一个空指针来防止此事的发生,那么套用到list这上面反而就不行了,因为我们在迭代器里面还是需要访问到当前头节点的,也就是_head不可以为空。还是需要初始化,那么跟我们的传统写法差比不大,实现也可没有也罢。
size
size的实现非常简单,我们直接复用就好了,在mylist的成员变量之中加入一个size,由于我们复用了insert和erase来构筑了mylist的增删查改,我们只需要在触发了insert和erase的时候对size++或者--就可以了。
size_t size()
{
return _size;
}
不过由于复用了push_back一类函数在拷贝构造以及赋值操作符重载内部,其中size的变化还需要额外处理,也就是赋值时也需要更新被赋值的变量的_size。这一部分不做记述,逻辑简单也不复杂。
类型名问题
我们查阅官方文档的时候,可能会对赋值操作符的重载产生一定的疑惑
啥玩意?list后面的模板参数怎么没了?
在解释这个问题之前我们再次回顾一边类和对象中类型的问题。
- 普通类的类名 == 类型
- 类模板的类名 != 类型 而类模板<模板参数> == 类型
那么根据我们上面的回顾,这里应该是list<T>才对,怎么官方文档是这样子的呢?那么换成我们自己实现的试一试
- 也是没有问题的,这里其实算是C++语言设计的一个陷阱,对于赋值操作符重载来说,类模板的名称也被归为了类型,这其实并不符合我们的使用习惯,但是从语法角度来讲是合理的。
不过平常我们能不用就不用,毕竟还是容易造成歧义。
->重载问题
我们创建一个自定义类型来尝试我们的list能否存储和读取
struct Location
{
Location(int x = 0,int y = 0)
:_x(x),_y(y)
{}
int _x;
int _y;
};
当我们想要遍历访问这个类的时候,由于我们没有重载这个类的流插入运算符,将会报错
但是重载一个流插入未免有些麻烦,Location的成员变量是内置类型,也是一个结构体,那么我们简单一点直接取出来访问就好,那么一个结构体访问其中的成员变量非常简单,使用->是我们常用的手段。
- 但是->一般是用于结构体指针的,我们还没有重载,但是p->data等价于(*p).data,所以使用(*it)._x这个语句是可行的。
不过这样子的可读性还是比较差的,所以我们还是需要重载->操作符以方便我们访问结构体。
- 既然->作用于一个结构体指针,那么我们就直接把存放于当前节点的数据指针捞出来即可,那么我们在迭代器类里面重载一下,把它的地址捞出来放到指针里头去
T* operator->()
{
return &_pnode->_data;
}
那么根据我们的理解,整个过程应该是这样的:
it->返回的是当前节点里面的值的地址也就是T*,根据我们当前的程序,返回的应该是Location*,那么我们想要取得里面的数据就应该再加上一个->,因为原生的结构体指针已经提取出来了,使用->是可以的
为了验证我们的猜想,我们先上一个->试一试,此时应该提取出的是当前值的指针
怎么回事?不应该是指针吗,怎么直接提取出了其中的值?
原因则是:在这段过程中,如果按照我们理论上的语法来实现,应该写成it->->_x 才可以,但是这样子的写法可读性太低了,所以编译器自己优化了这个过程,实际上确实是使用了两次->操作符,但是我们写一次即可
那么这样就万事大吉了?我们不能忘记const对象的问题。
T* operator->()
{
return &_pnode->_data;
}
我们的确对普通对象的->进行了重载,但是当我们调用const迭代器的时候,这段代码可不会返回const T* 而是T* ,const对象随便更改这种情况绝对不是我们想看到的。
那么前文我们对模板的灵活运用在这里就可以再次发光发热了,我们多加上一个模板参数称之为Ptr
template <class T,class Ref,class Ptr>
struct _list_iterator
{
typedef List_node<T> Node;
typedef _list_iterator<T,Ref,Ptr> Self;
}
typedef List_node<T> Node;
typedef _list_iterator<T,T&,T*> iterator;
typedef _list_iterator<T, const T&,const T*> const_iterator;
Ptr operator->()
{
return &_pnode->_data;
}
这样const就成功的生效了。
到这,一个仿照STL具有基础功能的list就实现完毕了,希望对你有点帮助!