ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

C++ STL list底层源码剖析:节点结构、哨兵节点与迭代器机制

C++ STL list底层源码剖析:节点结构、哨兵节点与迭代器机制 用过STL的人基本都绕不开list这个容器看起来太简单了——双向链表嘛插入删除O(1)。可真被问到list的迭代器为什么不是一个指针它的insert到底做了什么为什么list没有自己的sort就是编译错误的时候很多写了几年C的人也会愣一下。这篇文章我想从list的底层实现出发把节点结构、哨兵节点、迭代器机制和核心操作源码一层层拆开搞清楚它究竟是怎么工作的。源码以开源社区的libstdc风格实现为蓝本剥掉模板包装看核心骨架。对刚接触STL源码的读者来说这篇能帮你建立完整的底层认知少走弯路。1. list的底层数据结构双向链表的核心设计1.1 序列容器里的第三个选择C标准库提供了三种经典序列容器vector、deque、list还有C11加入的forward_list。vector用连续内存随机访问快但头部插入删除是O(n)deque用分段连续内存两头操作是摊还O(1)但中间插入删除依然是O(n)。list解决的就是这个特定痛点只要你知道位置在任何地方插入或删除都是O(1)而且不会让其他元素的迭代器失效。这个能力完全来自底层数据结构——双向链表。每个节点独立分配节点之间靠两个指针连接插入删除只需要修改相邻节点的指针指向不涉及元素本身的搬移。这是理解list一切行为的起点。1.2 节点结构数据加两个指针在libstdc的实现里节点不是简单的一个T加两个指针而是拆成了基类和派生类两层// 基类只存指针 struct _List_node_base { _List_node_base* _M_next; _List_node_base* _M_prev; }; // 派生类继承指针加上数据 templatetypename T struct _List_node : public _List_node_base { T _M_data; };为什么要这么拆两个原因。第一所有不依赖具体T类型的链表操作都可以只操作指针。比如后面要讲的_M_hook把节点挂到链表上和_M_unhook把节点从链表上摘下来它们只关心_M_next和_M_prev两个指针根本不需要知道T是什么。把指针放在基类里这些函数就能用不依赖模板参数的形式写出来编译器不用为每个T都实例化一份。第二内存布局更清晰。64位系统下一个listint节点的大小是两个指针16字节int数据4字节对齐填充4字节总共24字节。稍后看到内存对比时你会体会到这个节点大小直接决定了list的缓存不友好性。1.3 哨兵节点让所有边界情况消失这是list实现里最精妙的一个设计。链表里常见的问题是边界处理空链表怎么表示在头部插入和尾部插入要不要特判迭代器怎么判断已经走完了libstdc的做法是list对象内部维护一个哨兵节点也叫头节点它不属于有效数据但永远存在。templatetypename T, typename Alloc class _List_base { _List_node _M_node; // 哨兵节点 public: iterator begin() { return iterator(_M_node._M_next); } iterator end() { return iterator(_M_node); } };哨兵节点的_M_next指向第一个有效节点_M_prev指向最后一个有效节点。空链表时哨兵节点的_M_next和_M_prev都指向它自己。这个设计带来的好处是巨大的空链表和非空链表统一处理插入、删除代码不需要写if (empty())分支end()不是nullptr而是一个真实存在的地址所以while (it ! lst.end())的循环永远安全从尾部往前遍历也安全--end()就是最后一个元素不会越界插入和删除操作里哨兵节点就像一个普通节点一样参与指针连接边界情况自动消失我在教学时习惯把哨兵节点比作环形走廊里的起点标志——它不占任何座位但你在走廊里走永远知道起点在哪也永远不会走出边界。2. list迭代器的实现原理为什么它不是一个裸指针2.1 迭代器必须是类对象vector的迭代器在绝大多数实现里就是一个裸指针因为vector元素内存连续it就是指针1简单直接。但list不行它的节点散落在堆上it必须从当前节点的_M_next取出下一个节点地址。所以list的迭代器是一个类内部封装了一个指向节点的指针通过重载运算符来模拟指针行为templatetypename T, typename Ref, typename Ptr struct _List_iterator { _List_node_base* _M_node; _List_iterator operator() { _M_node _M_node-_M_next; return *this; } _List_iterator operator--() { _M_node _M_node-_M_prev; return *this; } bool operator(const _List_iterator x) const { return _M_node x._M_node; } };注意这里_M_node的类型是_List_node_base*而不是_List_nodeT*。为什么因为迭代器只操作指针不需要知道T。等真正访问数据时才把基类指针转回派生类指针。这个设计让迭代器的增删查操作不用依赖模板参数间接减少了模板实例化代码量。2.2 解引用操作符从指针到引用迭代器的operator*是访问数据的关键。标准库的实现大概是reference operator*() const { return *static_cast_List_nodeT*(_M_node)-_M_valptr(); } pointer operator-() const { return *operator*(); }static_cast_List_nodeT*把基类指针转回具体的节点类型然后取出_M_data。operator-则是先取引用再取地址这样it-member这种表达式才能正常工作。这些操作符必须返回引用而不是值否则it-member或者it-value 1这种修改就无效了。这也是为什么迭代器要定义reference和pointer类型别名——它们是STL算法和容器交互的契约。2.3 迭代器失效规则list最让人安心的地方看源码之前先记住list迭代器失效的几个结论它们直接来自上面的指针结构insert和splice操作不会使任何迭代器失效erase只会使被删除元素的迭代器失效其他迭代器全部保持有效push_back、push_front同样不会让已有迭代器失效前提是分配内存不抛异常对比一下vectorvector只要发生扩容所有迭代器全部失效erase中间元素后面所有迭代器全部失效。list之所以能这么稳根源就在于它不搬元素节点地址一旦确定就不变。但请注意list的节点地址不变不代表引用永远有效。如果那个元素本身被erase了你手里还攥着指向它的迭代器那依然是悬空指针。可靠不等于万能这句话下面还会反复提到。3. 核心操作源码拆解push、insert、erase是怎么改指针的3.1 push_back的完整链路push_back是所有操作里最简单直观的顺着它看整个插入机制void push_back(const T x) { _M_insert(end(), x); } void _M_insert(iterator __position, const value_type __x) { _List_nodeT* __tmp _M_create_node(__x); // 分配节点拷贝构造 __tmp-_M_hook(__position._M_node); // 把节点挂到position前面 }_M_create_node负责两件事用分配器分配一块sizeof(_List_nodeT)的内存然后在上面执行T的拷贝构造。这一步如果抛异常内存会正确回滚这是异常安全的基础。真正改变链表结构的是_M_hook它是从SGI STL时代流传下来的经典四步指针操作void _M_hook(_List_node_base* __position) { this-_M_next __position; this-_M_prev __position-_M_prev; __position-_M_prev-_M_next this; __position-_M_prev this; }逐行解释一下假设要在节点P前面插入新节点NN-_M_next P新节点后继指向PN-_M_prev P-_M_prev新节点前驱指向P的前驱P-_M_prev-_M_next NP原来的前驱的后继指向N第一个断开的链接在这里接上P-_M_prev NP的前驱指向N步骤3和4的顺序不能颠倒因为第4行要用到的P-_M_prev在第3行执行后还保留着旧值。实际执行时第2行已经先把旧前驱保存到了N-_M_prev里所以第3行和第4行的先后其实就变得不敏感了。但在自己手写链表时我强烈建议用局部变量先保存旧前驱再改指针这样最不容易出错。3.2 insert任意位置插入的通用原语push_back只是insert在end()位置的特例。insert(position, x)的正确定义是在position指向的元素之前插入新元素返回新插入元素的位置。iterator insert(iterator position, const T x) { _List_nodeT* __tmp _M_create_node(x); __tmp-_M_hook(position._M_node); return iterator(__tmp); }这段代码提示我们一个很容易忽略的事实list的insert返回的是指向新节点的迭代器而vector::insert返回的是指向最后一个插入元素的迭代器。两者默认行为都是在pos之前插入但返回值语义相同只是list的O(1)和vector的O(n)差距明显。如果需要批量插入同一个值n次标准库内部会循环调用_M_insert每次新建独立节点。这里没有做一个节点存多个值之类的优化因为链表的设计就是每个元素一个节点。3.3 erase从链表里摘下节点删除操作比插入稍微多几个步骤因为除了改写指针还要释放资源和内存void _M_unhook() { _List_node_base* __next this-_M_next; _List_node_base* __prev this-_M_prev; __prev-_M_next __next; __next-_M_prev __prev; } iterator erase(iterator position) { _List_nodeT* __victim static_cast_List_nodeT*(position._M_node); __victim-_M_unhook(); // 1. 断开前后指针 // 2. 调用T的析构函数 // 3. 释放节点内存 return iterator(position._M_node-_M_next); // 返回被删元素的后继 }_M_unhook做的是_M_hook的逆操作让前驱的_M_next直接指向后继让后继的_M_prev直接指向前驱被删除的节点就从链表上脱落了。注意三点。第一返回的迭代器指向被删元素的下一个位置这是为了配合it lst.erase(it)这种惯用写法。如果你写成lst.erase(it)然后继续用it就是未定义行为——it已经悬空了。第二删除后必须调用析构函数。T可能有内部资源比如string的堆内存漏掉析构就是内存泄漏。标准库的做法是先让节点的数据成员执行析构再释放节点内存。第三_M_unhook不关心哨兵节点。如果删除的是最后一个有效元素那么__prev和__next都是哨兵节点它们互相指向对方链表回到空状态。哨兵节点让这种情况不需要任何if判断。3.4 spliceO(1)的链表拼接list有一个其他容器都没有的成员函数——splice作用是把一个链表的一段元素整体转移到另一个链表时间复杂度O(1)。void splice(iterator position, list x) { if (x.empty()) return; // 把x的整个链表段转移到position之前 position._M_node-_M_transfer(x.begin()._M_node, x.end()._M_node); }底层实现的核心是_M_transfer——将[first, last)区间整体移动到position之前。这个操作只需要修改三个节点的指针不复制任何元素所以是常数时间。std::list::splice配合splice还能实现O(1)的移动单个元素和合并两个链表片段。这在某些场景下极其有用比如实现维护按时间排序的任务队列时把一个任务从一个队列挪到另一个队列splice比eraseinsert快一个数量级——因为它压根不构造和析构元素。4. 内存占用与性能画像list到底省了什么、贵在哪里4.1 节点分配每次插入都是一次堆分配这是list最容易被忽视的性能代价。vector::push_back在容量足够时只是拷贝元素到预先分配的内存里不涉及堆分配而list::push_back每次都要调用分配器申请一块新内存。以64位系统下的listint为例每个节点24字节其中只有4字节是你的数据20字节是管理开销16字节指针4字节填充。如果存储的是int这种小对象list的账面性能非常难看。默认情况下list使用标准分配器std::allocatorT底层是new/delete。频繁插入删除会导致大量小内存块的分配与释放可能带来碎片化和性能抖动。某些旧实现里list有专门的内存池来缓解这个问题现代标准库默认不再做节点池把内存策略交给使用者通过Allocator模板参数定制。4.2 list vs vector vs forward_list一张表看懂维度vectordequelistforward_list底层结构连续数组分段连续数组双向链表单向链表随机访问O(1)O(1)O(n)最低效O(n)中间插入删除O(n)且搬移元素O(n)但仍搬移O(1)改指针O(1)改指针头插/头删O(n)O(1)O(1)O(1)尾插/尾删O(1)摊还O(1)O(1)O(n)迭代器失效门槛扩容全失效、中途插入后移中间插入失效较多仅删除元素失效仅删除元素失效内存开销/元素低可能有预留中等高两指针对齐低一指针反向遍历支持支持支持不支持默认排序std::sortstd::sort成员函数list::sort成员函数forward_list::sort我把这个表格当作选型时的决策参考要随机访问就vector要两端操作就deque要中间插入且迭代器稳定就list想省一个指针的内存且只需单向遍历就forward_list。4.3 什么场景真正需要list根据我自己的项目经验适合list的场景大致有三个特征已知位置的频繁插入删除。如果你总是需要先find再插入那find本身O(n)的代价可能淹没插入O(1)的好处此时vector二分未必更差。迭代器需要长期保持稳定。比如管理一堆对象其他地方长期持有指向这些对象的迭代器且这些对象会被删除和新增。这时list的非删除元素迭代器永不失效特性极其宝贵。元素本身不适合移动。C11以后移动语义缓解了vector的拷贝代价但某些类型依然移动成本高或不可移动而list插入时只需要初始化新节点从不搬动已存在元素。反过来遍历为主的算法千万别用list。链表节点在堆上分散遍历时每次都要跳到一个随机地址缓存局部性极差。实测大量场景下listint的遍历性能比vectorint慢一个数量级都不奇怪。5. 实战坑点速查迭代器失效、性能误判与排查经验5.1 迭代器失效的经典陷阱erase的返回值最经典的错误是这样写std::listint lst {1, 2, 3, 4, 5}; for (auto it lst.begin(); it ! lst.end(); it) { if (*it % 2 0) { lst.erase(it); // 错误erase后it失效再是未定义行为 } }正确写法是for (auto it lst.begin(); it ! lst.end();) { if (*it % 2 0) { it lst.erase(it); // erase返回下一个迭代器 } else { it; } }这里要特别澄清一个流传很广的误解在list里erase后直接丢弃返回值、然后it通常看起来也能跑因为节点虽然被释放但指针值还没变。这是典型的碰巧没崩不是合法行为。调试器查不出来不代表没问题使用悬空迭代器就是未定义行为。5.2 遍历中删除多个节点的安全姿势除了erase返回值还有一个替代方案被不少老项目使用——remove_iflst.remove_if([](int x) { return x % 2 0; });remove_if是list的成员函数底层等价于遍历erase但它把删除逻辑封装好了不会出现迭代器失效问题。标准库还有remove删除所有等于某值的元素、unique删除相邻重复、merge归并两个有序链表等成员函数它们都比手写循环更安全。用成员函数而不是算法有一个技术原因很多标准算法依赖随机访问迭代器而list只提供双向迭代器。std::remove能在list上运行但它的返回值对list没什么意义因为它不能真的收缩容器必须配合erase使用而且中间过程会搬移元素——和list的设计思想冲突。凡是list独有的成员函数尽量优先用成员版本。5.3 排序别用std::sort另一个高频报错是std::sort(lst.begin(), lst.end()); // 编译错误没有随机访问迭代器std::sort要求随机访问迭代器list的双向迭代器不满足。正确做法是用lst.sort()。list::sort的实现通常是归并排序也有混合插入排序的优化版本时间复杂度O(n log n)但它不需要随机访问也不需要额外的大块内存只要求能双向遍历。注意std::stable_sort也不能用于list。如果面试或笔试里遇到如何给链表排序这类题答案既可以是list::sort也可以自己写归并排序——自己写归并排序时递归式地拆链表、合并同样只需要双向迭代能力。5.4 性能误判list不总是快好几次听人说我用了list所以插入删除就是O(1)性能肯定没问题。这种说法忽略了前置条件。我做过一个简单测试在100万个元素的容器中间循环插入和删除位置已知的元素list确实比vector快很多但如果是随机遍历一遍再插list因为缓存不友好总耗时反而可能超过vector。还有个容易被忽略的角度节点大小。如果T本身很大比如一个几百字节的结构体list节点里多出两个指针的开销占比就变小了list的优势会被放大。反之如果T是int、bool这类小对象list等于每个元素平白多付16字节的管理成本数据量一大内存占用和缓存性能都吃亏。5.5 调试建议看看节点长什么样排查链表相关问题时我习惯把核心逻辑简化成最小可复现demo再配合gdb或者打印节点地址验证。比如怀疑迭代器失效就在erase前后打印迭代器指向的节点地址你会发现erase后那个地址的节点已经被释放往里面读数据就是踩野指针。如果你在阅读标准库源码认准几个关键函数_M_hook插入、_M_unhook摘除、_M_transfer区间转移。这三个函数理解了list的绝大部分成员函数都能找出脉络。其他函数无非是组合它们再加上分配、析构、异常处理。最后一个经验分享写自定义链表时先实现哨兵节点再实现插入删除。没哨兵节点的链表每个操作都要小心翼翼判断头尾代码又长又容易漏。照着list的设计先定义一个哨兵头节点插入删除的四步指针操作就能和标准库一样干净。这个设计思路不光适用于C的链表很多环形队列、LRU缓存之类的结构都可以借鉴把边界特判消灭在每个数据结构自己的实现里而不是责怪调用者不小心。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表