ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

手写C++ list:带头双向循环链表的完整实现与设计哲学

手写C++ list:带头双向循环链表的完整实现与设计哲学

如果你翻开任何一本数据结构教材,链表几乎都是必讲的内容;可当你真正想把一个链表做成能放进项目里复用的容器,而不是作业里那种只有一个next指针的 demo,你会很快发现:事情远没有“一个节点存数据、串一串指针”那么简单。这篇博文想聊的正是 C++ 标准库中list(带头双向循环链表)的完整模拟实现,核心围绕增删查改四条主线展开。但我不打算只贴代码——每一步选型、每一个接口取舍背后的理由,以及我在手写过程中踩过的坑,都会一并讲透。无论你是正在学习 STL 源码的新手,还是准备面试时被“实现一个 list”问住的求职者,按这篇的思路走一遍,你收获的不只是一个能跑的链表,而是一套“容器该怎么设计”的思维方式。

1. 先理解:list 为什么偏偏是“带头双向循环”

list这个容器在 STL 里经常被误解为“和单链表差不多”,但标准库实际上选择的是带头节点 + 双向 + 循环三合一形态。这不是炫技,而是三个各有用意的工程决策拼在一起的结果。

1.1 循环:让首尾操作和 ++/-- 对称起来

单向链表最大的痛点在于:你只能从 head 往后走,想走到最后一个节点必须遍历 O(N)。双向链表的出现解决了“往前还要回去”的问题,但如果没有设计成循环结构,尾部节点的_next指向nullptr,那么“从尾部往前走”依然要从头开始走一遍才能找到尾节点。

而循环链表让最后一个节点的_next指回头节点,头节点的_prev指向最后一个节点。这个设计的直接好处是:头尾两个方向都是 O(1) 可达。你再也不用维护单独的_tail指针,原因是_head->_prev就是尾节点;你也不需要判断“是不是空链表”,因为空链表也只有一个哨兵节点指向自己,逻辑完全统一。

1.2 带头节点(哨兵):让边界条件消失

很多人第一次看带头节点觉得多余:不就是多一个不存数据的空节点吗?但你想想单链表里写头插有多难受——要先判断_head == nullptr、要改头指针、还要小心参数顺序。而带了哨兵头节点后,无论链表是否为空,结构始终对称:头节点的_next永远是第一个有效节点,头节点的_prev永远是最后一个有效节点。于是头插、尾插、指定位置插入,全都收敛到同一个insert(pos, val)函数,不需要任何 if 分支判断边界状态。

哨兵节点本身是list对象内部持有的常驻节点,它在构造时创建、析构时释放。遍历时从_head->_next开始,到_head结束。它不参与数据存储,也不对外暴露给用户迭代器。

1.3 双向:空间换时间的典型交换

双向链表每个节点多一个_prev指针,64 位系统下多 8 字节。换来的是任意已知节点的前插和前删都是 O(1),以及反向迭代、从尾向头遍历的能力。在 STL 中,list的迭代器类型是BidirectionalIterator(双向迭代器),支持++和--,但不像vector那样支持it += n。设计选型本质上就是在回答:这个容器最频繁的操作是什么?list的核心使用场景是“频繁在中间插入删除且不关心随机访问”,那么双向是值回票价的。

补充:模拟实现时你也可以先做成不带头、不循环、单向的版本,但你会发现几乎所有接口都要写if (_head == nullptr)特判,代码量和出错率都会明显上升。带哨兵节点的双向循环结构,是这些设计里“代码最统一、心智负担最小”的方案。

2. 搭骨架:从节点到 list 类的最小框架

有了设计思路,第一步就是把节点和list的壳子搭好。这里有两个前置知识点需要先厘清:struct与class的使用偏向,以及类模板里类型别名的管理。

2.1 节点结构:一个节点三件套

节点本身非常简单:

