文章目录
-
- 引入
- 一、先看节点和遍历边界
-
- [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++;:
Self old = *this复制迭代器,把"指向 10"的地址保存到另一个对象。- 修改
it的地址,使it指向 20。 - 按值返回
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 的迭代器就不再是一组难记的符号。
对照资料: