【C++list】底层结构、迭代器核心原理与常用接口实现全解析

🔥个人主页:爱和冰阔乐

📚专栏传送门:《数据结构与算法》C++

🐶学习方向:C++方向学习爱好者

⭐人生格言:得知坦然 ,失之淡然


博主简介

文章目录


前言

在前一篇文章我们介绍了list的各种接口的实现,今天我们将通过模拟实现list底层的各个接口,让大家对list的使用与逻辑更加清楚,通过迭代器的各类问题的拆解和运算符重载让我们对STL各个容器的不同特性与共同点更加清晰

一、官方源码的探究

在实现list的底层前,我们先看下官方的核心成员变量,link_type node,其中link_type是list_node*,也就是说是节点的指针

下面我们看下其的初始化,在空初始化中,链表为空并不是把节点的指针给成空,而是给了个节点,让其的前驱指针和后继指针均指向自己,在C语言阶段的数据结构中我们便知道这个节点是哨兵位头节点

注意: 这里创捷新的节点不是new的,而是使用get_node出来的,这里是由于内存池的原因,后续再介绍

二、list底层的构建及其尾插

2.1 list底层探索

实现链表首先需要实现节点,即其的前驱指针,后继指针,和其保存的数据

cpp 复制代码
namespace hxx
{
   template<class T>
   class list_node
   {
         T _data;
         list_node* _prev;
         list_node* _next;
list_node(const T& data)
:_data(data)
,_next(nuppptr)
,_prev(nullptr)
{}
    }:
    }

链表是一个个节点组成的结构,在实现完节点后,我们便可以轻松实现list了的初始化结构,我们仿照官方实现的结构来实现,由于我们并没有实现内存池,因此这里创建新节点依旧是new

注意: 这里我们多定义了个size变量是为了方便计算节点的数量,每次增加节点或者删除节点,直接让size++/- - 更加方便,不需要在遍历链表

为什么官方在初始化list时,使用的方法是头结点的next和prev指针均指向自己,是为了防止后面实现的end和begin接口在空节点的特殊情况下的处理

cpp 复制代码
template <class T>
class list
{
   typedef list_node<T> Node;
public:
    list()
    {
      _head=new Node;
      _head->next=_head;
      _head->prev=_head;
      _size=0;
    }

private:
    Node*_head;
    size_t size;
} 

2.2 push_back

在实现尾插前我们需要找到链表的尾部, 哨兵位头结点的前驱指针便是尾节点tail,再让tail的后继指针指向要尾插的新节点newnode,newnode的前驱指针指向tail,最后别忘让newtail成为新的尾节点,因此需要让newtail的后继指针指向哨兵位头结点,哨兵位头结点指向newnode即可

cpp 复制代码
void push_back()
{
  //newd对象后需要调用构造函数,因此还需要再节点类型里定义构造函数
    Node*newnode=new Node(x);
    Node*tail=_head->prev;
    tail->next=newnode;
    newnode->prev=tail;
    newnode->next=_head;
    _head->prev=newnode;
    ++size;
   }

三、实现普通迭代器的遍历

list相对于vector和string来说,难实现的是迭代器,因为vector和string的结构有着先天的优势------地址是连续的,使用原生指针可以直接访问。因此list的迭代器需要类型对节点的指针进行封装

在上面我们完成了list的基本底层结构和尾插,为了判断我们实现的代码是否有问题,那么我们打印一遍就知道了,这里必然要用到迭代器去遍历链表,因此实现list版本的迭代器是不可或缺的重点

我们只需要将如下的代码跑通便代表迭代器实现了

cpp 复制代码
void test()
{
  list<int> lt;,
  lt.push_back(1);
  lt.push_back(2);
  lt.push_back(3);
  lt.push_back(4);
  
  list<int>::iterator it=lt.begin();
  while(it!=lt.end())
  {
     cout<<*it<<" ";
     ++it;
   }
   cout<<endl;
}

3.1运算符*/++/--的重载

那么这里的迭代器我们需要如何实现其底层结构?我们要去遍历节点,我们使用节点的指针是搞不定的,因为list的地址是不连续的,++指针是不能找到下一个节点。但是节点力存放了下一个节点的地址,因此我们考虑用类进行封装通过重载运算符实现迭代器,由于直接解引用得到的不是data数据,因此我们可以重载operator*来实现,同理也可以重载++来找到下一个节点