template <class T> struct ListNode { ListNode<T>* _prev; // 前驱指针 ListNode<T>* _next; // 后继指针 T _data; // 存储数据 ListNode(const T& val = T()) : _prev(nullptr) , _next(nullptr) , _data(val) {} };

为什么用struct而不是class?因为节点内部的数据成员需要被list类和迭代器类直接访问,如果用class则默认私有,要写一堆friend声明。struct成员默认公有,写模拟实现时更清爽。构造函数里给_data一个默认参数T(),这样ListNode<int> node;也能直接构造出数据为 0(默认初始化)的节点。

2.2 list 类的整体框架

template <class T> class List { public: typedef ListNode<T> Node; typedef __ListIterator<T, T&, T*> iterator; typedef __ListIterator<T, const T&, const T*> const_iterator; List() { empty_init(); } private: void empty_init() { _head = new Node; _head->_prev = _head; _head->_next = _head; } private: Node* _head; };

这里我提前写了一个empty_init()私有函数。为什么需要它?因为构造函数、拷贝构造函数、赋值运算符的重载都需要“初始化出一个空的带头循环链表”这一操作,抽成函数后三处复用,避免代码重复。后面你会看到它的重要性。

2.3 为什么公开接口只用迭代器而不直接给节点指针

很多初学者容易问:insert的参数用Node*不行吗?当然可以,但那就把内部结构暴露给了使用方。用户如果持有一个裸节点指针,在链表的拷贝、销毁、移动之后指针就会悬空。而迭代器是一个“轻量级视图”,它只负责提供遍历和访问语义,具体节点所有权由list对象管理。这也是 STL 容器“数据封装 + 迭代器解耦”设计思想的体现。所以下面一节,是整个模拟实现中最绕但也最核心的迭代器部分——你绕不开它,咬牙啃下来就豁然开朗了。

3. 重头戏:为什么 list 的迭代器必须自己实现

vector的迭代器可以直接用T*,因为它的元素在内存中是连续排列的,++就是地址加一个元素大小。但list的节点在堆上是离散分布的,你用底层T*指针执行ptr++,只会跳到紧挨着的内存地址,那里大概率是一块无关数据。

所以list迭代器的本质,是一个对Node*做了运算符重载的“智能指针”类。你让使用者觉得它像指针,但++、--、*、->全部被翻译成对链表的节点跳转和数据访问。

3.1 迭代器类的核心代码

template <class T, class Ref, class Ptr> struct __ListIterator { typedef __ListIterator<T, Ref, Ptr> Self; typedef ListNode<T> Node; Node* _node; __ListIterator(Node* node) : _node(node) {} Ref operator*() { return _node->_data; } Ptr operator->() { return &_node->_data; } Self& operator++() { _node = _node->_next; return *this; } Self operator++(int) { Self tmp(*this); _node = _node->_next; return tmp; } Self& operator--() { _node = _node->_prev; return *this; } Self operator--(int) { Self tmp(*this); _node = _node->_prev; return tmp; } bool operator!=(const Self& it) const { return _node != it._node; } bool operator==(const Self& it) const { return _node == it._node; } };

我再来解释几个一定有人困惑的设计点:

前自增与后自增的区别:前自增返回Self&,不产生临时对象;后自增必须返回Self(值),因为你得先保存自增前的状态。在循环里写++it而不是it++,性能差异在这里,尤其是节点数量大时自增次数多,后自增会重复构造临时对象。

Ref和Ptr两个模板参数的作用:如果只写一个模板参数T,那么iterator和const_iterator就要写两份几乎一样的迭代器类。引入Ref(引用类型)和Ptr(指针类型)后,iterator用<T, T&, T*>,const_iterator用<T, const T&, const T*>,同一份代码自动适配 const 语义。

operator->的返回值:返回&_node->_data,这样当用户想访问节点数据的成员时,it->member编译器会先调用operator->拿到T*,再对T*取成员。比如list<Person>中迭代器指向Person对象,it->age就等价于(*it).age。

3.2 begin() 与 end() 的对应关系

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); }

这里end()返回的是哨兵节点的迭代器,不是nullptr。这样设计让空链表的begin() == end()成立,while (it != end())的循环写法统一且安全。同时也是循环结构的自然结果:end 就是“头节点那个位置”,在 end 处插入就等于尾插。

注意:begin() const返回const_iterator,因为此时this是 const 的,不能把非 const 迭代器从 const 容器里拿出来去修改数据。这是我踩过的一个典型漏洞,忘了写 const 版本,结果在只读函数里无法遍历容器。

3.3 为什么迭代器类型名可以像容器一样用

list<int>::iterator it = l.begin();之所以能写出这种风格,是因为我们在List类内部用typedef把迭代器类暴露成了嵌套类型。这是 STL 容器的惯例,使用者只需要写typename List<T>::iterator或者依赖自动类型推导auto it = l.begin()。把这一点做好,你的模拟实现才“像个库”。

4. 逐层拆解:增删查改的核心实现与调用关系

这是标题里的主角。增删查改代码量不大,难的是理解每一步为什么这么连。我按照“插入 → 删除 → 查找 → 修改”的顺序,把每一类操作拆开讲。

4.1 插入:一个 insert 通吃所有场景

iterator insert(iterator pos, const T& val) { Node* cur = pos._node; Node* prev = cur->_prev; Node* newNode = new Node(val); prev->_next = newNode; newNode->_prev = prev; newNode->_next = cur; cur->_prev = newNode; return iterator(newNode); } void push_back(const T& val) { insert(end(), val); } void push_front(const T& val) { insert(begin(), val); }

看这段代码的巧妙之处:插入核心只需要调整四条指针。先通过pos拿到当前节点cur,再通过cur->_prev拿到前驱prev,然后让prev->_next指向新节点、新节点的前驱后驱分别指向prev和cur、最后cur->_prev回指新节点。

