开篇介绍:
hello 大家,我们这么快就又见面了,nice to meet you again。
那么我们在前面几篇博客中,是一路火花带闪电的去将string和vector搞定,无论是它们的使用方法,还是模拟实现,我们都统统拿下,可以说从从容容、游刃有余,当然,也有人可能是匆匆忙忙、连滚带爬,哈哈哈哈哈,开个玩笑,反正不论怎么样,我们算是对string和vector画上了一个不错的句号,剩下的就是需要大家去不断刷题巩固了,该掌握的大家都掌握了。
OK,那么很显然,在我们之前的学习之路中,我们在搞定了顺序表这一个数据结构之后,就迎来了链表这一新的数据结构,带给我们极大的震撼和嘎嘎好使,那么那么,哈哈,很显而易见,我们在搞定了vector和string这两个类顺序表的数据结构之后,就要迎来了链表这一数据结构,在C嘎嘎中,它叫作------list,那么我在这里先提个醒,list是双向带头链表哦,并不是单链表,这个我们在上一篇博客中对list等等详细介绍中也有提到。
所以,按照我们之前的套路,我们接下来的两篇博客,就是要去模拟实现C++中list这一数据结构,话不多说,我们,发车。
创建list的节点类:
那么我们知道,链表中最重要的一个东西,就是节点,毕竟链表就是由一个一个节点串联起来的,这个我们在之前也说过很多次了,那么对于C嘎嘎的list,也是一样的,所以,我们要先去创建一个节点类,那么又由于我们模拟实现的list是双向带头链表,所以,大家可以先去看一下这篇博客进行复习复习:对于数据结构:双向链表的超详细保姆级解析_双向链表分类 知乎-CSDN博客
所以节点类需包含指向前一个节点的指针、指向下一个节点的指针以及数据存储成员,由于节点内的数据需要被外部随时访问,采用 struct 创建(其成员变量默认 public 访问权限)更为合适。
又因无法提前确定存储的数据类型,需通过类模板定义,让用户在使用时显式指定数据类型以实现灵活复用。这个是关键的一个点。
那么接下来,其实我们就可以写出节点类来了,不过在这里还要多说一个注意点,因为我们是类模版,而类模版的使用一向是要求要显式实例化,那么我们知道,节点里面要有指向前后两个节点的指针,然后这个两个指针是用的节点类的类型,所以我们在创建这两个指针的时候,也是要对类型进行类模版显式实例化的,但是呢,其实也是可以不用的,额,因为编译器其实会帮我们自动加上去的,但是最好还是我们自己去显式实例化,下面是完整代码:
cpp
template <typename type1>
struct list_node
{
type1 mdata = type1(); // 存储数据
list_node* mprev = nullptr; // 指向前一个节点的指针
list_node* mnext = nullptr; // 指向下一个节点的指针
// 注:在类模板内部,编译器会自动将 list_node 解析为 list_node<type1>
// 因此 list_node* mprev 等价于 list_node<type1>* mprev,简化了代码书写
// 这是 C++ 标准允许的语法,用于减少冗余,提升可读性
// 节点构造函数:传入值初始化数据成员
list_node(const type1& val = type1())
: mdata(val)
{} // mprev 和 mnext 采用默认初始化(nullptr)
// 析构函数使用编译器默认生成版本(无需手动管理动态内存)
};
大家可以看到,其实本质上不难的,但是还是需要大家对前面讲的内容有高度的掌握才行,只能说,C嘎嘎确实是个难的语言,哈哈哈。
创建list的迭代器类:
OK大家,打起10086万分精神,模拟实现lsit的传说级难度要来了,同时也是我们这段时间学习的一个最难的难度,即list的迭代器类,大家接下来跟好我的步伐,gogogo,出发咯。
我们知道,一个容器的,很重要的一个东西其实就是迭代器,那么list的迭代器,也正是list的难点所在。
要实现链表的迭代器,需单独创建一个迭代器类。这是因为链表与数组存在本质区别:数组的内存空间是连续的,其指针可以直接通过加减加运算来移动位置,并且能直接通过解引用获取对应位置的数据;而链表由一个个地址不连续的节点组成,无法像数组那样操作指针。
对于链表的迭代器,虽然仍是通过指针指向节点来实现,但它不能直接通过指针加一个数来指向后面的节点,仅支持前置 ++、后置 ++、前置 --、后置 -- 操作,且这些操作都需要我们自行进行运算符重载。同时,对链表迭代器解引用以获取所指位置的数据,也需要我们自定义实现相关逻辑。
此外,由于我们无法预先知道链表中存储的数据类型,所以迭代器类需要设计为类模板,由用户在使用时显式指定数据类型进行实例化。又因为迭代器需要被频繁访问,所以将其设置为 struct 类型,这样其成员变量默认具有 public 访问权限,便于外部操作。
OK,接下来要深入拆解模拟实现 list 里最核心、最关键的环节,我们从底层逻辑一步步展开:
首先必须明确一个前提:链表与数组的内存存储本质差异 ,直接决定了它们的指针操作逻辑完全不同。数组的所有元素在内存中是连续排列的,比如int arr[5]的元素会占用 5 个连续的int大小的内存空间。这种连续性使得指向数组元素的指针(比如int* ptr = &arr[0])具备天然的 "可计算性"------ 通过ptr + 1,指针能直接跳到下一个元素的地址(因为下一个元素就在当前地址 +sizeof(int)的位置);通过*ptr,能直接读取当前地址对应的内存数据。这些操作都是编译器原生支持的,无需额外处理。
但链表完全不同。链表的每个节点(比如list_node)是独立在堆上申请的内存,节点之间的地址可能毫无关联(比如第一个节点在 0x1000,第二个可能在 0x2000,两者之间没有固定偏移)。节点之间的关联完全依赖内部的mprev和mnext指针:当前节点的mnext指向后一个节点的地址,mprev指向前一个节点的地址。这种 "离散存储 + 指针关联" 的特性,导致指向链表节点的原生指针(比如list_node* node_ptr)几乎 "无用"------ 你不能通过node_ptr + 1获取下一个节点(因为下一个节点的地址不是当前地址 + 固定值),也不能仅通过*node_ptr直接得到 "用户需要的数据"(因为*node_ptr得到的是整个节点对象,而用户需要的是节点内的mdata)。
正因为如此,我们必须单独封装一个迭代器类 ,通过重载运算符来 "模拟" 数组指针的操作逻辑,让用户能像操作数组迭代器一样方便地使用链表迭代器。但这里有个绝对核心的点:无论迭代器类封装了多少功能,它的底层必须依赖一个节点指针,这个指针是迭代器的 "灵魂"。
为什么必须是节点指针?我们从三个具体场景拆解:
-
定位节点的唯一方式 :链表的遍历、插入、删除等操作,本质上都是 "找到目标节点" 后再操作。而找到节点的唯一途径,就是通过节点指针 ------ 迭代器要知道 "当前指向哪个节点",才能通过该节点的
mnext跳到下一个节点,通过mprev回到上一个节点。如果迭代器里没有节点指针,它就成了 "无头苍蝇",根本不知道自己在链表的哪个位置。 -
对接
begin()和end()函数的基础 :begin()函数的作用是返回 "指向链表第一个有效节点的迭代器",end()返回 "指向哨兵节点的迭代器"(哨兵节点是链表的终止标记)。这两个函数的底层实现,其实是返回对应节点的指针(比如begin()返回sentinel->mnext,end()返回sentinel)。迭代器要接收这些返回值,内部必须有一个节点指针来存储它们 ------ 否则,begin()和end()返回的指针无处安放,迭代器也就无法确定遍历的起点和终点。 -
所有运算符重载的底层支撑 :迭代器的核心功能(
++、--、*、->),本质上都是对内部节点指针的操作:++it(前置 ++):实际执行的是it.mnode = it.mnode->mnext(让节点指针指向当前节点的下一个节点);--it(前置 --):实际执行的是it.mnode = it.mnode->mprev(让节点指针指向当前节点的上一个节点);*it(解引用):实际执行的是return it.mnode->mdata(通过节点指针获取节点内存储的数据);it->(箭头运算符):实际执行的是return &(it.mnode->mdata)(通过节点指针获取数据的地址,方便访问自定义类型的成员)。
如果用一个比喻来具体化:节点指针就像一把 "基础扳手",它只能完成 "夹住某个螺栓"(即 "指向某个节点")这一个基础动作;而迭代器类就像一个 "电动扳手",它把这把基础扳手集成在内部,同时增加了 "自动顺时针转动"(对应++)、"自动逆时针转动"(对应--)、"显示螺栓型号"(对应*)等功能 ------ 这些附加功能全靠内部的基础扳手才能实现。
反过来讲,如果迭代器类里没有这个节点指针,后果会非常具体:
- 想遍历链表时,
++it不知道该往哪里移动(因为没有节点指针,无法获取mnext); - 想获取数据时,
*it不知道该读取哪个节点的mdata(因为没有节点指针,找不到目标节点); - 调用
begin()后,迭代器无法存储 "第一个有效节点的指针",遍历的起点就不存在了; - 调用
end()后,迭代器无法存储 "哨兵节点的指针",也就不知道何时该停止遍历(可能会越界访问)。
所以,最根本的结论是:链表的迭代器,本质上是一个 "增强版的节点指针" ------ 它的核心是一个指向节点的指针,但通过类的封装和运算符重载,具备了类似数组指针的操作体验(比如++移动、*取数据)。这个节点指针是迭代器类必须包含的成员变量,所有功能都围绕它展开;没有它,迭代器就成了没有核心部件的空壳,无法完成任何实际操作。甚至begin()和end()函数的返回值,本质上也是通过迭代器类包装后的节点指针 ------ 前者包装 "第一个有效节点的指针",后者包装 "哨兵节点的指针"。
大家好好理解,很重要,还有一点就是,我们在外面创建的迭代器,其实都是迭代器类变量,但是我们在内部进行操作的时候,是去操作迭代器类变量里面的成员变量的节点指针的,这一点大家仔细体会,后面会进一步理解。
迭代器类的成员变量和重命名:
那么我们知道,能偷懒就偷懒,所以对于节点类和迭代器类那么长的名字,我们就要进行重命名,这个就很简单了,我们看下面的代码:
cpp
typedef list_node<type1> node; // 对节点类重命名,简化冗长名称,需显式实例化
typedef list_iterator<type1, ptr, ref> self;
// 将当前迭代器类自身重命名为self,方便使用
// 也可命名为iterator,但self语义更清晰:代表「当前类自身」(如self&即当前类的引用)
// 对初学者而言,self更直观(明确指向"自己这个类"),而iterator可能与list类中
// 的iterator别名(typedef list_iterator<type1> iterator;)产生轻微混淆
// (虽作用域不同不会导致编译错误,但会降低代码可读性)
其实就是为了偷懒,那么还有迭代器类里面的boss,其实就是创建一个节点指针作为迭代器类的成员变量,上面说了,节点指针就是迭代器类的灵魂,整个迭代器类的所有操作都是围绕着这个节点指针进行操作的,那么其实就长下面这样子:
cpp
node* mnode;//创建节点指针成员变量
begin 和 end 这两个用于标记链表遍历起点和终点的函数,我们会将其统一放在后续的 list 类中实现,而当前这个迭代器类的核心设计目标,就是让 list 的迭代器能够拥有与数组迭代器完全一致的使用体验 ------ 对用户而言,无需关注底层存储差异,既能通过解引用操作(*it)*直接获取迭代器所指向节点中存储的实际数据,也能通过前置 ++、后置 ++、前置 --、后置 -- 操作灵活控制迭代器在链表中的移动方向(向前或向后定位节点),还能通过 -> 操作直接访问节点数据(若数据为自定义结构体 / 类类型时,可便捷访问其内部成员)。
要实现这些与数组迭代器一致的核心操作,由于链表节点的离散存储特性,编译器无法提供原生支持,因此必须通过在迭代器类中对、++、--、-> 这些运算符进行手动重载,才能将这些操作映射为对链表节点指针的合法操作,最终让用户获得简洁、统一的使用接口。
迭代器类的构造函数和析构函数:
OK大家,作为了一个类,怎么可能没有构造函数呢,那么其实迭代器类的构造函数也是迭代器类的关键所在,我们下面的list主体类的begin和end等获取迭代器函数都是通过这个迭代器类的构造函数实现的,我再次强调,我们在外面创建的迭代器,统统都是迭代器类变量,而不是节点指针,所以毋庸置疑,迭代器类的构造函数,尤为重要。
那么其实实现起来也是很简单的,毕竟我们的迭代器类就一个成员变量,我们只要对这个成员变量进行初始化就行,大家直接看代码:
cpp
// 迭代器构造函数
// 用于接收list类中begin和end函数传递的头尾节点指针,完成迭代器初始化
list_iterator(node* val = nullptr) // 默认为nullptr(保底值),支持无参构造
: mnode(val) // 初始化列表直接初始化核心成员变量mnode(节点指针)
{}
其实就是接收外面常量的节点指针,从而去创建出一个存储着这个节点指针的迭代器类而已。
至于迭代器类的析构函数,拜托,又没有申请空间,肯定是不需要我们自己手动实现的啦,编译器自己生成的就够了。
迭代器类的对*(解引用)的运算符重载函数:
我们知道,在数组中对迭代器执行解引用操作(*it)*就能直接访问到对应的数据,这是因为数组的迭代器本质是原生指针,编译器原生支持这种操作;
但链表的节点指针(指向 list_node 的指针)却不支持这样的直接解引用 ------ 对节点指针解引用得到的是整个节点对象,而非节点中存储的实际数据(mdata),因此必须在迭代器类中对解引用运算符()进行重载。
需要特别注意的是,迭代器类中所有的操作(包括解引用在内),本质都是围绕其内部的节点指针成员变量(mnode)展开的,目的是通过封装节点指针的操作逻辑,让迭代器对外呈现出与数组迭代器一致的使用方式。
正因为这些操作的对象是迭代器自身的成员变量(mnode),而非外部传入的数据,所以解引用运算符的重载函数不需要接收任何参数 ------ 例如解引用操作的核心逻辑是通过 mnode 访问节点的 mdata(即 return mnode->mdata),整个过程仅依赖迭代器自身存储的节点指针,无需外部提供额外参数。
下面我就给出详细代码:
cpp
// 重载解引用运算符*
// 返回当前迭代器指向节点中存储的实际数据
ptr operator*()
{
return mnode->mdata; // 直接通过内部节点指针mnode获取节点的mdata并返回
}
so easy
迭代器类的对->的运算符重载函数:
我们知道,链表的节点所存储的数据可能是自定义类型(如结构体、类),当需要访问这类自定义类型数据的内部成员时,若不重载->运算符,按照常规逻辑,需要先对迭代器解引用(*it)得到节点中存储的mdata(即自定义类型对象),再通过点运算符(.)访问其成员变量,也就是写成(*it).成员变量的形式 ------ 这是因为*it本质上返回的是节点的mdata,而mdata正是我们存储的自定义类型对象。
但为了让迭代器的使用体验与数组迭代器保持一致(数组迭代器可直接通过it->成员变量访问自定义类型成员),也为了书写更简洁直观,我们需要对迭代器的->运算符进行重载。
不过这个运算符的实现逻辑看起来有些特殊:它并不直接返回成员变量的值,而是返回mdata的地址(即&mnode->mdata),这是因为编译器会自动对->运算符进行特殊处理 ------ 当我们写it->成员变量时,编译器会先调用operator->()得到mdata的地址,再自动通过该地址访问对应的成员变量,相当于隐式完成了(it.operator->())->成员变量的逻辑,这种实现方式虽然看起来 "奇怪",但却是 C++ 中让迭代器支持->访问自定义类型成员的标准且高效的方式。
下面我们看详细代码:
cpp
// 重载箭头运算符->
// 用于访问节点数据(自定义类型)的成员变量
ref operator->()
{
return &(mnode->mdata); // 返回节点中mdata的地址
}
// 注:编译器会自动将 it->name 解析为:
// (it.operator->())->name,即通过mdata的地址访问其成员name
// 无需深入理解底层细节,只需知道可通过 it-> 直接访问自定义类型成员即可
还是需要认真理解的。
迭代器类的对前置++和--的运算符重载函数:
注意点:
++,其实就是让迭代器指向的位置往后一位,即让节点指针指向它原本所指向的节点的下一个节点,--,其实就是让迭代器指向的位置往前一位,即让节点指针指向它原本所指向的节点的上一个节点。
但需要明确的是,迭代器的 ++ 和 -- 操作并非简单地让迭代器类内部的节点指针成员变量(mnode)往后或往前移动一下就结束。
因为从本质上来说,外部代码使用的迭代器是整个迭代器类的实例对象(即创建的是迭代器类变量),而不是迭代器类内部的节点指针本身 ------ 我们虽然是在迭代器类内部对节点指针 mnode 进行移动操作(比如让 mnode 指向 mnext 或 mprev),但外部始终是通过迭代器类对象来调用这些操作、使用迭代器的功能。
因此,++ 和 -- 操作的返回值本质上也必须是迭代器类类型,而不能是迭代器类的成员变量(节点指针),这是一个很容易混淆的关键点。
基于此,我们将 ++ 和 -- 运算符重载函数的返回类型设置为迭代器类自身的引用(self&):这样做一方面可以避免编译器为返回值创建多余的临时对象,提升代码执行效率;另一方面能够支持链式操作(比如 ++(++it)、--(--it)),让迭代器的使用方式完全符合 STL 规范,保持与标准库迭代器一致的使用体验。
前置++:
cpp
// 重载前置++运算符
// 功能:将迭代器移动到下一个节点(当前节点的mnext指向的节点)
self& operator++()
{
// 本质是通过修改内部节点指针mnode的指向实现移动
// 然后返回当前迭代器对象的引用(*this)
mnode = mnode->mnext;
return *this;
}
前置--:
cpp
// 重载前置--运算符
// 功能:将迭代器移动到上一个节点(当前节点的mprev指向的节点)
// 实现逻辑与前置++一致,仅移动方向相反
self& operator--()
{
mnode = mnode->mprev;
return *this;
}
还是比较简单的,只要大家能理解上面那段红色的字。
迭代器类的对后置++和--的运算符重载函数:
注意点:
后置 ++ 和 -- 运算符的返回类型与前置版本相同(均为迭代器类自身类型),两者的核心区别仅在于使用逻辑:后置操作的语义是 "先使用当前迭代器的值,再执行 ++ 或 --"。
因此,实现后置运算符时,需要先创建一个局部变量来保存当前迭代器的状态(即复制一份当前迭代器对象,保留原本的节点指针指向),接着再对当前迭代器内部的节点指针执行移动操作(让 mnode 指向 mnext 或 mprev),最后返回之前创建的那个局部变量(即未执行移动操作的原始迭代器状态)。
由于返回的是函数内部创建的局部变量,而局部变量在函数执行结束后会被销毁,所以后置运算符的返回类型不能是引用(self&),只能是迭代器类的值类型(self)------ 这样返回的是局部变量的副本,避免了返回已销毁对象的引用导致的未定义行为。
后置++:
cpp
// 重载后置++运算符(参数int仅作为区分前置/后置的标记,无实际意义)
// 功能:先返回当前迭代器状态,再将迭代器移动到下一个节点
self operator++(int)
{
self temp(mnode); // 用当前节点指针创建临时迭代器(保存当前状态)
mnode = mnode->mnext; // 移动当前迭代器的节点指针
return temp; // 返回未移动的临时迭代器(原始状态)
}
后置--:
cpp
// 重载后置--运算符(参数int仅作为区分标记)
// 功能:先返回当前迭代器状态,再将迭代器移动到上一个节点
self operator--(int)
{
self temp = *this; // 直接复制当前迭代器对象(保存当前状态)
mnode = mnode->mprev; // 移动当前迭代器的节点指针
return temp; // 返回未移动的临时迭代器(原始状态)
}
嗯,简单。
迭代器类的对==和!=的运算符重载函数:
迭代器的相等判断(==)核心逻辑是判断两个迭代器是否指向链表中的同一个节点,而由于迭代器的核心成员是指向节点的指针变量(mnode),其本质就是比较这两个迭代器各自内部的节点指针变量(mnode)是否指向同一个内存地址 ------ 如果两个迭代器的 mnode 指针值完全相同,说明它们指向的是链表中同一个节点,此时判断结果为真(相等);如果 mnode 指针值不同,说明它们指向的是不同节点,判断结果为假(不相等)。
下面我就给出完整代码:
cpp
// 重载相等运算符==
// 判断两个迭代器是否指向同一个节点(比较内部节点指针是否相同)
bool operator==(const list_iterator<type1, ptr, ref> com) const
{
return mnode == com.mnode; // 比较当前迭代器与传入迭代器的节点指针
}
// 重载不等运算符!=
// 判断两个迭代器是否指向不同节点(比较内部节点指针是否不同)
bool operator!=(const list_iterator<type1, ptr, ref> com) const
{
return mnode != com.mnode; // 比较当前迭代器与传入迭代器的节点指针
}
OK大家,到这里,我们的迭代器类算是暂时告一段落,但是注意,是暂时,后面我们会对这个迭代器类进行一个升级,但是这个得放到后面再说,就比如现在:
通过一个类模版实现const和非const的迭代器类:
目前的实现看似已经完成了迭代器的核心功能,但实际上仍有一处关键疏漏:我们只实现了支持读写操作的普通迭代器,却没有实现只读不写的 const_iterator 迭代器。这种缺失会直接导致一个问题:当需要对 const 修饰的 list 对象使用迭代器时(例如通过 const 成员函数返回迭代器),由于普通迭代器允许修改数据,无法与 const 对象的只读需求匹配,进而导致拷贝构造函数和 = 赋值运算符重载在涉及 const 场景时无法正常工作。因此,必须补充实现 const_iterator 迭代器以覆盖只读场景。
实现 const_iterator 有两种可行的方法:
第一种方法是单独编写一个全新的 const 迭代器类模板,其结构与普通迭代器类基本一致,唯一的区别是将解引用运算符()和箭头运算符(->)的返回值类型改为 const 修饰的类型(例如 const T & 和 const T)
大家可能会好奇为什么仅需对解引用运算符()和箭头运算符(->)的返回值类型改为 const 修饰的类型呢?
这是因为 const 迭代器(const_iterator)的核心语义是 "只读不写"------ 它允许访问数据,但禁止修改数据。而迭代器的各种操作中,只有解引用(*)和箭头(->)运算符直接涉及 "获取数据并可能修改数据",其他操作(如 ++、--、==、!=)仅用于迭代器的移动或比较,不涉及数据的读写权限,因此无需修改。
具体来说:
- 解引用运算符(*)的作用是返回迭代器指向的节点数据(mdata),普通迭代器返回的是可修改的引用(T&),而 const 迭代器需要返回不可修改的 const 引用(const T&),确保用户无法通过 *it 修改数据。
- 箭头运算符(->)的作用是返回数据的地址(用于访问自定义类型的成员),普通迭代器返回 T*,const 迭代器需要返回 const T*,确保用户无法通过 it->member 修改成员数据。
而 ++、-- 等操作仅改变迭代器指向的节点(移动指针),不涉及数据本身的读写权限,因此它们的实现对普通迭代器和 const 迭代器是完全一致的,无需额外修改。
简言之,const 迭代器与普通迭代器的差异仅在于 "能否通过迭代器修改数据",而这种差异仅体现在直接返回数据或数据地址的 * 和 -> 运算符上,因此只需修改这两个运算符的返回值类型即可实现 const 迭代器的 "只读" 语义。
这种方式虽然直观,但会导致大量重复代码 ------ 两个迭代器类的大部分逻辑(如 ++、--、==、!= 等操作)完全相同,仅 * 和 -> 的返回值有差异,造成代码冗余,且后续维护时需要同时修改两个类,容易出现不一致。
第二种方法则更为高效,它利用模板的特性让编译器自动生成所需的迭代器类型(包括普通迭代器和 const 迭代器)。具体做法是:在迭代器类模板中,将和 -> 运算符重载函数的返回值类型用模板参数(例如 ptr 和 ref)来表示,而非硬编码为固定类型。这样,当用户显式实例化迭代器时,若传入的模板参数是普通引用(T&)和普通指针(T),就会生成支持读写的普通迭代器;若传入的是 const 引用(const T&)和 const 指针(const T*),则会生成只读的 const_iterator。通过这种方式,仅需一个迭代器类模板,就能同时满足读写和只读两种需求,既避免了代码冗余,又能让编译器根据实例化参数自动适配返回值类型,完美覆盖所有使用场景。
大家应该很好理解。下面我们看具体示例:
cpp
template <typename type1, typename ptr, typename ref>
struct list_iterator
{
typedef list_node<type1> node;
typedef list_iterator<type1, ptr, ref> self;
node* mnode;
list_iterator(node* val = nullptr)
: mnode(val)
{}
ptr operator*()
{
return mnode->mdata;
}
ref operator->()
{
return &(mnode->mdata);
}
......
}
然后在list类中,我们创建迭代器就是这样子:
cpp
typedef list_iterator<type1, type1&, type1*> iterator;
typedef list_iterator<type1, const type1&, const type1*> const_iterator;
//就是这么简单,这么一来,传入的参数不同,编译器就会生成两个类模版
可以发现,这么一来,代码简洁度不知道高了多少倍,希望大家注意。
OK,到了这里,我们就把迭代器类给讲的差不多了,剩下的就是list类的实现了,这个就简单很多了,我们之前也有说过,那么我们也已经讲了许久了,我们暂做休息,把剩下的内容,放到下一篇博客去。
结语:
亲爱的朋友们,当你读到这里时,想必屏幕前的你,指尖或许还残留着敲击键盘的温度,脑海里还回荡着 "节点指针""运算符重载" 这些词语的余响 ------ 别急着合上页面,我们不妨停下脚步,回头看看这一路走过的路。
从 string 到 vector,再到今天的 list 迭代器,我们像是在数据结构的森林里徒步:起初在顺序表的平原上稳步前行,熟悉了连续内存的规律;而当踏入链表的山地时,脚下的路忽然变得曲折,那些离散的节点、相互勾连的指针,像缠绕的藤蔓,总让人在不经意间迷路。但你看,此刻的你,已经亲手拆解了 list 迭代器最核心的逻辑,从节点类的定义到迭代器的运算符重载,从普通迭代器到 const 迭代器的巧妙实现,每一行代码的背后,都是你与 "复杂" 对抗的痕迹。
还记得吗?我们刚开始聊节点类时,你或许觉得 "不就是个带前后指针的结构体吗",可当真正用模板去定义它时,才发现 "list_node* mprev" 里藏着编译器的温柔 ------ 它默默帮我们补全了模板参数,让代码少了冗余;聊到迭代器的核心时,你可能也曾困惑 "为什么非要用一个类来包装指针",直到我们拆解了 ++、--、*、-> 的每一个操作,才恍然大悟:原来迭代器的本质是 "带着工具的指针",那些重载的运算符,不过是给指针装上了适配链表的 "轮子",让它能在离散的内存里像数组指针一样灵活移动。
最让人头疼的,大概是 const 迭代器的实现吧?一开始觉得 "再写一个类不就行了",可当意识到代码会冗余时,又跟着模板参数的思路绕了弯 ------ 原来用 ptr 和 ref 这两个模板参数,就能让编译器自动生成两种迭代器,这种 "一次编写,两处复用" 的智慧,是不是像解开了一个精巧的谜题?你看,编程的美妙往往就藏在这些 "原来可以这样" 的瞬间里:它不只是机械地堆砌代码,更是用逻辑串联起一个个看似孤立的知识点,最终形成一张能解决问题的网。
其实,学习数据结构的过程,就像在搭建一座桥。节点是桥墩,指针是连接桥墩的钢索,而迭代器,就是让我们能在桥上自由行走的路面。刚开始搭建时,我们总担心某个桥墩不稳(比如节点的指针没初始化),害怕钢索连接错了方向(比如 ++ 运算符指向了 mprev),可当整座桥终于能承载 "遍历""访问" 这些功能时,那种成就感,或许就是编程带给我们最直接的快乐。
我知道,这一路你或许有过卡顿:可能盯着 "operator->() 返回的是地址" 这个点皱过眉,可能在前置 ++ 和后置 ++ 的返回值区别上犯过迷糊,可能对着模板参数的传递逻辑挠过头。但请相信,这些 "卡壳" 不是你的短板,而是成长的印记。就像学骑自行车时总要摔几次才能掌握平衡,学编程时也总要在某个知识点上 "卡" 住,才能把它真正内化成自己的东西。
而且你要知道,我们今天啃下的 list 迭代器,可不是个 "小角色"。它是 STL(标准模板库)设计思想的缩影 ------ 用封装隐藏复杂,用模板实现通用,用迭代器统一接口。当你理解了 "迭代器让 list 能用和 vector 一样的方式遍历" 时,你就已经触碰到了 "泛型编程" 的核心:不管底层是连续内存还是离散节点,用户看到的接口始终一致。这种 "化繁为简" 的思维,比记住某个函数的实现更重要,因为它能帮你在未来面对更复杂的问题时,找到一条清晰的路径。
或许有朋友会说:"我只是想会用 list 就行,何必费劲去模拟实现?" 但你看,当你亲手写过迭代器的 operator*(),就会明白为什么解引用能拿到数据;当你实现过 operator++(),就会知道遍历链表时 "++it" 背后发生了什么。这种 "知其然,更知其所以然" 的底气,会让你在使用 STL 时更有自信 ------ 遇到 bug 时,你能更快定位到问题可能出在迭代器失效上;优化代码时,你能明白为什么 list 的插入删除比 vector 快。
接下来的博客里,我们会继续完成 list 类的实现,把今天的迭代器和节点串联起来,实现 push_back、insert、erase 这些常用接口。到那时,你会发现,今天打下的迭代器基础,就像给房子搭好了梁,剩下的砌墙、装窗,不过是按部就班的事。
最后,想对你说:编程的学习从来不是一蹴而就的。可能今天看懂了迭代器,过两天又忘了 const 和非 const 的区别;可能现在能写出 operator++(),下次动手时又犹豫返回值该用引用还是值。这都没关系,重要的是别停下脚步。就像我们今天拆解迭代器一样,把每个模糊的知识点拆成小问题,一个个攻克;把写过的代码多敲几遍,直到手指记住那种逻辑的流动。
数据结构的森林里还有很多风景等着我们去探索,list 之后还有 map、set,还有更复杂的算法。但请相信,你今天为迭代器付出的每一份思考,都在悄悄为你积蓄力量。当某天你能轻松看懂 STL 源码,能自如地用数据结构解决问题时,回头看看此刻为 "mnode 指针该指向 mnext 还是 mprev" 而纠结的自己,一定会笑着说:"原来我早就走了这么远。"
休息好了吗?下一篇博客,我们继续把 list 的故事讲完。到那时,你亲手实现的 list,会像你精心打磨的工具,在你的编程工具箱里,闪着属于你的光。我们,下篇再见。