cpp 复制代码
//由于链表的类型不确定,因此需要使用模板
template <class T>
//这里使用struct是因为其默认是公有,可以直接给list提供公共接口
struct list_iterator
{
   typedef list_node<T>  Node;
   //重载++就是迭代器++,也就是迭代器的类型,这里可以typedef下
   typedef list_iterator<T> Self;
    Node* _node;
   
   //构造函数
   list_iterator(Node* node)
   :_node(node)
   {
   }
   T&  operator*()
   {  
    
    return _node->_data;
   }
   
  Self& operator++()
   {
    _node=node->_next;
    return *this;
   }
    
    //两个迭代器的比较
   bool operator!=(const Self& s)
    {
        return _node!=s._node;
    }

};

实现完前置++/- -,我们再实现下后置++/- -,后置是返回++/- -之前的值,但是其在实现的时候还是需要++/- -

cpp 复制代码
//后置++
Self& operator++(int)
{
    Self tmp(*this);
    _node=_node->next;   
    return *this;
}

//后置- -
Self& operator--(int)
{   
   //在这里我们用到了拷贝,但是我们自己却没有实现拷贝构造函数,而是让编译器自己生成的,这里仅是值拷贝,因为我们希望迭代器    
   //iterator也指向该节点,这里就是浅拷贝,而不是深拷贝,注意并不是有指针就是深拷贝,而是要看指针所指向的资源是否属于自己,属于自己需要深拷贝,而迭代器中的指针指向的资源不属于它的,因此仅为浅拷贝

    Self tmp(*this);
    _node=_node->prev;   
    return *this;
}

总结:资源不属于当前对象,只是 "借用" 或 "引用" 资源,资源的生命周期由其他对象管理,那么拷贝时只需要浅拷贝,因为即使多个指针指向同一份资源,也不会有冲突(资源的释放由真正的所有者负责)

,我们解引用不再使用对象+点来访问,而是通过->l来进行,因c还需要运算符重载下->

3.2 自定义类型下的运算符重载

如若list的类型不再是内置类型而是自定义类型如struct时,下面我们来看个struct类型的例子,看是否可以遍历list

cpp 复制代码
struct AA
{
   int a1=1;
   int a2=1;


};


void test1()
{
   list<AA> lt;
   lt.push_back(AA());
   lt.push_back(AA());
   lt.push_back(AA());
   lt.push_back(AA());
   
   list<AA>::iteerator it=lt.begin();
   while(it!=lt.end())
   {   
   cout<<*it<<" ";
   ++it;

   }
   cout<<endl;
}

结果演示:

很显然我们运行错误,因为lt解引用后是自定义类型,<<不支持自定义类型的使用,因此在这里有三种方法

1.在struct里面重载流插入

2.通过(*lt)._a1,(*lt)_a2来访问

3.通过重载operator->

这里我们按照编译器所推荐的->的重载进行解决

cpp 复制代码
T* operator->()
{
     return &_node->_data;
}

list<AA>::iterator it=lt.begin();
while(it!=lt.end())
{
   //lt调用operator->,operator->返回的是T*,_data是AA类型的,返回的便是AA*,那么AA*是怎么访问_a1的?
   //实际上这里应该是两个箭头:it->->_a1,但是在编译器中不支持两个箭头,第一个箭头是运算符重载(lt.operator->())返回底层的指针AA*,第二个箭头便是对AA*的_a1的解引用(原生指针)
   cout<<lt->_a1<<":"<<lt->_a2<<endl;
   ++it;
}

cout<<endl;

3.3 迭代器遍历

最后我们在list类的public typedef下迭代器

cpp 复制代码
typedef list_iterator<T> iterator;

iterator begin()
{
  
 //  return iterator (_head->_next);
   //当然也可以如下写法,因为单参数构造函数支持隐式类型转换
   return _head->_next;
}


iterator end()
{

   //隐式类型转换,这里需要注意end返回的尾节点的后一个节点,尾节点是_head->_prev,因此后一个节点是_head
    iterator _head;

}

经过上面的代码实现迭代器便已经可以跑了,下面我们在测试代码试下

cpp 复制代码
#include"list.h"

hxx::test1();

因为只要new对象了必然会调用构造函数,node类中我们没有写构造函数因此导致报错,在写构造函数时我们需要注意push_back new的对象是带参的构造,因此需要给list类中的node传参,这里需要注意的是只能传匿名对象T(),或者可以让list_node类中的构造函数传匿名对象T()

