C++——STL容器——List

发布于:2024-05-16 ⋅ 阅读:(25) ⋅ 点赞:(0)

1. 前言 

        List也是STL容器的一种,是C++提供的链表结构的容器。C++中所提供的list是双向带头循环链表,我们这篇文章通过自己模拟实现来学习list的使用。

        为了避免和库中的命名冲突,也为了封装的考虑,我们将我们的list放入一个命名空间之中以和外界隔离。

namespace m_list	//为自己实现的list定义命名空间
{
    //代码
}

2. 链表的结点

        因为链表的各个元素是由一个个结点串起来的,所以我们首先需要定义出结点。

	//结点
	template <class T>
	struct ListNode	//使用类模板来定义结点的信息
	{
		//双向链表,节点内存前驱指针和后继指针,以及值
		ListNode<T>* _prev;
		ListNode<T>* _next;
		T _val;
		//构造函数,使用初始化列表初始化
		ListNode(const T& val = T())	//参数注意使用const引用,因为实参可能是常性的
			:_prev(nullptr)
			,_next(nullptr)
			,_val(val)
		{}
	};

        对于链表节点的定义,有几点需要解释:

        ①我们采用泛型编程的思想,使用类模板来定义结点的结构体以保证满足各种数据与类型的结点。
        ②由于我们的链表是双向带头循环链表,所以结点的成员变量应该包括前驱指针、后继指针和结点的值。

        ③构造函数缺省值为我们已经使用了多次的T(),且为了保证变量和常量值均可被传递,需要使用const引用修饰参数。

3. 链表

3.1 成员变量

        同样是为了满足泛型编程的需求采取类模板定义链表类。链表类管理着链表,所以其成员变量中存储着链表的头结点的指针,类型就是结点类模板实例化的指针。另外一个参数则是表示链表的size。

	//链表
	template <class T>
	class list
	{
	private:
		typedef ListNode<T> Node;	//使用结点时需要对结构体模板参数实例化,因此使用typedef简化代码
		
		Node* _head;	//链表的头
		size_t _size;	//链表的规模
	};

3.2 构造函数

3.2.1 无参构造

        无参构造为我们给出了最基本的构造要求,由于是带头链表,所以应该在构造函数中将链表初始化完成,也就是创建好头结点并且指针指向对应的位置。

		//无参构造
		//双向带头链表,所以需要先new一个头结点,并且前后指针都指向自己
		list()
			:_head(new Node)
			, _size(0)
		{
			_head->_prev = _head;
			_head->_next = _head;
		}

3.2.2 初始化列表构造

        初始化列表在vector的文章中接触过,这里不再解释。初始化列表构造也需要完成无参构造中的操作,创建出头结点,然后再进行元素的逐个插入。所以此处会用到尾插函数以及迭代器,这部分会在后续介绍。

		list(std::initializer_list<T> il)
			:_head(new Node)
			, _size(0)
		{
			_head->_prev = _head;
			_head->_next = _head;
			for (auto& e : il)
			{
				push_back(e);
			}
		}