为什么不需要边界判断?因为这是一个带头循环链表。当链表为空时,end()的_node就是哨兵头节点,cur->_prev也是头节点自己,上面的四步照常执行,链表从空状态变成只有一个节点。这就是哨兵设计最大的价值。

当你insert(end(), val)时,cur是哨兵,prev是原尾节点,结果就是在尾节点和哨兵之间插入——尾插完成。当你insert(begin(), val)时,cur是原第一个节点,prev是哨兵,结果就是头插。头插、尾插、任意位置插入,统一成一个函数,这就是封装的意义。

一个值得补充的细节:插入操作不会导致任何已有迭代器失效。因为新节点是独立分配的,不移动任何已有节点,用户之前拿到的迭代器指向的节点地址不变。这和vector插入可能引起扩容导致迭代器全体失效形成了鲜明对比。

4.2 删除:单点删除与首尾删除

iterator erase(iterator pos) { Node* cur = pos._node; Node* prev = cur->_prev; Node* next = cur->_next; prev->_next = next; next->_prev = prev; delete cur; return iterator(next); } void pop_back() { erase(--end()); } void pop_front() { erase(begin()); }

删除的关键是先把cur的前后节点互相接上,再释放cur。因为循环链表保证prev和next一定存在(哪怕在极端情况下它们是同一个哨兵节点),所以也不需要判空。

特别要说明--end()这个写法。end()指向哨兵,--后移到哨兵的前驱,即最后一个有效节点。所以pop_back()在只有一个有效节点时,--end()得到的就是那个节点,删除后链表为空,结构仍然正确。同时注意:erase返回的是被删除节点的下一个节点,这一点在遍历删除时必须用到。

4.3 查找:普通遍历与std::find

查找没有太多花活,就是从头到尾遍历:

iterator find(const T& val) { iterator it = begin(); while (it != end()) { if (*it == val) { return it; } ++it; } return end(); }

也可以直接用标准库算法std::find(l.begin(), l.end(), val),因为我们的迭代器符合输入迭代器的要求。但如果你自己实现了find成员函数,要注意:这里的时间复杂度是 O(N),list天生不具备随机查找能力,这也是它和vector的定位差异。

4.4 修改:通过迭代器解引用直接赋值

修改数据本质上是“定位 + 解引用赋值”:

// 找到值为 3 的节点,改成 30 auto it = l.begin(); while (it != l.end()) { if (*it == 3) { *it = 30; break; } ++it; }

*it返回的是_node->_data的引用,所以*it = 30直接修改的是节点内存中的数据,不需要任何额外接口。这也是我们前面在迭代器里重载operator*返回Ref(引用)而不是值的原因——返回引用才能实现赋值写入,返回值只能读取。

4.5 一个完整的增删查改演示

