☰
手写STL list:从节点设计到迭代器与哨兵节点的完整实现
2026/10/20 22:37:15 网站建设 项目流程

我们直接用代码说话。这一次彻底拆解STL中list的底层实现,手写一个可用的简化版本。如果你已经看过vector的模拟实现,会发现list的思路完全不同:vector是连续内存,list是分散的节点;vector的迭代器就是原生指针,list的迭代器必须自己封装。这两个差异决定了list的实现难度不在数据操作,而在迭代器设计和节点管理。

这篇文章会从结构设计开始,逐步实现节点类、迭代器类、链表本体,最后补齐增删改查和迭代器操作,并附上我实际调试中踩过的坑。适合正在学STL源码、准备面试手写容器、或者想彻底搞懂链表底层机制的读者。

1. 整体框架:先把list拆成三个独立模块

1.1 为什么list不能像vector那样直接用指针做迭代器

先回忆一下vector的实现:内部维护一块连续内存,begin就是数组首地址,end就是末尾地址,迭代器自增就是指针加一,随机访问就是指针偏移。这种设计极其优雅,所以vector的迭代器直接用T*就够了。

但list的节点在内存中是分散的,每个节点只知道自己的前驱和后继。如果我们像vector那样用一个指针指向节点,++操作根本没有意义——指针加一后指向的内存地址里存的不是下一个节点,而是未知数据。

这就是list必须自定义迭代器的根本原因。迭代器要封装“从一个节点跳到下一个节点”这个动作,而不是单纯的地址运算。初学者最容易在这里栽跟头:以为迭代器就是指针的语法糖,实际上它是“遍历策略”的载体。

1.2 三层结构的职责划分

我习惯把list拆成三个独立模块来写,每个模块只做一件事:

  • 节点结构体:只负责存储数据和指针链接,相当于停车场的车位,只管车停进来和记住相邻车位的位置。
  • 迭代器类:只负责遍历逻辑,封装节点指针的移动、解引用、比较操作,相当于GPS导航,告诉你下一步去哪、当前在哪。
  • 链表本体:负责内存管理、增删改查、迭代器的获取,相当于停车场管理处,统一调度所有车位。

这种分层的好处是:迭代器不直接依赖链表本体,即使把链表整体换掉,只要节点结构不变,迭代器照常工作。STL源码也是这么组织的,我们照着这个骨架写就不会乱。

2. 节点与迭代器:最容易被忽视的两个基础类

2.1 节点设计:头节点到底要不要存数据

节点结构体非常简单,两个指针加一个数据成员:

template <typename T> struct ListNode { ListNode<T>* _next; ListNode<T>* _prev; T _data; ListNode(const T& data = T()) : _next(nullptr), _prev(nullptr), _data(data) {} };

构造函数给了默认值T(),这样头节点可以用默认构造初始化,不需要显式传数据。这里有一个关键决策:头节点是哨兵节点,不存有效数据。哨兵节点的_next指向第一个有效节点,_prev指向最后一个有效节点。空链表时,哨兵节点的_next和_prev都指向自己。

这个设计绝对是list的灵魂。有了哨兵节点,begin()、end()、insert()、erase()这些操作就不需要判断链表是否为空、访问的是不是第一个节点,代码统一性大大提高。我还见过有人不用哨兵节点,直接在头指针上做空判断,结果每个函数都要写一堆if,维护成本翻倍。

注意:头节点虽然不存有效数据,但它确实是实实在在分配出来的节点,构造函数会调用T()默认构造。如果T没有默认构造函数,这里就需要特殊处理,真实STL用的是__aligned_membuf对齐缓冲区来避免这个问题,我们简化版直接要求T支持默认构造即可。

2.2 迭代器设计:模板参数为什么要给三个

迭代器的核心操作就四个:解引用、前后移动、判等、箭头访问。但如果只把这四个方法写死,const_iterator就要重写一遍几乎一模一样的代码。模板参数传三个值可以解决这个问题:

