资讯动态

手写STL list:从节点设计到迭代器与哨兵节点的完整实现

发布时间:2026/10/20 22:37:20 来源:尧图企业网站定制
我们直接用代码说话。这一次彻底拆解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 { ListNodeT* _next; ListNodeT* _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 ListNodeT Node; typedef ListIteratorT, 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 TPtr决定operator-返回的是T*还是const T*。这样我们在list本体里直接typedef两个版本typedef ListIteratorT, T, T* iterator; typedef ListIteratorT, 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 listint内部用begin()拿到的就是const_iterator如果把它赋值给一个普通iterator变量会直接编译报错。解决方法是给ListIterator加一个拷贝构造函数允许非const版本向const版本转换template typename T, typename Ref, typename Ptr struct ListIterator { // ... ListIterator(const ListIteratorT, T, T* other) : _node(other._node) {} // 这样 const_iterator 可以通过拷贝兼容 iterator };注意这个构造函数只有当Ref const T、Ptr const T*时才有效因为普通iterator的模板参数是T和T*传入ListIteratorT, T, T*参数对它来说会产生歧义。但反过来用const_iterator构造iterator是禁止的因为ListIteratorT, T, T*没有接收ListIteratorT, const T, const T*参数的构造函数编译器会帮我们拦截这种不安全的行为。3. 链表本体哨兵节点与空链表的正确姿势3.1 成员变量和构造/析构链表本体只维护一个哨兵节点指针template typename T class list { public: typedef ListNodeT 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 listT other) { empty_init(); for (const auto e : other) { push_back(e); } }迭代器区间构造函数用了模板可以接受任意类型的迭代器不只是list自己的迭代器。这样listint 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(listT other) { std::swap(_head, other._head); } listT operator(listT 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操作在任意位置插入新节点只需要改四个指针不需要搬移任何数据。如果实现好了insertpush_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 { ListNodeT* _next; ListNodeT* _prev; T _data; ListNode(const T data T()) : _next(nullptr), _prev(nullptr), _data(data) {} }; template typename T, typename Ref, typename Ptr struct ListIterator { typedef ListNodeT Node; typedef ListIteratorT, Ref, Ptr Self; Node* _node; ListIterator(Node* node nullptr) : _node(node) {} ListIterator(const ListIteratorT, 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 ListNodeT Node; typedef ListIteratorT, T, T* iterator; typedef ListIteratorT, 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 listT other) { empty_init(); for (const auto e : other) push_back(e); } listT operator(listT other) { swap(other); return *this; } void swap(listT 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() { listint 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崩溃。我见过一个真实案例某开发者写了listint l1; l1.push_back(1); listint l2 l1;然后两个对象各自析构程序在退出时崩溃报的正是double free。所以只要涉及自定义析构的类拷贝构造和赋值运算符必须一并处理这是C的三法则list这种管理复杂资源的类甚至该考虑五法则。6.4 迭代器里存储的是裸指针我们简化版迭代器存储裸Node*。如果链表被析构或clear所有迭代器都会变成野指针。这在单线程代码里通常还好但如果你把迭代器存下来跨函数使用一定要保证链表对象生命周期更长。另一种更隐蔽的情况swap两个list后旧迭代器指向的还是原来那个链表只是链表身份换了这也容易造成迷惑。STL规定swap后迭代器仍然有效但指向的元素现在属于另一个容器了。如果不清楚这个规则很容易在代码里阴沟翻船。6.5 size()的O(n)性能陷阱STL的list::size()在C11之前是O(n)C11标准强制O(1)。我们简化版是遍历链表O(n)时间。面试时如果被问到为什么list访问元素慢除了不能随机访问还要提到计算长度也要遍历。如果项目里频繁调用size()建议在list内部维护一个_size成员每次插入删除同步更新。STL的真实实现就是这么做的空间换时间。但这个简化和增加指针成员一样需要同步维护任何绕过insert/erase直接改指针的操作都可能破坏计数一致性。7. 测试与验证只信现象不信直觉7.1 写几个断言测试别只看输出手写容器最容易出现的幻觉是“输出对了就行”。我强烈建议用断言验证边界行为。比如#include cassert void test_list() { listint 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()); listint 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: listT _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 ListNodeT* create_node(const T data) { node_count; return new ListNodeT(data); } // delete时配套减一测试结束后打印node_count如果归零说明没有泄漏。这个土办法在面试答题或本地验证时非常方便比上工具快得多。8. 从简化版到真实STL还差几步我们的简化版已经涵盖了list的核心机制但真实的STL list还做了很多工程化优化反向迭代器通过reverse_iterator适配器包装普通迭代器反向遍历时实际调用底层迭代器的--。节点内存池小块内存频繁分配在list场景下非常常见真实实现会配合内存分配策略做池化减少系统调用。异常安全如果T的拷贝构造抛异常节点的链接和内存管理必须保持一致不能泄漏也不能弄脏链表结构。移动语义C11后移动构造和移动赋值比深拷贝高效得多只需转移指针所有权。allocator支持用户可以通过自定义分配器控制内存从哪来这在嵌入式或特殊场景中很重要。这些扩展点每一个都值得单独写一篇。但如果你手写list能像我上面这样从节点到迭代器再到链表本体完整实现并且解释清楚为何哨兵节点让代码更统一、为何const_iterator不能直接由普通iterator构造、为何erase后要接返回值那面试官大概率会直接给你过——因为这说明你不是背答案而是真的理解了链表的本质。最后再多说一句实操心得手写容器类时先把所有基本操作的逻辑在草稿纸上画一遍节点指针的变化尤其是insert和erase那四根线新节点-next、新节点-prev、prev-next、cur-prev画清楚再动手敲代码。指针指错一根调试一小时起步。我每次教新人都建议他们写链表代码前先把指针变化图写出来这个习惯能帮你省下大量查错时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