前言: 要理解list,其实它这个容器对应的数据结构就是双链表。这是库里的原话哦,所以接下来的实现呢,你尽可能把思路向双链表靠,你会轻松很多! 只不过,我们引入泛型编程,会有模板类的实现,以及一些细节,链表的特征就是: 节点之间在内存上不是连续的而是通过指针指向前后节点。 这也就导致了它的迭代器跟之前我们学的vector和string的迭代器是不同的!
不过正是因为这样你会对迭代器的封装,泛型编程的运用更深一步掌握,进一步感受其中的魅力!
一. 成员变量
1.1 图解list中的node和list类 以及迭代器类
node :
一个个node组成双链表之后:
我们的这个双向循环的双链表是带有头节点的,所以在list中存放一个_head就可以控制整个链表了,其次在list中还封装了一个_size存放节点数量。
迭代器封装的其实主要是对位置的封装这个位置类型其实就是节点 node*封装 ,它的移动是对pos的移动,这部分到后面开始讲解。
1.2 迭代器类实现
首先要对迭代器的行为有一定的理解,其实迭代器本质上是指向该节点的一个pos,我们理解为它是一个指向位置的指针,对迭代器常用的用法就是 ++, --, 以及解引用,我们以前的vector和string的模拟实现的迭代器 直接就是开辟了一段连续的内存,所以迭代器类型都可以是T*了,而且对于连续的内存的位置移动当然可以用++,--访问前一个和后一个内存。
但是对于list底层是一个个的链表节点,它的移动只能是 node.pre 和node.next,但是为了统一容器的行为,我们的c++标准,就对list的迭代器进行了封装,对于它的移动也统一成了--以及++ 以及* 还有前置和后置--加加 以及 != 和==

总之 你可以把迭代器当作指向当前位置的指针了,但是它的类型是__list_iterator<T>类型的
开始尝试实现迭代器类吧:
cpp
// 迭代器本质上是指向位置的一个指针 我们这里要封装它的行为
template<class T>
struct __list_iterator {
// typedef 类型 node的和 自身的
typedef list_node<T> Node;
typedef __list_iterator self;
//成员变量
Node* _Node;
// 开始完善迭代器行为
// 前置后置 ++ -- 以及 != == -> 和*
__list_iterator(Node*node)
:_Node(node)
{
}
// 对位置的移动返回的类型都是迭代器类型 self
// 前置++和--
self operator++()
{
_Node = _Node->_next;
return *this;
}
self operator--()
{
_Node = _Node->_pre;
return *this;
}
// 后置 ++ --
self operator++(int)
{
self tmp(*this); // 拷贝构造一份当前位置的迭代器 用来返回 后置++
_Node = _Node->_next;
return tmp;
}
self operator--(int)
{
self tmp(*this);
_Node = _Node->_pre
return tmp;
}
// 其实这里的比较 是对迭代器的比较 但是迭代器中指向的是当前位置的节点 所以我们的行为迭代器指向的是不是同一个节点即可
bool operator!=(const self& it){
return _Node != it._Node;
}
bool operator==(const self& it) {
return _Node == it._Node;
}
// *和 ->的实现
T& operator *() {
return _Node->_data;
}
// 比如我们要通过迭代器 it 访问_data结构体中的成员的时候
/// it->name 我们的迭代器的->是没用传递其他的成员的 所以它会被转化为
// (operator->())->name
T* operator ->() {
return &_Node->_data;// 返回的是_data的地址
}
// != 和== 其实比较的就是 数据data
};
对于这里的迭代器行为的实现值得注意的一点是:这里的operator->()的实现这里涉及到一个编译器的行为 ,因为当想通过迭代器访问节点对应的数据的成员的时候
也就是 如 it->name 的时候 就好像这个迭代器就是这个节点的指针一样 ,而且这个迭代器指向的内容还是这个节点的数据。 但是我们自己实现->的时候 是没写参数的 这是因为 it->name的行为 会被转化为 (operator->() )->name 所以这里需要返回的反而是data的地址,我们查看data位置的值。
同样的当我们想*it一下 其实返回的也应该是 data的引用才对。
1.3 迭代器类的优化
我们现在发现 其实 我们在其中会出现 T 以及 T&和T* 所以我们对他们也分别创建一个模板: 一一对应: temlate(class T,class Ref,class Ptr); 修改后的代码就是 把原来代码中的T* 和 T& 修改即可 : 如下代码
这样的把同一个模板类的 不同形式 T* 和T& 也当成一个类型 这也符合编译器的行为,
进而来说 编译器把对权限的范围也加入到类型的检测,所以 const的类型也是一样的哦,所以这样做的的目的之后我们封装const迭代器的时候 把原本需要 <T,const T*,const T&>
变成一套代码<T,Ref,Ptr> 我只能说这里的泛型编程的魅力真的很爽,一套代码多处复用,总之我寥寥几句你没有感触,强烈建议手搓试试这一块。
cpp
template<class T,class Ref,class Ptr>
struct __list_iterator {
// typedef 类型 node的和 自身的
typedef list_node<T> Node;
typedef __list_iterator self;
//成员变量
Node* _Node;
// 开始完善迭代器行为
// 前置后置 ++ -- 以及 != == -> 和*
__list_iterator(Node* node)
:_Node(node)
{
}
// 对位置的移动返回的类型都是迭代器类型 self
// 前置++和--
self& operator++()
{
_Node = _Node->_next;
return *this;
}
self& operator--()
{
_Node = _Node->_pre;
return *this;
}
// 后置 ++ --
self& operator++(int)
{
self tmp(*this); // 拷贝构造一份当前位置的迭代器 用来返回 后置++
_Node = _Node->_next;
return tmp;
}
self& operator--(int)
{
self tmp(*this);
_Node = _Node->_pre;
return tmp;
}
// 其实这里的比较 是对迭代器的比较 但是迭代器中指向的是当前位置的节点 所以我们的行为迭代器指向的是不是同一个节点即可
bool operator!=(const self& it) {
return _Node != it._Node;
}
bool operator==(const self& it) {
return _Node == it._Node;
}
// *和 ->的实现
Ref operator *() {
return _Node->_data;
}
// 比如我们要通过迭代器 it 访问_data结构体中的成员的时候
/// it->name 我们的迭代器的->是没用传递其他的成员的 所以它会被转化为
// (operator->())->name
Ptr operator ->() {
return &_Node->_data;// 返回的是_data的地址
}
// != 和== 其实比较的就是 数据data
};
1.4 迭代器的实现

