C++入门第八篇---STL模板---list的模拟实现

前言:

有了前面的string和vector两个模板的基础,我们接下来就来模拟实现一下list链表模板,我还是要强调的一点是,我们模拟实现模板的目的是熟练的去使用以及去学习一些对于我们本身学习C++有用的知识和用法,而不是单纯的去模拟实现。希望大家在学习之前先搞清楚目的再去行动,切忌盲目努力。

list的大致介绍:

在STL模板中,list模板实现的是一个双向带头循环的链表。
1. list是可以在常数范围内在任意位置进行插入和删除的序列式容器,并且该容器可以前后双向迭代。
2. list的底层是双向链表结构,双向链表中每个元素存储在互不相关的独立节点中,在节点中通过指针指向其前一个元素和后一个元素。
3. list与forward_list非常相似:最主要的不同在于forward_list是单链表,只能朝前迭代,已让其更简单高效。
4. 与其他的序列式容器相比(array,vector,deque),list通常在任意位置进行插入、移除元素的执行效率更好。
5. 与其他序列式容器相比,list和forward_list最大的缺陷是不支持任意位置的随机访问,比如:要访问list的第6个元素,必须从已知的位置(比如头部或者尾部)迭代到该位置,在这段位置上迭代需要线性的时间开销;list还需要一些额外的空间,以保存每个节点的相关联信息(对于存储类型较小元素的大list来说这可能是一个重要的因素)

所以在这里我想说一下,对于想要频繁支持任意位置增删的数据来说,使用list更为划算,但list遍历却很麻烦,但vector对于增删很麻烦,需要全部串动一遍数据,不过对于任意位置的访问却很简单,这就是两者在不同情况下的使用特点,我们应该按照不同的场景去灵活使用。
可以用下面的这张图来理解:
在这里插入图片描述
有了前面的双向带头循环链表模拟实现的基础,现在让我们正式开始模拟实现吧。

模拟实现list:

1.节点 链表 :

节点:

首先,对于链表来说,每一个节点都应该是一个独立的结构体,我在这里将其设为结构体,其目的就是让其数据都是开放的,在C++中默认struct类型是public权限的,然后就是常规的节点结构体的书写方法如下:

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

	list_node(const T& x=T())//注意这里不要给赋值,C++STL模板也是支持对内置类型进行拷贝构造的,而这里的数据不一定是内置类型,一旦是自定义类型就得调用拷贝构造了,所以我们这里使用匿名构造
		:_data(x),
		,_next(nullptr)
		,_prev(nullptr)
	{}
};

处于为了让我们的每一个节点可存储的数据是任意类型的,我们使用模板来定义类,同时我们写出来我们这个节点类的构造函数,其原理很简单,但是我们要注意我们的缺省参数的给法,在模板使用之后,我们的内置类型也开始能支持构造函数的,同时我们的节点的数据也有可能是自定义类型,所以我们在这里给缺省值直接给其匿名构造的缺省值,这样同时满足了内置类型和自定义类型双重数据类型可以通过拷贝构造,这个很关键,我在vector那里讲过,在这里我再提及一次。,然后就是很常规的把指针先指向空和我们的数据给过去即可。

链表:

首先由于我们要实现的是一个双向带头循环的链表,那么自然我们只需要记录我们的头节点即可,通过头节点我们可以去访问任意的数据,通过指针的迭代即可。所以,我们的链表类的成员只要包含一个头节点的指针以及一个记录数据个数的size即可,如下:

private:
	Node* _head;//类的成员只有一个哨兵位节点作为头节点
	size_t _size;//利用这个变量实时统计,就省去了从头遍历一遍链表的时间

我们同样需要对头节点进行初始化,我在这里这样实现:

void empty_init()//空初始化,创建一个哨兵节点出来
{
	_head = new Node;
	_head->_next = _head;
	_head->_prev = _head;
}
list()//构造函数
{
	empty_init();
	_size = 0;
}

有了对头节点的初始化,我们同时直接把链表类的构造函数写出来,即初始化头节点的同时再初始化size即可。
有了这两步,我们的链表的大体模型就出来了,创造节点类和链表类链接。

2.迭代器:

