C++:list(2)

1. list的模拟实现

和前面string和vector的模拟实现一样,我们也需要创建一个list的头文件:list.h,把函数声明和定义写在头文件当中。使用测试文件test.cpp来测试我们模拟实现出来的list的代码:

因为我们知道list的底层实际上是一个双向链表,就像这样:

_prev是前驱指针,存储上一个节点的地址,_next是后驱指针,存储下一个节点的地址,_data是数据域,存储自身要保存的有效数据。并且在双向链表中,头尾节点是互相指向的,即:节点1的_prev存储的是节点4的地址,节点4的_next存储的是节点1的地址。

标准命名空间里的list是用类模板的形式实现的,所以在我们模拟实现list时,也需要把它写成类模板的形式。而类模板有class类模板和struct类模板,这两个类模板的区别在于成员的默认访问权限,class默认成员的访问权限是:private,struct默认成员的访问权限是:public。

而在一般的习惯当中,list的头尾节点以及存储的数据内容是会被频繁进行读取的,所以第一种方法是写成友元函数,第二种方法就需要设计成"公有的"以便于读取,也就是public。所以我们在模拟实现list的时候习惯这样写:

1.1 list的默认构造函数

默认构造函数的作用是在实例化对象时,自动完成该对象的初始化。对于双向链表 来说,初始化就意味着有一个哨兵位 ,或者也可以称为哨兵节点。哨兵位是一个不存储有效数据的 "虚拟节点",它的核心作用是简化链表的插入、删除等操作的逻辑,避免处理 "空链表""头节点 / 尾节点单独判断" 的复杂情况。哨兵位有两个特点:

1. 不存有效数据:哨兵节点的_data字段通常无意义(不会被使用);

2. 构成循环结构 :在双向链表中,哨兵节点的 _next 指向链表的第一个有效节点,_prev 指向链表的最后一个有效节点;同时,链表的最后一个有效节点的 _next 指向哨兵节点,第一个有效节点的 _prev 也指向哨兵节点,最终整个链表形成一个 "双向循环链表"。

那么哨兵位的作用到底是什么呢?在没有哨兵位的普通双向链表中,删除头节点时,要单独判断 "头节点是否为空";删除尾节点时,要单独处理尾节点的指针;空链表时还要避免操作空指针,逻辑会很繁琐。而有哨兵位的双向循环链表:无论链表是否为空、删除的是头 / 尾节点,都可以用统一的逻辑操作,因为所有有效节点的前后指针都指向有效节点或哨兵节点,不存在 "空指针" 的情况。

所以我们的默认构造函数就可以这样写:

大家主要注意这一行代码:_head = new Node; 它的作用是:在 list 类模板执行默认构造函数创建空链表时,通过 new 关键字在堆上动态分配并创建一个 list_node<T>类型的哨兵节点,同时将该哨兵节点在堆上的内存地址赋值给 list 类的私有成员指针_head,使_head 指针成功指向这个哨兵节点。

并且非常重要的一点:_head**不是一个 "头节点",而是一个指向"哨兵节点"的指针。**也就意味着这个默认构造函数写出来之后,当我实例化一个对象时:

  1. 这个对象中只有一个_head指针,指向堆上的一个哨兵节点。

  2. 这个哨兵节点的前后指针都指向自己,形成了一个闭环。

  3. 此时实例化出来的这个链表是空的,没有存储任何有效数据。

1.2 list的插入函数:push_back

list的插入函数指的是尾插:push_back。刚刚我们对一个实例化的对象进行了初始化,接着就需要向其中插入数据。插入数据实际上是要改变原先链表当中尾节点的后驱指针即_next的指向,让其指向新节点,新节点的前驱指针指向原链表当中的尾节点,并且原先链表的前驱节点要指向新节点,新节点的后驱指针指向原先链表的头节点。这就意味着我们有四处地方需要改动:

并且大家看这一行代码:Node* newnode = new Node(x); ,这里使用关键字new 初始化了一个Node类型的数据,这就要调用Node类型的默认构造函数,在上面我们写了Node是list_node<T>的重命名,所以我们现在把这个list_node<T>的默认构造函数补上:

在这里我们最好写一个缺省值,但是大家要注意的是,在一开始我们也不确定到底会存储什么样的数据,所以不能直接写成x=1,x=2.5这样的形式,因为如果你写x=1,就默认x是int类型,写x=2.5就默认x是double类型,所以在这里我们直接使用T类型的默认构造函数,让其自动帮我们识别类型然后进行初始化。