有了迭代器类,我们之后对迭代器的实现就简单很多了。 首先看上面这张图片,我们首先是对迭代器的名字typedef一下,也为之后编写代码轻松很多,注意这里的const的迭代器了吗,正好符合我们把const T&和const T*也当成两种不同的类型,也展性了我们一套代码多个地方复用的好处。
接下来的实现思路有点巧妙,始终铭记一个事情,迭代器就是pos,相当于这个位置的一个指针一样,我们要实现它的行为就跟一个指针一样,统一stl容器迭代器的行为就是为此。
如下图 假设已经搭建好的双链表现在我们的迭代器要实现的几个函数一共是四个代码如下:
首先观察begin 它应该是头节点的下一个位置才对,其次是end,准确来说end()指向的应该是最后元素的下一个位置,这里双链表其实很巧妙的就是头节点嘛,这里一种都是带头节点双链表的优势。
为什么说它这样设计很好呢,当你要实现insert在指定位置插入的时候,从0个元素开始插入的时候,这个时候list中就一共_head的头节点,但是你要插入的时候,注意我们(指定位置插入是插入该位置之前哦)
这样的话相当于尾插,为什么呢,当插入最后一个元素不就是相当于插入最后一个元素的之前一共位置吗,那就是end()-- 的位置 end()对应的是 _head头节点 它的pre不就是最后一共元素嘛。这样做有了头节点就不需要判空了,众所周知,判空很痛苦的。