首先让我们考虑一下迭代器的本质,我们都知道迭代器的本质是对指针进行操控,解引用,迭代加加,判断是否到结尾。通过封装一个指针,我们是可以做到这些的,例如string和vector,因为首先他们都是以数组为基本的容器去处理的,并且他们很多都是单一的数据类型,直接解引用就能得到,但是节点不同,首先节点内部就存在多个成员变量,也就是说,对于自定义类型的解引用是没有默认的,我们必须要自己去写运算符重载才可以。再说加加和减减的问题。我们为什么可以对vector和string加加和减减呢?这是因为它们的底层都是数组,其空间地址是连续的,指针可以通过加加连续的迭代,但是对于链表来说,每一个节点都是一个独立的个体,他们的空间位置是不连续的,你指针的加加和减减毫无意义,包括判断结尾也是,你没有默认的判断方式,你可以用下面这张图理解我的意思:
在这里插入图片描述
那怎样解决这个问题呢?由此,我们就封装一个类来模拟迭代器,通过运算符重载去解决问题,这个便是我们list库的最关键最核心的部分,即在处理我们没法直接利用指针去封装的自定义类型的迭代器时,通过使用类去构建一个迭代器模拟指针来实现。

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)
	{}
	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)//后置--
	{
		self tmp(*this);
		_node = _node->_prev;
		return tmp;
	}
	 Ref operator*()//迭代器的解引用
	{
		return _node->_data;
	}
	 Ptr operator->()//箭头解引用,针对数据_data为自定义类型的时候方便我们去访问数据
	{
		return &_node->_data;
	}
	bool operator!=(const self& s)
	{
		return _node != s._node;
	}
	bool operator==(const self& s)
	{
		return _node == s->_node;
	}
};

在这里我同样使用一个struct来封装类,这样方便我们后续的数据访问不会受到权限的限制,我们的成员只有一个,那便是我们的节点的指针Node* node,我们依旧是去模拟指针的作用
1.首先是构造函数,对于迭代器来说,他没有缺省值的可能性,也就是说只要使用了迭代器必然要为其赋一个初值。然后就是常规的构造过程,我在这类不多赘述了。
2.下一个便是前置加加和后置加加的问题,结合前面学过的知识,为了区分他们两个我们要在后置带上一个int以示区分,在这里注意前置和后置的返回值问题,前置由于直接操作指针,故我们返回的是之前存在的node,故我们引用返回,而对于后置来说,我们返回的是当前的指针,但实际上我们的node已经指向下一个了,这就需要我们再创建一个变量来存储原先的位置,所以我们的返回值是传值返回,这个细节要注意别弄错了。
3.对于解引用的问题,同样由于我们的data本身就是存在的,所以我们依旧使用引用返回,由于node本身是结构体的指针,故我们要使用箭头去访问而不是.。
4.对于箭头的返回,我们在这里返回的是我们data的地址而不是data本身,因为我们的data也有可能是一个自定义类型的数据,这导致我们可能还需要一层访问去确定访问我们data数据里的哪一个数值。
在这里有一个很奇怪的地方:如果我们的data真的是自定义类型,如下:

