C++STL初阶(8):list的简易实现(上)

经过对list的使用的学习,我们模拟STL库中的list,实现自己的list

目录

1.观察库中的链表的实现

2.list的实现

[2.1 基本结构](#2.1 基本结构)

[2.2 基本接口](#2.2 基本接口)

3.迭代器(本篇重点)

[3.1 迭代器的构建](#3.1 迭代器的构建)

[3.2 联系list](#3.2 联系list)

[3.3 ListIterator的完善](#3.3 ListIterator的完善)

[3.3.1 不需要写析构函数](#3.3.1 不需要写析构函数)

[3.3.2 拷贝构造和赋值运算符](#3.3.2 拷贝构造和赋值运算符)

[3.3.3 operator-> (双箭头变单箭头)](#3.3.3 operator-> (双箭头变单箭头))

4.const迭代器

[5. list中的insert和erase](#5. list中的insert和erase)


1.观察库中的链表的实现

与观察库中的vector相同,list依然是只观察最主要的部分即可: stl_list.h

(需要代码压缩包的可以评论区留言或者私信我)

首先一上来就是一个结构体模版 , 此处库中将void_pointer(也就是指向自己的指针)设计成一个void* , 所以在之后的使用中必定涉及到强转来使用,我们更建议直接设计成T*

然后是一个超长的和iterator有关的结构体模版的定义:

(此处没有截图完整,在压缩包中48~94行)

由此我们可以推测,双向迭代器是一个类封装的,++不再是vector中简单的原生指针。++

最后来到链表,通过观察我们可发现,主要成员变量就是一个node:

++而node的类型是一个节点的指针++

初始化:无参构造就是实现一个哨兵位,哨兵位的next和prev都指向自己

get_node:通过内存池(为了效率)或得空间

其它插入方法:

push_back 以及 insert

都是将原来的元素往后顺移一个,将希望放入的元素放在position的位置

我们还能观察出,position被当作一个指针一样通过.去访问对应的元素。

在学习C语言时期使用的单链表需要单独考虑为空的时候的操作,但是当我们使用带头双向循环链表时就能发现,只有头结点的时候这样操作也是没有问题的。


2.list的实现

2.1 基本结构

在C语言的实现中,链表中的每一个元素都是通过结构体包含的,我们此处也使用struct而非class,具体原因我们之后再解释。

节点:

由头尾指针,一个数据构成。

链表:

ListNode<T>这个名字太长了,我们将其typedef一下(库中也是这么实现的)。

类成员只需要一个哨兵位节点的指针,也就是_head即可。

通过一个哨兵位的节点就能找到所有的节点

2.2 基本接口

编译器自动生成的构造函数就是生成一个指针_head,但是list一定是需要开出一个确切的空间的

需要我们自己实现默认构造

因此,默认构造就是生成一个哨兵位。调用此函数一定是该链表刚开始被创建的时候,我们应当让此时在堆上开出的哨兵位的next和prev都指向自己

cpp 复制代码
list() {
	_head = new Node();
	_head->_next = _head;//_head是Node类型的变量,我们此处访问他的成员变量
	_head->_prev = _head;//如果Node(也就是ListNode<T>)是一个class
}                        //成员变量默认就会变成私有,会被拒绝访问 

new Node()相当于new了一个ListNode<T> , 也就是调用了ListNode<T> 的 默认构造

复习如何push_back:

先找到当前的tail , 记录此节点的prev , 然后改变tail的next的指针指向,指向新加入的元素。

cpp 复制代码
void push_back(const T& x) {
	Node* tmp = new Node(x);
	Node* pretail = _head->_prev;

	pretail->_next = tmp;
	tmp->_next = _head;
	tmp->_prev = pretail;
	_head->_prev = tmp;

为什么struct而不是class:

如果我们之前的节点用的是class,写这样一段代码之后在主程序运行会报错(并且不实例化就不会报错,在运行之前编译器不会发现这个错误),原因如下:

  1. 模版不会被编译。只有在实例化以后才会编译模版类

并且不是所有的成员函数都会实例化,调用哪个才实例化哪个(按需实例化),因此其他没有被使用的函数可能会有语法错误,但是不会被发现。只有当使用这个有错误的语法后,错误才会被报出

  1. ListNode没有构造函数

因为我们push_back中写了一个new Node(x);

而此时我们没有写这样的传参构造。

★★★但是我们此时不写push_back是不会报错的,再来梳理一下我们的构造:

只写上图中43行的默认构造的代码是没问题的,我们执行list<int> li1;后,直接调用list()构造。

在new Node()的过程中,因为结构体模版是可以实现自动生成默认构造的,所以我们直接不传参即可。

在new出来但是执行26、27行代码之前,_next和_prev都是走的编译器默认生成的结构体中的构造函数,作为指针被赋值为0x00000000000000000000

然后我们执行26 27行代码,将开出来的Node的地址赋给_next和_prev,完成哨兵位节点的创建。list的初始化是和Node的初始化分不开的,两者相互联系。

++注意:new Node()和new Node是不一样的,后者不会调用构造函数,只是开空间++

3.访问权限问题

原因在于class下默认的都是private,是不能被访问的。

然后我们又在list的private下访问了_next和_prev,所以必须用struct,否则:

解决如下:

cpp 复制代码
template<typename T>
struct ListNode {
	ListNode<T>* _next;
	ListNode<T>* _prev;
    T data;

	ListNode(const T& x = T())
		:_next(nullptr),//一个新创建的节点的next和prev不需要指向任何地方,赋给nullptr即可
		_prev(nullptr),
		_data(x)
	{}
};

或者class加上修饰限定符public也可以:

当一个类需要全部被访问,不希望有private修饰的成员时,cpp多直接用struct


关于哨兵位的初始化:不能用0,因为不确定T到底是int还是string或者vector等。。。。

​​​​​​​​​​​​

建议使用匿名构造:

或者全缺省( 反正我们在ListNode<T>里面是加了参数T()的 )


3.迭代器(本篇重点)

3.1 迭代器的构建

之前都是使用的原生指针T*作为iterator

因为其物理空间都是连续的,所以可以直接用指针。

而我们刚才由源码中观察得到,链表的指针是封装过的自定义类型。

由上一文得,因为我们的iterator是需要能够++和--的,倘若iterator是一个Node* 的话

原生指针的++--都是加减一个类型的大小,Node*的++--只能跨过一个相同大小的空间去到相邻的一个应该不存在的Node

但是,Node*是有办法找到下一个节点或者上一个节点的,如果我们能让这个iterator的++是去移动到(*iterator)._next对应的那个元素,--是移动到_prev对应的那个元素就好了。

因此,我们的做法是:

自己封装一个iterator,然后重载运算符

为了避免混淆名字,别直接取iterator------------因为每一种数据结构都包含iterator

封装Node* , 因为其作为一个类,是可以重载++ 和 --的,这样就达到了我们的目的

然后再在list类内部用typedef规范我们的迭代器即可。

cpp 复制代码
typedef ListIterator iterator ;

实现iterator:

先实现框架:

cpp 复制代码
template<typename T>
class ListIterator {
    
	typedef ListNode<T> Node;
	typedef ListIterator<T> _self;//这里是模仿库中的写法,将自己的名字缩短一点
    Node* _pnode;
public:
	ListIterator(Node* pnode) //之后在list中就可以通过iterator it(li1.begin())等构造
		:_pnode(pnode)// iterator it = li1.begin()也可以,这是隐式类型转换
	{}

	operator++
	operator--	
};

再来实现++(要在参数处加int才是后置加加):

cpp 复制代码
_self& operator++() {
	_pnode = _pnode->_next;
	return *this;
}

this是指向ListIterator的指针,我们让iterator指向了下一个节点,并且传引用返回了它自己

小练习:

cpp 复制代码
_self operator++() {
	return _self(_pnode->_next);//测试一下能不能跑,并阐述理由
}

答案是不会编译报错,也能运行,但是这样并没有起到++的作用,而是+1的作用。

同理实现--和解引用:

cpp 复制代码
_self& operator--() {
	_pnode = _pnode->_prev;
	return *this;
}
T& operator*() {
	return _pnode->_data;
}

使用迭代器访问各种元素时,经常用到!=判断是否走到了末尾:

cpp 复制代码
bool operator!=(const _self& it) {
	return this->_pnode != it._pnode;
}

除了前置++--,还有后置的:


3.2 联系list

再回到list中:

begin()需要指向的是head->next

end()需要指向的是最后一个节点的下一个位置,所以就是头结点

cpp 复制代码
iterator begin() {
	return iterator(_head->_next);
}
iterator end() {
	return iterator(_head);
}

同时,在调用测试时:

因此还需要把 ListIterator中的几个函数变为公有。

(!=作为重载的运算符,因为需要访问it的_node所以也是不能访问的)


如果此时只有一个头结点也不要紧,it表示的是begin->next,是自己,end也是自己,就不会进入该循环,逻辑是自洽的

并且,由于for循环就是自动去找iteraotr和begin()与end()三个名字,我们也能使用循环for

我们不能直接改变内置类型的行为,但是可以通过封装的方式来将该内置类型封装成想要的样子。

理解清楚这张图:

this是一个指向self(也就是ListIterator<T>)的指针 ,return *this就相当于把这个iterator传回去了


3.3 ListIterator的完善

3.3.1 不需要写析构函数

Node说到底其实是链表的东西,所以在封装的iterator中不能直接给他释放了

3.3.2 拷贝构造和赋值运算符

都使用浅拷贝即可,系统自动生成的就是浅拷贝

因为我们的类成员只有一个指针,在对iterator使用=时就是希望能够让两个iterator指向同样的Node 。

3.3.3 operator-> (双箭头变单箭头)

就像刚才观察的position一样,除了* , 还需要能够通过->来访问

先实现一个箭头访问:

(注意,_node的类型是Node* , 箭头优先级也是高于取地址的)

假设我们的T是一个控制坐标的自定义类型Pos

​​​​​​​

我们再对自己的链表进行测试:

it解引用之后是一个Pos,但是Pos没法直接被留提取<<给输出,因此报错。

我们当然可以"投降",一个一个的访问元素:

但是我们更应该直接通过iterator去访问pos的成员变量:

关于箭头的理解,此处比较麻烦:

首先,对于(*it)._row 是比较好理解的。 我们重载的*返回的是一个T的引用,这个引用的类再通过.来访问自己的成员_row

其次,对于it->_row 就有点麻烦了。->作为我们重载的一个单目运算符,it->返回的是_data的地址,也就是一个T* , 相当于就变成了 T*_row , 按照我们之前的学习,这样是不能访问的。应当是两个箭头:(指针通过箭头访问内部成员)

显式调用的话应该是:

但是为了代码的可读性,编译器不支持这样使用,而是选择省略了一个箭头。

两个箭头的意义是不一样的,第一个是运算符重载的调用,第二个是原生指针访问内部成员。

(日常的迭代器用operator->的机会不多,之后在学习图(map)的时候会比较多)

特殊现象:

关于为什么begin()的返回值可以++,也就是

cpp 复制代码
++li1.begin();

虽然返回值具有常性,但是编译器会对内部函数的返回值特殊处理。有常性,但不是只有常性。这样的返回值任然可以调用那些非const的成员函数

我们稍加总结:

匿名对象的const和临时对象的const是不一样的,虽然不能直接给引用,但是可以调用非静态的成员函数


4.const迭代器

在使用场景中,一定有使用const_iterator的时候,比如传引用(传引用时都建议加上const),我们都建议传一个const修饰的链表,该链表的迭代器就需要是const_iterator


不能直接在list<int> :: iterator 前面加const ,

cpp 复制代码
const list<int>::iterator it2 = li1.begin();//错误样例

这样写的话,it2就不能++和改变自己的值了

这样的iterator是不能修改的,而我们希望的是有一个指向的内容 不能修改但是自己是可以修改的const_iterator。

那么到底是什么内容在控制iterator是否能被修改呢?

核心模块:

是否能修改,就看operator* 返回的是T& 还是 const T&;

operator->返回的是T*还是const T*

因此我们可以实现两个类,一个是刚刚的ListIterator ,另一个是我们再实现一个ListConstIterator

改一改名字,修改一下两个核心模块的函数返回值即可。

cpp 复制代码
template<typename T>
class ListConstIterator {

	typedef ListNode<T> Node;//与class list中的命名保持一致性
	typedef ListConstIterator<T> _self;
	Node* _pnode;
public:
	ListConstIterator(Node* pnode)
		:_pnode(pnode)
	{}

	//_self operator++() {
	//	return _self(_pnode->_next);//测试一下能不能跑
 //   }
	_self& operator++() {
		_pnode = _pnode->_next;
		return *this;
	}
	_self operator++(int) {
		_pnode = _pnode->_next;
		return _self(_pnode->_prev);
	}
	_self& operator--() {
		_pnode = _pnode->_prev;
		return *this;
	}
	_self operator--(int) {
		_pnode = _pnode->_prev;
		return _self(_pnode->_next);
	}
	const T& operator*() {
		return _pnode->_data;
	}
	const T* operator->() {
		return &_pnode->_data;
	}
	bool operator!=(const _self& it) {
		return this->_pnode != it._pnode;
	}

};

这样我们就能达到目的了。

别忘了再去写const对应的begin和end

注意此处的lt也必须是const list<int> lt ;


读者朋友们是否会觉得这样的实现有点过于冗余呢?

因为ListIterator<T>和ListConstIterator<T>两者非常相像,只有少数接口不一样

能否不定义一个新的类呢:

此时编译器会分别拿T和const T 分别去实现两个迭代器类出来

class ListIterator是满足了要求,但是class list中依然实例化的是T, 若调用:

​​​​​​​

那么初始化const_iterator的就是一个T而非const T,会报错。

解决方案:

直接将引用和指针传入,一个不使用const , 一个使用const修饰

iterator:

对照着来看:


5. list中的insert和erase

insert不会发生迭代器失效,但是erase还是有迭代器失效的。

首先看insert :

按照合理的顺序更改各个指针的指向即可

再看返回值:

指向用户插入的第一个元素:

cpp 复制代码
iterator insert(iterator pos, const T& x) {
	Node* tmp = new Node(x);

	tmp->_next = pos._pnode;
	tmp->_prev = pos._pnode->_prev;
	pos._pnode->_prev->_next = tmp;
	pos._pnode->_prev = tmp;

	return iterator(tmp);
}

(返回临时变量时就不要传引用返回了,返回一个tmp的拷贝更加合适)


类似于vector , erase返回的也是被删除的下一个位置的迭代器

同时还要注意断言,不要把哨兵位给删掉了

cpp 复制代码
iterator erase(iterator pos) {
	assert(pos != end());//别把哨兵位给删了
	Node* cur = pos._pnode;
	Node* prev = pos._pnode->_prev;
	Node* next = pos._pnode->_next;

	prev->_next = next;
	next->_prev = prev;
	delete cur;
	return iterator(next);
}
相关推荐
环能jvav大师8 分钟前
基于R语言的统计分析基础:使用dplyr包进行数据操作
大数据·开发语言·数据分析·r语言
百里与司空9 分钟前
学习CubeIDE——定时器开发
stm32·单片机·嵌入式硬件·学习
天下无贼!11 分钟前
2024年最新版Vue3学习笔记
前端·vue.js·笔记·学习·vue
float_com19 分钟前
【STL】stack,deque,queue 基础,应用与操作
c++·stl·极速入门
懒洋洋大魔王24 分钟前
7.Java高级编程 多线程
java·开发语言·jvm
=(^.^)=哈哈哈25 分钟前
Golang如何优雅的退出程序
开发语言·golang·xcode
学习使我变快乐26 分钟前
C++:用类实现链表,队列,栈
开发语言·c++·链表
茶馆大橘29 分钟前
【黑马点评】已解决java.lang.NullPointerException异常
java·开发语言
lmy_t33 分钟前
C++之第十二课
开发语言·c++
马剑威(威哥爱编程)36 分钟前
除了递归算法,要如何优化实现文件搜索功能
java·开发语言·算法·递归算法·威哥爱编程·memoization