3.2.3 拷贝构造

        拷贝构造也需要通过同样的方式创建出头结点。然后将元素进行尾插,此处则是需要尾插函数,由于传参考虑到const和非const的链表对象,因此使用const修饰的链表作为参数,因此遍历这个链表取出数据就需要const迭代器。

		//拷贝构造
		//拷贝构造初始化出头结点,然后将结点逐个插入
		list(const list<T>& lt)
			:_head(new Node)
			,_size(0)
		{
			_head->_prev = _head;
			_head->_next = _head;

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

3.3 析构函数(以及clear函数)

        clear函数通过迭代器的遍历将链表结点进行意义释放删除。析构函数z则是复用clear函数,将链表结点全部释放后,再释放头结点,完成析构操作。

		//析构函数
		//析构需要释放所有的结点,可以通过逐个结点删除的方式来复用函数,完成释放
		~list()
		{
			clear();
			delete _head;

			_head = nullptr;
		}
		void clear()
		{
			iterator it = begin();
			//使用迭代器对链表结点进行删除时,注意无需it++
			while (it != end())
			{
				it = erase(it);
			}
		}

3.4 赋值运算符重载(以及swap函数)

        赋值运算符函数的实现我们已经很熟悉了,就是传值传参产生一次拷贝,生成临时对象,然后交换二者,即可得到“借尸还魂”的效果。swap函数则是将待交换对象之间的成员变量值进行交换即可。

		void swap(list<T>& lt)
		{
			std::swap(_head, lt._head);
			std::swap(_size, lt._size);
		}
		//赋值运算符重载
		list<T> operator=(list<T> lt)
		{
			swap(lt);
			return *this;
		}

3.5 链表属性函数

        主要使用到size和empty函数,实现十分简单。

		size_t size()
		{
			return _size;
		}
		bool empty()
		{
			return _size == 0;
		}

3.6 插入删除函数

3.6.1 插入函数

3.6.1.1 随机位置插入insert

        我们主要使用下图中的第一种重载形式,insert函数在pos位置前插入val的值。

         插入数据的逻辑就是通过迭代器变量pos找到对应的位置,然后创建新的结点,然后进行链接就好了。

		//插入
		//对于list而言,不存在扩容的行为,所以list的插入不会导致迭代器失效
		//insert在pos位置前插入val,为满足临时变量的插入需要使用const引用
		void insert(iterator pos, const T& val)
		{
			Node* next = pos._node;
			Node* prev = next->_prev;
			Node* newnode = new Node(val);

			prev->_next = newnode;
			newnode->_prev = prev;
			newnode->_next = next;
			next->_prev = newnode;

			++_size;
		}
3.6.1.2 尾部插入

        复用insert函数。

void push_back(const T& val)
{
	insert(end(), val);
}
3.6.1.3 头部插入

        复用insert函数。

void push_back(const T& val)
{
	insert(end(), val);
}
3.6.1.4 迭代器失效(否)

        和vector不同,由于链表的特殊结构,所以在插入值的过程中并不会发生让结点地址产生改变的情况,所以list的插入不会让迭代器失效。

3.6.2 删除数据

3.6.2.1 随机位置删除

        erase删除pos位置处的值,并且返回新的迭代器变量,因为迭代器存在失效的可能。

        删除pos位置的值,首先是通过pos找到对应的结点,然后将链表链接起来后释放pos位置的结点。在这里细致梳理一下(没什么强调的,就是梳理一下流程),释放对应结点时,由于结点是一个自定义的结构体类型,所以此时会调用结点结构体的析构函数,由于我们没有写,所以编译器默认生成,对于内置类型(如_prev、_next和内置类型实例化的value)不做处理,而对于自定义类型(自定义类型实例化的val)则会调用其析构函数。

        在最后返回更新后迭代器的值,由于迭代器类的构造函数是单参数,所以支持隐式类型转换。 

		//删除
		//erase由于释放结点,所以有迭代器失效的可能性,所以删除操作需要返回迭代器提供更新
		iterator erase(iterator pos)
		{
			Node* cur = pos._node;
			Node* prev = cur->_prev;
			Node* next = cur->_next;
			
			prev->_next = next;
			next->_prev = prev;
			delete cur;
			//cur是ListNode<T>类型,delete时会调用对应的析构函数
			//由于ListNode<T>的成员变量都是内置类型,所以无需手动释放资源,使用编译器生成的析构函数即可
			--_size;

			//return next;	//也可以,单参数构造函数支持隐式类型转换
			return iterator(next);
		}
3.6.2.2 尾部删除

        复用erase函数。

		iterator pop_back()
		{
			erase(--end());
		}
3.6.2.3 头部删除

        复用erase函数。

		iterator pop_front()
		{
			erase(begin());
		}
3.6.2.4 迭代器失效

        因为删除节点会使得原空间被释放,所以存在迭代器失效的可能,迭代器失效的原因和vector一样,由于存在删除,后续的元素会向前补齐,迭代器指向的内容发生变化,所以在删除造作后,记得使用返回值更新迭代器。

4. 迭代器

        迭代器可谓是list中最值得学习的部分了,我们将通过list的迭代器实现明白如何包装一个迭代器让其符合使用逻辑,以及如何通过类模板的方式提高代码复用率。

        迭代器最重要的一点就是不论内部实现和底层如何花里胡哨,最后使用的方式一定是一致的。

4.1 迭代器类

        对于链表而言,由于其物理存储空间不连续,所以迭代器不可以使用原生的指针,因为自增等行为有很大不同。因此需要我们自己根据需要实现迭代器,因此封装一个迭代器类,来支持链表的特性。

        那么我们首先要明确这个迭代器需要哪些参数。

        首先,因为实例化的参数不同,所以迭代器也必然不同,因此第一个模板参数应该可以表示迭代器之下的数据的数据类型。

        其次,对于迭代器,为了支持const和非const的迭代器,同时为了代码复用,所以在迭代器模板中增加模板参数,来选择const和非const,因此我们就需要考虑清楚const和非const的迭代器之间存在哪些不同。是否是const的区别只在于能否对值进行修改,所以返回值是与存储的元素直接关联的函数,即两个解引用操作。因此考虑他们的返回值不同,对模板参数增加两个,分别代表*解引用和->解引用的返回值。

4.1.1 迭代器模板与成员变量

        因为*解引用操作返回的是元素的引用,->解引用操作返回的是元素的指针,所以使用T表示真正索要存储数据的数据类型,增加Ref表示其const或非const的引用,Ptr表示其const或非const的指针。

        为了可以很方便地进行链表遍历,所以使用结点指针的方式作为迭代器,这样方便进行遍历,同时是一个指针所以节省空间。

//迭代器
//对于链表而言,由于其物理存储空间不连续,所以迭代器不可以使用原生的指针,因为自增等行为有很大不同
//因此需要我们自己根据需要实现迭代器,因此封装一个迭代器类,来支持链表的特性
//对于迭代器,为了支持const和非const的迭代器,同时为了代码复用,所以在迭代器模板中增加模板参数,来选择const和非const
//由于const版本迭代器只在解引用的时候有不同,所以增加两个模板参数对解引用的返回值进行控制
template <class T, class Ref, class Ptr>
struct ListIterator
{
	//类比学习,迭代器的作用相当于元素的替身。
	//在string中,string是由一个个字符组成的,迭代器变量表示字符串中的字符,通过解引用操作即可访问其值
	//在vector中,vector是由一个个元素组成的,迭代器变量表示数组中的元素,通过解引用操作可以访问该元素
	//同理,对于list是由一个个结点组成的,迭代器应该表示链表的结点,因此采用结点指针的方式定义迭代器
	typedef ListNode<T> Node;

	Node* _node;

};

4.1.2 构造函数

        对于iterator迭代器,其存在一定是指向某个结点的,所以使用带一个参数的构造函数为结点指针成员赋值,即迭代器此时就是这个结点的高层次的代替。

		//构造函数
		ListIterator(Node* node)
			:_node(node)
		{}

4.1.3 解引用

4.1.3.1 *操作符重载

        再次提及这句话:迭代器最重要的一点就是不论内部实现和底层如何花里胡哨,最后使用的方式一定是一致的。所以回忆曾经的迭代器使用,当对一个迭代器遍历it进行*it操作后,表达式结果就是迭代器指向元素的值的引用,可以进行访问修改。

        对于链表也不例外,我们索要返回的正是这样一个迭代器所指向元素的引用。对于this指针,其成员变量_node就是对应元素所在结点的指针,所以返回_node->_val,就相当于返回了引用,达到目的。

        至于返回值,由Ref模板参数决定,当为T类型的const时,Ref就会是const T,否则就是T。所以返回值类型为Ref&即可。

		//解引用
		//*it
		//迭代器追求的是不考虑组织形式,采取一致的格式进行遍历
		//因此,在list中,要保证*it可以像其他容器一样拿到对应的数据,所以*it应当返回T
		//对于*it,非const对象拿到的是非const返回值,const对象拿到的是const返回值
		Ref& operator*()
		{
			return _node->_val;
		}
4.1.3.2 ->操作符重载

        为什么需要->操作符呢,在我们的印象中似乎*解引用已经足够了。那么这时候就需要考虑当T被实例化为一个结构体,我们此时拿到了迭代器变量it,我们知道*it是取得其元素,那么对于结构体我们想一步到位访问结构体对象中的成员,就会用到it->a类似的情景。

	void Test2()
	{
		A a{ 'a',{1,1} };
		list<A> lt1;
		lt1.push_back(a);
		lt1.push_back({ 'b', { 2,2 } });
		list<A>::iterator it1 = lt1.begin();
		while (it1 != lt1.end())
		{
			std::cout << it1->c1 << ' ';
			++it1;
		}
		std::cout << std::endl;

		it1 = lt1.begin();
		std::cout << it1->a[0] << ' ' << it1->a[1] << std::endl;
		++it1;
		std::cout << it1->a[0] << ' ' << it1->a[1] << std::endl;
	}

        为了应付这种情况所以出现了->重载。->重载函数的返回值是this指针的_node->_val的指针,返回地址后再->即可找到需要的成员。仔细思考,发现似乎少了一个->操作符,因为一个->是迭代器操作符重载,返回了自定义类型(实例化T)的指针,应该再来一个->指向T的成员。但是这样写会让语法变得奇怪,不易读,所以规定省略一个->来保证可读性。所以实质上,原本的it->->a,可以写作it->a,实际上是it.operator->()->a。

        返回类型Ptr和Ref一样,根据需求传递const T*或者T*即可。

4.1.4 自增自减与判断

        需要强调的是为了区分前置++/--和后置++/--,会给后置操作符一个int参数。

		//对于list,迭代器自增自减的行为即是找到next和prev结点
		//前置++
		ListIterator<T, Ref, Ptr>& operator++()
		{
			_node = _node->_next;
			return *this;
		}
		//后置++
		ListIterator<T, Ref, Ptr> operator++(int)
		{
			ListIterator<T, Ref, Ptr> tmp(_node);
			_node = _node->_next;
			return tmp;
		}
		//前置--
		ListIterator<T, Ref, Ptr>& operator--()
		{
			_node = _node->_prev;
			return *this;
		}
		//后置--
		ListIterator<T, Ref, Ptr> operator--(int)
		{
			ListIterator<T> tmp(_node);
			_node = _node->_prev;
			return tmp;
		}
		//==和!=
		bool operator==(const ListIterator<T, Ref, Ptr>& it)
		{
			return _node == it._node;
		}
		bool operator!=(const ListIterator<T, Ref, Ptr>& it)
		{
			return _node != it._node;
		}

4.2 迭代器在list类中

        在解决了迭代器形式和操作的问题后就好办了,在list类中实现const和非const的begin和end函数即可。

        模板参数已经做过介绍,iterator的构造函数是单参数的结点指针,所以支持单参数隐式类型转换。

		//迭代器
		//对于*it,非const对象拿到的是非const返回值,const对象拿到的是const返回值
		//对于it->,非const对象拿到的是非const指针,const对象拿到的是const指针
		typedef ListIterator<T, T&, T*> iterator;
		typedef ListIterator<T, const T&, const T*> const_iterator;
		//begin和end返回值都是迭代器类的对象,单参数构造函数所以支持隐式类型转换
		//非const
		iterator begin()
		{
			return _head->_next;
		}
		iterator end()
		{
			return _head;
		}
		//const
		const_iterator begin() const
		{
			return _head->_next;
		}
		const_iterator end() const
		{
			return _head;
		}

4.3 迭代器总结

        为了实现迭代器正常功能所以将迭代器用类模板进行了封装,又为了const考虑增加两个模板参数来控制。因为模板和结构体相互交错,所以此处对于->和*给出两个分析示例,帮助理清逻辑:

	void Test3()
	{
		list<A> lt1;
		lt1.push_back({ 'a',{1,1} });
		list<A>::iterator it1 = lt1.begin();
		it1->c1;
		//it1实际的类型是ListIterator<A,A&,A*>,成员是ListNode<A>的结构体指针,这个结构体是链表的结点,包含成员前序、后继指针和值
		//it1的构造函数将ListNode<A>*类型的参数赋值给it1下的成员(链表结点指针),使用的是lt1调用的begin,返回值是ListIterator<A,A&,A*>类型,其中的结点是头结点的下一个结点
		//ListIterator<A,A&,A*>类型的参数通过隐式类型转换传参给构造函数,赋值给it1对象
		//it1->c1调用了Ptr operator->()函数,Ptr被实例化为了A*,返回了_node->_val的地址,也就是返回了lt1链表中的值的地址,即结构体{ 'a',{1,1} }的地址
		//省略了一个->操作符,找到了c1成员,实际上是it1.operator->()->c1
		
		list<int> lt2;
		lt2.push_back(1);
		list<int>::iterator it2 = lt2.begin();
		*it2;
		//it2实际的类型是ListIterator<int,int&,int*>,成员是ListNode<int>的结构体指针,这个结构体是链表的结点,包含成员前序、后继指针和值
		//it2的构造函数将ListNode<int>*类型的参数赋值给it1下的成员(链表结点指针),使用的是lt1调用的begin,返回值是ListIterator<int,int&,int*>类型,其中的结点是头结点的下一个结点
		//ListIterator<int,int&,int*>类型的参数通过隐式类型转换传参给构造函数,赋值给it2对象
		//*it2调用了Ref operator*()函数,Ref被实例化为了int&,返回了_node->_val的引用,也就是返回了lt2链表中的值的引用,即结点内容 1 的引用
	}