list:迭代器与资源管理实现的综合叙述(下)

文章目录

    • 引入
    • 一、先看节点和遍历边界
      • [1.1 一个数据节点,两个方向](#1.1 一个数据节点,两个方向)
      • [1.2 空链表并不是没有任何节点](#1.2 空链表并不是没有任何节点)
    • 二、节点指针为什么需要包装
      • [2.1 原始指针的操作与我们要的操作不同](#2.1 原始指针的操作与我们要的操作不同)
      • [2.2 先忽略 const,理解最小实现骨架](#2.2 先忽略 const,理解最小实现骨架)
    • 三、逐个拆解运算符重载
      • [3.1 operator*:返回引用,才能修改真实元素](#3.1 operator*:返回引用,才能修改真实元素)
      • [3.2 operator->:让箭头指向元素,而不是节点](#3.2 operator->:让箭头指向元素,而不是节点)
      • [3.3 前置 ++:改变自身,返回自身引用](#3.3 前置 ++:改变自身,返回自身引用)
      • [3.4 后置 ++:保存旧副本,再改变自身](#3.4 后置 ++:保存旧副本,再改变自身)
      • [3.5 -- 同理,但移动条件不同](#3.5 -- 同理,但移动条件不同)
      • [3.6 比较的是位置,不是元素数值](#3.6 比较的是位置,不是元素数值)
    • 四、通过三个模板参数生成两种迭代器
      • [4.1 为什么只用 T 不够](#4.1 为什么只用 T 不够)
      • [4.2 在 list 中填入两组类型](#4.2 在 list 中填入两组类型)
      • [4.3 为什么不能简单换成 list_iterator< const T >](#4.3 为什么不能简单换成 list_iterator< const T >)
      • [4.4 begin / end 的 const 重载把类型连接到容器](#4.4 begin / end 的 const 重载把类型连接到容器)
    • [五、const 的三个位置,含义各不相同](#五、const 的三个位置,含义各不相同)
    • 六、节点操作和资源管理
      • [6.1 insert:保存前驱,再接上四条连接](#6.1 insert:保存前驱,再接上四条连接)
      • [6.2 erase:先保存后继,再删除当前节点](#6.2 erase:先保存后继,再删除当前节点)
      • [6.3 拷贝构造与赋值重载](#6.3 拷贝构造与赋值重载)
    • 七、小结

续接上篇:list 的使用:把节点、位置和操作联系起来的综合叙述(上)

代码仓库:《list测试与模拟实现》

引入

在学习了解过前面的容器后,构造、析构、深拷贝和交换这些基础操作已经基本熟练,不必再次深入。对于 list ,与之前容器的最大不同之处就是其迭代器的实现:同一个节点指针,被包装成类对象后,怎样获得 *it、it->成员、++it 这些表达式的含义?怎样复用移动逻辑,却让不同迭代器获得不同访问权限?

一、先看节点和遍历边界


1.1 一个数据节点,两个方向

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

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

list_Node<int> 的 _data 是 元素数据,两个连接指向相同类型的节点。构造全新的节点时先保存元素,再将连接初始化为空;只有插入链表后,连接才代表真正的前后关系。

1.2 空链表并不是没有任何节点

_head 指向哨兵。空链表时 _head->_next == _head、_head->_prev == _head,元素数量为 0。非空时,哨兵 next 指向首数据节点,prev 指向尾数据节点。

因此 begin 包装的是 _head->_next,end 包装 _head;空链表中二者相等。哨兵节点的存在让尾插能复用 insert(end(),x),但end 只是边界,不允许读取哨兵的 _data。

二、节点指针为什么需要包装


2.1 原始指针的操作与我们要的操作不同

假设 Node* p 指向保存 10 的节点:

表达式 原始 Node 指针 希望迭代器提供的含义
*p / *it 整个节点对象 元素 10 的引用
++p / ++it 指针算术,试图前进一个 Node 沿当前节点的 next 找到后继
p->... / it->... 访问节点成员 访问元素对象的成员
相等比较 比较地址 比较是否表示同一个节点位置

节点各自分配,不构成供 p 做数组遍历的连续 Node 数组。

双向链表节点内存并不是连续 的,因此,对原始的节点指针直接 ++,既不能表达"沿链接前进",也不能拿计算出的地址当作后继节点访问。

所以需要一个类,把节点指针存进去,再给操作定义合理的含义。这就是迭代器包装。迭代器不拥有节点,不在析构时释放节点;

vector 迭代器:就是原生指针,内存连续,++直接指针算术即可。

list 迭代器:类封装(包装节点指针) ,重载operator++内部执行 _node = _node->_next。

2.2 先忽略 const,理解最小实现骨架

cpp 复制代码
template<class T>
struct simple_iterator
{
    list_Node<T>* _node;

    simple_iterator(list_Node<T>* p) 
        : _node(p) 
        {}

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

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

it 是迭代器对象,it._node 是它记录的地址。复制 it 通常只是复制这个地址,所以 auto another = it 得到独立的迭代器对象,但两者最初指向同一元素。随后 ++another 改变 another 的地址成员,不改变 it,也不复制链表。

三、逐个拆解运算符重载


以下代码中的 Self 表示当前这一种迭代器类型,Ref 表示解引用返回类型,Ptr 表示箭头返回类型。

3.1 operator*:返回引用,才能修改真实元素

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

普通迭代器的 Ref 表示的是 T&。表达式 *it 调用 it.operator*(),返回节点里那个 T 对象的引用。因此 *it = 8 能够改变真实元素.

如果返回 T,得到的是元素副本,不能提供正常的可写迭代器语义,而且对复杂的类还可能发生一些不必要的复制。

3.2 operator->:让箭头指向元素,而不是节点

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

当元素是简单的一个类 A(有成员变量int _a1),此时希望写的是 it->_a1,而不是暴露 _node->_data._a1。返回 &_node->_data 得到元素指针,编译器对箭头重载继续应用箭头访问,最终将会通过这个真实指针来访问成员。

真实过程等价于:

cpp 复制代码
//it 指向有效 A 元素。
it->_a1;
it.operator->()->_a1;
(*it)._a1;

3.3 前置 ++:改变自身,返回自身引用

cpp 复制代码
//前置++
Self& operator++()
{
	_node = _node->_next;
	return *this;
}

++it 调用无额外参数的 operator++。第一句沿链接改变 it 保存的地址;第二句返回 it 自身的引用,所以结果代表的是新位置。

这里容易混淆的是 *it 与 *this:

  • *it:it 是类对象,调用重载 operator*,得到元素引用。
  • *this:this 是指向当前迭代器对象的真实指针,使用内置解引用,得到的是迭代器对象本身。

所以 return *this 不会递归调用元素解引用,也不返回节点数据。正好匹配上了 Self&。

3.4 后置 ++:保存旧副本,再改变自身

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

it++ 调用带 int 占位参数的版本。这个 int 只用于让编译器区分前置与后置,没有其它特殊含义。

假设 it 指向 10,next 是 20,执行 auto old = it++;:

  1. Self old = *this 复制迭代器,把"指向 10"的地址保存到另一个对象。
  2. 修改 it 的地址,使 it 指向 20。
  3. 按值返回 old,使表达式结果仍表示原来的 10。

3.5 -- 同理,但移动条件不同

cpp 复制代码
//前置--
Self& operator--()
{
	_node = _node->_prev;
	return *this;
}

//后置--
Self operator--(int)
{
	Self tmp(*this);
	_node = _node->_prev;
	return tmp;
}

前置 -- 返回自身引用,后置 -- 返回旧副本。都沿 prev 移动。非空链表的 end 有最后一个元素作前驱,所以可以先复制 end 再 --;begin 没有接口意义上的前驱,所以不能 --begin。

写法 相应成员调用 改变 it 吗 返回什么
++it it.operator++() 是,沿 next 改变后的 it 自身引用
it++ it.operator++(0) 是,沿 next 修改前的独立副本
--it it.operator--() 是,沿 prev 改变后的 it 自身引用
it-- it.operator--(0) 是,沿 prev 修改前的独立副本

3.6 比较的是位置,不是元素数值

cpp 复制代码
bool operator!=(const Self& s) const
{
	return _node != s._node;
}

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

两个节点都保存 5,元素值相等,但迭代器不相等。若错误地比较 _data,重复元素的存在就会破坏遍历。

不要随意比较来自不同容器的迭代器。公开的接口只保证其规定比较域内的行为。

四、通过三个模板参数生成两种迭代器


4.1 为什么只用 T 不够

如果 iterator 始终返回 T&,通过 const 容器仍能修改元素,违背只读访问要求。若始终返回 const T&,普通容器也失去可写迭代器。

初始做法是可以复制两份类,一份返回可写类型,一份返回只读类型,但其中 ++、--、比较的代码完全一样,造成了大量的代码冗余,而且如果后续需要修改的话还要改两遍。

下面则通过三个模板参数的配合实现了两种迭代器的返回:

cpp 复制代码
template<class T, class Ref, class Ptr>
struct list_iterator
{
	typedef list_Node<T> Node;
	typedef list_iterator<T, Ref, Ptr> Self;
	//...

T 决定节点内的元素类型;Ref 决定 operator* 返回什么;Ptr 决定 operator-> 返回什么。链表的移动沿 Node 的链接进行,不依赖 Ref 与 Ptr,因此两种可以共用。

4.2 在 list 中填入两组类型

cpp 复制代码
template<class T>
class list
{
public:
	typedef list_Node<T> Node;
	typedef list_iterator<T, T&, T*> iterator;
	typedef list_iterator<T, const T&, const T*> const_iterator;
	//...

这是代码实现的核心,下面暂时把 T 代为 int:

参数或成员 普通迭代器 只读迭代器
完整类型 list_iterator<int,int&,int*> list_iterator<int,const int&,const int*>
T int int
Ref int& const int&
Ptr int* const int*
Node list_Node<int> list_Node<int>
_node list_Node<int>* list_Node<int>*
解引用 可修改元素的引用 只读元素引用
++ / -- 修改迭代器的位置 同样可以修改迭代器的位置

4.3 为什么不能简单换成 list_iterator< const T >

如果把 const 放到节点的 T 上,Node 就会变成 list_Node<const int>,它和 list_Node<int> 是两种不同节点类型。

我们要的不是把节点结构换掉,而是让同一批节点得到不同的元素访问接口 。T 不变,Ref 与 Ptr 加 const,正好表达这个需求。

4.4 begin / end 的 const 重载把类型连接到容器

cpp 复制代码
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);
}

非 const 容器调用非 const begin,得到 iterator;const 容器只能调用 const begin,得到 const_iterator。两者都包装同一个位置,只是返回类型不同。

五、const 的三个位置,含义各不相同


写法 能移动迭代器吗 能通过它修改元素吗
iterator it 能 能
const_iterator cit 能 不能
const iterator fixed 不能 能
const const_iterator fixed_cit 不能 不能

第三行最容易误解:const iterator 只是一个不能改变地址成员的迭代器对象,相当于关注"位置固定";它的 Ref 仍然是 T&,因此正常解引用仍能写元素。const_iterator 的类型则改变了 Ref 与 Ptr,相当于关注"访问只读"。

可以类比指针,普通 const_iterator 的接口权限近似 const T*;而const iterator 则近似与 T* const。

六、节点操作和资源管理


6.1 insert:保存前驱,再接上四条连接

cpp 复制代码
//指定位置前插入数据
iterator insert(iterator pos, const T& x)
{
	Node* cur = pos._node;
	Node* newNode = new Node(x);

	cur->_prev->_next = newNode;
	newNode->_prev = cur->_prev;
	newNode->_next = cur;
	cur->_prev = newNode;

	++_size;

	return newNode;
}

insert(end(), x) 是尾插,insert(begin(), x) 是头插。空链表时 prev 和 cur 都是哨兵,仍使用同一套步骤。

6.2 erase:先保存后继,再删除当前节点

cpp 复制代码
//删除指定位置数据
iterator erase(iterator 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;
}

删除以后不能再读取 cur->next,所以提前保存 next。assert 则防止误删 end;

6.3 拷贝构造与赋值重载

cpp 复制代码
//初始化头节点
void empty_init()
{
	_head = new Node();
	_head->_prev = _head;
	_head->_next = _head;
	_size = 0;
}

//构造函数
list()
{
	empty_init();
}

//拷贝构造
list(const list<T>& lt)
{
	empty_init();
	for (auto& e : lt)
	{
		push_back(e);
	}
}

//拷贝交换
void swap(list<T>& lt)
{
	std::swap(_head, lt._head);
	std::swap(_size, lt._size);
}

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

这里的赋值重载种按值参数 other 是副本,不是源对象的引用。交换后,本对象取得复制内容,other 取得本对象旧节点;函数结束时清理旧节点。自赋值也先复制,再交换,不会提前清空源。(与之前的 string 和vector 一样)

七、小结


理解这份实现时,可以一直沿着同一个问题往下问:这个表达式修改的是迭代器的位置、节点的连接,还是节点中的元素?分清这三层,再把返回类型与模板参数对应起来,list 的迭代器就不再是一组难记的符号。

对照资料:

相关推荐
我不会起名字3221 小时前
二叉树的遍历与二叉搜索树:把递归、迭代、还原树一次讲透
数据结构·leetcode·二叉树·二叉搜索树
wdfk_prog1 小时前
LWIP教程 03:从 `low_level_input()` 到 `pbuf_free()`——`pbuf` 的数据视图、Chain 与引用计数
运维·网络·笔记·学习
MayZork1 小时前
Qt 开发集成CMake + vcpkg
开发语言·c++·qt
书生执笔画浮沉2 小时前
Linux桌面端应用向鸿蒙PC迁移适配纪实
c++·qt·c·信创·海思·libc·ohos
(Charon)2 小时前
【C++面试】单例模式:懒汉式与饿汉式的实现、区别与线程安全
开发语言·c++·面试
某不知名網友2 小时前
ROS2入门:命令行启动节点与Launch程序
c++·机器人
yi0112 小时前
Leetcode 49 用 “身份证‘‘巧解
人工智能·笔记·python·算法·leetcode·哈希表
ouynagda2 小时前
嵌入式Linux i.MX6ULL系统移植学习笔记
linux·笔记·学习
小米里的大麦2 小时前
C++ 移动语义
c++·后端