void testList() { List<int> lt; lt.push_back(1); lt.push_back(2); lt.push_back(3); lt.push_front(0); // 当前链表:0 1 2 3 auto it = lt.begin(); while (it != lt.end()) { std::cout << *it << " "; ++it; } std::cout << std::endl; // 查找值为 2 的位置,并插入 20 auto pos = lt.find(2); lt.insert(pos, 20); // 链表:0 1 20 2 3 // 修改 pos 处的数据 pos = lt.find(20); *pos = 99; // 链表:0 1 99 2 3 // 删除头部和尾部 lt.pop_front(); // 链表:1 99 2 3 lt.pop_back(); // 链表:1 99 2 }

这段代码就是一个最小的使用闭环。注意别在find返回end()时直接解引用修改,那是对哨兵节点数据的非法写入,属于未定义行为。插一句:如果链表里可能不存在这个值,一定要先判断pos != lt.end()再进行后续操作。

5. 构造与析构:拷贝控制里的那些边角料

增删查改是“会跑就行”,但要真正成为一个能放进项目里的容器,构造、拷贝、赋值、销毁这些生命周期管理才是拉开差距的地方。

5.1 默认构造、clear 与析构

void clear() { iterator it = begin(); while (it != end()) { it = erase(it); } } ~List() { clear(); delete _head; _head = nullptr; }

clear复用erase,每次删除后拿到下一个迭代器继续走,直到回到哨兵。这里要特别强调:不能用while (it != end()) { erase(it); ++it; },因为erase(it)已经把it指向的节点释放了,++it变成对已释放内存的解引用访问,悬空指针问题。正确姿势是it = erase(it),利用返回值天然获得下一个有效节点的迭代器。

析构函数先clear()释放所有数据节点,再delete _head释放哨兵节点。顺序不能反,否则数据节点链表断了,clear无法正常遍历。

5.2 拷贝构造函数:先建空壳再逐个尾插

List(const List<T>& lt) { empty_init(); for (const auto& e : lt) { push_back(e); } }

为什么先empty_init()再push_back?因为push_back依赖_head已经是一个合法的哨兵节点。如果你直接给_head设为nullptr再push_back,insert(end(), val)里cur->_prev会对空指针解引用——直接崩溃。这也解释了为什么我一开始就把empty_init抽出来:拷贝构造函数和默认构造函数都需要它。

5.3 赋值运算符:传值交换是精简好写的惯用法

void swap(List<T>& lt) { std::swap(_head, lt._head); } List<T>& operator=(List<T> lt) { swap(lt); return *this; }

这个写法的精妙之处在于利用了“传参拷贝”:operator=的参数是List<T> lt,外部传入的实体会被拷贝构造出一个临时对象,我们在函数内部把自己的_head和这个临时对象的_head交换。临时对象走出函数后自动析构,它此时持有的是我们原先的旧链表,于是旧链表被自动释放。这既完成了赋值,又自动处理了自我赋值和异常安全的问题,不用写复杂的“判断this != &lt”逻辑。这是现代 C++ 里copy-and-swap惯用法的典型场景。

5.4 为什么要手动管理而不是依赖默认拷贝

如果不写拷贝构造和赋值,编译器生成的默认版本会做浅拷贝:两个List对象共享同一个_head指针,析构时双重释放,必然崩溃。这是手写任何资源管理类的核心纪律——拷贝控制三件套(析构、拷贝构造、赋值)必须同步考虑。

5.5 迭代器失效问题:list 和 vector 的最大区别

关于迭代器失效,我们前面提到过插入不失效,这里单独把删除拎出来理清楚:

操作vectorlist
插入可能导致所有迭代器失效(扩容时重新分配内存)已持有的迭代器不受影响
删除删除点之后的迭代器全部失效只有被删除节点的迭代器失效
当前迭代器在删除后无法安全使用无法安全使用,需要用返回的下一个迭代器

这也是list在“删除时不希望其他引用失效”的场景下比vector更有优势的原因。大家在刷题和业务开发中如果遇到过“遍历中删除元素导致迭代器崩溃”,八成就是用了vector而没有正确处理返回迭代器;换成list会稳定很多。

6. 实战踩坑:list 模拟实现中的高频问题排查清单

这节我专门整理手写list时反复出现的错误类型。每一个都是我在调试时真实遇到过的。

6.1 哨兵节点初始化遗漏

如果你忘了在构造函数里把_head->_prev和_head->_next都指向_head,那么第一次push_back时insert里的prev->_next操作会解引用一个未初始化的野指针。排查方法很简单:在构造函数里打断点,检查_head->_next == _head和_head->_prev == _head是否同时成立。

6.2 const 版本的 begin/end 忘写

只写非 const 版本,那么在const List<int>&上调用遍历会编译报错。有不少人会在这时选择“强转去 const”,这是坏味道。正确做法是补上 const 版本并返回const_iterator。这类错误属于底盘问题,越早发现越好。

6.3 erase 之后的 ++it

这是我见过最多人犯的错:erase(it)释放了it,然后++it访问已释放内存。即使不崩溃,也是 UB。记住统一的遍历删除模式:

it = lt.erase(it); // 用返回值继续遍历

6.4operator++(int)的返回值类型

后置自增如果返回引用,就会返回一个指向临时对象的引用,函数结束临时对象销毁后变成悬空引用。所以后置版本必须返回Self值,前置版本返回Self&。很多人写到这里会顺手都用Self&,等被编译器警告或程序崩溃后才意识到问题。

6.5 数据成员访问权限混乱

如果节点用class却不写friend,迭代器访问_node->_data时就会编译失败。我在 2.1 里推荐struct,就是便于模拟实现时少折腾访问控制。如果你真的需要用class,记得在节点、迭代器、list三者之间做好友元声明,工作量会明显增加。

6.6 性能误区:别拿 list 当排序和随机访问工具

list的find和排序都是 O(N log N) 起步的内存操作,频繁随机访问它的效率远低于vector。如果你发现自己大量使用it++来模拟下标访问,那容器选型大概率需要重新考虑。list的优势场景是“频繁中间插入删除 + 对迭代器稳定性有要求”,想清楚这一条,才能真正用好这个容器。

最后分享一个我手写完整套实现后的体会:单纯把代码跑通不难,难的是把每个接口的边界条件都推理清楚。比如“空链表时begin()和end()相等”“删除后返回的是哪个迭代器”“const 版本应该返回什么类型”,这些问题想明白了,你对 C++ 对象生命周期和容器设计哲学的理解就会上一个台阶。如果后续有兴趣,还可以继续扩展list的reverse_iterator、merge、unique、sort等接口,思路都是在现有骨架上增加语义层次——但前提是,这篇里这些基础设计你已经亲手写对了一遍。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进