1.3 list的迭代器

1.3.1 普通迭代器

我们之前在模拟实现string和vector的迭代器的时候,使用的都是它们的原生指针,因为它们在内存中的物理位置是连续的,但是对于链表来说,节点是分散在内存当中的,它的物理位置是不连续的。并且双向链表中存储的是三个内容,在第一次解引用指针的时候,得到的并不是我所需要的数据,而是我的目标节点。同时当我想对指针进行++操作以找到下一个节点的地址,在双向链表当中也是无法实现的,只能通过_next指针找到下一个节点。这也应证了我们之前说的:迭代器是类似指针的东西,但并不是指针。

那么这个时候符号运算符重载的作用就体现出来了,比如我可以重新去封装 ++ 这个符号,当我使用 ++ 的时候,就是让Node指向Node->next。或者是重新封装解引用 * 这个符号,当我使用这个符号时,实际上是取node里面的_data数据。

如果使用原生指针去模拟实现迭代器,无法达到预期效果,所以我们可以用类封装Node*,重载运算符,控制迭代器的行为。

我们将迭代器以类模板的形式实现,同时也和之前实现链表时一样,把链表节点的结构体list_node<T>重命名为Node以简化代码书写。随后在迭代器中定义了一个节点指针成员_node,而迭代器的构造函数_list_iterator接收一个Node*类型的参数node,该参数是外部传递过来的已存在的链表节点指针,其作用是让迭代器的成员_node指向这个传入的节点,从而明确迭代器要操作的具体链表节点。

我们拿前面写过的遍历打印操作举例,首先我们需要完成解引用和++的操作,并且我们看到还有一个判断是否相等的符号 != ,所以我们先把刚刚写的迭代器类模板完善一下:

解引用是要找到节点中的数据_data,++是要找到节点的下一个节点,然后我们还需要begin和end迭代器,这个时候我们再把_list_iterator重命名成iterator来符合我们之前的迭代器写法,然后在list类模板中编写begin和end迭代器:

这里end的返回的是_head,即哨兵位节点是因为:

在这段代码当中,我们的判断条件是it是否等于lt1的end迭代器,在循环当中还有一个++操作,因为我们的最后一个节点也是要打印的,打印完了之后++it,此时it走到哨兵位,这个时候才应该是结束的标志,所以end迭代器返回的是哨兵位。

接下来测试一下我们刚刚编写的代码:

我们接着再把迭代器更完善一下,既然有前置++,就会有后置++。有 != ,就会有 = ,展示一下代码:

这就是一个相对较完善的迭代器类模板。

1.3.2 const迭代器

另外我们还需要思考一下const迭代器的写法,我们这个const迭代器的目的是要让我迭代器本身可以修改,但是迭代器指向的内容不能修改,对于list链表来说,就不能像前面的vector一样直接在迭代器前面加const,因为这样的话就会限制迭代器本身不能修改,与我们的需求相违背。

核心原因在于二者底层存储结构与迭代器实现形式的差异:vector 底层是连续动态数组,其迭代器可直接用原生指针实现,普通迭代器对应T*,const 迭代器只需使用const T*(指向常量的原生指针)即可天然实现 "只读" 权限约束,无需额外编写代码。

而 list 底层是离散的双向链表,无法用原生指针作为迭代器,必须自定义类模板封装节点指针并手动重载迭代器相关操作,这种自定义类无法通过简单添加const实现权限限制

就像这样,下面红色框选的内容就是常见的错误写法,这样写是让迭代器本身不能被修改,那就无法进行迭代的操作了。

因此需要单独编写_list_const_iterator类模板,通过让其解引用运算符返回const T&来实现 "只读" 的 const 迭代器功能。在这里直接向大家展示代码:

我们是直接又写了一个const迭代器的类模板,把返回类型变成_list_const_iterator,然后再在list类模板中写const版本的begin和end迭代器:

这样的话const版本的迭代器也可以正常使用了。

但是这样的const版本的迭代器有一个弊端就是:太冗长了。我这个const版本的迭代器说实话只有在解引用的部分用了const T&,其他的都和普通的迭代器没什么区别,却还要写这么一大堆出来,有没有办法解决这个问题呢?这个时候模板的魅力就体现出来了,我们可以使用多参数模板:

