ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C++ list模拟实现:从双向循环链表到迭代器设计全解析

C++ list模拟实现:从双向循环链表到迭代器设计全解析 先放一句结论list 这个容器背接口谁都会但真正能把它讲透的关键是“模拟实现”这四个字。你只要自己手写过一遍双向循环链表再看标准库源码会发现很多之前死记硬背的东西突然就通了。这篇博文就围绕 C list 的底层结构、迭代器设计、节点管理以及模拟实现时最容易踩的坑展开适合正在学 STL 源码、准备 C 面试、或者被链表改来改去搞到头疼的读者。1. 内容整体设计与思路拆解1.1 为什么模拟实现 list 比背接口更重要很多初学者学 list第一反应是把 push_back、push_front、insert、erase 这些接口背下来然后刷几道算法题就觉得自己会了。但真到了面试或者项目里要自己封装一个链表结构时往往卡在迭代器的实现、节点的内存管理、以及 const 版本和非 const 版本怎么共用一套代码这些细节上。模拟实现 list 的核心价值不在于让你写出一个比标准库更高效的容器而在于通过这个过程把 C 的几个关键机制串起来类模板、内存分配、迭代器 traits、引用折叠、const 重载、异常安全。标准库的 list 之所以看起来“复杂”是因为它把工程上的边界情况全考虑进去了而我们自己实现一个精简版正是为了逐步理解这些边界情况为什么存在。我的建议是不要一上来就去看 libstdc 的完整源码那几千行代码会直接劝退。先从“能跑、能遍历、能插入删除”的最小版本开始然后逐步加上 const 支持、拷贝构造、移动语义最后再对比标准库看差距这样学习曲线会平滑很多。1.2 双向循环链表 vs 单向链表选型背后的考量list 的标准实现是双向循环链表这不只是“标准库这么写了我也这么写”而是有明确的工程权衡。单向链表只保存 next 指针内存占用确实小一些但代价是删除一个节点时必须从头遍历找到前驱节点复杂度 O(n)。对于标准库这种需要通用性的容器来说这个代价不可接受。双向链表每个节点多一个 prev 指针换来的是 O(1) 的插入和删除。循环链表的设计更巧妙。如果没有循环你需要单独维护 head 和 tail 两个指针每次插入删除都要判断边界而循环链表用一个哨兵节点头节点把首尾连起来让“首节点的前驱”和“尾节点的后继”都指向哨兵这样所有的插入删除逻辑都统一了不再需要特判空链表或边界情况。我画过很多次这个结构最直白的理解方式是哨兵节点是一个“假”的节点它不存有效数据只充当锚点。begin() 返回的是哨兵的下一个节点end() 返回的就是哨兵本身。这样一来空链表就是“哨兵的前驱和后继都指向自己”遍历时跳到哨兵就说明走完了代码逻辑会非常干净。2. 核心细节解析与实操要点2.1 节点设计数据、指针与内存分配的平衡list 的节点定义是整个实现的地基。标准库中节点结构大概长这样template typename T struct __list_node { __list_node* _next; __list_node* _prev; T _data; };有几个细节值得注意第一_next和_prev的类型是“指向节点的指针”而不是“指向 T 的指针”。这意味着指针操作都是在节点层面进行的数据只是节点里的一块内存。这个设计的好处是插入和删除只关心指针的重新连接完全不需要移动数据本身。第二数据成员_data是直接内嵌在节点里的而不是用一个T*指向堆上单独分配的内存。这样做的好处是减少了内存碎片化——每个节点只需要一次内存分配数据和指针在同一个内存块里。坏处是当 T 的构造或析构抛异常时需要小心处理节点内存和对象生命周期的关系。第三list 默认使用std::allocatorT来分配节点内存。这里有一个容易被忽略的知识点标准库的 allocator 通常按 T 的大小来分配但 list 节点的大小是sizeof(__list_nodeT)所以标准库内部用的是allocator_traits::rebind机制把allocatorT重绑定为allocator__list_nodeT。我们自己实现时可以省掉这层 rebind直接用std::allocator__list_nodeT逻辑上是一样的。如果不关心内存池这种性能细节直接用new和delete也可以但必须注意不能用new __list_nodeT一次性分配并构造因为节点的构造分两步——先分配原始内存再在内存上构造 T 对象。标准库用的是“分配 定位 new”两段式就是为了把内存分配和对象构造解耦这样异常发生时可以分别处理。2.2 迭代器设计为什么 list 的迭代器不能是裸指针这是模拟实现里最关键的一个设计点。vector 的迭代器可以直接用T*因为它的内存是连续分布的裸指针天然支持、--、、-、[]这些操作。但 list 的节点在内存里是东一个西一个的裸指针的只会跳到相邻的内存地址根本不是下一个节点。所以 list 的迭代器必须封装成一个类重载operator和operator--时实际执行的是_node _node-_next和_node _node-_prev。这就把“节点层面”的指针移动转换成了“逻辑层面”的迭代器移动。初始化版本的核心代码类似template typename T, typename Ref, typename Ptr struct __list_iterator { typedef __list_nodeT node; node* _node; __list_iterator(node* n nullptr) : _node(n) {} Ref operator*() const { return _node-_data; } Ptr operator-() const { return (_node-_data); } __list_iterator operator() { _node _node-_next; return *this; } __list_iterator operator(int) { __list_iterator tmp(*this); _node _node-_next; return tmp; } __list_iterator operator--() { _node _node-_prev; return *this; } __list_iterator operator--(int) { __list_iterator tmp(*this); _node _node-_prev; return tmp; } bool operator(const __list_iterator other) const { return _node other._node; } bool operator!(const __list_iterator other) const { return _node ! other._node; } };写到这里就遇到了一个经典问题普通迭代器和 const 迭代器怎么共用代码2.3 三个模板参数普通迭代器与 const 迭代器共舞我见过很多初学者试图用“继承”来解决 const 迭代器问题——写一个const_list_iterator继承自list_iterator然后重写返回值。这个方案在 C 里是行不通的因为operator*的返回类型不同不能靠简单的继承覆盖来解决而且继承还会引入不必要的虚函数开销。标准库的做法是给迭代器模板加两个额外的参数Ref引用类型和Ptr指针类型。普通迭代器实例化时传T和T*const 迭代器实例化时传const T和const T*typedef __list_iteratorT, T, T* iterator; typedef __list_iteratorT, const T, const T* const_iterator;这样一套代码两种行为。operator*的返回值用Refoperator-的返回值用Ptr编译器在实例化的时候自动生成对应版本。普通迭代器解引用得到T可以修改const 迭代器解引用得到const T只能读。这个技巧不只在 list 里用map、set、deque 的迭代器实现都是这个套路学会了可以举一反三。这里有一个值得思考的细节operator的参数用的是const __list_iterator而不是Ref或Ptr。因为比较的是迭代器本身即节点指针跟指向的数据类型无关。所以两个不同类型的迭代器比如 iterator 和 const_iterator能不能比较严格来说它们不是同一个类型不能直接比较。标准库通过类型转换让 iterator 可以隐式转换成 const_iterator这个细节我们可以在模拟实现时先忽略等整体跑通了再补。3. 实操过程与核心环节实现3.1 整体框架搭建从 list 类模板开始我们按“最小可用”原则来实现。第一版只支持默认构造、push_back、push_front、迭代器遍历、销毁。后面再逐步加拷贝构造、赋值、insert、erase。template typename T class list { public: typedef __list_nodeT node; typedef __list_iteratorT, T, T* iterator; typedef __list_iteratorT, const T, const T* const_iterator; private: node* _head; // 哨兵节点 public: list() { _head new node; _head-_next _head; _head-_prev _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); } void push_back(const T val) { node* new_node new node; new_node-_data val; // 这里有问题下面详细说 node* tail _head-_prev; tail-_next new_node; new_node-_prev tail; new_node-_next _head; _head-_prev new_node; } void push_front(const T val) { node* new_node new node; new_node-_data val; node* first _head-_next; _head-_next new_node; new_node-_prev _head; new_node-_next first; first-_prev new_node; } };这里 push_back 里new_node-_data val是第一个坑。new node会默认构造一个 T 对象如果 T 没有默认构造函数这段代码直接编译失败。而且这个是“先默认构造再赋值”等于多了一次无意义的构造开销。正确的做法是使用“分配 定位 new”node* new_node (node*)operator new(sizeof(node)); new (new_node-_data) T(val); new_node-_next nullptr; new_node-_prev nullptr;先分配裸内存然后在_data这块内存上用拷贝构造函数直接构造。这样 T 只需要支持拷贝构造不需要默认构造。我们用了一个辅助函数来完成这个操作node* create_node(const T val) { node* new_node (node*)operator new(sizeof(node)); new (new_node-_data) T(val); return new_node; } void destroy_node(node* p) { p-_data.~T(); // 显式调用析构 operator delete(p); }对应地销毁节点也要两步先析构 T 对象再释放内存。这个习惯要养成后面处理异常安全时会少很多麻烦。3.2 插入操作统一接口的精妙之处在双向循环链表里insert 可以统一成一个操作在指定迭代器位置之前插入节点。iterator insert(iterator pos, const T val) { node* cur pos._node; node* prev_node cur-_prev; node* new_node create_node(val); prev_node-_next new_node; new_node-_prev prev_node; new_node-_next cur; cur-_prev new_node; return iterator(new_node); }然后 push_back 和 push_front 都可以复用 insertvoid push_back(const T val) { insert(end(), val); } void push_front(const T val) { insert(begin(), val); }这就是循环链表 哨兵节点的威力不需要特判空链表不需要区分头插尾插的边界所有情况走同一套代码。insert 的返回值是新插入节点的迭代器这个行为和标准库一致方便链式调用。3.3 erase 操作返回值的学问erase 的返回值是被删除节点的下一个节点。iterator erase(iterator pos) { node* cur pos._node; node* prev_node cur-_prev; node* next_node cur-_next; prev_node-_next next_node; next_node-_prev prev_node; destroy_node(cur); return iterator(next_node); }为什么要返回下一个节点因为在遍历时删除当前元素是高频操作如果不返回下一个节点删除后迭代器就失效了你没有办法继续遍历。auto it lst.begin(); while (it ! lst.end()) { if (*it % 2 0) { it lst.erase(it); // 正确 } else { it; // 注意删除时不走 it } }这里有个典型的误用有些新手会在 erase 之后仍然执行it导致跳过被删除节点的下一个元素。因为 erase 已经返回了下一个节点的迭代器你再就会跳过它。这是面试常考的坑也是实际开发里最容易写错的地方。3.4 拷贝构造与赋值运算符深拷贝的三条黄金法则list 持有堆内存必须遵守“三之法则”Rule of Three析构函数、拷贝构造函数、拷贝赋值运算符三个要么都自己写要么都不写。拷贝构造的写法是用尾插法逐个复制list(const listT other) { _head new node; _head-_next _head; _head-_prev _head; for (const auto val : other) { push_back(val); } }拷贝赋值的写法有很多种推荐现代 C 的“copy-and-swap”手法void swap(listT other) noexcept { std::swap(_head, other._head); } listT operator(const listT other) { if (this ! other) { listT tmp(other); // 拷贝构造 swap(tmp); // 交换哨兵指针 } // tmp 析构时带走旧数据 return *this; }这个写法的巧妙之处在于如果拷贝构造抛异常tmp 没有构造成功当前对象保持不变异常安全有保障。而且代码极简不需要手动做“释放旧节点 拷贝新节点”这种容易出错的步骤。析构函数则要一个一个销毁节点注意不是只 delete 哨兵节点~list() { clear(); delete _head; } void clear() { node* cur _head-_next; while (cur ! _head) { node* next cur-_next; destroy_node(cur); cur next; } _head-_next _head; _head-_prev _head; }3.5 完整测试验证核心功能的正确性下面是一段比较完整的测试代码覆盖了构造、插入、删除、拷贝、迭代器操作#include iostream #include cassert int main() { mylist::listint lst; lst.push_back(1); lst.push_back(2); lst.push_front(0); // 验证遍历 int expected[] {0, 1, 2}; int idx 0; for (auto it lst.begin(); it ! lst.end(); it) { assert(*it expected[idx]); } // 测试 insert auto it lst.begin(); it; // 指向 1 lst.insert(it, 99); // 现在应该是 0, 99, 1, 2 // 测试 erase it lst.begin(); it; // 指向 99 it lst.erase(it); assert(*it 1); // 测试拷贝构造 mylist::listint copy(lst); assert(copy.size() 3); // 测试 const 迭代器 const mylist::listint const_ref copy; int sum 0; for (auto it const_ref.cbegin(); it ! const_ref.cend(); it) { sum *it; } assert(sum 3); // 测试赋值 mylist::listint assign; assign lst; assert(assign.size() 3); std::cout All tests passed! std::endl; return 0; }注意这段测试里用到了size()我们还没有实现可以临时加一个计数器成员_size或者遍历计算。标准库的 list 的 size() 是 O(1) 的实际实现里会维护一个_size成员每 insert 加一每 erase 减一。我们在模拟实现时也应该加上这个计数器否则在循环里反复调用 size() 会导致不必要的 O(n) 遍历。4. 常见问题与排查技巧实录4.1 迭代器失效什么时候旧的迭代器还能用这是 list 和 vector 最大的区别也是面试必问题。vector 的迭代器在插入/删除后很容易失效因为底层数组可能重新分配内存所有迭代器都指向了已被释放的内存。list 的节点的内存是独立的插入和删除只是重新连接指针只要节点本身没有被销毁指向它的迭代器就依然有效。具体来说操作list 迭代器失效情况vector 迭代器失效情况insert其他迭代器全部有效如果触发扩容全部失效erase只有被删除节点的迭代器失效被删元素之后的所有迭代器失效push_back其他迭代器全部有效如果触发扩容全部失效push_front其他迭代器全部有效不支持 O(1) 头插这就意味着在 list 里你可以放心地保存一个指向某个元素的迭代器然后在其他地方插入或删除节点这个迭代器还是能用的。这个特性在实现 LRU 缓存时非常重要——用 list 保存元素顺序用 unordered_map 保存 key 到迭代器的映射删除任意元素都是 O(1)因为迭代器不会失效。4.2 编译报错模板类成员函数的“未定义引用”模拟实现 list 时最容易遇到的编译错误是“undefined reference tomylist::listint::push_back(int const)”。这个错误的原因要从模板编译模型说起。普通函数的声明和定义可以分离声明放在 .h定义放在 .cpp链接时能找到。但类模板不是这样编译器在实例化listint的时候必须看到模板的完整定义否则它只知道“这个类有 push_back 这个成员”但不知道具体怎么实现只能寄希望于链接时找到。如果 push_back 的实现放在单独的 .cpp 里没被 #include 进来链接就失败了。解决方法是把模板的声明和实现都放在同一个头文件里。这确实违背了很多人“把接口和实现分离”的直觉但模板就是这样工作的。如果确实想分离可以显式实例化但那样就只能支持你显式列出的类型失去了泛型的意义。4.3 性能陷阱list 真的很强吗list 的插入删除是 O(1)这是它的卖点。但要理解这个 O(1) 是“在已知位置的前提下”的 O(1)。如果你先要查找一个元素再删除查找本身是 O(n) 的整体还是 O(n)。而且 list 有一个隐藏的性能杀手缓存不友好。链表节点在堆上零散分布遍历时每次访问一个节点都可能发生缓存缺失。在数据量较大时list 的遍历性能可能比 vector 差一个数量级。我自己做过简单的基准测试在 100 万元素规模下vector 顺序遍历大约比 list 快 5 到 10 倍。这不是说 list 没用而是说要选对场景需要频繁在中间插入删除、且元素数量不大时list 很合适需要频繁随机访问、或者需要高性能顺序遍历时vector 才是正解。C 社区有句调侃“没有最好的容器只有最合适的容器。”这句话在 list 身上体现得尤其明显。4.4 模拟实现中的几个经典 bug我在手写 list 过程中踩过几次坑每次都在同样的地方整理出来给各位参考第一个 bug 是循环链表的头尾连接漏改。写 push_back 时改了尾节点的_next指向新节点但忘了改新节点的_prev指向旧尾节点结果遍历时从头到尾正常反向遍历时就崩了。链表操作的铁律是涉及几个节点的指针就得更新几个一个都不能少。第二个 bug 是哨兵节点被误删。写 erase 时只判断了“迭代器不能是 end()”但没想过如果用户真的传了 end() 进来会怎样。标准库规定 erase(end()) 是未定义行为但我们自己实现时应该加一个断言assert(pos ! end())在调试期就暴露出问题而不是等到运行时随机崩。第三个 bug 是拷贝构造里忘记初始化_head。如果没有先给哨兵节点分配内存就直接 push_back会在空指针上解引用。这个错误很隐蔽因为编译器不一定报警只在运行时崩。排查方法是每次写构造函数第一行就初始化所有指针成员养成肌肉记忆。4.5 进阶为什么标准库的 list 没有 size() 的性能问题我们现在实现的 list 如果每次 size() 都遍历时间复杂度是 O(n)。C11 之前的标准确实允许 list::size() 是 O(n) 的所以有些老实现真的会遍历计数。但 C11 之后标准强制要求 size() 必须是 O(1)库的实现普遍采用“成员计数器 每次插入删除时更新”的方式。我们的模拟实现要跟上现代标准也维护一个_size成员void push_back(const T val) { insert(end(), val); } iterator insert(iterator pos, const T val) { // ... 原有逻辑 ... _size; return iterator(new_node); } iterator erase(iterator pos) { // ... 原有逻辑 ... --_size; return iterator(next_node); } size_t size() const { return _size; }但这里要注意insert 和 erase 是被 push_back 等接口复用的所有进出口都维护好_size就不会出现计数不一致的问题。最怕的是有的地方直接操作节点指针绕过 insert/erase_size就会飘排查起来极度痛苦。所以我的建议是内部操作一律走统一接口宁可多写几行代码也不要在多个地方手动操作节点。5. 个人经验与扩展建议模拟实现 list 这个练习做完之后最有价值的收获不是“我写了一个能跑的链表”而是看标准库源码时不再觉得那些模板参数、allocator、rebind 是天书了。你知道了迭代器为什么要三参数模板知道了节点为什么要两段式构造知道了 const 迭代器是怎么复用代码的——这些知识迁移到 map、set、unordered_map 的实现里全都适用。基于我自己的练习经验给大家几个建议。第一先跑通最小版本再优化不要一上来就追求完美push_back能跑通再考虑 const 迭代器const 迭代器跑通了再考虑异常安全一步一步来。第二准备一个调试用的print_all()函数每实现一个接口就打印一遍链表内容肉眼确认指针连接是否正确这比用调试器单步跟踪高效得多。第三写测试用例的时候一定要覆盖边界空链表插入删除、在 end() 位置插入、删除最后一个元素、拷贝一个空 list、对空 list 执行 clear——这些边界全过了才说明你的实现基本可靠。如果还想继续深入建议做两个扩展练习一个是给 list 实现splice接口把一段链表从一个 list 转移到另一个 list这需要处理跨链表节点的指针重连另一个是添加移动构造和移动赋值理解移动语义对容器性能的提升。做完这两个练习你的 list 模拟实现差不多就有标准库七成功力了剩下的三成是 allocator 优化和异常安全细节等真正用到时再回来抠也不迟。
返回列表