template <typename T, typename Ref, typename Ptr> struct ListIterator { typedef ListNode<T> Node; typedef ListIterator<T, Ref, Ptr> Self; Node* _node; ListIterator(Node* node = nullptr) : _node(node) {} Ref operator*() { return _node->_data; } Ptr operator->() { return &(operator*()); } 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& other) const { return _node != other._node; } bool operator==(const Self& other) const { return _node == other._node; } };

模板参数Ref决定operator*返回的是T&还是const T&,Ptr决定operator->返回的是T*还是const T*。这样我们在list本体里直接typedef两个版本:

typedef ListIterator<T, T&, T*> iterator; typedef ListIterator<T, const T&, const T*> const_iterator;

迭代器内部持有一个Node*成员,前移就是_node = _node->_next,后移就是_node = _node->_prev——这比vector的指针运算语义清晰得多,也体现了list迭代器是双向迭代器的本质:只能前后移动,不能随机跳转。

这里有个小细节:正因为迭代器不支持it + n这样的随机访问,list的sort()方法和vector完全不同。std::sort要求随机访问迭代器,list只能用自己的成员函数sort(),内部实现是归并排序。这个差异在面试里经常被问到,背下来不如理解:归并排序只需要双向移动,正好匹配list的能力边界。

2.3 const迭代器的类型转换问题

再深一层,当我们写出iterator和const_iterator两个typedef后,会遇到一个麻烦:iterator无法隐式转换为const_iterator。因为它们是两个不同的类模板实例,编译器不知道它们之间有继承关系。

在list内部,begin()的const版本和非const版本可以分开写,但用户代码里经常混用。比如函数参数是const list<int>&,内部用begin()拿到的就是const_iterator,如果把它赋值给一个普通iterator变量,会直接编译报错。

解决方法是给ListIterator加一个拷贝构造函数,允许非const版本向const版本转换:

