资讯动态

手写C++ STL list:双向带头循环链表与迭代器深剖

发布时间:2026/10/16 8:01:33 来源:尧图企业网站定制
很多人在学C容器的时候vector用得飞起一到list就有点犯怵。原因倒也不难猜list在语法层面和vector没有太大区别无非是push_back、insert、erase那一套但一旦涉及底层实现双向链表的结构、迭代器的行为、内存分配的节奏就比vector那种“连续数组”的直觉模型复杂得多。我当年第一次尝试手写list模拟实现时被迭代器封装、const迭代器复用、析构时节点释放顺序这些问题折磨得不轻但也正是那段调试经历让我把C对象模型、引用折叠、模板编程这些知识真正串了起来。这篇文章不打算通篇讲源码而是以“自己实现一个可用的list”为主线把我踩过的坑、琢磨过的设计取舍以及实际测试中发现的问题都记录下来。你要是正在学STL源码、准备面试、或者单纯想把链表这块基础夯实这篇应该能给你一些比教科书更接地气的参考。完整实现我放在了最后中间的拆解部分会配合代码片段建议你一边看一边自己敲一遍效果会好很多。1. 先看清list的本质双向带头循环链表1.1 它和vector到底差在哪要理解list最直接的办法是和vector做对照。vector底层是一段连续的内存空间像一个数组只是它会自动扩容、自动管理大小。list底层则是节点之间通过指针串联每个节点在堆上独立分配节点之间用指针连接逻辑上连成一条线。对比几个关键点对比项vectorlist底层结构连续数组双向带头循环链表随机访问支持O(1)不支持只能遍历插入删除中间需要挪动元素O(n)只需改指针O(1)迭代器类型随机访问迭代器双向迭代器缓存友好性高连续内存低节点离散插入时迭代器可能失效扩容或挪动不失效除了被删节点这就能解释一个很常见的困惑为什么有些场景list明明“插入很快”整体性能反而不如vector因为list插入确实只动指针但它遍历起来缓存命中率极低每个节点都是独立的堆内存CPU缓存根本没法预取下一块数据。所以如果你的操作主要是“遍历访问”vector几乎总是更快如果你的操作是“频繁在中间插删且不太遍历”list才有真正的用武之地。1.2 为什么标准库选择“带头循环”结构初学链表时很多人接触的是不带头节点的单向链表头节点指向第一个有效元素尾节点指向空。但STL的list用的是“带头节点循环”的双向链表结构这背后是有讲究的。带头节点意味着链表里永远有一个哨兵节点也叫dummy node或head node它不存储有效数据仅作为起点和终点。这样带来的好处是所有插入删除逻辑不需要单独处理“空表”或“插入在头部/尾部”的特殊情况统一一套代码就能覆盖所有位置。循环则让尾节点直接指向哨兵节点遍历时从哨兵节点出发转一圈回到哨兵节点即结束。哨兵节点在STL里通常就是list的一个成员变量叫header或node。它不是用户能直接看到的元素而是list内部管理的标记节点。这个设计看似多花了一个节点的内存但实际上让代码边界条件极度简化在面试手撕代码时尤其舒服——你再也不用纠结“删除头节点时需不需要特殊处理”这种细节了。提示理解哨兵节点的关键是记住——它既是链表的起点也是终点任何位置插入删除都能通过前后指针找到对称的操作方式。2. 模拟实现第一步节点结构与迭代器封装2.1 节点结构最朴素的双向节点模拟实现list的第一步是定义节点结构。每个节点需要三个成员存储数据的_val、指向前一个节点的_prev、指向后一个节点的_next。template class T struct ListNode { ListNodeT* _prev; ListNodeT* _next; T _val; ListNode(const T val T()) : _prev(nullptr), _next(nullptr), _val(val) {} };这里注意默认参数T()这个写法要求T可以被默认构造。对int、double这类内置类型没问题对自定义类型需要支持默认构造。不过list的插入操作一般不会走默认构造真正用的多的是带值构造。实际使用中节点经常是直接new出来的所以构造函数必须写对否则后面insert传参时会编译报错或者行为诡异。我第一次写的时候偷懒没写构造函数结果new一个节点时编译器报了老半天的错后来老老实实补上了。2.2 迭代器不是指针但行为要像指针list的迭代器可能是整个模拟实现里最核心、也最容易写崩的部分。vector的迭代器可以用原生指针实现因为它的内存连续指针天生支持、--、n等操作。但list的节点在内存中不连续原生指针的想跳到下一个节点必须拿到当前节点的_next指针再跳这不是指针默认行为能做到的。所以list的迭代器必须封装成一个类把节点的访问逻辑封装进运算符重载里。迭代器类的设计目标很明确让用户在外部用起来和指针一样。也就是要支持解引用*it、箭头访问it-member、前后移动it、it、--it、it--、相等比较it ! end()。template class T, class Ref, class Ptr struct ListIterator { typedef ListNodeT Node; typedef ListIteratorT, Ref, Ptr Self; Node* _node; ListIterator(Node* node nullptr) : _node(node) {} Ref operator*() { return _node-_val; } Ptr operator-() { return _node-_val; } 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 s) const { return _node s._node; } bool operator!(const Self s) const { return _node ! s._node; } };这个模板参数里Ref和Ptr最关键它们决定了迭代器是普通迭代器还是const迭代器。普通迭代器传的是T和T*const迭代器传的是const T和const T*。用一个模板同时生成两种迭代器就避免了把const版本从头到尾复制一遍维护两份代码的痛苦。这个技巧在很多开源项目里都能看到属于非常经典的模板复用手法。2.3 为什么迭代器模板参数要设计成三个有很多讲解会告诉你“迭代器模板参数是T、Ref、Ptr三个参数”但不会解释为什么。我一开始也是照猫画虎后来调试const版本溢出时才真正想明白。问题出在operator的返回类型上。如果只保留一个T参数operator只能返回T那const迭代器也不能返回const T。而list的const迭代器用户拿到的是const list需要通过迭代器只读访问元素如果const迭代器返回的是普通的T用户就能通过迭代器修改const list里的内容这破坏了const语义。解决办法就是加Ref模板参数。普通迭代器实例化时Ref为Tconst迭代器实例化时Ref为const Toperator的返回类型直接写Ref。同理Ptr控制operator-的返回类型普通版本是Tconst版本是const T*。有人会问为什么不直接让operator*返回T然后const迭代器单独写一个类当然可以但那样就要维护两份几乎一样的迭代器代码任何一处修改都要同步两遍实际项目中很痛苦。用模板参数一个类搞定两份功能这是更工程化的做法。注意这个“一个模板多个实例”的思想和STL里很多容器的实现思路一脉相承。写模拟实现时别只顾着抄代码把这个设计理解透了后续自己写别的容器时也能举一反三。3. 模拟实现核心操作插入、删除、构造与析构3.1 list类的主干一个哨兵节点撑起整个结构有了节点和迭代器list本体就可以设计了。list类只需要一个成员哨兵节点指针。哨兵节点在初始化时它的_prev和_next都指向自己表示空链表。template class T class list { public: typedef ListNodeT Node; typedef ListIteratorT, T, T* iterator; typedef ListIteratorT, const T, const T* const_iterator; list() { _head new Node(); _head-_prev _head; _head-_next _head; } private: Node* _head; };这个设计的巧妙之处在于空链表不是“没有节点”而是“只有一个节点且它环着自己”。这样一来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); }begin指向哨兵节点的下一个即第一个有效节点end指向哨兵节点本身。因为哨兵节点始终存在且始终在逻辑上处于序列末尾之后所以end迭代器永远是有效的“空位”。这种设计让空表的迭代和正常表的迭代完全统一不需要在任何遍历代码里判断“是不是空表”。3.2 insert与erase链表的灵魂操作insert是list的核心操作标准库的insert会在指定位置之前插入新节点。理解了哨兵节点后insert可以只写一份因为头部插入就是insert(begin(), val)尾部插入就是insert(end(), val)中间插入就是任意迭代器位置插入。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); }这个实现里有几个边界细节值得反复推敲。第一如果pos是end()那cur就是哨兵节点prev就是尾节点插入位置实际上是链表末尾没问题。第二如果pos是begin()那cur是第一个有效节点prev是哨兵节点插入位置是头部也没问题。第三如果链表为空begin()和end()都指向哨兵节点prev还是哨兵节点插入后链表就一个节点仍然没问题。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返回的是“被删除节点的下一个节点”的迭代器。为什么不是返回void因为如果用户在用循环删除时删掉当前节点后迭代器已经失效没办法安全地必须用返回值拿到下一个有效位置。这个设计理念和vector的erase保持一致都是为“边遍历边删除”的场景兜底。3.3 头插尾插只是insert的语法糖push_back、push_front、pop_back、pop_front直接复用insert和erase就行不需要再单独写一遍链表操作逻辑。这是为什么我强烈建议实现时把核心逻辑收敛到insert和erase两个函数里所有公共操作都复用它们。void push_back(const T val) { insert(end(), val); } void push_front(const T val) { insert(begin(), val); } void pop_back() { erase(--end()); } void pop_front() { erase(begin()); }这段代码看起来简单但注意--end()这个操作。end()是哨兵节点--后就是最后一个有效节点。如果链表为空end()前面还是它自己erase会直接删除哨兵节点这是不允许的。所以标准库对空list调用pop_back或pop_front是未定义行为使用方需要自己保证非空。这个约束和vector是一致的不需要额外防御。3.4 拷贝构造与赋值运算符深拷贝是硬要求list的拷贝构造不能像默认拷贝那样只是浅拷贝指针否则两个list会共享同一套节点析构时双重释放直接崩溃。深拷贝的实现思路是先创建空的哨兵节点然后遍历源链表把每个元素依次尾插进来。list(const listT lt) { _head new Node(); _head-_prev _head; _head-_next _head; for (const auto e : lt) { push_back(e); } }赋值运算符我推荐用“拷贝并交换”的技巧既简洁又安全。先拷贝一个临时对象再把临时对象和当前对象交换临时对象析构时会把旧节点全部释放。listT operator(listT lt) { swap(_head, lt._head); return *this; }这里有个细节参数是listT lt而不是const listT这样传参时就会调用拷贝构造生成临时对象。如果调用者传入的是一个左值这里会走一次深拷贝赋值完临时对象析构时释放旧内容。如果调用者传入右值编译器会优先匹配移动构造效率更高不过我们这里没实现移动构造所以都会走拷贝。这个写法特别适合需要强异常安全的场景因为拷贝发生在赋值之前哪怕拷贝过程中抛异常当前对象也不会被破坏。3.5 析构函数清空所有节点再释放哨兵析构的关键在于要释放所有有效节点最后再释放哨兵节点。如果漏掉任何一个有效节点就会内存泄漏。最稳妥的方式是复用clear和pop_back。void clear() { iterator it begin(); while (it ! end()) { it erase(it); } } ~list() { clear(); delete _head; _head nullptr; }注意erase返回下一个节点的迭代器所以循环里可以直接it erase(it)不用手动。这是一个非常典型的“边遍历边删除”写法稍加留意就能发现它的优雅之处。如果把erase的返回值设计成void这个循环就得先保存next再删除cur麻烦而且容易出错。提示模拟实现时强烈建议把clear、erase、析构这套删除相关的逻辑放一起写并且反复测试空表、单节点表、多节点表三种情况。删除是最容易出内存问题的地方值得多花时间。4. 迭代器失效问题与const迭代器的实现细节4.1 什么情况下list迭代器会失效list的迭代器失效规则和vector差别很大这也是面试高频考点。vector的insert可能导致全部迭代器失效因为扩容会搬内存vector的erase会导致被删除位置之后的迭代器全部失效因为元素要往前挪。list不同链表的insert只动局部指针不会影响其他节点的地址所以list的insert不会让任何已有迭代器失效。list的erase只让被删除的那个节点的迭代器失效其他迭代器全部保持有效。这个特性正是list在“需要长期持有指向元素的迭代器”场景下的核心优势。但要注意一点list.erase后被删除节点的迭代器理论上虽然还存在但你绝对不能再去解引用或者它。这个迭代器指向的节点已经delete了属于悬垂指针访问是未定义行为可能当时不崩换个场景就崩。所以正确的删除姿势是拿好返回值再走。// 正确示例删除所有偶数 listint::iterator it lt.begin(); while (it ! lt.end()) { if (*it % 2 0) { it lt.erase(it); // 用返回值接住下一个位置 } else { it; // 没删才正常 } }如果写成erase(it); it;第二个it访问的是已释放节点的_next属于越界访问。很多初学者在这里踩坑运行结果看起来好像没问题其实是典型的未定义行为“看起来没问题”只是没触发崩溃而已。4.2 const迭代器复用模板实现避免重复代码const迭代器最容易被忽略但它是list实现里绕不开的一环。如果用户定义了一个const listint那begin()返回的必须是const_iterator通过它只能读元素不能修改元素。之前设计的迭代器用三个模板参数实现了这种复用。这里把Ref和Ptr换为const版本即可typedef ListIteratorT, T, T* iterator; typedef ListIteratorT, const T, const T* const_iterator;但在list类的内部const版本和非const版本的begin/end都写了两个版本这是必要的重载iterator begin() { return iterator(_head-_next); } const_iterator begin() const { return const_iterator(_head-_next); }这里有一个容易混淆的知识点begin()const版本返回const_iterator是因为const成员函数里的this是const的所以返回的迭代器必须让用户只能读不能改。非const成员函数返回普通iterator才能支持修改。有一个更棘手的问题是当我在list内部写其他函数时如果同时需要const和非const两个版本很容易写出重复代码。这时可以用一个辅助模板来解决或者直接接受写两遍工程上更朴素的做法是接受轻微重复因为代码量不大。4.3 完整模拟实现参考代码把前面的设计串起来我这里贴一份相对完整的模拟实现代码。代码风格以清晰为主没有做太多极致优化但功能完整能通过基本的测试。建议复制到本地编译运行自己加断点看节点指针的变化比光看文章理解深得多。#include iostream #include cassert namespace my { template class T struct ListNode { ListNodeT* _prev; ListNodeT* _next; T _val; ListNode(const T val T()) : _prev(nullptr), _next(nullptr), _val(val) {} }; template class T, class Ref, class Ptr struct ListIterator { typedef ListNodeT Node; typedef ListIteratorT, Ref, Ptr Self; Node* _node; ListIterator(Node* node nullptr) : _node(node) {} Ref operator*() { return _node-_val; } Ptr operator-() { return _node-_val; } 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 s) const { return _node s._node; } bool operator!(const Self s) const { return _node ! s._node; } }; template class T class list { public: typedef ListNodeT Node; typedef ListIteratorT, T, T* iterator; typedef ListIteratorT, const T, const T* const_iterator; list() { _head new Node(); _head-_prev _head; _head-_next _head; } list(const listT lt) { _head new Node(); _head-_prev _head; _head-_next _head; for (const auto e : lt) { push_back(e); } } listT operator(listT lt) { swap(_head, lt._head); return *this; } ~list() { clear(); delete _head; _head nullptr; } 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); } void push_back(const T val) { insert(end(), val); } void push_front(const T val) { insert(begin(), val); } void pop_back() { erase(--end()); } void pop_front() { erase(begin()); } 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); } iterator erase(iterator pos) { assert(pos ! end()); Node* cur pos._node; Node* prev cur-_prev; Node* next cur-_next; prev-_next next; next-_prev prev; delete cur; return iterator(next); } void clear() { iterator it begin(); while (it ! end()) { it erase(it); } } size_t size() const { size_t count 0; const_iterator it begin(); while (it ! end()) { count; it; } return count; } bool empty() const { return _head-_next _head; } private: Node* _head; }; } // namespace my这个实现里size()采用逐一遍历统计时间复杂度O(n)和STL的list一样没有维护size字段。原因是维护size需要在每次insert、erase时同步更新增加操作的额外开销。链表插入删除本身是O(1)如果额外维护一个计数器多线程下还要考虑同步问题不如用的时候遍历统计。5. 常见问题与排查技巧实录5.1 我实际调试中踩过的坑写这个模拟实现时我踩了不少坑整理成一张表基本覆盖了新手最容易犯错的几个点。问题现象根本原因解决方案编译报错说找不到默认构造函数节点构造函数没写或默认参数不对确保ListNode构造函数里有const T val T()空表调用pop_back直接崩溃没注意erase(--end())在空表时删除哨兵调用前判断empty()禁止空表操作删除元素后继续导致死循环或乱跳erase后没有用返回值接住下一个位置改为it erase(it)拷贝构造后两个list互相干扰浅拷贝节点指针导致共享节点使用深拷贝逐个节点复制const list调用begin()报错const版本begin()返回类型或const修饰缺失确保有begin() const重载返回const_iterator自定义类型放入list编译不过自定义类型没有默认构造或拷贝构造不可用查看错误信息需要类型满足list要求析构时崩溃clear()后没释放哨兵节点析构里除了clear还要delete _head先说第一个坑。当时我用了一个没有默认构造函数的自定义类型去实例化list结果ListNode的构造函数里T() val这一步直接编译失败。不是list本身有错而是这个类型不满足“节点需要支持默认构造”的要求。解决办法有两种给自定义类型加默认构造函数或者把节点设计成不用默认构造。STL的实现其实也要求T满足一定条件所以在实际使用中如果你放入list的自定义类型没有默认构造运行到某些操作时会出问题。这是模板代码的“隐式约束”写类模板时往往不会把约束写在注释里但使用方必须注意。再说erase和insert配合的坑。我一开始写erase是返回void的然后在clear里这么写iterator it begin(); while (it ! end()) { erase(it); it; }这段代码的错误在于erase后it已经失效再就是访问已释放内存。运行起来有时正常有时崩溃完全看运气。后来改成返回下一个迭代器后这问题就根治了。所以设计接口时返回值不要只看“好像不需要”要考虑循环删除这类常见使用场景。5.2 测试思路从空表到单节点到多节点我的习惯是写一小段测试代码覆盖各种边界情况。别小看这个工作很多隐藏bug就是在边界情况下暴露的。第一步建空表和单元素表。先验证begin()和end()是否相等遍历循环是否正确退出。空表遍历如果死循环多半是哨兵节点的_next、_prev没有正确指向自己。第二步验证插入。从空表开始逐个push_back若干元素打印链表验证顺序。再换push_front验证倒序。最后用insert在中间插入检查链表顺序是否吻合。第三步验证删除。依次pop_back清空、pop_front清空、用erase删除中间节点。每次都打印链表内容和size确保没有内存泄漏可以在析构里加断点或者用工具检测。第四步验证拷贝构造和赋值。拷贝一份后修改原list确认拷贝出来的list不受影响。赋值后释放旧内容确认没有重复释放。第五步验证const迭代器。定义一个const list只读遍历尝试修改元素看是否报编译错误。这一步检验const语义是否真正生效。这几步走下来基本能把一个链表实现里最常见的bug都排查出来。我建议你在自己实现时也照这个顺序测一遍省得后期从复杂的真实业务场景里翻bug效率太低了。5.3 面试中常被追问的list细节这个模拟实现做完后我对list相关的面试题基本不发怵了。有几个追问点特别高频顺带梳理一下。第一list和vector的迭代器失效规则。答案整理成一句话list的insert不失效任何迭代器erase只失效被删节点的迭代器vector的insert可能全部失效erase后从删除点开始到末尾全部失效。面试官喜欢追问“为什么list的insert不会失效”这就得从内存连续性回答——list节点内存离散改指针不影响节点位置所以迭代器里保存的节点地址始终有效。第二为什么list没有operator[]。因为链表内存不连续无法通过下标直接算出地址必须从头遍历。随机访问的时间复杂度是O(n)所以STL只提供双向迭代器不支持随机访问。面试官如果问“vector可以用下标list为什么不行”也从这个角度回答。第三自定义类型放入list需要满足什么条件。至少需要可拷贝构造、可赋值拷贝赋值或移动赋值析构函数正常。如果list要调用T的默认构造比如节点默认构造T还需要可默认构造。实际使用中如果你只是往容器里塞元素一般不会触发默认构造但为了保险起见建议默认构造函数还是写一个。第四list怎么排序。标准库的全局std::sort要求随机访问迭代器list不行所以list有自己的成员函数sort()。它的排序思路本质上是归并排序利用链表的合并特性时间复杂度O(n log n)。如果你需要频繁排序list的效率确实不如vector所以“list真的用得少吗”这个问题的答案往往取决于排序和遍历的频率。写在最后的一点个人体会整个模拟实现写下来我的感触是list的代码量不大但每个细节都暗藏着设计哲学。哨兵节点让边界条件消失迭代器封装让外部使用体验接近原生指针insert和erase作为核心操作让所有插入删除统一简洁const迭代器通过模板复用避免了双份代码。这些设计不是凭空来的是多年工程实践沉淀出来的结果。我刚开始写的时候也犯嘀咕觉得标准库都写好了自己再写一遍有什么意义。真正写完以后才明白模拟实现的价值不在“多写了一份能跑的代码”而在亲手踩过那些坑之后你会真正理解为什么标准库要这样设计——为什么erase返回迭代器、为什么用哨兵节点、为什么const迭代器要单独设计。这些理解是看任何源码解读都替代不了的。如果你也在学这个内容我建议你别急着看下一章先把上面的代码敲一遍改一改、测一测尝试自己加一个reverse()或者把迭代器改成支持反向遍历能跑通了list这块算是真正啃下来了。后面再看STL源码或者面到相关的问题都会轻松很多。

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

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

免费获取报价 →
↑