资讯动态

C++ std::list深度解析:从双向链表到迭代器与性能选型

发布时间:2026/10/16 8:01:33 来源:尧图企业网站定制
很多人拿到C标准库第一反应是vector太好用了list却常常被忽略。直到遇到频繁在中间插入删除、需要按顺序遍历并动态修改元素的场景才想起std::list。也有人觉得list不过是一个双向链表代码随便一写就能跑但真要自己动手做一遍list的模拟实现才会发现里面藏着一堆关于对象生命周期、迭代器设计、容器抽象和异常安全的细节。这篇文章想帮正在学list、或者已经用过但没写过底层实现的开发者把list的节点结构、迭代器封装、常见坑位和它与vector的性能差异一次说清楚。我会按自己平时写容器模拟的顺序来讲先看懂需求再拆设计然后手写一个足够日常使用的精简list最后把迭代器失效和性能选型这两个最容易出问题的地方单独拿出来聊。中间会穿插一些“我试过”“我踩过”的经验不会只贴一段代码就完事。1. 先从需求说起list到底解决什么问题很多人学C容器第一个深入的是vector因为连续内存、随机访问、缓存友好用起来很顺手。但vector有一个原生短板在中间插入或删除元素时要把插入点之后的所有元素整体往后或往前搬最坏情况是O(N)。数据量小的时候无所谓数据量一旦上来这个搬移成本会直接变成性能瓶颈。list的设计思路完全反过来不保证内存连续每个元素是一个独立的节点节点之间通过指针串联。这样在任何已知位置插入或删除一个节点只需要修改前后两个节点的指针时间复杂度是O(1)。代价也很明显失去了随机访问要找第N个元素必须从头部或尾部一路走指针复杂度O(N)。这是一个典型的用空间换时间、用“访问速度”换“修改速度”的取舍。1.1 vector在中间插入/删除的痛点我拿一个真实例子来说。维护一个按分数排序的排行榜要求随时往中间插入新纪录。如果用vector每次插入都要把插入位置后面的所有元素复制或移动一遍复杂度O(N)。如果这个列表有十万条记录插入一条就意味着几万、十几万次元素赋值。虽然每次赋值本身很快但频繁插入时整体耗时非常可观。还有个更麻烦的问题vector的插入可能会触发扩容扩容意味着重新分配内存、搬移所有旧元素、释放旧内存。如果你在遍历vector的过程中顺手插入元素很多迭代器可能就失效了。相比之下list在插入新节点时不会搬移已有节点只有被删除的节点对应的迭代器会失效其他迭代器都依然有效。这个特性在很多需要保留外部引用的场景里非常关键。1.2 list用空间换时间的选择list为每个节点额外存储两个指针在64位环境下就是16字节的额外开销。如果你存的是int这类小对象节点总大小可能从4字节涨到24字节甚至更多内存开销是vector的好几倍。所以list从来不是“通用容器”它更适合元素本身的拷贝/移动成本高、插入删除操作频繁、且几乎不需要随机访问的场景。很多初学者会误以为list一定比vector快其实这是不对的。list只是在“中间插入删除”和“迭代器稳定性”这两个维度上有明显优势在遍历、排序、随机访问这些维度上反而更慢。真正的高手从来不会无条件迷信某个容器而是先想清楚自己的操作模式再选择工具。1.3 一眼看明白vector与list基本差异对比项vectorlist底层结构连续存储数组双向链表随机访问支持O(1)不支持O(N)中间插入/删除平均O(N)需要搬移仅改指针O(1)扩容有可能导致迭代器失效无扩容概念迭代器失效insert/erase可能使大量迭代器失效仅erase的迭代器失效内存占用小布局紧凑每个节点额外两个指针缓存友好度高连续访问低节点乱序分配所以list真正解决的是“需要稳定迭代器 频繁中间修改”的问题。理解了这一点下一步才能看懂它的内部设计为什么要这样实现。2. list核心设计拆解哨兵节点与迭代器封装如果只是“能用”容器那标准库已经写好了不需要自己造轮子。但模拟实现的价值在于你会被迫去理解list内部每一个指针的含义、每一个成员函数为什么这样设计。我个人觉得list最值得拆解的两个点一个哨兵节点一个是迭代器封装。2.1 双向链表的节点长什么样双向链表的节点一般是这样template typename T struct list_node { list_node* prev; list_node* next; T data; explicit list_node(const T value T()) : prev(nullptr), next(nullptr), data(value) { } };这个结构本身很简单真正复杂的是节点之间的链接规则。list最核心的trick是使用一个不存储有效数据的头节点哨兵节点并且让整个链表形成环哨兵节点的next指向第一个真实节点prev指向最后一个真实节点第一个节点的prev指向哨兵节点最后一个节点的next也指向哨兵节点。这样一来空链表的状态就是head-next head; head-prev head;。别小看这个环形设计它直接把“处理边界条件”这件事简化了。如果没有哨兵节点push_front、push_back、insert、erase都要为“链表为空”“插入到第一个位置”“删除最后一个节点”分别写特殊判断代码量和出错概率都会成倍上升。2.2 哨兵节点为什么让代码清爽很多我最早自己写链表的时候用的是带二级指针的裸链表每个操作都要判断head nullptr写出来的函数全是if分支。后来照着标准库思路改成哨兵节点所有边界情况自动消失。比如判断链表是否为空只需判断head-next head。比如在任意位置插入节点只需要拿到当前位置的节点指针cur、前驱节点prev cur-prev然后统一执行四步指针修改。由于头节点始终存在插入到头部和插入到中间没有任何区别代码完全不用分支。删除节点也是一样只需要修改被删节点前后两个节点的指针然后delete掉目标节点。正是因为这个哨兵节点在list生命周期内一直存在所以list的end()迭代器可以直接指向哨兵节点遍历时一旦走到head就代表走到末尾非常自然。2.3 迭代器为什么不能让裸指针直接当迭代器vector的迭代器可以简单理解成封装了原生指针因为节点在内存里连续指针自增自然走到下一个元素。list不行节点在内存里是分散的裸指针执行ptr只会移动到相邻地址不可能到达下一个节点。所以list的迭代器必须自己封装节点指针并重载operator和operator--让自增变成ptr ptr-next自减变成ptr ptr-prev。另外还需要重载operator*和operator-来访问节点里的数据。这一层封装就是list迭代器最核心的部分。标准库还会把迭代器分成不同类型list的迭代器是双向迭代器不是随机访问迭代器。因为不支持it n这种操作所以很多依赖随机访问的算法比如std::sort不能直接用在list迭代器上list才需要提供自己的成员sort。这也是模拟实现时值得留意的一个点。3. 手写一个精简list模拟实现的关键环节下面我直接给一个能跑的精简版list。重点看节点初始化、迭代器封装、insert/erase的指针修改顺序以及拷贝构造和析构资源管理。为了突出核心成员函数只保留最常用的几个但已经足够你在这基础上继续扩展。3.1 先把节点和迭代器框架搭起来template typename T struct list_node { list_node* prev; list_node* next; T data; explicit list_node(const T value T()) : prev(nullptr), next(nullptr), data(value) { } }; template typename T, typename Ref, typename Ptr struct list_iterator { typedef list_iteratorT, Ref, Ptr self; typedef list_nodeT node; node* node_ptr; explicit list_iterator(node* p nullptr) : node_ptr(p) {} Ref operator*() const { return node_ptr-data; } Ptr operator-() const { return (node_ptr-data); } self operator() { node_ptr node_ptr-next; return *this; } self operator(int) { self tmp(*this); node_ptr node_ptr-next; return tmp; } self operator--() { node_ptr node_ptr-prev; return *this; } self operator--(int) { self tmp(*this); node_ptr node_ptr-prev; return tmp; } bool operator(const self other) const { return node_ptr other.node_ptr; } bool operator!(const self other) const { return node_ptr ! other.node_ptr; } };这里用Ref和Ptr两个模板参数是为了让同一个迭代器模板既能生成普通迭代器T、T*也能生成const迭代器const T、const T*。很多模拟实现会把const_iterator单独再写一份代码大量重复用模板参数复用是更干净的做法。3.2 list壳哨兵头的初始化与begin/endtemplate typename T class list { public: typedef list_nodeT node; typedef list_iteratorT, T, T* iterator; typedef list_iteratorT, const T, const T* const_iterator; list() { create_head(); } iterator begin() { return iterator(head_-next); } const_iterator begin() const { return const_iterator(head_-next); } iterator end() { return iterator(head_); } private: node* head_; size_t size_; void create_head() { head_ new node; head_-prev head_; head_-next head_; size_ 0; } };注意create_head()里必须通过new构造节点而不能先分配裸内存再memset。因为list_node里面有T类型的成员T可能有自己的构造函数memset会把对象状态破毁后面再调用析构函数时就会出问题。这个点看起来基础但我在不少模拟实现里见过。3.3 push_back与insert要留意异常安全iterator insert(iterator pos, const T value) { node* cur pos.node_ptr; node* prev cur-prev; node* new_node new node(value); prev-next new_node; new_node-prev prev; new_node-next cur; cur-prev new_node; size_; return iterator(new_node); } iterator push_back(const T value) { return insert(end(), value); } iterator push_front(const T value) { return insert(begin(), value); }很多人会问insert明明是往pos前面插入但push_front传begin()为什么就是头部插入原因很简单begin()指向第一个真实节点往begin()前面插自然就成了新的头节点。这种统一的写法正是哨兵节点带来的好处。还有一个细节容易被忽视新节点用new node(value)创建时构造函数可能会抛异常。如果T的拷贝构造函数抛异常新节点构造失败此时链表还没有被修改链表的状态是完好无损的。这叫做强异常安全保证。反之如果先改了指针再创建节点一旦抛异常链表就乱了。所以new这一步必须放在指针修改之前。3.4 erase与clear节点回收的顺序不能乱iterator erase(iterator pos) { node* cur pos.node_ptr; iterator next_pos(cur-next); cur-prev-next cur-next; cur-next-prev cur-prev; delete cur; --size_; return next_pos; } void clear() { node* cur head_-next; while (cur ! head_) { node* nxt cur-next; delete cur; cur nxt; } head_-next head_; head_-prev head_; size_ 0; } ~list() { clear(); delete head_; }erase的指针修改顺序是先让前驱节点的next指向后一个节点再让后一个节点的prev指向前一个节点然后才能delete。如果先delete再改指针就会访问已经被释放的内存属于典型UB。clear在循环里必须提前保存nxt cur-next否则delete当前节点后再取cur-next就悬空了。这个错误非常隐蔽尤其在节点很多、析构又复杂的时候可能不是立刻崩溃而是等到内存被重用后才报错。析构函数要干两件事先把所有真实节点全部释放再把哨兵节点本身释放掉。只释放哨兵节点而不clear会造成内存泄漏内存检测工具会立刻告诉你“仍有未释放的分配”。3.5 拷贝构造与赋值深拷贝的两种常见写法list(const list other) { create_head(); for (const_iterator it other.begin(); it ! other.end(); it) { push_back(*it); } } void swap(list other) noexcept { std::swap(head_, other.head_); std::swap(size_, other.size_); } list operator(list other) { swap(other); return *this; }拷贝构造用遍历原链表尾插的方式逐个复制节点。这里必须重新new每一个节点不能直接复制指针否则两个list会共享同一批节点析构时double free。赋值运算符我偷懒用了copy-and-swap形参other拷贝一份副本再把副本的内部状态和当前对象交换离开函数时other自动析构正好把旧节点释放掉。这个写法既简洁又保证了异常安全是我个人比较推荐的方式。如果不用copy-and-swap直接按“先clear再逐个插入”来写一旦中途new节点抛异常当前对象已经变成了半空状态这就不满足异常安全了。所以我对模拟实现的最低要求是插入时强保证拷贝赋值时至少保证不泄漏。4. 迭代器失效问题一个很容易被绕进去的坎迭代器失效是C容器面试和实战中都很常见的话题。vector和list的规则差别很大需要分开记。4.1 vector迭代器失效规则 vs list规则vector的内存是连续的插入可能触发扩容扩容后所有迭代器全部失效。即使没有扩容插入点到末尾之间的元素也会被搬移所以插入点之后的所有迭代器同样失效。删除同理删除点之后的所有迭代器都受影响。这是vector的“批量失效”。list的节点地址是独立的、稳定的插入新节点不会搬移任何旧节点因此insert不会让任何已有迭代器失效。erase虽然会让指向被删除节点的迭代器失效但其他迭代器全部不受影响。这是list的“局部失效”。举个具体例子你持有一个指向list中间节点的迭代器在头部插入一百万个节点这个迭代器依然有效可以正常解引用。换成vector头部插入一次都可能让所有迭代器失效更别说一百次了。这个特性让list很适合“持有指向元素的外部指针同时不断从容器增删其他元素”的数据结构比如缓存淘汰、任务队列。4.2 erase之后为什么必须用返回的迭代器标准库list的erase返回被删除元素的下一个位置的迭代器。原因有两个第一被删除节点已经释放指向它的迭代器成了悬空迭代器继续使用是UB第二调用者往往需要继续遍历“下一个节点”如果erase不返回又不得不手动保存next之后再erase容易出错。正确写法是std::listint lst; for (auto it lst.begin(); it ! lst.end(); ) { if (*it % 2 0) { it lst.erase(it); } else { it; } }错误写法是在erase之后继续使用原来的itfor (auto it lst.begin(); it ! lst.end(); ) { if (*it % 2 0) { lst.erase(it); // it已经悬空 it; // 未定义行为 } else { it; } }这在很多机器上可能碰巧不崩溃因为被释放的内存还没被重用但这是纯粹的UB换一个编译器、加一点内存检测工具立刻现原形。我自己在模拟实现里也专门复现过这个错误之前以为“指针只是指向了一块被释放的内存应该还能读”实际上析构函数、内存分配器的行为都会让这个前提不成立。4.3 哪些操作会影响list迭代器除了erase之外clear会让所有指向真实节点的迭代器失效但指向end()的迭代器仍然有效因为哨兵节点还在。如果整个list对象析构了所有迭代器当然全部失效包括end()。splice、merge这类list专有操作要特别注意把一个list里的节点转移到另一个list时指向该节点的迭代器本身依然有效但它现在“属于”另一个容器。这种情况在标准里是特例如果你没有把握最稳妥的做法是操作完之后重建迭代器。还有一点与vector相反在list里同时使用insert和已有迭代器时不用担心插入导致迭代器被“搬移”到新地址。因为这正是list的核心优势节点地址不变迭代器就稳定。5. 性能选型list不是“万能链表”很多初学者看完list的O(1)插入删除觉得list天下无敌。真实性能却没那么简单复杂度的优势必须结合硬件和操作模式看。5.1 复杂度相同不代表现实速度一样list的插入删除确实是常数时间但这个常数很大。一次插入需要new一个节点new背后是内存分配器可能牵涉到系统调用、内存池管理、锁竞争然后要修改前后两个节点的指针最后析构旧节点时还要delete。一次delete也可能触发内存合并和系统调用。相比之下vector在尾部push_back经过连续内存和优化后非常快。所以只有在“操作次数很多、每次操作本身复杂”的情况下list的O(1)优势才能转化为肉眼可见的性能。如果只是插入几十个元素、每个元素又是个intvector的无脑memmove反而碾压list。这就像快递柜和上门配送从单件包裹看上门更灵活但大批量小包裹还是集中仓储更高效。5.2 缓存友好度才是现代CPU的胜负手现代CPU对连续内存的访问效率远高于跳跃访问。vector遍历时内存地址是连续递增的CPU会提前加载缓存行预取器也能很好工作。list遍历时每个节点分散在堆上访问完一个节点再取下一个指针大概率要访问另一块内存几乎每次都cache miss。我做过一个小实验向一个vector和一个list里先插入100万个数然后完整遍历计算总和。vector的遍历耗时大概只有list三分之一甚至更低。这个差距与数据量、分配器行为、机器配置有关但趋势非常稳定。因此如果主要操作是遍历list不是好选择。有些场景为了中间插入方便选了list最后却因为频繁遍历而慢得离谱这就属于选型失误。5.3 什么场景该用list什么场景该换容器list比较合适的场景有几个需要频繁在已知位置插入删除并且元素本身不能随便搬移需要长期持有指向元素的迭代器/引用同时其他操作不会导致失效需要实现LRU缓存、消息队列中间删除、按用户维度的分组管理等结构。如果只是需要“能快速从头部删除、尾部追加”的队列deque通常比list更合适。deque的内存由多段连续块组成支持头尾O(1)插入删除缓存友好度也比list好。如果元素很少、数量基本固定甚至可以用vector加逻辑删除来模拟链表因为紧凑存储带来的性能优势可能远大于一次搬移的代价。选容器的核心是先问自己三个问题访问模式是随机还是顺序修改频繁发生在头尾还是中间迭代器需要多稳定顾全这三个问题list该不该用基本就有答案了。6. 模拟实现中的常见错误与排查经验写模拟实现最大的收获不是把代码抄一遍而是把编译器、内存检测工具和调试过程中的反馈变成自己的经验。下面这些错误我基本都踩过列出来供你排查。6.1 节点初始化与内存释放的粗心问题第一个常见错误是使用new node[1]或malloc(sizeof(node))后对节点区域memset清零。list_node里面有T对象T可能有虚函数、资源成员、非平凡构造函数memset会把对象内部状态变成“既非构造、又非完全清零”的随机形态。正确做法永远是new一个节点让构造函数完成初始化。第二个常见错误是节点释放顺序没理清。delete节点后还去访问它的prev或next这在节点少时可能运气好不崩溃但本质上已经是悬空指针访问。我的排查习惯是统一先断开链表链接、再delete节点必要时先保存需要访问的指针。clear循环里尤其要在delete之前把next节点保存下来。6.2 迭代器类型定义不完整标准库允许STL算法通过迭代器上的typedef判断迭代器能力。很多模拟实现为了让代码最短只实现了operator*和operator忘了定义iterator_category、value_type、difference_type、pointer、reference这五个标准typedef。结果就是把list迭代器传给某些算法模板时编译报错报得莫名其妙。在C17之前可以用继承std::iterator来补但那个类配C17已经被标记为过时。现在更推荐在迭代器类内部手动定义这些类型。我一般在list_iterator最上面补上using iterator_category std::bidirectional_iterator_tag; using value_type T; using difference_type std::ptrdiff_t; using pointer Ptr; using reference Ref;这样既能让标准算法正确识别也能在写模板时少踩很多坑。迭代器类型归属于容器的设计这种接口细节决定了容器能不能和STL算法玩到一起去。6.3 测试时如何确认迭代器是否正确最笨也最有效的办法是专门写一个测试函数覆盖空链表、插入头部、插入尾部、中间插入、删除头部、删除尾部、清空、拷贝、赋值。每一步都用循环遍历并把节点数量、节点值打印出来再和预期对比。更严格一点用内存检测工具检查有没有泄漏和越界。我在模拟实现之后通常跑三件事用new[]/delete[]配对数量确认或者直接用内存检测工具看分配/释放次数写一个包含“erase后继续使用旧迭代器”的错误用例观察工具能否报告未定义行为反复构造、清空、拷贝同一个list几十次确认没有随着操作次数增加而泄漏。这个习惯救了我很多次。一个看起来正常的功能在循环几千次后才泄漏几十字节这种问题靠肉眼很难发现但工具一查就清楚了。6.4 几个我踩过的坑前置自增和后置自增返回值搞错。前置自增返回迭代器本身的引用后置自增返回旧值的副本。很多人图省事让后置也返回引用结果链式表达式it行为异常。STL的迭代器语义是和内置类型对齐的后置自增必须返回旧值。const_iterator和iterator混用。在const成员函数里调用begin()会得到const_iterator但如果不小心把它赋值给iterator就会报错。我在实现时用同一个类模板的Ref/Ptr参数区分两种类型却忘了加“iterator可隐式转换为const_iterator”的机制。标准库是支持这个转换的如果模拟版不提供很多用惯了STL的代码会编译失败。可以给const_iterator模板单独加一个接收普通iterator的构造函数或者直接提供转换操作。还有一个经常忽略的点成员函数要区分const重载。比如begin()和end()在有const对象的场景下必须同时提供const版本否则只能对非constlist遍历。我在写模拟版本时刚开始只写了非const版本后续用const引用传list就发现编译不过才意识到这个问题。最后分享一个我自己的习惯每次模拟实现list这样基础容器我不会在写完能跑之后就收工而是会拿它和标准库的接口做一次逐项对比把所有能编译的成员函数都列出来看看自己缺了什么。缺迭代器typedef就补typedef缺const版本就补const版本缺异常安全的逻辑就调整new的顺序。这样一轮下来对C对象模型、引用折叠、模板实例化的理解会比单纯看教程深得多。如果今天只是想把list用起来标准库其实够用了没必要重复造轮子。但如果你想搞懂“为什么list的insert不使迭代器失效”“为什么erase要返回迭代器”“为什么list的sort不同于std::sort”自己写一个精简版是最高效的路径。最后一个小建议写完后记得跑一遍内存检测工具它能帮你发现很多你看代码看不出的问题。等这一版干净了再去翻标准库源码你会发现原来那些看起来简单的设计背后全是边界情况和异常安全。

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

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

免费获取报价 →
↑