struct AA
{
	AA(int a1=0,int a2=0)//自定义类型的构造函数
		:_a1(a1)
		,_a2(a2)
	{}
	int _a1;
	int _a2;
};
void test3()
{
	list<AA> a1;
	list<AA>::iterator it1 = a1.begin();
	while (it1 != a1.end())
	{
		cout << it1->_a1 << " " << it1->_a2 << endl;//在这里它隐藏了一个箭头,因为我们哪怕是访问it1里面的operator的data后,这里的data也是AA类型的,然后才能去访问AA里面的数据,由于我们取地址,所以我们访问也是->去访问,这是一个很奇怪的点,希望特殊去记住,这里本来是it1.operator->()->_a1,但是在这里直接省略了一个箭头
		it1++;
	}

你会发现一个问题是,我们只需要一个箭头就能访问到a1或者a2,但实际上我们的第一个箭头应该是先访问我们的data的地址,然后通过data再去访问我们里面的具体元素,也就是说在这里它省略了一个箭头,但是这样也可以访问,我认为其本质原因在于,用两个箭头对于我们来说不是很好理解,所以编译器在这里优化了一下,省略了其中的一个箭头,变得让我们更好理解了,但我们自身不能忽略,实际上它应该是it1.operator->()->_a1.
5.对于判断相等和不相等的问题,很简单,我们直接利用指针是否相等即可。
这样,通过运算符重载和封装,我们变得到了我们的迭代器类,但是此时我们还有一个问题需要解决,即对于const类型的数据访问我们要特殊处理,这时候就有人提出了一个问题:这不是很简单么?直接在我们的iterator迭代器上加一个const不就解决问题了么?
这是个很严重的误解:如果我们对iterator前面加上const,在这里我们甚至没法对指针本身进行修改了,因为它const限制的是我们类里面的数据,而我们是需要类里的node的变化去访问数据的,所以,显然直接加const是不行的,我们可以再去定义一个新的类型const_iterator,让它作为我们的const迭代器即可,但是,重新写一个未免太麻烦了,能不能用模板的知识来简化代码呢?
这是完全可以的,让我们看一看我们迭代器代码的前几行:

template<class T,class Ref,class Ptr>
struct __list_iterator
{
	typedef list_node<T> Node;
	typedef __list_iterator<T,Ref,Ptr> self;

你会发现,我同时定义了三个模板变量,那这是为什么呢?
让我们想一想我们模拟指针主要模拟的是哪些东西:解引用,指针操作,数据本身,他们实际上反映在我们的返回值上,也就是T T& T*这三个方面,其他的对于const迭代器和非const迭代器来说都是相同的,因此,我们定义三个模板变量,让编译器自己去做选择,你可以看到我迭代器的返回值直接就是Ref Ptr,然后我下面的list去typedef的时候,就直接是:

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;

这样,编译器根据你的名字为其自动匹配迭代器是const还是非const,从而在返回值部分返回对应的模板实例化的返回类型,从而让我们实现了一份代码就可以实现双类型迭代器的作用,这个很关键,是list的核心部分,我的建议是反复研究琢磨透为止。
封装了我们的迭代器iterator 和const_iterator后,我们接下来的函数功能就很简单了,我下面几乎都上代码做简单的讲解:

3.增删:

1.任意插入:

iterator insert(iterator pos,const T& x)//任意插,在pos位置之前去插入一个值
{
	Node* cur = pos._node;
	Node* newnode = new Node(x);
	Node* prev = cur->_prev;
	prev->_next = newnode;
	newnode->_prev = prev;
	newnode->_next = cur;
	cur->_prev = newnode;
	_size++;
	return newnode;
}

2.任意删除:

iterator erase(iterator pos)//任意删,这里会涉及到迭代器失效的问题,pos对应的空间被释放后,pos的指向就无效了,故我们在这里返回它的下一个位置以防止迭代器失效
{
	Node* cur = pos._node;
	Node* prev = cur->_prev;
	Node* next = cur->_next;
	delete cur;
	prev->_next = next;
	next->_prev = prev;
	_size--;
	return next;
}

4.链表节点个数:

size_t size()//链表的节点个数
{
	return _size;
}

这里便是我为何要创建一个size成员的原因,因为链表的遍历统计个数很麻烦,所以我们实时统计,直接就省去了遍历的过程,节省运算的时间。

5.头尾迭代器位置返回:

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

const_iterator end()const
{
	return _head;
}

iterator begin()//构成重载,自动匹配
{
	return _head->_next;
}

iterator end()
{
	return _head;
}

你会看到这里,有了我们的模板,我们的一套迭代器就可以像以前那样去使用了。
在这里我要说我对迭代器的理解:
迭代器完美体现了封装,倘若不模拟,我们之只需要一套方法使用,但是模拟是完全不同的,封装屏蔽了底层实现和封装细节,提供统一的增删查改的遍历方式,你会发现,对于任意的数据类型,他们的迭代器的底层是天差地别的,但是他们使用起来确实方法相同的,这便是迭代器最为巧妙的地方。

6.拷贝构造 赋值运算符重载:

拷贝构造:

我们的拷贝构造,在这里由于浅拷贝的原因,我们和我们的vector一样,同样使用依次遍历尾插到结尾的方式,如下:

list(const list<T>& it)//拷贝构造
{
	empty_init();
	for (auto ch : it)//遍历it,一个一个插入到我们要构造的list中
	{
		push_back(ch);
	}
}

赋值运算符重载:

利用现代写法,直接交换头节点即可:

void swap(list<T> it)
{
	std::swap(_head, it._head);//注意,我们的交换在这里要用std自带的交换,在这里直接交换头指针即可,其他的根本不用交换,成员里本身也没有
	std::swap(_size, it._size);
}
list<T>& operator=(list<T> it)//赋值运算符重载现代写法,即直接拷贝构造交换即可
{
	swap(it);
	return *this;
}

7.清除数据和析构函数:

清除数据:

注意,在这里要注意的问题是,我们的清除数据不是将整个链表销毁,而是清楚数据,所以我们要保留我们的头节点,头节点是在析构函数的时候才会被消除的,这是清除数据和析构函数的不同之处,我们要想清楚。

void clear()//清除数据,注意清理数据不是完全销毁链表,所以我们不销毁头节点
{
	iterator it = begin();
	while (it != end())
	{
		it=erase(it);//这里不用加加,it自动返回下一个节点,我们直接用it接收即可
	}
}

在这里我们采用一个一个删除的方式进行,注意我们的erase是会返回下一个位置的值的,故我们的it要接收,否则会有迭代器失效的问题。

析构函数:

本质上就是清除数据+销毁头节点:

	~list()//析构函数
	{
		clear();
		delete _head;
		_head = nullptr;
		_size = 0;
	}

以上便是我们的list最关键的一些功能的模拟实现,其余的功能有了这些基础实现起来是很简单的,在这里我就不多说了。

总结:

对于list来说,封装一个迭代器,这个是很关键的,我认为这是我们对类和对象的进一步理解才能完全掌握的知识,所以我的建议是我们要反复思考和模拟实现这个迭代器,或者你可以上网去找一找我们STL–list库的底层,其琢磨为何要这样去实现库,这将有助于我们理解迭代器,同时帮助我们去更好的使用list模板库。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/171266.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

泛型进阶:通配符

基本概念 对泛型不了解的可以看这篇博客&#xff1a;数据结构前瞻-CSDN博客 一般来说&#xff0c;&#xff1f;在泛型里的使用就是通配符 看看下面的代码 class Message<T> {private T message ;public T getMessage() {return message;}public void setMessage(T m…

Qml使用cpp文件的信号槽

文章目录 一、C文件Demo二、使用步骤1. 初始化C文件和QML文件&#xff0c;并建立信号槽2.在qml中调用 一、C文件Demo Q_INVOKABLE是一个Qt元对象系统中的宏&#xff0c;用于将C函数暴露给QML引擎。具体来说&#xff0c;它使得在QML代码中可以直接调用C类中被标记为Q_INVOKABLE的…

【Sql】sql server还原数据库的时候,提示:因为数据库正在使用,所以无法获得对数据库的独占访问权。

【问题描述】 sql server 还数据库的时候&#xff0c;提示失败。 点击左下角进度位置&#xff0c;可以得到详细信息&#xff1a; 因为数据库正在使用&#xff0c;所以无法获得对数据库的独占访问权。 【解决方法】 针对数据库先后执行下述语句&#xff0c;获得独占访问权后&a…

Python 和 Ruby 谁是最好的Web开发语言?

Python 和 Ruby 都是目前用来开发 websites、web-based apps 和 web services 的流行编程语言之一。 【这个时候又人要说PHP是世界上最好的语言了】 我就不说PHP 最好的方法 VS 以人为本的语言 社区: 稳定与创新 尽管特性和编程哲学是选择一个语言的首要驱动因素&#xff0c…

stack和queue简单实现(容器适配器)

容器适配器 stack介绍stack模拟实现queue 介绍queue模拟实现deque stack介绍 stack模拟实现 以前我们实现stack&#xff0c;需要像list,vector一样手动创建成员函数&#xff0c;成员变量。但是stack作为容器适配器&#xff0c;我们有更简单的方法来实现它。 可以利用模板的强大…

C语言生成dll与lib文件

环境要求 新建一个空白项目&#xff0c;可以是exe的&#xff0c;也可以直接是dll的&#xff0c;也可以是啥都没有的空项目&#xff0c;推荐创建空项目&#xff0c;项目创建好以后进行配置&#xff0c;共两步 第一步&#xff0c;打开项目属性 第二步&#xff0c;设置配置类型…

基础课10——自然语言生成

自然语言生成是让计算机自动或半自动地生成自然语言的文本。这个领域涉及到自然语言处理、语言学、计算机科学等多个领域的知识。 1.简介 自然语言生成系统可以分为基于规则的方法和基于统计的方法两大类。基于规则的方法主要依靠专家知识库和语言学规则来生成文本&#xff0…

java中的抽象

1.当一个类中给出的信息不够全面时&#xff0c;&#xff08;比方说有无法确定的行为&#xff09;&#xff0c;它给出的信息不足以描绘出一个具体的对象&#xff0c;这时我们往往不会实例化该类&#xff0c;这种类就是抽象类。 2. 在Java中&#xff0c;我们通过在类前添加关键字…

Redis篇---第九篇

系列文章目录 文章目录 系列文章目录前言一、如果有大量的 key 需要设置同一时间过期,一般需要注意什么?二、什么情况下可能会导致 Redis 阻塞?三、缓存和数据库谁先更新呢?前言 前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击…

南京--ChatGPT/GPT4 科研实践应用

2023年随着OpenAI开发者大会的召开&#xff0c;最重磅更新当属GPTs&#xff0c;多模态API&#xff0c;未来自定义专属的GPT。微软创始人比尔盖茨称ChatGPT的出现有着重大历史意义&#xff0c;不亚于互联网和个人电脑的问世。360创始人周鸿祎认为未来各行各业如果不能搭上这班车…

MySQL 数据库下载

1 最新版 MySQL :: Download MySQL Community Server 2 存档版本(Archived Versions)-历史版本 MySQL :: Download MySQL Community Server (Archived Versions) 3 下载(样例: zip 方式) tip1&#xff1a; 可以下载安装文件的方式&#xff0c;也可以使用压缩包方式&#xff…

Git详解

Git是一个开源的分布式版本控制系统&#xff0c;常用于软件开发中对代码版本管理。Git具有版本控制、协作开发、分支管理、代码审查等功能&#xff0c;能够记录每次代码修改的内容和时间&#xff0c;并能够回滚到任意历史版本&#xff0c;方便团队协作和代码维护。 Git的基本概…

腾讯云5年服务器2核4G和4核8G配置租用价格表

腾讯云百科整理五年云服务器优惠活动 txybk.com/go/txy 配置可选2核4G和4核8G&#xff0c;公网带宽可选1M、3M或5M&#xff0c;系统盘为50G高性能云硬盘&#xff0c;标准型S5实例CPU采用主频2.5GHz的Intel Xeon Cascade Lake或者Intel Xeon Cooper Lake处理器&#xff0c;睿频3…

SQL 中的 NULL 值:定义、测试和处理空数据,以及 SQL UPDATE 语句的使用

SQL NULL 值 什么是 NULL 值&#xff1f; NULL 值是指字段没有值的情况。如果表中的字段是可选的&#xff0c;那么可以插入新记录或更新记录而不向该字段添加值。此时&#xff0c;该字段将保存为 NULL 值。需要注意的是&#xff0c;NULL 值与零值或包含空格的字段不同。具有 …

大数据Doris(二十五):Stream Load数据导入演示和其他导入案例

文章目录 数据导入演示和其他导入案例 一、数据导入演示

万字解析设计模式之 装饰者模式

一、装饰者模式 1.1概述 装饰者模式是一种结构型设计模式&#xff0c;它允许在运行时动态地为一个对象添加额外的职责。它以一种透明的方式来扩展对象的功能&#xff0c;而不需要通过子类来实现。在装饰者模式中&#xff0c;有一个基本对象&#xff0c;也称为组件&#xff0c;…

2023年亚太杯数学建模思路 - 案例:感知机原理剖析及实现

文章目录 1 感知机的直观理解2 感知机的数学角度3 代码实现 4 建模资料 # 0 赛题思路 &#xff08;赛题出来以后第一时间在CSDN分享&#xff09; https://blog.csdn.net/dc_sinor?typeblog 1 感知机的直观理解 感知机应该属于机器学习算法中最简单的一种算法&#xff0c;其…

鸿蒙原生应用/元服务开发-AGC分发如何编译打包应用

软件包规范 在正式打包应用前&#xff0c;请确保已了解HarmonyOS应用软件包规范。 操作步骤 1.打开DevEco Studio&#xff0c;菜单选择“Build > Build Hap(s)/APP(s) > Build APP(s)”。 2.等待编译构建。编译完成后&#xff0c;将在工程目录“build > outputs >…

Vue2问题:分享一个通用多文件类型预览库

前端功能问题系列文章&#xff0c;点击上方合集↑ 序言 大家好&#xff0c;我是大澈&#xff01; 本文约2000字&#xff0c;整篇阅读大约需要3分钟。 本文主要内容分三部分&#xff0c;第一部分是需求分析&#xff0c;第二部分是实现步骤&#xff0c;第三部分是问题详解。 …

一文详看大模型长文本如何评估:四大主流评测数据集的任务设计、数据集构建方案

大语言模型&#xff08;LLM&#xff09;尽管在各种语言任务中表现抢眼&#xff0c;但通常仅限于处理上下文窗口大小范围内的文本。 有越来越多的基准被提出来测试LLM的长文本理解能力。 当前具有代表性的长文本评测主要包括Zero-SCROLLS、L-Eval、LongBench以及loogle四个基准…
最新文章