我们可以把迭代器的模板写成多参数模板,因为既然只有在解引用部分的返回类型不一样,那我就用另一个参数来完成代码简化,在list模板当中的iterator和const_iterator也不是同一种东西,它们只不过是同一个类模板实例化出来的两种不同的类型。

那么此时,一开始写的const迭代器的冗长的代码就不需要了。但是要注意的是,因为list_iterator的模板参数从一个变为两个,那么list_iterator中的返回类型也都需要改变,这里我们可以再用typedef对返回类型进行重命名,我们暂且命名为:Self。

这里再次进行重命名的原因,第一是因为这个类型的长度也是挺长的,重命名的话可以解决代码冗长的问题,另外如果我们还需要再加一些参数的时候,只需要改那一行代码,而不用去修改其他行的返回类型。

1.3.3 特殊迭代器

介绍这个迭代器之前要先给大家一个场景:现在有一个棋盘,有横行和竖行,通过横行和竖行的数据就可以确定点位,现在在棋盘上任意确定几个点位,然后把点位遍历打印下来。

首先先确定一个类 Pow,然后编写默认构造函数。

接着将每个位置都视为一个节点,用链表存储,然后插入四个点位。此时我们会面临的一个问题该如何把每个点位打印出来?我们来观察一下 *it 到底是什么,从底层的角度来看,it迭代器解引用之后就是链表中存储的节点,也就是Pos类型的数据,我们想要取的是Pos当中的_row和_col,所以第一个办法是写成这样的形式:

既然*it是节点,并且这个节点是一个类,那我们就去找Pos中的两个对象。

还有一种方法,就是使用 " ->" 符号:

但是我们的迭代器是一个类,而作为自定义类中,如果没有重载这个符号的话,编译就不会通过,所以我们需要先重载运算符:

但是大家要注意的是,it -> 实际上是这样的形式:it.operator -> ( ) ,当这个函数运算完之后,返回的是节点中_data的地址,也就是 it -> 的结果是一个地址,所以按理来说真正的写法应该是这样:it -> -> _row,这才能取到_row的值,但是在C++中,这样的写法被优化了,两个箭头会被优化成一个,并且如果你想写两个的话,还会编译报错。

同样的,对于像Pos这样的自定义类型,我们也需要有特殊的迭代器,这时就可以继续使用多参数类模板:

这样的话特殊迭代器也完善了。

在此还要多说一句的是:

在这里这个类型名称比较长,我们还可以使用auto来优化代码。

1.4 list的插入和删除

这里要讲的插入和删除指的是:insert和erase函数,先讲insert函数。那为什么在这里又多讲一个插入函数insert呢?是因为对于这个insert函数,它已经与迭代器紧密相连了,所以我选择放在迭代器之后再讲它。

我们先看看list容器中的insert,参数中都有迭代器。经过前面的学习我们可以了解到,迭代器存在的原因就是为了避免需要底层细节这个繁琐的事情还能达到遍历的效果,效率是非常高的,所以使用频率会很高。

那么指定位置插入必然有一个位置pos,对于插入操作我们可以做出示意图:

我们先记录下pos位置的节点和pos位置的节点的前一个节点,然后将newnode这个新节点插入到其中,且insert函数还有返回值,返回值是刚刚插入进来的这个节点的迭代器:

大家可以从这段代码中了解到,为什么前面对于list节点和迭代器都要写成struct的类模板了,因为在平常的代码编写当中会频繁涉及到。

接下来是erase函数,是删除指定位置节点,先画出示意图:

删除cur节点之后,只需要将prev和next两个节点首尾相连即可:

大家还需要注意的是erase函数会涉及到迭代器失效的问题,所以它会有返回值,返回值是pos位置的下一个位置。

模拟实现了指定位置的插入和删除之后,我们可以用这两个函数来封装头尾的插入删除函数,即:push_back、push_front、pop_back、pop_front。

就像这样,代码就简洁了许多,然后我们来简单测试一下:

在删除当中我们还需要模拟实现一个clear函数,它的作用是删除掉所有的节点,除了哨兵节点:

我们顺便也可以把析构函数给写出来,析构函数就是相当于进阶版的clear,在清楚数据的前提上再把哨兵位也释放掉。

1.5 list的拷贝构造函数