cpp 复制代码
template<class T>
struct list_node
{
	T _data;
	list_node<T>* _next;
	list_node<T>* _prev;

	list_node(const T& data = T())
		:_data(data)
		, _next(nullptr)
		, _prev(nullptr)
	{
	}
};

结果演示

总结:最后我们也实现了list的迭代器的接口,虽然list和string,vector的实现不同,但是最后的效果都一样,举个很简单的例子,每个人前往相同目的地,有的人是走路,有的人骑车,有的人开车,方式各不相同,但是最后都到达了终点。这里是一样的,上层调用接口都是一样的方法,底层各不相同

四、实现const迭代器的遍历

在模拟vector的底层时我们实现了print_container的打印,我们直接把代码CV过来·,并用其打印测试下list

cpp 复制代码
template<class Container>
void print_container(const Container& v)
{
  for(auto e:v)
  {
  cout<<e<<" ";
  }
   cout<<endl;
}
void test1()
{
list<AA> lt;
lt.push_back(A());
lt.push_back(A());
lt.push_back(A());
lt.push_back(A());

for(auto e:lt)
{
  cout<<e<<" ";
}
cout<<endl;
print_container(lt);
}

编译器报错说范围for出了问题,那么我们在test1()中屏蔽print_container()并调用范围for来进行测试,但是奇怪的是我们在test1函数里面实现的范围for并没有问题

这是因为在test1中使用的是普通容器,因此将范围for转换成普通的迭代器,而print_contaier()中实现的是const迭代器,对应的便是const容器,但是print_container()中并没有实现const迭代器,因此其中的范围for跑不了

注意:为什么这里const迭代器的使用是const_iterator,而不是const iterator,这里的原理和C语言中Tconst ptr指的是指针本身不能被修改(const在之后修饰的是指针本身),const T* ptr(const在*之前修饰的是指针指向的内容)。
因此const iterator,const在iterator之前修饰的是iterator即迭代器本身不能被修改,那么便无法++,也就不能遍历,因此我们使用const_iterator即指向的内容不能被修改,而list访问数据是通过迭代器中的*/->进行的,因此想要让const迭代器实现,必须让它们变为const函数才可以。

4.2 按需实例化

cpp 复制代码
// 按需实例化
template<class Container>
void print_container(const Container& con)
{
	// const iterator -> 迭代器本身不能修改
	// const_iterator -> 指向内容不能修改
   list<int>::const_iterator it=con,begin();
	while (it != con.end())
	{
		*it += 10;
    cout << *it << " ";
		++it;
	}
	cout << endl;

	for (auto e : con)
	{
		cout << e << " ";
	}
	cout << endl;
}
void test_list1()
{
	list<int> lt;
	lt.push_back(1);
	lt.push_back(2);
	lt.push_back(3);
	lt.push_back(4);

	list<int>::iterator it = lt.begin();
	while (it != lt.end())
	{
		*it += 10;

		cout << *it << " ";
		++it;
	}
	cout << endl;

	for (auto e : lt)
	{
		cout << e << " ";
	}
	cout << endl;
	//print_container(lt);
}