template <typename T, typename Ref, typename Ptr> struct ListIterator { // ... ListIterator(const ListIterator<T, T&, T*>& other) : _node(other._node) {} // 这样 const_iterator 可以通过拷贝兼容 iterator };

注意这个构造函数只有当Ref = const T&、Ptr = const T*时才有效,因为普通iterator的模板参数是T&和T*,传入ListIterator<T, T&, T*>参数对它来说会产生歧义。但反过来,用const_iterator构造iterator是禁止的,因为ListIterator<T, T&, T*>没有接收ListIterator<T, const T&, const T*>参数的构造函数,编译器会帮我们拦截这种不安全的行为。

3. 链表本体:哨兵节点与空链表的正确姿势

3.1 成员变量和构造/析构

链表本体只维护一个哨兵节点指针:

template <typename T> class list { public: typedef ListNode<T> Node; private: Node* _head; // 哨兵节点,不参与数据存储 };

初始化时创建哨兵节点,让它的_next和_prev都指向自己,表示空链表:

void empty_init() { _head = new Node(); _head->_next = _head; _head->_prev = _head; } list() { empty_init(); } template <class Iterator> list(Iterator first, Iterator last) { empty_init(); while (first != last) { push_back(*first); ++first; } } list(const list<T>& other) { empty_init(); for (const auto& e : other) { push_back(e); } }

迭代器区间构造函数用了模板,可以接受任意类型的迭代器,不只是list自己的迭代器。这样list<int> l2(vector.begin(), vector.end())也能成立。不过这里要求传入的迭代器支持解引用、比较和自增,符合输入迭代器的要求即可。

析构函数要把所有有效节点全删掉:

~list() { clear(); delete _head; _head = nullptr; } void clear() { Node* cur = _head->_next; while (cur != _head) { Node* next = cur->_next; delete cur; cur = next; } _head->_next = _head; _head->_prev = _head; }

单看清空逻辑挺简单,但有几个坑要提醒:

  • 遍历结束判断条件是cur != _head,不是cur != nullptr。因为哨兵节点永远存在,链表尾部不可能出现空指针。
  • 删除当前节点前一定先把next保存下来,否则delete cur后cur->_next已经变成野指针,再访问就崩了。
  • clear()后链表仍可用,只是空了,哨兵节点还活着。这和vector::clear()语义一致,不会释放容量。

3.2 赋值操作要防自赋值

赋值运算符重载不能直接clear再push_back,因为如果*this和other是同一个对象,清空后other也没了。最稳妥的方式是拷贝并交换:

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

参数按值传递,进来时已经完成一次拷贝,然后交换指针,出函数时other带着旧资源自动析构。这个惯用法既安全又简洁,STL里大量使用。我第一次写赋值运算符时直接clear()后再拷贝,结果用户执行l = l时链表直接空掉,调试了半天才反应过来。

3.3 迭代器接口的正确返回值

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()返回的是哨兵节点的迭代器,不是最后一个有效节点的下一个位置。正因为哨兵节点存在,end()才不需要额外处理空链表的情况。空链表时begin()和end()返回的都是哨兵节点迭代器,begin() == end()自然成立。

4. 核心操作实现:insert和erase是万能钥匙

4.1 insert:再也不用担心头插和尾插的边界

list真正强大的地方在于insert操作:在任意位置插入新节点,只需要改四个指针,不需要搬移任何数据。如果实现好了insert,push_back和push_front都变成它的特例。

iterator insert(iterator pos, const T& data) { Node* cur = pos._node; Node* prev = cur->_prev; Node* new_node = new Node(data); new_node->_next = cur; new_node->_prev = prev; prev->_next = new_node; cur->_prev = new_node; return iterator(new_node); }

这个实现的好处是:pos == end()时,cur就是哨兵节点,prev是最后一个有效节点,插入位置正好在链表尾部,这就是尾插。pos是begin()时,prev是哨兵节点,插入位置在头部,这就是头插。所以:

void push_back(const T& data) { insert(end(), data); } void push_front(const T& data) { insert(begin(), data); }

同样,erase也能统一实现:

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

erase返回下一个有效节点的迭代器,这样在循环中删除多个元素时,直接用返回值更新迭代器即可,不会出现迭代器失效问题。

4.2 pop_back和pop_front也别忘记

有了erase,头删和尾删顺手完成:

void pop_back() { erase(iterator(_head->_prev)); } void pop_front() { erase(iterator(_head->_next)); }

唯一要注意的是,空链表调用pop_back会删除哨兵节点自身,导致链表结构彻底损坏。使用前务必检查empty(),这一点和STL语义一致:空容器的pop操作是未定义行为。

4.3 resize和assign:不常用的函数其实藏着细节

resize在面试手写中不一定考,但真实项目里会遇到。它的语义是:如果当前大小大于n,删除多余节点;如果小于n,用val填充到n个节点。

void resize(size_t n, const T& val = T()) { size_t sz = size(); if (sz < n) { for (size_t i = sz; i < n; ++i) { push_back(val); } } else if (sz > n) { iterator it = begin(); std::advance(it, n); while (it != end()) { it = erase(it); } } }

std::advance也是面试常客——list的迭代器是双向迭代器,advance内部会按需走++或--,时间复杂度O(n)。随机访问迭代器则直接做+=。STL通过迭代器类别决定算法实现,这是泛型编程的核心思想之一。

5. 一个完整可运行的list雏形

把以上碎片拼起来,加上size()、empty()、front()、back(),就是一个能跑起来的简化版list:

#include <iostream> #include <algorithm> template <typename T> struct ListNode { ListNode<T>* _next; ListNode<T>* _prev; T _data; ListNode(const T& data = T()) : _next(nullptr), _prev(nullptr), _data(data) {} }; template <typename T, typename Ref, typename Ptr> struct ListIterator { typedef ListNode<T> Node; typedef ListIterator<T, Ref, Ptr> Self; Node* _node; ListIterator(Node* node = nullptr) : _node(node) {} ListIterator(const ListIterator<T, T&, T*>& other) : _node(other._node) {} Ref operator*() { return _node->_data; } Ptr operator->() { return &(operator*()); } 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& other) const { return _node != other._node; } bool operator==(const Self& other) const { return _node == other._node; } }; template <typename T> class list { public: typedef ListNode<T> Node; typedef ListIterator<T, T&, T*> iterator; typedef ListIterator<T, const T&, const T*> const_iterator; private: Node* _head; void empty_init() { _head = new Node(); _head->_next = _head; _head->_prev = _head; } public: list() { empty_init(); } ~list() { clear(); delete _head; _head = nullptr; } list(const list<T>& other) { empty_init(); for (const auto& e : other) push_back(e); } list<T>& operator=(list<T> other) { swap(other); return *this; } void swap(list<T>& other) { std::swap(_head, other._head); } 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); } bool empty() const { return _head->_next == _head; } size_t size() const { size_t count = 0; for (const_iterator it = begin(); it != end(); ++it) ++count; return count; } T& front() { return _head->_next->_data; } T& back() { return _head->_prev->_data; } iterator insert(iterator pos, const T& data) { Node* cur = pos._node; Node* prev = cur->_prev; Node* new_node = new Node(data); new_node->_next = cur; new_node->_prev = prev; prev->_next = new_node; cur->_prev = new_node; return iterator(new_node); } 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 push_back(const T& data) { insert(end(), data); } void push_front(const T& data) { insert(begin(), data); } void pop_back() { erase(iterator(_head->_prev)); } void pop_front() { erase(iterator(_head->_next)); } void clear() { Node* cur = _head->_next; while (cur != _head) { Node* next = cur->_next; delete cur; cur = next; } _head->_next = _head; _head->_prev = _head; } };

测试代码顺手贴上:

int main() { list<int> l; l.push_back(1); l.push_back(2); l.push_front(0); for (auto it = l.begin(); it != l.end(); ++it) { std::cout << *it << " "; } std::cout << std::endl; auto it = std::find(l.begin(), l.end(), 1); if (it != l.end()) { l.insert(it, 99); } for (const auto& e : l) { std::cout << e << " "; } std::cout << std::endl; return 0; }

这段代码依赖范围for循环,而范围for底层就是begin()和end()迭代器,所以我们不需要额外实现rbegin()等反向迭代器接口,测试就能跑通。

6. 实操中容易踩的坑:迭代器失效与内存泄漏

6.1 erase之后迭代器到底还能不能用

vector的插入/删除会导致迭代器失效,是因为底层数组可能重新分配或元素搬移。list完全不同:插入删除只影响当前节点的前驱和后继指针,其他节点的地址保持不变。因此:

  • insert后,原pos迭代器仍然有效,可以继续使用。
  • erase后,被删除节点的迭代器完全失效,因为那个节点已经delete了,访问就是use-after-free。
  • erase返回的迭代器指向下一个有效节点,循环删除时一定要接住这个返回值。

我记得有次写循环删除集合里某些元素时,直接++it,没接收erase的返回值。小数据量测试没事,一旦命中条件删除节点,下一次++it访问到的就是野指针,程序随机崩溃或输出脏数据。排查许久才意识到是迭代器失效问题。

6.2 析构函数里的清空顺序

析构函数应该先clear()释放所有有效节点,再delete _head。如果一上来就delete _head,后续遍历就找不到链表入口了。顺序反了表面看可能不报错,但内存泄漏检查工具会不停报警。

还要注意哨兵节点本身是new出来的,也必须delete,否则每次创建list都会泄漏一个节点大小的内存。有些简化教程为了省代码,析构只clear()不删_head,测试时因为进程退出操作系统回收内存所以看不出问题,但长期驻留的服务进程跑几个小时内存就涨上去了。

6.3 拷贝构造必须深拷贝

没写拷贝构造时,编译器默认生成浅拷贝。两个list对象会指向同一个哨兵节点,析构时同一块内存被释放两次,直接double free崩溃。

我见过一个真实案例:某开发者写了list<int> l1; l1.push_back(1); list<int> l2 = l1;,然后两个对象各自析构,程序在退出时崩溃,报的正是double free。所以只要涉及自定义析构的类,拷贝构造和赋值运算符必须一并处理,这是C++的三法则,list这种管理复杂资源的类甚至该考虑五法则。

6.4 迭代器里存储的是裸指针

我们简化版迭代器存储裸Node*。如果链表被析构或clear,所有迭代器都会变成野指针。这在单线程代码里通常还好,但如果你把迭代器存下来跨函数使用,一定要保证链表对象生命周期更长。

另一种更隐蔽的情况:swap两个list后,旧迭代器指向的还是原来那个链表(只是链表身份换了),这也容易造成迷惑。STL规定swap后迭代器仍然有效,但指向的元素现在属于另一个容器了。如果不清楚这个规则,很容易在代码里阴沟翻船。

6.5 size()的O(n)性能陷阱

STL的list::size()在C++11之前是O(n),C++11标准强制O(1)。我们简化版是遍历链表,O(n)时间。面试时如果被问到为什么list访问元素慢,除了不能随机访问,还要提到计算长度也要遍历。

如果项目里频繁调用size(),建议在list内部维护一个_size成员,每次插入删除同步更新。STL的真实实现就是这么做的,空间换时间。但这个简化和增加指针成员一样,需要同步维护,任何绕过insert/erase直接改指针的操作都可能破坏计数一致性。

7. 测试与验证:只信现象,不信直觉

7.1 写几个断言测试,别只看输出

手写容器最容易出现的幻觉是“输出对了就行”。我强烈建议用断言验证边界行为。比如:

#include <cassert> void test_list() { list<int> l; assert(l.empty()); assert(l.size() == 0); l.push_back(1); l.push_back(2); assert(l.size() == 2); assert(l.front() == 1); assert(l.back() == 2); l.pop_front(); assert(l.front() == 2); assert(l.size() == 1); l.clear(); assert(l.empty()); list<int> l2(l); l2.push_back(10); assert(l2.size() == 1); assert(l.size() == 0); l = l2; assert(l.size() == 1); assert(l.front() == 10); }

多写几个这样的测试函数,比盯着终端输出判断结果可靠得多。特别是拷贝构造和赋值运算——这两个最容易出问题,偏偏在简单测试里最不容易暴露。

7.2 用容器适配器验证insert/erase

可以再进一步,用list实现一个简易栈或队列,如果适配器行为正常,说明list的push/pop/front/back接口没有问题:

template <typename T> class MyQueue { private: list<T> _data; public: void push(const T& v) { _data.push_back(v); } void pop() { _data.pop_front(); } T& front() { return _data.front(); } bool empty() const { return _data.empty(); } size_t size() const { return _data.size(); } };

pop_front在我们的list里是O(1),所以这个队列的push和pop也是O(1)。如果哪一步实现有误,适配器的行为会立刻不正常。

7.3 内存泄漏检测

Linux下可以用valgrind,没有环境就手动在new处加计数:

// 全局统计 static int node_count = 0; template <typename T> ListNode<T>* create_node(const T& data) { ++node_count; return new ListNode<T>(data); } // delete时配套减一

测试结束后打印node_count,如果归零说明没有泄漏。这个土办法在面试答题或本地验证时非常方便,比上工具快得多。

8. 从简化版到真实STL,还差几步

我们的简化版已经涵盖了list的核心机制,但真实的STL list还做了很多工程化优化:

  • 反向迭代器:通过reverse_iterator适配器包装普通迭代器,反向遍历时++实际调用底层迭代器的--。
  • 节点内存池:小块内存频繁分配在list场景下非常常见,真实实现会配合内存分配策略做池化,减少系统调用。
  • 异常安全:如果T的拷贝构造抛异常,节点的链接和内存管理必须保持一致,不能泄漏也不能弄脏链表结构。
  • 移动语义:C++11后,移动构造和移动赋值比深拷贝高效得多,只需转移指针所有权。
  • allocator支持:用户可以通过自定义分配器控制内存从哪来,这在嵌入式或特殊场景中很重要。

这些扩展点每一个都值得单独写一篇。但如果你手写list能像我上面这样从节点到迭代器再到链表本体完整实现,并且解释清楚为何哨兵节点让代码更统一、为何const_iterator不能直接由普通iterator构造、为何erase后要接返回值,那面试官大概率会直接给你过——因为这说明你不是背答案,而是真的理解了链表的本质。

最后再多说一句实操心得:手写容器类时,先把所有基本操作的逻辑在草稿纸上画一遍节点指针的变化,尤其是insert和erase那四根线(新节点->next、新节点->prev、prev->next、cur->prev),画清楚再动手敲代码。指针指错一根,调试一小时起步。我每次教新人,都建议他们写链表代码前先把指针变化图写出来,这个习惯能帮你省下大量查错时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询