我们所说的list的拷贝构造函数主要应对的是深拷贝,因为如果我们不主动编写拷贝构造函数的话,编译器自动生成的拷贝构造函数在进行拷贝的时候实现的是浅拷贝,而我们自己编写的拷贝构造函数进行的是深拷贝。

首先需要对象有自己的空间大小,然后把目标对象的内容依次尾插到拷贝对象当中。

并且因为构造函数和拷贝构造函数当中都有一个空初始化的操作,所以为了优化代码,我们可以把空初始化这段代码再重新封装成一个函数,这样的话代码使用起来就会简约很多:

除此之外我们还可以写一个构造函数用来支持初始化列表,这会用到标准命名空间里面的类模板initializer_list:

这样的操作可以支持我们有这样的写法:

但是如果当我想要进行赋值操作,比如写:lt1=lt2,此时我们还需要重载一下运算符,因为这是列表而不是一个常量对象,所以我们前面写的等于的符号运算符重载不能胜任。而这个重载有两种写法,一种是传统写法,一种是现代写法。

这是传统写法,先将 lt1 的节点清空,再把 lt2 的值依次插入到 lt1 中,而现代写法是这样的:

我们通过标准命名空间里面的swap函数将 lt1 和 lt2 的哨兵节点交换即可。

1.6 list的size函数

我们知道size函数的作用是获取元素个数,但是对于链表这个结构来说,想要得知它的节点个数是比较困难的,因为节点分散的存储在内存当中,获取个数比较麻烦。

第一种方法是遍历整个链表然后记录下遍历的次数。

还有一种办法就是直接设置一个成员变量,用来记录列表的个数。因为我们在前面写的头部尾部的插入、删除操作时,都是用insert和erase函数进行封装的,那我们就在erase和insert这两个函数当中,对链表的节点个数进行加减操作:

1.7 一点点小问题

我们在前面为了代码的简约,把打印的步骤封装成了一个函数print,但是我们当时封装的时候,这个print函数只适用于int类型的链表,如果是其他类型的链表,那么这个print函数就无法使用,所以我们想要把这个print函数写成类模板的形式,但此时会遇到一些问题:

问题主要出现在这一行代码上,在模板函数中,list<T>::const_iterator是一个依赖于模板参数 T 的名称,编译器在模板未实例化时,无法确定它是一个类型还是一个静态成员变量,因此需要用 typename 关键字来明确告诉编译器:这是一个类型。

代码就是这样子,或者更简洁的方法就像下面注释的那一行一样,直接使用auto自动识别类型,就可以避免这个模板未实例化的问题。

1.8 代码

cpp 复制代码
#pragma once

namespace chen
{
	template<class T>
	struct list_node
	{
		T _data;
		list_node<T>* _next;
		list_node<T>* _prev;

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

	template<class T , class Ref , class Ptr>
	struct _list_iterator
	{
		typedef list_node<T> Node;
		typedef _list_iterator<T, Ref , Ptr> Self;

		Node* _node;

		_list_iterator(Node* node)
			:_node(node)
		{}

		Ref operator*()
		{
			return _node->_data;
		}

		Ptr operator->()
		{
			return &_node->_data;
		}

		Self& operator++()
		{
			_node = _node->_next;
			return *this;
		}

		Self operator++(int)
		{
			Self tmp = *this;
			_node = _node->_next;
			return tmp;
		}

		Self& operator--()
		{
			_node = _node->_prev;
			return *this;
		}

		Self operator--(int)
		{
			_list_iterator<T> tmp = *this;
			_node = _node->_prev;
			return tmp;
		}

		bool operator!=(const Self& it) const
		{
			return _node != it._node;
		}

		bool operator==(const Self& it) const
		{
			return _node == it._node;
		}
	};

	//template<class T>
	//struct _list_const_iterator
	//{
	//	typedef list_node<T> Node;

	//	Node* _node;

	//	_list_const_iterator(Node* node)
	//		:_node(node)
	//	{}

	//	const T& operator*()
	//	{
	//		return _node->_data;
	//	}

	//	_list_const_iterator<T>& operator++()
	//	{
	//		_node = _node->_next;
	//		return *this;
	//	}

	//	_list_const_iterator<T> operator++(int)
	//	{
	//		_list_const_iterator<T> tmp = *this;
	//		_node = _node->_next;
	//		return tmp;
	//	}