该段代码在编译器上可以跑,但是我们仔细看下,这里使用的是const迭代器,但是我们在while循环中对迭代器指向的内容进行了修改,这里便是因为模板走的是按需实例化(不能直接调用生成,只有实例化后才能调用生成对应的代码),函数模板在这里没有进行调用,编译器只会对其进行基础的扫描查看模板中有无明显的错误(多写一个;少个[),但不会检查细节的错误(调用才实例化),因此如若将print_contaier放出来则报错

4.3 模板解决迭代器代码冗余

为了实现各个场景下list的使用,const迭代器中所有接口均要再从普通迭代器中拷贝一份,但是这样便会导致代码冗余,这里我们看下官方是通过同一个类模板传三个模板参数实现的T,T&,T*实现的,这样我们就不需要写两个类便可以实现


代码实现:

cpp 复制代码
template<class T,class Ref,class Ptr>
struct list_iterator
{
   typedef list_node<T,Ref,Ptr> Node;
   typedef list_iterator<T,Ref,Ptr>Self;
   Node*_node;
   list_iterator(Node* node)
   :_node(node);
   {}
   
   Ref operator*()
   {
      return _node->data;
    }
    Ptr opertor->()
    {
    //箭头返回对象类型的指针,指针解引用找到其存储的数据
    	return &_node->_data;
    }
}

总结:官方实现的类模板给给编译器,因为给了不同的模板参数,编译器实例化了两个不同的类

五、迭代器失效

在链表中的迭代器失效与string和vector不同,因为在链表中插入数据不再导致迭代器失效,因为list的地址不连续,在目标节点前插入数据,不需要挪动其他数据,只需要让目标结点和插入节点的指针进行链接即可,迭代器便不会失效

cpp 复制代码
void test_list()
{
   list<int> lt;
   lt.push_back(1);
   lt.push_back(2);
   lt.push_back(3);
   lt.push_back(4);
   
   list<int>::iterator it=lt.begin();
   lt.insert(it,10);
   *it+=100;
   
   print_container(lt);

}

但是删除节点便会导致迭代器失效,因为删除目标节点会导致指向目标节点的迭代器成为野指针

cpp 复制代码
auto it=lt.begin();
while(it!=lt.end())
{
 //删除所有的偶数
   if(*it%2==0)
   {
       lt.erase(it);

    }
    else
    {
    ++it;
    }
}

因此我们这里同样需要让迭代器去接受erase的返回值(返回下一个位置的迭代器)

六、常见接口的实现及其源码

5.1 insert 插入

在pos位置之前插入数据,我们需要知道pos位置的前驱指针,并将prev newnode 和pos节点三者重新进行连接,注意在插入完数据后还需要让_size++

cpp 复制代码
iterator insert(iterator pos, const T& x)
{
	Node* cur = pos._node;
	Node* prev = cur->_prev;
	Node* newnode = new Node(x);

	// prev newnode cur
	newnode->_next = cur;
	cur->_prev = newnode;
	newnode->_prev = prev;
	prev->_next = newnode;

	++_size;

	return newnode;
}

5.2 push_back /push_front

我们在前面实现了insert插入后,发现push_back /push_front不再需要自己实现,直接赋用insert即可,因为头插和尾插的底层依旧是insert

cpp 复制代码
void push_front(const T& x)
{
   insert(begin(),x);

}

//在哨兵位头结点之前插入数据相当于尾插
void push_back()
{
    insert(end(),x);
}

5.3 erase/pop_back/pop_front

删除pos位置节点,只需要找到pos位置的前驱和后继节点,再进行链接即可,最后不要忘记- -size

注意: erase不能删除哨兵位头结点

cpp 复制代码
iterator erase(pos)
{   

     assert(pos!=end());
     Node*prev=pos._node->prev;
     Node*next=pos._node->next;

     prev->_next=next;
     next->_prev=prev;
    
    delete pos._node;
    --size;
    
    return next;
}

实现完erase后,和insert一样,pop_back(尾删)也可以赋用erase

cpp 复制代码
void pop_back()
{   
//因为end指向的是最后一个有效节点的下一个位置,需要让end--走到最后一个有效位置
     erase(--end());
}

同理pop_front(头删)也是如此

cpp 复制代码
void pop_front()
{
//begin指的就是第一个有效节点,无需- -
   erase(--begin());
}

5.4 核心接口源码

list核心接口源码实现https://gitee.com/zero-point-civic/c-initial-stage/tree/master/list/list


总结

学完了list的底层实现后我们必须要知道const迭代器和普通迭代器如何实现遍历链表及按需实例化和链表核心接口的实现,最后感谢各位大佬的支持,你们的支持就是我前进的动力

相关推荐
编程岁月3 小时前
java面试0106-java什么时候会出现i>i+1和i<i-1?
java·开发语言·面试
Ms.lan3 小时前
C++数组
数据结构·c++·算法·visual studio
~~李木子~~3 小时前
归并排序算法
数据结构·算法·排序算法
练习时长一年4 小时前
Java开发者进阶之路
java·开发语言
去往火星4 小时前
文字转语音——sherpa-onnx语音识别离线部署C++实现
开发语言·c++
一点都不方女士4 小时前
.NET Framework 3.5官网下载与5种常见故障解决方法
c++·windows·framework·.net·动态链接库·运行库
semicolon_hello5 小时前
C++中 optional variant any 的使用
开发语言·c++
草莓熊Lotso6 小时前
《测试视角下的软件工程:需求、开发模型与测试模型》
java·c++·测试工具·spring·软件工程
报错小能手6 小时前
C++笔记(基础)string基础
开发语言·c++·笔记