重生之我要手写 C++ list:从底层结构到 const 迭代器与迭代器失效全解

目录

[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.list类的实现逻辑

[4.1 构造函数 & 拷贝构造函数 & 赋值重载 & 析构函数](#4.1 构造函数 & 拷贝构造函数 & 赋值重载 & 析构函数)

[4.2 赋值重载 & 交换函数(经典拷贝交换写法)](#4.2 赋值重载 & 交换函数(经典拷贝交换写法))

[4.3 insert与erase的实现](#4.3 insert与erase的实现)

[insert 实现](#insert 实现)

erase实现

[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自身服务。

类中包含两个核心成员变量:

  1. 哨兵位头节点_head:双向链表的标志性设计,不存储有效数据,专门用来简化链表的边界判断逻辑;
  2. 有效元素个数_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容器的经典写法:

  1. 传值调用:形参lt是一个独立的临时拷贝,拥有完全独立的资源,天然避免自赋值问题;
  2. 自定义swap :不使用编译器默认的逐元素拷贝交换,而是直接交换两个链表的哨兵位头指针长度,效率达到极致 O(1);
  3. 自动回收:函数结束后,临时变量 lt 生命周期结束,会自动释放当前对象原本的旧资源,全程无需手动管理内存,安全又简洁。

我们将资源清理拆分为两步:先通过clear清空所有有效数据节点(保留哨兵位),再由析构函数释放哨兵位,分工明确且安全。

cpp 复制代码
// 清空链表:删除所有有效节点,保留哨兵位
void clear()
{
	auto it = begin();
	// 遍历删除,erase 返回下一个有效迭代器,避免迭代器失效
	while (it != end())
	{
		it = erase(it);
	}
}

// 析构函数:先清数据,再释放哨兵位
~list()
{
	// 第一步:清空所有有效节点
	clear();
	// 第二步:释放哨兵位头节点
	delete _head;
	// 置空指针,防止野指针
	_head = nullptr;
}

资源清理采用分层设计:

  1. clear函数 :负责清空链表的所有有效数据节点,但保留哨兵位头节点。遍历过程中利用erase的返回值(下一个有效迭代器)避免迭代器失效,清空后链表回到初始空状态,可继续复用;
  2. 析构函数:先调用 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});

类模板在被调用时才会进行实例化,但这里有一个关键细节:并非将整个类模板的所有成员函数一次性全部实例化,而是仅对实际调用到的接口进行实例化。这里我们先做一个初步介绍,后续章节会展开详细讲解。

相关推荐
paeamecium2 小时前
【PAT甲级真题】- Favorite Color Stripe (30)
数据结构·c++·算法·pat
练习时长一年2 小时前
xlsx文件下载异常问题
java·开发语言
secret_to_me2 小时前
裴行俭VS袁天罡和李淳风
开发语言
2601_953465612 小时前
M3U8 在线播放器:无需安装,一键调试 HLS 直播流
开发语言·前端·javascript·开发工具·m3u8·m3u8在线播放
郭涤生2 小时前
C++ 线程同步复习
开发语言·c++
Full Stack Developme2 小时前
Hutool EnumUtil 教程
开发语言·windows·python
XMYX-02 小时前
18 - Go 等待协程:WaitGroup 使用与坑
开发语言·golang
feifeigo1232 小时前
基于遗传算法的矩形排样MATLAB实现
开发语言·matlab
他是龙5512 小时前
65:JS安全&浏览器插件&工具箱等
开发语言·javascript·安全