	//	_list_const_iterator<T>& operator--()
	//	{
	//		_node = _node->_prev;
	//		return *this;
	//	}

	//	_list_const_iterator<T> operator--(int)
	//	{
	//		_list_const_iterator<T> tmp = *this;
	//		_node = _node->_prev;
	//		return tmp;
	//	}

	//	bool operator!=(const _list_const_iterator<T>& it) const
	//	{
	//		return _node != it._node;
	//	}

	//	bool operator=(const _list_const_iterator<T>& it) const
	//	{
	//		return _node = it._node;
	//	}
	//};

	template<class T>
	class list
	{
		typedef list_node<T> Node;
	public:
		typedef _list_iterator<T, T& , T*> iterator;
		typedef _list_iterator<T,const T& , const T*> const_iterator;

		//typedef _list_const_iterator<T> const_iterator;

		iterator begin()
		{
			return iterator(_head->_next);
		}
		iterator end()
		{
			return iterator(_head);
		}

		const_iterator begin() const
		{
			return const_iterator(_head->_next);
		}
		const_iterator end() const
		{
			return const_iterator(_head);
		}


		void empty_initialization()
		{
			_head = new Node;
			_head->_next = _head;
			_head->_prev = _head;
		}

		list()
		{
			empty_initialization();
		}

		list(const list<T>& lt)
		{
			empty_initialization();

			for (auto& e : lt)
			{
				push_back(e);
			}
		}

		list(initializer_list<T> li)
		{
			empty_initialization();
			for (auto& e : li)
			{
				push_back(e);
			}
		} 

		void swap(list<T>& lt)
		{
			std::swap(_head, lt._head);
		}

		list<T>& operator=(list<T> lt)
		{
			swap(lt);
			return *this;
		}

		//list<T>& operator=(const list<T>& lt)
		//{
		//	if (this != &lt)
		//	{
		//		clear();
		//		for (auto& e : lt)
		//		{
		//			push_back(e);
		//		}
		//	}
		//	return *this;
		//}

		~list()
		{
			clear();
			delete _head;
			_head = nullptr;
		}

		void clear()
		{
			iterator it = begin();
			while (it != end())
			{
				it = erase(it);
			}
		}


		void push_back(const T& x)
		{
			insert(end(), x);
		}
		void push_front(const T& x)
		{
			insert(begin(), x);
		}

		void pop_back()
		{
			erase(--end());
		}
		void pop_front()
		{
			erase(begin());
		}


		iterator insert(iterator pos, const T& val)
		{
			Node* cur = pos._node;
			Node* prev = cur->_prev;
			Node* newnode = new Node(val);

			prev->_next = newnode;
			newnode->_prev = prev;
			newnode->_next = cur;
			cur->_prev = newnode;
			++_size;
			return iterator(newnode);
		}

		iterator erase(iterator pos)
		{
			Node* cur = pos._node;
			Node* prev = cur->_prev;
			Node* next = cur->_next;

			prev->_next = next;
			next->_prev = prev;
			delete cur;
			--_size;
			return iterator(next);
		}

		//size_t size() const
		//{
		//	size_t n = 0;
		//	for (auto& e : *this)
		//	{
		//		++n;
		//	}
		//	return n;
		//}

	private:
		Node* _head;
		size_t _size;
	};
}

本文到此结束,如果有讲解的不好或者错误的地方,请大家批评或指正,感谢各位读者的阅读。

相关推荐
Huangichin2 小时前
C++期末复习
数据结构·c++·算法
草莓熊Lotso2 小时前
Linux 命令行参数与环境变量实战:从基础用法到底层原理
linux·运维·服务器·开发语言·数据库·c++·人工智能
枫叶丹42 小时前
【Qt开发】Qt系统(七)-> Qt网络安全
c语言·开发语言·c++·qt·网络安全
草莓熊Lotso2 小时前
Qt 控件核心入门:从基础认知到核心属性实战(含资源管理)
运维·开发语言·c++·人工智能·后端·qt·架构
曹轲恒10 小时前
Java中断
java·开发语言
施棠海11 小时前
监听与回调的三个demo
java·开发语言
時肆48511 小时前
C语言造轮子大赛:从零构建核心组件
c语言·开发语言
赴前尘11 小时前
golang 查看指定版本库所依赖库的版本
开发语言·后端·golang
de之梦-御风11 小时前
【C#.Net】C#开发的未来前景
开发语言·c#·.net