拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

手写C++ STL list:模拟实现带头双向循环链表与迭代器封装

手写C++ STL list:模拟实现带头双向循环链表与迭代器封装 1. 为什么要手写一个 List 模拟实现C 标准模板库里的 list 是很多人接触 STL 的第一个顺序容器但也是很多人学完就忘的一个容器。原因很简单vector 可以靠着连续内存、随机访问这些直观概念建立认知但 list 涉及带头双向循环链表、节点独立分配、迭代器缓存失效这些容易绕进去的细节。我在带新人的时候发现与其反复看源码和文档不如亲手写一个最小可用的模拟版。等你把 list 的骨架自己搭出来迭代器失效、插入删除复杂度、内存碎片这些小毛病就都会变得非常具体后面再去看 libstdc 的源码你会发现每一行都能和你的实现对上。这篇博文就围绕模拟实现这件事完整拆解我从零写 list 的整个过程怎么设计节点、怎么封装迭代器、怎么处理深拷贝和异常安全、怎么用几个简单的测试用例验证正确性。适合正在学 C 和 STL 的读者也适合想复习容器底层原理的开发者。重点不是抄一份源码而是搞懂每一个设计决策背后的原因。2. 整体设计与思路拆解2.1 为什么选用带头双向循环链表STL 标准库的 list 实现基本都是带头节点的双向循环链表。这个结构看起来绕但实际上有一个很关键的好处空链表不需要特判。用一个单独的哨兵头节点作为首尾相遇点链表为空时它的 next 和 prev 都指向它自己这样插入、删除、遍历都可以用统一的逻辑处理不用单独判断 this 是不是第一个节点。如果用普通的双向链表每次在头部插入、尾部插入都要判断链表是否为空。写起来麻烦不说还会在边界条件上出 bug。带头循环链表把边界情况吞掉了。这也是 STL 为什么选择这种结构的原因——不是炫技而是为了把特殊逻辑归一化。模拟实现的时候我也强烈建议直接用带头循环否则你会在 erase、clear 这种函数里不断写 if (head nullptr) 的分支纯属自找麻烦。另外循环链表配合哨兵头节点遍历的终止条件非常干净。迭代器走到 head 即 end不需要额外记录长度。这也是后文迭代器实现里判断 end 的基础。2.2 节点与容器的拆分设计list 节点既要存数据又要存前后指针。我的设计是单独用一个节点结构体template typename T struct ListNode { T data; ListNode* prev; ListNode* next; };注意这里的 data 是直接存放对象而不是指针。STL 的 list 也是这样做的好处是对象生命周期完全由容器管理插入时直接在节点里构造对象避免多余的指针跳转和额外内存分配。容器本体的设计则相对简洁只需要一个头节点指针和一个节点数量计数。内存分配通过 allocator 来进行但模拟时可以先用 new / delete重点是把结构逻辑跑通。整个 List 类只需要管理哨兵头节点所有操作都围绕它展开这就是典型的一个节点撑起一个容器。2.3 迭代器必须独立封装这是模拟 list 时最核心的设计决策。如果像早年一些教材那样直接在 List 类里用裸指针当迭代器你会发现it根本不能前进——因为链表节点的 next 指针不是连续的。vector 可以T*直接当迭代器list 不行。所以迭代器必须是一个独立的类内部保存指向节点的指针并重载、--、*、-、、!等操作符。这也是 STL 迭代器设计思想的体现把如何移动如何解引用封装到迭代器内部上层算法只需要依赖统一的迭代器接口就可以工作在任意容器上。这里有一个我踩过的坑最开始我把迭代器直接内嵌在 List 类里导致 const 版本的 begin() 和普通版本 begin() 需要维护两份逻辑。后来看了标准库的做法才知道最好把迭代器抽成模板类并通过 List 的类型别名暴露给外部。这样代码结构干净也方便后续实现 const 迭代器。2.4 区分普通迭代器和 const 迭代器很多初学者会以为 const 迭代器只是在迭代器前面加个 const比如const Iterator。但这样写是错误的——const Iterator表示迭代器本身不可变即不能执行it但*it返回的仍然是 T仍然可以修改元素。而我们需要的 const 迭代器是指迭代器可以移动但无法通过它修改指向的数据。标准库的实现通常采用模板参数区分但模板参数区分会让代码写起来很繁琐特别是在需要复用普通迭代器大部分代码的情况下。更实用的做法是定义一个基础的迭代器模板用 bool 参数标记是否为 consttemplate typename T, bool IsConst struct ListIterator { using Node ListNodeT; Node* node; ... typename conditionalIsConst, const T, T::type operator*() const; };这种方式虽然比标准库的实现略粗糙但逻辑清晰方便理解。如果没有这个设计你就得手写两个几乎重复的迭代器类后续维护代价很高。模拟实现的目的是学原理不代表要完全复刻标准库的全部技巧但核心区分一定要做对。3. 核心细节解析与实操要点3.1 哨兵头节点到底怎么初始化哨兵头节点不需要存储有效数据但它的 data 仍然要构造。我见过有人用new ListNodeT然后不初始化 data这样看着省了一件事但如果你在遍历或者调试时意外访问了头节点的 data就会得到一个未初始化对象行为未定义。更稳妥的做法是给 ListNode 写构造函数让 data 调用 T 的默认构造函数。标准库中这是必然发生的因为 allocator 会构造这个节点T 的默认构造是必要的。如果 T 没有默认构造函数你这个模拟版本就无法构造一个空 list 了。怎么解决可以用malloc分配内存再手动构造或者给节点设计一个不初始化 data 的私有构造函数。模拟实现阶段我建议先忽略这个问题直接要求 T 可默认构造把精力放在链表逻辑上。3.2 插入操作的接线顺序在任意位置插入节点我习惯把四根指针全部先接好再更新原先的相邻节点。具体来说在 pos 之前插入节点 cur需要修改五个指针cur.next 指向 poscur.prev 指向 pos.prevpos.prev.next 指向 curpos.prev 指向 cur注意顺序很重要。如果先执行pos.prev cur那么原来的pos.prev就找不到了。经验法则是先用临时变量或先修改 cur 自己的指针因为 cur 未接入链表怎么改都安全再修改它周边的节点。我推荐的顺序是cur-next pos; cur-prev pos-prev; pos-prev-next cur; pos-prev cur;这个顺序的优点是前两步只影响到新节点自己的指针不会破坏原链表的结构。后面两步再真正接入链表。如果写反了很难排查因为 bug 只在特定插入位置才会暴露。3.3 删除操作不能丢节点删除节点同样要注意顺序。常规做法是先把前后节点连起来再释放目标节点Node* prev pos-prev; Node* next pos-next; prev-next next; next-prev prev; delete pos; --size;如果先 delete 再修改相邻指针就会访问已释放内存。另外删除时要记得维护 size这是模拟实现里容易漏的地方会导致 later 遍历计数和 size() 不一致。3.4 拷贝构造与赋值的深拷贝陷阱list 默认的拷贝构造会做浅拷贝这是所有自定义容器都必须处理的问题。在模拟实现里我通过先初始化哨兵头节点然后遍历源链表逐个 push_back 来解决List(const List other) { head new Node(); // 初始化空链表 size_ 0; for (auto it other.begin(); it ! other.end(); it) { push_back(*it); } }赋值运算符推荐使用 copy-and-swap 技巧避免自赋值和异常安全问题List operator(List other) { swap(other); return *this; }这里other是按值传入的已经完成了深拷贝。swap 之后other会持有原来的数据并在函数结束时自动析构。这个技巧的妙处在于如果拷贝抛出异常当前对象没被修改如果拷贝成功赋值一定安全。这是 C 里写容器的一个基本素养我用这个技巧规避了一堆 tricky 的问题。3.5 析构函数要逐层清理析构函数不能简单只 delete 头节点否则所有节点都泄漏了。正确做法是调用 clear() 删除所有数据节点再释放头节点。注意 clear() 之后要把 head 的 next、prev 重新指向自己保持容器处于可复用状态而析构可以依赖 clear() 做完了这个清理动作。我自己的实现是在 ~List() 里先 clear()然后 delete head最后将 head 置空防止野指针。3.6 迭代器失效问题插入操作不会使已有迭代器失效这是 list 相对于 vector 的一个巨大优势。因为插入不改变已有节点的地址迭代器保存的节点指针依然有效。但删除操作会让指向被删除节点的迭代器失效这是必然的。在使用erase后原来的迭代器就不要再用了这也是为什么 STL 的 erase 会返回下一个有效迭代器。在模拟实现里我还踩过另一个坑迭代器自增是用node node-next但如果 node 已经被删除这条链就断了。所以在写测试用例时删除后一定要接收返回值。4. 实操过程与核心环节实现4.1 第一阶段节点类和 List 类框架先写出最底层的节点以及 List 的成员变量和基础接口template typename T struct ListNode { ListNode* prev; ListNode* next; T data; ListNode(const T value T()) : prev(nullptr), next(nullptr), data(value) {} }; template typename T class List { public: using Node ListNodeT; List() { head new Node(); head-next head; head-prev head; size_ 0; } ~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; size_ 0; } size_t size() const { return size_; } bool empty() const { return size_ 0; } private: Node* head; size_t size_; };这个阶段先不实现迭代器push_back 用裸指针写一下先把链表跑通。我建议初学者不要一上来就写完整的模板先用 int 测通再模板化。但我这个示例直接模板化是没问题的因为 node 的 data 构造已经处理好默认值。4.2 第二阶段实现 push_back 和 push_front这两个函数是插入的基础也是后续 insert 的简化版。我用 push_back 举例void push_back(const T value) { Node* newNode new Node(value); Node* tail head-prev; newNode-next head; newNode-prev tail; tail-next newNode; head-prev newNode; size_; }push_front推导同理newNode 放到 head-next 的位置。注意每次插入后都要让 head 的 prev 或者 next 指向新节点保证环链完整。这里其实可以看到有了哨兵头尾插和头插的差别只是相对 head 的位置不同逻辑非常一致。4.3 第三阶段迭代器类完整实现迭代器是这次模拟的核心代码稍长值得逐行解释template typename T, bool IsConst false struct ListIterator { using Node ListNodeT; using ValueType typename std::conditionalIsConst, const T, T::type; using Pointer ValueType*; using Reference ValueType; Node* node; ListIterator(Node* n nullptr) : node(n) {} Reference operator*() const { return node-data; } Pointer operator-() const { return node-data; } ListIterator operator() { node node-next; return *this; } ListIterator operator(int) { ListIterator tmp *this; node node-next; return tmp; } ListIterator operator--() { node node-prev; return *this; } ListIterator operator--(int) { ListIterator tmp *this; node node-prev; return tmp; } bool operator(const ListIterator other) const { return node other.node; } bool operator!(const ListIterator other) const { return node ! other.node; } };这里的关键点是 operator* 根据 IsConst 决定返回 const T 还是 T。当 IsConsttrue 时ValueType 是 const TReference 就是 const T外部代码就无法通过*it xxx来修改数据。这个模式比复制两份代码清晰得多也是我建议大家尽量采用的方法。前置 和后置 的区别一定要理解。前置 返回引用后置 返回旧值的拷贝。后置 的实现代价更高因为它多了临时对象所以除非必要循环里用it而不是it。这不仅仅是一个风格问题在这里也是通用指导。4.4 第四阶段将迭代器接入 List 类在 List 类中定义两个类型别名using iterator ListIteratorT, false; using const_iterator ListIteratorT, true;然后实现 begin / end / cbegin / cenditerator 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); } const_iterator cbegin() const { return const_iterator(head-next); } const_iterator cend() const { return const_iterator(head); }注意 const 版本返回 const_iterator。对于 const 对象调用 begin() 会返回 const 类型的迭代器这样编译器就能阻止修改操作。如果没有这一步const List 对象照样可以通过 begin() 修改数据这是不合理的。4.5 第五阶段insert 和 erase 的统一封装有了迭代器insert 和 erase 更能体现 STL 的设计iterator insert(iterator pos, const T value) { Node* cur new Node(value); Node* p pos.node; cur-next p; cur-prev p-prev; p-prev-next cur; p-prev cur; size_; return iterator(cur); } iterator erase(iterator pos) { Node* p pos.node; Node* next p-next; Node* prev p-prev; prev-next next; next-prev prev; delete p; --size_; return iterator(next); }insert 返回新插入节点的迭代器erase 返回被删除节点之后的迭代器这样在循环中做条件删除非常方便。这也是为什么我在测试代码里会这样写for (auto it lst.begin(); it ! lst.end(); ) { if (*it % 2 0) { it lst.erase(it); } else { it; } }如果不用返回值删除后it就已经野了这也是 list 使用者最容易犯的错。4.6 第六阶段测试用例设计我写了四个测试维度构造与空判、插入与正反向遍历、拷贝构造与赋值、erase 迭代器失效验证。#include iostream #include cassert int main() { Listint lst; assert(lst.empty()); for (int i 0; i 5; i) lst.push_back(i); // 正向遍历 int index 0; for (auto it lst.begin(); it ! lst.end(); it) { assert(*it index); } // 反向遍历用 --end验证循环链 auto it lst.end(); --it; assert(*it 4); // 拷贝构造 Listint copy(lst); copy.push_back(100); assert(lst.size() 5); assert(copy.size() 6); // erase 奇偶 for (auto it lst.begin(); it ! lst.end(); ) { if ((*it) % 2 0) it lst.erase(it); else it; } // 剩下 1 和 3 assert(lst.size() 2); std::cout All tests passed. std::endl; return 0; }这个测试看似简单但覆盖了最重要的行为哨兵头节点正确性、循环链的边界条件、拷贝独立性、迭代器失效后的修复。如果这些测试全过说明你的模拟实现大体上符合 STL list 的核心语义。4.7 内存检测Linux 下我习惯用valgrind --leak-checkfull ./a.out来验证有没有内存泄漏。模拟实现写完后这一条必须跑。我初版写漏了析构函数释放所有节点valgrind 直接报出来几百个 lost 块。把它当成一个强制检查项比肉眼 review 代码可靠得多。5. 常见问题与排查技巧实录5.1 死循环遍历到哨兵头没有跳出很多人第一次写循环链表结束条件搞成节点指针为 null结果一跑就死循环。原因是哨兵头的 next 指向的是自己永远不可能为 null。正确的结束条件是和 end() 迭代器比较也就是当前节点等于 head。排查方法很简单打印每个节点的 data看看是否出现了未定义的值。因为对于 int 类型data 初始化可能是 0所以如果 end() 判断写错会多打一个 head 的温度数据。调试时我建议在 head 节点里放一个 magic number当然实际不能这么干或者直接打印地址看 current head 时跳出。5.2 段错误erase 后继续使用旧迭代器这是最常见也最隐蔽的问题。删除节点后迭代器指向的内存已经释放但迭代器本身还保存着这个悬空指针。如果你再调用it实际上是在访问已释放的节点指针轻则读取脏数据重则段错误。我的排查经验是在 erase 后立即加一行打印it.node的地址并在operator中断言node ! nullptr。这个防御式编程帮我在联调阶段省了很多时间。5.3 拷贝构造后互相影响如果忘记深拷贝只是把 head 指针复制过去那么两个 List 对象会共享同一个哨兵头不对更危险的是共享同一串数据节点。任何一个对象析构时会把另一个对象的数据也释放了接下来另一个对象的所有操作都是未定义行为。我在测试里用copy.push_back(100)然后验证原 lst 还是 5 个元素这一步是快速检验深拷贝是否生效的直观办法。如果你的实现只是浅浅地复制 head这一步会立刻暴露。5.4 什么时候需要写移动构造模拟实现基本只要求拷贝语义但工程上 list 这种资源管理类移动构造能显著提升性能特别是在函数返回一个大 list 时。C11 之后标准库容器都支持移动语义。我的模拟版补充了移动构造函数List(List other) noexcept : head(nullptr), size_(0) { swap(other); }这里利用了 other 即将析构的特性直接把它的内部指针窃取过来再让 other 处于空状态。因为临时对象的析构开销很低整个过程不需要深拷贝。这是值得加上的优化哪怕模拟实现也能加深对移动语义的认识。5.5 内存分配器的位置标准库 list 使用的是 allocator它的目的是为了内存池复用减少系统调用。模拟实现里我完全使用 new/delete对于学习原理足够了但如果你要在性能敏感场景使用类似容器需要研究一下内存池。我的体会是先用裸 new 把逻辑写对再优化内存管理。否则你可能在内存池的 bug 和链表 bug 之间来回挣扎。5.6 模板的编译错误怎么看模板代码的报错信息非常长跟希腊语一样。我的排查习惯是按模块隔离测试先用裸类型 int 测 List 的 push_back 和遍历如果通过再测试 List 看析构是否有问题最后测试自定义类。每一步都将错误范围缩小比在完整代码上盲目试错高效很多。6. 这个模拟实现还能怎么扩展写到这里基本的 list 已经立住了。如果要进一步逼近标准库有几个方向可以继续折腾实现merge、splice、sort、remove这类成员函数这能加深对 list 是链表特化容器的理解尤其sort不能用 std::sort 来对付非随机访问迭代器。加入emplace_back用变长模板和完美转发构造函数参数这能体会标准库如何避免临时对象的拷贝。把节点内存分配切换成简单的内存池检查性能变化你会直观感受到 list 的节点分配开销远远高于 vector 的连续块分配。测试list_iterator的std::bidirectional_iterator_tag特性把它和一个通用算法库结合验证迭代器分类的作用。我自己在写完这个模拟之后再去看libstdc的_List_base和_List_node基本上能看懂全貌了。之前觉得绕的_M_hook和_M_unhook其实就是教科书里那几行指针接线的底层封装方法。就我个人的实际体验来说手写一个容器模拟最大的收获不在代码量而在于它让你把迭代器是容器和算法之间的桥梁这句话变成肌肉记忆。裸指针解决不了链表迭代必须通过对象封装对象一旦被设计出来const 版本的问题、值语义的问题、异常安全的问题就会接连出现。这些恰恰是 STL 设计的精华。最后再分享一个小技巧如果编译环境支持 C11 或更新建议自己加static_assert(std::is_default_constructibleT::value, )之类的编译期约束能在模板实例化时报出更明确的错误。写容器模拟好的诊断信息能给你省大半天的调试时间。
返回列表