cpp
// 观察迭代器行为 其实const迭代器是指它指向的内容不可修改 使用我们这里其实想权限只读的的是data啊
// 迭代器中返回有关data的也就是 *和 -> 返回该节点数据的指针 和引用
// 始终要记住 我们的list的带头节点的所以内部实现的时候 begin指向的是 头节点(_head)的下一个
// 迭代器的的实现
iterator begin() {
return _head->_next;// 这里会有单参的隐式类型转换 由节点类型到 迭代器类型
}
iterator end() {
return _head;
}
//// const迭代器
//const_iterator const_begin() const{
// return _head->next;// 会自动实例化模板 const的迭代器类
//}
//const_iterator const_end() {
// return _head->pre;
//}
// 你也可以显式调用const迭代器类的构造函数
// const迭代器
const_iterator const_begin() const {
return const_iterator(_head->_next);// 会自动实例化模板 const的迭代器类
}
const_iterator const_end() const {
return const_iterator(_head); // end指向的应该是最后一个元素的下一个位置 不就是head吗
}
迭代器实现的介绍,我们的迭代器呢现在开始是一个对象,我们封装了迭代器,内部的成员变量主要就是Node* _Node 这个元素,所以每次我们需要移动等等我们封装这个类的行为为++,-- ,*和-> 以及!=这些。
现在我们的迭代器类实现之后,我们可以直接return _head。 这样的原因其实就是c++的特性单参数的隐式类型转换调用构造函数,当然你也可以明确的写构造函数
二. 修改
很高兴你能看到这,哈哈哈,不过你放心,list的模拟实现最难的已经过去了,就是理解迭代器类这样的行为,到这之后的都是小卡拉米。 稍微介绍一下list后面的实现思路:
前面先把list的数据结构了解清楚,画图,在设计list的成员变量,Node结构体实现了,迭代器类(结构体)实现了,之后实现迭代器的函数。
在之后就是容器实现的基本思路了,把插入实现(一般是先实现尾插然后实现一下构造函数)但是对于链表这样的更适合先实现指定位置的插入,在把头插,删和尾插,删,这都是很方便的哦!` (链表特性嘛在任意位置插入删除都是O(1))
所以我们的思路就是把insert和eraser实现 在实现 pop// push_back 和 pop//push_front
然后实现构造函数 : 空参构造一开始就实现了,其次就是constructor(n,val) 以及 class InputIterator 迭代器范围构造。
2.1 iterator insert(iterator pos,T val=T())
它的返回值是新插入位置的pos的迭代器, 对于迭代器的传参我们用的是指传参,在c++标准中认为它是轻量级的,没必要用引用,还有一共理由就是担心传入的迭代器失效了,list跟其他的内存连续的容器不同,不存在迭代器失效问题的。
insert的参数也是很经典的val的默认值是T()
如下图我们来理解插入的过程:我们要在pos的位置前面插入一共节点,那我们先开辟好一共节点并且初始化它,在把val的值给节点的_data.
其次就是从pos的位置取出来Node* 也就是当前位置节点的指针,然后保存存当前节点(Node*)cur的指针,和前面位置的指针(Node*pre), 然后把开辟好的: newnode 前驱指向pre ,后继指向 next 然后把cur->_pre =newnode, pre->_next = newnode

cpp
iterator insert(iterator pos, T val = T())
{
// 其实 对于insert的插入是指在pos位置的前面插入 其实这样写的好处就是不用判空
// 双链表 插入 提前 存好 当前节点和它的后节点
// 下面的写法 要判空的 比如插入第一个元素的时候 可能会有空指针的情况
/*Node* cur = pos._Node;
Node* next = pos._Node->_next;
Node* newnode = new Node(val);
newnode->_next = next;
newnode->_pre = cur;
cur->_next = newnode;
next->_pre = newnode;*/
//++_size;
//return newnode; 或者显式写
Node* cur = pos._Node;
Node* pre = pos._Node->_pre ;
Node* newnode = new Node(val);
newnode->_next = cur;
newnode->_pre = pre;
pre->_next = newnode;
cur->_pre = newnode;
_size++;
return iterator(newnode);
}
2.2 iterator erase(iterator pos)
指定位置删除:
- 一般位置: , 存前驱 存后继 ,删除pos节点
需要判断 如果是头节点 就不做任何事情,判断的目的就是为了之后少写代码,因为实现尾删头删,resize都可能出现想删头节点的冲动,注意头节点不需要你删,析构自己带走就行。
demo:
cpp
iterator erase(iterator pos) {
if (pos._Node != _head) {
Node* next = pos._Node->_next;
Node* pre = pos._Node->_pre;
delete pos._Node;
next->_pre = pre;
pre->_next = next;
_size--;
return pre;
}
}
2.3 void push_back() 和 void push_front()
看吧有了迭代器实现以后,前面又实现了insert和erase后面的实现就是复用。。。。
注意这里传递迭代器,你可以画图稍微理解一下,以及对insert的理解你会发现传入在push_back中传入end():_head的位置 然后在指定位置插入是在指定节点的前驱插入,刚好就是链表中最后一共元素,这个思路真的很巧哈哈哈。
cpp
void push_front(T val =T())
{
insert(begin());
}
void push_back(T val = T()) {
// 尾插其实就是直接拿迭代器的end插入 对应的其实就是头节点的pre
insert(end(),val);
}
2.3 void pop_back()和 void pop_front()
始终牢记我们erase的行为是删除指定位置的节点,所以我们肯定不能把_head传进去的,即使你传进去了我们在erase自有判断哈哈哈,在头删尾删我们也做一个判断,那就是我们没有有效节点的时候别做处理了。
cpp
void pop_front()
{
if (_size != 0) {
erase(begin());
}
}
void pop_back()
{
if (_size != 0) {
erase(--end());
}
}
三. 3类构造函数顺手的析构
3.1 顺手先把析构写了
始终理解我们的迭代器内部封装了Node*指向当前位置节点。 所以有迭代器就有当前位置节点指针。
其次 现在实现析构,你也得记住咯,我们头节点最后删,最后删!重要的事情说三遍你说! ok 开始吧 先用迭代器遍历一下,把头节点之外的删一下,然后最后删头节点。
cpp
void clear() {
iterator it = begin();
while (_size!=0)
{
pop_front();
}
}
// 析构
~list() {
clear();
delete _head;
_head = nullptr;
_size = 0;
}
3.2 构造函数
构造函数(construtctor)我们实现一共写3种嘛其他的大同小异。
list() 空构造 ,list(size_t n ,T val = T())以及 template<class InputIterator>
list(InputIterator start,InputIterator last)
稍微注意一共事情 你观察后两种构造的实现,你看啊 当前你写一个list<3,6>你的本意可能是,构造一共list 3个6,但是 它们两都是int啊 这个时候 list(InputIterator start,InputIterator last) 一开挖去我来实例化了,因为对于list(size_t n ,T val = T()) 这里面反而还有一层int到size_t的类型转换啊。所以我们需要重载一个list(int n ,T val = T())
还有一个注意点,强烈注意的就是:每次构造函数中第一件事情就是就是!!!先创建并初始化头节点咯!
3.2.1 list()空参构造和创建头节点
cpp
void emptyInit() {
_head = new Node();
_head->_next = _head;
_head->_pre = _head;
_size = 0;
}
list() {
emptyInit();
}
3.2.2 list(size_t n ,T val = T())
其实这里的思路就是插入n个val你尾插n次即可,值得注意的是一定要在这之前先创建好头节点呐!
cpp
list(size_t n, T val = T())
{
emptyInit();
for (size_t i = 0;i < n;i++)
{
push_back(val);
}
}
3.2.3 template<class InputIterator> list(InputIterator start,InputIterator last)
这里迭代器的范围来初始化是很常见的使用对应是比如 int arr[] = {1,3,,421,2,213};
list(arr,arr+6);传入的迭代器就是int* ,你甚至还可以传入其他容器的迭代器,他们都统一好了,所以你可以直接写一共输入的迭代器类,访问它的元素就是*迭代器。
也记得先初始化头节点,不然你插入第一个元素就野指针问题看。
cpp
template<class InputIterator>
list(InputIterator start, InputIterator last)
{
emptyInit();
while (start != last)
{
push_back(*start);
start++;
}
}
四. 拷贝构造和赋值重载以及swap
现代写法的拷贝构造和赋值重载真的太爽了,就好像"自从买了点读机妈妈再也不用担心我学习了~ tiger一样哈哈哈"
总之就是拷贝构造复用构造, 赋值重载复用拷贝构造。
4.1 拷贝构造 list(cosnt list&List)
我们的拷贝构造呢,本质就是: 我想创建一个新的,但是想跟你那个一样,所以我们的策略就是先构造一个temp, 就是 list temp(List.const_begin(),List.cosnt_end());调用传过来的拷贝对象的迭代器来构造临时变量,最后在将this当前对象的_head 和_size交换一下,实现了转移。 这就是为什么我想让大家先写迭代器然后写修改在写构造函数的原因,一套又一套嘛!
cpp
void swap(list& List) {
std::swap(this->_head,List._head);
std::swap(this->_size,List._size);
}
// 拷贝构造 和 赋值操作符重载
list(const list& List)
{
emptyInit();
list tmp(List.const_begin(), List.const_end());
swap(tmp);
}
4.2 赋值重载 list& operator=(list tmp)
赋值重载的思路也挺爽的,我传递参数我选传值传参,这个时候tmp会调用拷贝构造跟需要的拷贝对象进行拷贝,然后我们在swa当前对象*this和tmp即可
cpp
// 赋值重载
list& operator=(list tmp)
{
swap(tmp);
return *this;
}
结语
到这我们基本实现了list的模拟实现,当然我这其中几乎都是实现的思路没有给大家一个测试用例,我实现的其中遇到的主要问题,一个是迭代器类的实现,一个是经典的链表出现空指针解引用问题,但是每个人实现起来都有自己的困难,期待大家的指点!