目录
[1. 结构介绍](#1. 结构介绍)
[2. 结点类的实现](#2. 结点类的实现)
[3. 迭代器的实现逻辑](#3. 迭代器的实现逻辑)
[3.1 operator* 重载](#3.1 operator* 重载)
[3.2 operator++ 和 operator--](#3.2 operator++ 和 operator--)
[3.3 operator== 与 operator!=](#3.3 operator== 与 operator!=)
[3.4 operator->](#3.4 operator->)
[4.1 构造函数 & 拷贝构造函数 & 赋值重载 & 析构函数](#4.1 构造函数 & 拷贝构造函数 & 赋值重载 & 析构函数)
[4.2 赋值重载 & 交换函数(经典拷贝交换写法)](#4.2 赋值重载 & 交换函数(经典拷贝交换写法))
[4.3 insert与erase的实现](#4.3 insert与erase的实现)
[insert 实现](#insert 实现)
[5. STL const迭代器的设计思想](#5. STL const迭代器的设计思想)
[5.1 实现与使用](#5.1 实现与使用)
[5.2 const 迭代器使用案例](#5.2 const 迭代器使用案例)
[6. 迭代器失效](#6. 迭代器失效)
[6.1 插入不失效的原因](#6.1 插入不失效的原因)
[6.2 删除失效的规则](#6.2 删除失效的规则)
[7. 特殊的构造方式](#7. 特殊的构造方式)
1. 结构介绍
如果我们要手动实现一个list容器,整体的结构设计非常清晰:除了核心的list类之外,我们还需要单独定义两个配套的类 :一个是节点类 ,一个是迭代器类 。节点类和迭代器类是专门为list容器服务的核心基础组件,支撑起整个链表的功能实现。
2. 结点类的实现
cpp
template <class T>
struct list_node{
T date;
list_node<T>* next;
list_node<T>* prev;
};
我们定义的这个节点结构体是模板类型,因此可以存储任意类型的数据,通用性极强。作为双向链表的核心单元,list_node由三部分组成:
- 数据域:存储实际的数据 T;
- 后继指针 next:指向当前节点的下一个节点;
- 前驱指针 prev:指向当前节点的上一个节点。
这是双向链表节点的标准结构,也是我们实现 list 容器的基础。
cpp
list_node(const T& x = T())
{
data=x;
next = nullptr;
prev = nullptr;
}
我们需要给节点类添加一个构造函数,功能很简单:初始化数据,并把前后指针都置空。这里的默认参数 T() 是关键,我们在实现vector时就讲过:
C++ 为了让模板兼容内置类型(int/double)和自定义类类型,专门规定内置类型也可以像调用构造函数一样初始化(比如int() 值为 0)。
最后说下为什么选择struct而不是 class:节点类和迭代器类是专门为list容器服务的底层结构,使用struct时成员默认是公有(public)的,list可以直接访问节点的指针和数据;
如果用class,成员默认私有,我们还需要专门写友元或者访问接口,非常繁琐。为了简化代码、方便操作,底层结构统一用struct最合适。
3. 迭代器的实现逻辑
cpp
template <class T>
struct list_iterator
{
typedef list_node<T> Node; // 给节点类型起别名,简化代码书写
Node* _node; // 迭代器的核心:封装链表节点指针
// 迭代器构造函数
list_iterator(Node* node)
: _node(node)
{}
};
list的迭代器底层并不是原生指针 ,而是对节点指针的一层封装。因为链表的内存结构不连续,无法依靠C/C++内置的指针算术运算(比如 ++/--)来实现遍历,所以我们必须手动修改这个封装指针的行为:解引用operator*、自增operator++、判断不等operator!=等运算符都需要我们自己重载实现。
除此之外,迭代器必须编写构造函数 。因为它不再是一个单纯的指针,不能直接用指针简单调用,必须通过构造函数将节点指针封装成迭代器对象才能使用。而且迭代器本质只是封装了一个指针,默认的浅拷贝就完全满足需求,所以我们不需要手动实现拷贝构造函数。
3.1 operator* 重载
原生指针解引用会直接返回对应类型的值,但我们的迭代器封装的是链表节点结构体 ,直接解引用没有意义。因此必须重载解引用运算符,让它的行为符合迭代器的设计:返回节点中存储的真实数据,这也是我们使用迭代器时最需要访问的内容。
这里用Ref(引用类型)作为返回值,既能避免拷贝,又支持对数据进行修改。
cpp
Ref operator*()
{
return _node->data;
}
3.2 operator++ 和 operator--
链表的节点内存不连续,迭代器无法像原生指针那样直接通过 ++/-- 实现位移,因此我们必须重载这两个运算符。我们将它们封装为迭代器向后移动一个节点 和向前移动一个节点的核心逻辑,保证使用者可以像操作普通指针一样使用迭代器。
bash
// 前置自增 ++it :迭代器后移,返回下一个位置的迭代器
self operator++()
{
_node = _node->next;
return *this;
}
// 前置自减 --it :迭代器前移,返回上一个位置的迭代器
self operator--()
{
_node = _node->prev;
return *this;
}
-
++:让迭代器指向当前节点的下一个节点
-
--:让迭代器指向当前节点的上一个节点
补充:self是迭代器自身类型的别名,和 Node一样用于简化代码书写~
3.3 operator== 与 operator!=
迭代器的相等和不等判断,逻辑非常简单:不比较数据,只比较底层封装的节点指针。只要两个迭代器指向同一个链表节点,就判定为相等,反之则不相等,这是list迭代器比较的唯一标准。
cpp
// 判断两个迭代器是否指向同一节点
bool operator==(const self& it) const
{
return _node == it._node;
}
// 判断两个迭代器是否指向不同节点
bool operator!=(const self& it) const
{
return _node != it._node;
}
3.4 operator->
该运算符主要用于迭代器访问自定义类型对象的成员,我们先给出重载代码,再结合实际案例理解其用法。
cpp
// 重载箭头运算符
T* operator->()
{
// 返回节点中数据的地址,支持直接访问自定义类型成员
return &_node->data;
}
假设我们定义一个AA类,并将其对象存入list中:
cpp
class AA
{
public:
AA(int a = 0, int b = 0)
:_a(a)
,_b(b)
{}
int _a;
int _b;
};
list<AA> l2;
l2.push_back(AA(1, 2));
list<AA>::iterator it = l2.begin();
访问list中AA对象的成员变量,有两种等价写法:
cpp
// 方式一:先解引用迭代器,再用 . 访问成员
cout << (*it)._b << " ";
// 方式二:直接使用 -> 运算符,写法更简洁直观
cout << it->_b << " ";
operator->的本质是语法糖 ,编译器会将it->_b解析为it.operator->()->_b。重载后的箭头运算符返回节点内数据的地址,相当于原生指针,让迭代器访问自定义类型成员的写法,和原生指针保持一致,使用起来更加便捷自然。
4.list类的实现逻辑
cpp
template <class T>
class list
{
private:
// 重命名节点类型,简化代码书写
typedef list_node<T> Node;
// 哨兵位头节点(不存储有效数据)
Node* _head;
// 记录链表有效元素个数
size_t _size;
public:
// 重命名迭代器类型,对外提供统一接口
typedef list_iterator<T> iterator;
};
为了让链表支持存储任意类型的数据,list类必须设计为类模板。在类的内部,我们需要将节点类和迭代器类重命名,极大简化后续代码的编写,让它们专门 list自身服务。
类中包含两个核心成员变量:
- 哨兵位头节点_head:双向链表的标志性设计,不存储有效数据,专门用来简化链表的边界判断逻辑;
- 有效元素个数_size:专门记录链表的元素数量,让获取链表大小的操作拥有 O (1) 的极致效率。
4.1 构造函数 & 拷贝构造函数 & 赋值重载 & 析构函数
为了简化代码、实现复用,我们先封装一个空初始化工具函数 ,构造和拷贝构造都会直接调用它。这个函数的核心逻辑:创建哨兵位头节点,并让头节点的前后指针都指向自身,形成双向循环链表的初始结构。
cpp
// 空初始化工具函数(私有,内部复用)
void empty_init()
{
// 创建哨兵位头节点
_head = new Node;
// 头节点自循环,构成双向循环链表
_head->next = _head;
_head->prev = _head;
// 初始元素个数为0
_size = 0;
}
// 默认构造函数
list()
{
empty_init();
}
// 拷贝构造函数
list(const list<T>& lt)
{
// 复用空初始化逻辑
empty_init();
// 遍历源链表,逐个尾插元素
for (auto& e : lt)
{
push_back(e);
}
}
4.2 赋值重载 & 交换函数(经典拷贝交换写法)
赋值重载这里采用了 C++ 最经典、最高效的"拷贝并交换"写法,代码极简且能完美规避自赋值、内存泄漏等问题。我们手写高效交换函数,摒弃编译器默认交换的低效拷贝,核心只交换链表指针。
cpp
// 自定义高效交换:仅交换头指针和大小,无任何数据拷贝 O(1)
void swap(list<T>& lt)
{
std::swap(_head, lt._head);
std::swap(_size, lt._size);
}
// 赋值重载:经典「传值 + 交换」写法
list<T>& operator=(list<T> lt)
{
// 直接交换资源,临时对象 lt 自动释放旧内存
swap(lt);
return *this;
}
赋值重载的设计是STL容器的经典写法:
- 传值调用:形参lt是一个独立的临时拷贝,拥有完全独立的资源,天然避免自赋值问题;
- 自定义swap :不使用编译器默认的逐元素拷贝交换,而是直接交换两个链表的哨兵位头指针 和长度,效率达到极致 O(1);
- 自动回收:函数结束后,临时变量 lt 生命周期结束,会自动释放当前对象原本的旧资源,全程无需手动管理内存,安全又简洁。
我们将资源清理拆分为两步:先通过clear清空所有有效数据节点(保留哨兵位),再由析构函数释放哨兵位,分工明确且安全。
cpp
// 清空链表:删除所有有效节点,保留哨兵位
void clear()
{
auto it = begin();
// 遍历删除,erase 返回下一个有效迭代器,避免迭代器失效
while (it != end())
{
it = erase(it);
}
}
// 析构函数:先清数据,再释放哨兵位
~list()
{
// 第一步:清空所有有效节点
clear();
// 第二步:释放哨兵位头节点
delete _head;
// 置空指针,防止野指针
_head = nullptr;
}
资源清理采用分层设计:
- clear函数 :负责清空链表的所有有效数据节点,但保留哨兵位头节点。遍历过程中利用erase的返回值(下一个有效迭代器)避免迭代器失效,清空后链表回到初始空状态,可继续复用;
- 析构函数:先调用 clear 释放所有数据节点,再单独释放哨兵位头节点,最后将指针置空防止野指针,完成整个链表的资源回收。
4.3 insert与erase的实现
insert 实现
双向链表的插入逻辑清晰且高效:先定位到插入位置的当前节点 和它的前一个节点,将新节点插入这两个节点之间,最后重新建立双向指针链接即可。全程只需修改指针,无需移动任何数据,时间复杂度为 O (1)。

cpp
// 在 pos 迭代器位置之前插入值为 x 的新节点
void insert(iterator pos, const T& x)
{
// 1. 获取插入位置的当前节点指针
Node* cur = pos._node;
// 2. 获取当前节点的前一个节点
Node* prev_node = cur->prev;
// 3. 创建新节点(调用节点构造函数初始化数据)
Node* new_node = new Node(x);
// 4. 双向链接新节点与当前节点
new_node->next = cur;
cur->prev = new_node;
// 5. 双向链接新节点与前一个节点
new_node->prev = prev_node;
prev_node->next = new_node;
// 6. 更新链表有效元素个数
_size++;
}
erase实现
erase的逻辑同样简洁高效:先定位待删除节点的前后节点 ,直接将前后节点双向链接,然后释放待删除节点即可。为了安全,必须断言检查不能删除end()迭代器(哨兵位)。同时,头插、尾插、头删、尾删等常用接口可以直接复用insert和erase。
cpp
// 删除 pos 位置的节点,返回下一个有效迭代器
iterator erase(iterator pos)
{
// 断言检查:禁止删除哨兵位 end()
assert(pos != end());
// 1. 定位待删除节点及其前后节点
Node* cur = pos._node;
Node* prev_node = cur->prev;
Node* next_node = cur->next;
// 2. 直接链接前后节点,跳过待删除节点
prev_node->next = next_node;
next_node->prev = prev_node;
// 3. 释放待删除节点
delete cur;
// 4. 更新链表大小
--_size;
// 5. 返回下一个有效迭代器,避免迭代器失效
return iterator(next_node);
}
// 常用接口复用:直接调用 insert/erase
// 头插:在 begin() 之前插入
void push_front(const T& x)
{
insert(begin(), x);
}
// 尾插:在 end() 之前插入(符合 STL 标准命名 push_back)
void push_back(const T& x)
{
insert(end(), x);
}
// 头删:删除 begin() 位置
void pop_front()
{
erase(begin());
}
// 尾删:删除 end() 的前一个位置
void pop_back()
{
erase(--end());
}
5. STL const迭代器的设计思想
要实现 const 迭代器以区分普通迭代器,不少开发者的第一反应是复制一份普通迭代器的代码,再针对性修改部分逻辑。这种方法虽然可行,但会导致代码冗余,大幅增加维护成本。下面我们介绍 STL 库中解决该问题的经典设计思路。
首先需要明确:我们为什么需要 const 迭代器?其核心作用是保证 const 容器的安全性 ------ 当对 const 迭代器执行解引用(*)或箭头运算符(->)时,返回的内容是只读的,无法被修改。
要实现这一点,关键在于控制这两个运算符的返回值类型。STL 的经典做法是将返回类型作为模板参数传入迭代器类:在实例化迭代器时,就明确其解引用和箭头运算符的返回类型,从而通过一套模板代码同时支持普通迭代器和 const 迭代器,避免代码冗余。
5.1 实现与使用
我们对迭代器的模板参数进行扩展:第一个参数传递链表存储的数据类型,第二、第三个参数分别传递对应的指针类型和引用类型。
cpp
template <class T, class Ptr, class Ref>
struct list_iterator
{
typedef list_node<T> Node; // 节点类型别名
typedef list_iterator<T, Ptr, Ref> Self; // 迭代器自身类型别名
Node* _node; // 封装的节点指针
// 迭代器构造函数
list_iterator(Node* node)
: _node(node)
{}
};
在list类内部,我们通过传递不同的模板参数,分别定义出普通迭代器 和const迭代器:
cpp
// 普通迭代器:解引用返回可读写的引用和指针
typedef list_iterator<T, T*, T&> iterator;
// const 迭代器:解引用返回只读的 const 引用和 const 指针
typedef list_iterator<T, const T*, const T&> const_iterator;
接下来是迭代器内部的核心修改:
将解引用和箭头运算符的返回值类型,替换为模板参数Ref和Ptr。本质目的是让迭代器模板根据传入的具体类型,精准确定操作的返回值是 "可读可写" 还是 "只读"。
cpp
// 解引用运算符:返回 Ref 类型(由模板参数决定是否为 const 引用)
Ref operator*()
{
return _node->data;
}
// 箭头运算符:返回 Ptr 类型(由模板参数决定是否为 const 指针)
Ptr operator->()
{
return &(_node->data);
}
通过这种设计,普通迭代器会返回T&和T*,允许修改数据;
const 迭代器则返回 const T& 和 const T*,保证数据只读,完美实现了两种迭代器的行为区分。
5.2 const 迭代器使用案例
下面是一个通用的容器打印模板函数,展示了 const 迭代器的典型使用场景:
cpp
// 通用容器打印函数:接收 const 引用,保证不修改容器
template <class Container>
void print_container(const Container& con)
{
// 注意:必须使用 typename 声明 const_iterator 是一个类型
typename Container::const_iterator it = con.begin();
while (it != con.end())
{
// const 迭代器解引用只读,无法修改数据
cout << *it << " ";
++it;
}
}
6. 迭代器失效
list迭代器的失效规则非常清晰:插入数据不会导致任何已有迭代器失效,但删除数据会使特定迭代器失效。
6.1 插入不失效的原因
list是典型的节点式容器,插入操作仅涉及创建新节点并修改相邻节点的指针链接,不会移动、重分配或复制已有节点。因此,所有已有迭代器指向的节点地址始终不变,迭代器本身依然完全有效。
6.2 删除失效的规则
删除操作只会使 **"指向被删除节点" 的迭代器失效,**因为该节点的内存已被释放,迭代器指向的地址变为无效;而指向其他节点的迭代器,由于节点地址未变、链表结构未受影响,仍然保持完全有效。
7. 特殊的构造方式
在 C++11 及以后的标准中,我们经常会见到一种便捷的构造方式,初始化列表构造函数(initializer_list constructor),它允许我们直接用花括号包裹的初始化列表来初始化容器。
cpp
vector<int> v = {1, 2, 3, 4, 5, 6};
list<int> lt = {1, 2, 3, 4, 5, 6};

要让我们自己实现的list支持这种初始化方式,必须提供一个接收std::initializer_list的构造函数。编译器会自动将 {1,2,3,4,5,6} 这类花括号列表,转换为一个std::initializer_list<T>类型的临时对象,然后调用这个构造函数完成初始化。
cpp
// 初始化列表构造函数
list(initializer_list<T> lt)
{
// 先初始化空链表
empty_init();
// 遍历初始化列表,逐个尾插元素
for (const auto& e : lt)
{
push_back(e);
}
}
std::initializer_list的底层实现非常轻量,它内部通常只包含两个指针(或一个指针加一个长度)。当编译器遇到{1,2,3,4,5,6}这类花括号列表时,会先在栈上创建一个匿名的 const 数组 来存储这些数据,然后让 initializer_list 的两个指针分别指向该数组的起始和末尾(或起始位置加长度)。我们的构造函数正是通过遍历这两个指针之间的元素,来完成容器的初始化。

初始化列表构造函数有两种常见调用方式。前面提到的是拷贝初始化形式:
而另一种是更严谨的直接初始化形式,语法上更明确地表达了 "调用构造函数" 的意图:
cpp
vector<int> v({1,2,3,4,5});
类模板在被调用时才会进行实例化,但这里有一个关键细节:并非将整个类模板的所有成员函数一次性全部实例化,而是仅对实际调用到的接口进行实例化。这里我们先做一个初步介绍,后续章节会展开详细讲解。