ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

手写C++ list:从节点设计到迭代器封装的完整实现

2026/9/28 7:10:20 拓冰建站 浏览量
手写C++ list:从节点设计到迭代器封装的完整实现 学C的人迟早会撞上一堵墙叫“STL源码”。我之前带学生刷C基础时讲list特别省事push_back一下、sort一下容器而已谁不会直到某天让所有人动手模拟实现一个list几乎一半人当场卡住——不是不会写链表而是突然意识到迭代器居然是一个类而不是原生指针头结点居然指向自己erase之后还要小心翼翼地接住返回值。这篇文章就是那次动手过程的完整复盘。我会从底层结点设计讲到迭代器封装再讲到插入删除、深拷贝和内存管理把每一行代码背后的“为什么”都掰开说清楚。适合已经学过类、模板、指针和运算符重载的C初学者也适合准备面试C岗位、想补容器底层知识的人。1. 为什么要自己模拟实现 list1.1 vector 与 list 的本质差异初学C时很多人把vector和list当成“都能存一堆数据的东西”。但它们的底层逻辑完全不同直接决定了各自的适用场景对比维度vectorlist底层结构连续数组双向循环链表随机访问O(1)支持[]O(n)不支持[]头部/中部插入删除O(n)需要搬移元素O(1)只改指针尾部插入均摊O(1)O(1)迭代器类型原生指针封装类缓存命中率高低内存碎片少每个结点独立分配模拟实现list的主要价值不在于“我实现了一个能用的容器”而在于逼你面对几个真正硬核的C问题结点之间用指针怎么串起来为什么迭代器不能直接用Node*模板类里怎样复用一套迭代器代码来同时支持普通和const语义深拷贝、赋值、析构这三件套到底怎么配合这些恰好是C从“会写”到“懂原理”的分水岭。1.2 选型背后的关键决策我在教学版里选择了“带头结点的双向循环链表”而不是教科书里常见的单链表或无头双向链表。原因有三第一双向循环让任意位置插入、删除的时间复杂度都是O(1)不需要像单链表那样为了删某个结点去遍历找前驱。第二带头结点可以统一空链表和非空链表的逻辑。空链表只有一个_head它的_next和_prev都指向自己插入和删除都不用单独判断“是不是空表”。第三循环结构让end()天然就是_head遍历时it ! end()判断的就是“回到头结点”这正好匹配STL中end()是尾后位置的设计。我之前也试过不带头结点的版本写插入删除时满脑子都是“如果pos是begin()怎么办”“如果链表为空怎么办”写到最后代码里全是if分支。换成带头结点后这些边界判断直接消失代码清爽太多了。1.3 整体架构与文件组织这个项目的代码可以分成三个层次从内到外是结点类ListNodeT只负责保存数据T _data和两个指针_prev、_next。迭代器类ListIteratorT, Ref, Ptr把结点指针封装成“迭代器语义”重载*、-、、--、、!。容器类listT对外提供begin、end、insert、erase、push_back等接口内部通过迭代器与结点打交道。工程上建议拆成list.h或Node.h、ListIterator.h、list.h三个头文件和main.cpp。但为了在一屏内看全整体结构我文章里的代码按逻辑顺序摆在一起你练习时可以按这个顺序敲进去编译。2. 底层结点与链表的建立2.1 结点长什么样结点是链表的最小单元定义如下namespace cjx { templateclass T struct ListNode { ListNodeT* _next; ListNodeT* _prev; T _data; ListNode(const T val T()) : _next(nullptr) , _prev(nullptr) , _data(val) {} }; }这里用struct而不是class因为结点内部字段希望对list类和迭代器类直接暴露写成struct省的private/public来回折腾。_next和_prev是结构里的前后指针_data是元素本体。构造函数里给了const T val T()这个默认值目的是让new ListNodeT()在没有显式传参时也能构造出哨兵结点。比如_head new Node;这时_data就是一个默认构造的T对象它后面不会参与真正的数据访问。2.2 链表的初始化空链表list类内部只需要一个成员Node* _head。无参构造函数要让链表处于“空但可用”的状态list() { _head new Node; _head-_next _head; _head-_prev _head; }这一步执行完后链表的图形是_head - [prev|data|next] ^ | -----------_head的_next和_prev都指向自己。向空链表插入第一个元素时insert(end(), val)就是在_head前面插入结果是head - node1 - head不需要任何特殊处理。这里的“为什么”值得多想一层如果不带头结点空链表的begin()就没有结点可返回所有操作都要判断“我是不是第一次插入”。而带头结点后空链表也有一个确定的地址begin()返回_head-_nextend()返回_head一切自然成立。2.3 为什么选择带头结点可能你会觉得多一个结点浪费内存。实际上这个结点只占一份指针和数据的内存换来的是代码分支大量减少。我带学生做过对比无头结点的listpush_back里至少要写if (_head nullptr) { _head new Node; ... } else { tail-_next newNode; ... }每个插入点都要照顾“首尾特判”。带头结点后insert和erase可以做到零分支。另一个角度是STL标准中end()返回的是最后一个元素之后的位置而链表本身并没有“最后一个元素之后”的物理地址。带头结点的循环链表把_head当作这个哨兵位置完美贴合了STL的区间语义[begin(), end())左闭右开。3. 迭代器最难也最值得写的一层3.1 为什么不能用 Node* 当迭代器vector的底层是连续数组所以它的迭代器可以直接用T*。但list的结点散落在堆上结点之间靠指针连接地址不连续。如果直接把Node*当作迭代器it会被编译器翻译成“指针向后走一个结点大小的字节数”结果你跑到一块和链表毫无关系的内存上。所以要实现一个迭代器类把、--、、!、*、-这些操作符全部重写为“沿着链表指针移动”或“访问结点内部数据”。对外表现上使用方式与vector迭代器完全一致这是STL“封装变化”思想的体现。3.2 迭代器类的模板设计与运算符重载下面这个类是整个模拟实现里信息密度最高的部分templateclass 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-_data; } Ptr operator-() { return (_node-_data); } 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; } };三个模板参数T, Ref, Ptr里T是元素类型Ref是“引用类型”Ptr是“指针类型”。普通迭代器传ListIteratorT, T, T*const迭代器传ListIteratorT, const T, const T*。operator*返回Refoperator-返回Ptr是本模板设计的精髓同一份代码实例化为普通迭代器时解引用返回可修改引用实例化为const迭代器时返回const引用外部尝试*it 1直接编译报错。后置为什么要在参数列表里写一个int这是C运算符重载的约定后置版本通过一个哑元参数与前置版本区分。后置的返回值为什么是Self而不是Self因为返回的必须是自增前的旧状态而旧状态的迭代器是一个临时对象返回引用就会悬空。operator和operator!比较的是结点地址不是_data值。这意味着两个迭代器只有指向同一个结点时才相等指向两个数据相同的不同结点也不相等这符合STL迭代器的语义。3.3 从 iterator 到 const_iterator 的一行复用在list类里给迭代器起别名typedef ListIteratorT, T, T* iterator; typedef ListIteratorT, const T, const T* const_iterator;这是教科书里经常略过但实际极其重要的技巧不需要单独写一个ConstListIterator类。真实STL源码在C98时代就是通过Ref、Ptr这两个模板参数复用同一份迭代器实现到了C11之后又引入了更复杂的萃取机制但初阶阶段掌握这个三参数模式已经完全够用。const版本的begin()和end()要注意返回类型const_iterator begin() const { return const_iterator(_head-_next); } const_iterator end() const { return const_iterator(_head); }const成员函数里只能调用const版本所以size()这样的只读方法通过const_iterator遍历时外部拿到的是不可修改的元素引用。4. 核心接口插入、删除、构造与析构4.1 insert 与 erase弄清楚返回值insert在pos之前插入一个新结点并返回新插入结点的迭代器iterator insert(iterator pos, const T val) { Node* cur pos._node; Node* prev cur-_prev; Node* newNode new Node(val); newNode-_next cur; cur-_prev newNode; prev-_next newNode; newNode-_prev prev; return iterator(newNode); }写这四步链接操作时最容易出错的点一定要先让newNode的两个指针指向cur和prev再去修改cur-_prev和prev-_next。如果你先把prev-_next改成newNode后续再想取prev的旧下一个结点就丢了。erase删除pos指向的结点并返回被删结点之后那个结点的迭代器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一定要返回下一个结点的迭代器。原因在于pos指向的结点被delete后外部迭代器pos立刻失效。如果你在循环里执行lt.erase(it)但不接收返回值下一步it访问的就是已经释放的内存这是野指针操作。4.2 push/pop 系列的复用有了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()); }注意pop_back里为什么要写成--end()而不是end()因为end()是哨兵结点不是最后一个元素。--end()才是真正的最后一个数据结点。这个细节也印证了带头结点设计的语义统一性哨兵位置永远作为“尾后”存在。4.3 构造与析构深拷贝和三件套无参构造已经写过了。接下来看迭代器区间构造templateclass InputIterator list(InputIterator first, InputIterator last) { _head new Node; _head-_next _head; _head-_prev _head; while (first ! last) { push_back(*first); first; } }很多初学者在这里犯的错误是先while循环push_back最后才_head new Node。这不行因为push_back内部会调用end()而end()依赖_head已经被初始化。所以构造函数的初始化顺序必须是先建好哨兵结点再往哨兵后面挂数据。拷贝构造要解决深拷贝问题list(const listT lt) { _head new Node; _head-_next _head; _head-_prev _head; for (const auto e : lt) push_back(e); }这里必须逐个访问lt的元素并push_back到新链表。为什么不能让it lt._head然后逐个结点地址复制因为那样两个list会共享同一批结点任何一个list析构时都会删除所有结点另一个list的指针变成悬空程序直接崩溃。深拷贝意味着每个结点都在堆上独立分配。赋值运算符重载我推荐“传值swap”的现代写法void swap(listT lt) { std::swap(_head, lt._head); } listT operator(listT lt) { swap(lt); return *this; }这里lt是传值参数函数调用时已经通过拷贝构造生成了一份全新的深拷贝。把_head和lt._head交换后当前对象的旧链表资源就转移到了lt身上函数结束lt析构自动释放。这段代码既处理了深拷贝又天然保证异常安全是C11之后非常推荐的赋值写法。析构函数负责回收所有资源~list() { clear(); delete _head; _head nullptr; }clear()把数据结点删干净delete _head释放哨兵结点。如果漏掉delete _head每构造一个list就泄漏一份堆内存如果漏掉clear()那么所有数据结点全泄漏。4.4 完整代码工程把前面所有片段拼起来就是一份可直接编译的list.h。我建议你按下面顺序放进文件#include cstddef namespace cjx { templateclass T struct ListNode { ... }; templateclass T, class Ref, class Ptr struct ListIterator { ... }; templateclass T class list { ... }; }类里别忘了size()、empty()这类基础接口。size()的实现很简单从头开始遍历到哨兵结点计数即可。对于链表O(n)的size()是正常的不要试图追求和vector一样的O(1)。5. 实操验证测试用例、编译与调试5.1 用一段测试代码把功能跑一遍写代码容易写对代码不容易。我在测试时用下面这段覆盖了主要功能点#include iostream #include string #include list.h int main() { cjx::listint lt; for (int i 1; i 5; i) lt.push_back(i * 10); cjx::listint::iterator it lt.begin(); while (it ! lt.end()) { std::cout *it ; it; } std::cout std::endl; lt.push_front(0); lt.pop_back(); lt.insert(lt.begin(), 15); lt.erase(lt.begin()); for (auto it lt.begin(); it ! lt.end(); it) std::cout *it ; std::cout std::endl; cjx::listint copy lt; for (const auto e : copy) std::cout e ; std::cout std::endl; cjx::liststd::string names; names.push_back(C); names.push_back(list); for (auto it names.begin(); it ! names.end(); it) std::cout *it ; std::cout std::endl; return 0; }我在测试里放了一个cjx::liststd::string目的是验证模板对非内置类型也能正常工作。很多初学者的模拟实现只测int换到std::string就暴露出一堆问题比如忘记处理const T深拷贝或者operator*返回了临时值导致std::cout *it无法编译。编译命令在Linux下用g -stdc11 -O0 -g main.cpp -o main ./main建议-O0和-g都打开方便调试。Windows下用Visual Studio直接建立空项目把list.h和main.cpp拖进去F5运行即可。5.2 “画链表”调试法链表调试的难点在于指针关系藏在内存里眼睛看不到。我的经验是遇到问题不要先盯着代码看先拿纸笔把链表的结点和指针画出来。比如插入一个结点画三个格子prev - [prev|data|next] - [cur|data|next] new - [prev|data|next]然后把你代码里的四步操作箭头一步步画上去。多数插入错误都是因为顺序问题导致某个指针被覆盖画一遍立刻就能看出来。Visual Studio监视窗口里可以添加表达式lt._head-_next-_data直接观察第一个元素也可以在迭代器变量上展开_node-_prev、_node-_next按箭头一个个追踪。Linux下用gdb的话p lt._head-_next-_data同样好用。5.3 常见内存问题的定位链表代码一大半问题都出在内存管理上。如果程序运行崩溃优先怀疑下面三种情况第一erase后继续使用被删迭代器。此时_node指向已释放内存或*都是非法访问。要在erase之后用返回值继续迭代。第二忘了写拷贝构造或赋值重载。两个list共享结点析构时同一内存被delete两次。Windows下表现为弹出“试图释放无效堆块”Linux下表现为double free or corruption。第三clear()里用while循环误写成it。如果你在erase前先那么被删结点的_next已经被释放访问的是野指针。正确写法是it erase(it);利用返回值跳到下一个有效结点。内存泄漏的排查工具我在Linux下用valgrindvalgrind --leak-checkfull ./main如果是Windows在main开头加上_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);程序退出后输出Detected memory leaks则会打印泄漏点。6. 常见问题与避坑实录6.1 迭代器失效问题迭代器失效是STL容器的高频考点也是模拟实现中最容易踩的坑。vector中插入或删除元素会让之后的所有迭代器失效因为底层数组可能重新分配或搬移。list则不同结点是独立分配的插入删除只影响被删除的那个结点其他迭代器依然有效。但“依然有效”不代表“没有坑”。最常见的错误是在循环里直接erase而不接收返回值// 错误写法 for (auto it lt.begin(); it ! lt.end(); it) { if (*it % 2 0) lt.erase(it); // it已被释放下面的it是野指针 }// 正确写法 auto it lt.begin(); while (it ! lt.end()) { if (*it % 2 0) it lt.erase(it); else it; }这个现象背后就是对删除逻辑的理解erase返回下一个有效位置是专门为了解决“删除后如何继续遍历”设计的标准用法。C11标准里vector、list的erase返回值都是如此。6.2 拷贝构造不写导致浅拷贝崩溃我见过太多初学者的list模拟实现只写了push_back和begin没有拷贝构造。然后写cjx::listint lt2 lt1;程序要么运行时直接崩要么析构时双重delete。原因很简单默认拷贝构造是浅拷贝两个list的_head指向同一份结点。lt2析构时先把结点都删了lt1析构时又删一遍同一块内存。这也是我在教学里反复强调“三件套要一起写”的原因拷贝构造、赋值运算符重载、析构函数这三个接口必须同时考虑。只要你的类里有指针成员默认版本大概率不够用。6.3 哨兵结点与 end() 的边界end()返回哨兵结点这个位置不能直接解引用。我见过学生写*lt.end()想拿最后一个元素结果拿到一个默认构造的T对象或者每次运行结果不确定。正确取最后一个元素的方式是*(--lt.end())。还有size()或empty()依赖于哨兵结点的指针关系。写empty()时不要自己数个数直接判断bool empty() const { return _head-_next _head; }这样O(1)完成而且语义非常清晰空链表的第一个数据结点就是哨兵自己。6.4 是否需要自己实现排序很多初学者模拟list到一个阶段后急着想给list写sort。我建议先不要写。原因在于list不能使用标准库的std::sort因为它要求随机访问迭代器STL为list单独提供了成员函数list::sort底层是归并排序。如果你对归并排序还不熟练强行写一个O(n²)的冒泡排序在链表上性能会很差。把基础的插入删除和迭代器彻底理解消化排序可以作为下一步的扩展练习。6.5 进阶练习建议做完基础模拟后可以按难度递增挑战下面几个方向给迭代器增加operator-、operator对于双向链表没有意义但你可以思考为什么标准库仍要求list迭代器是双向迭代器。增加rbegin()、rend()反向迭代器。这需要再写一个反向迭代器适配层强烈建议试一试。把结点分配从new改为malloc加显式构造。真实STL为了支持无默认构造类型并不直接在结点里构造数据对象而是用allocator分配原始内存再通过construct在指定位置构造对象。你不需要复刻整个allocator但可以自己实现一个最简单的版本试试。最后可以做一个小型性能对比同样插入100万个整数到vector和list分别比较尾部插入、头部插入、中部插入的时间你会对“哪个容器更快”有非常直观的认识。7. 我踩过的几个坑以及给你的建议模拟实现list这个项目我前后带过好几轮学生自己也重写过很多遍。回头看最值得注意的不是代码本身而是过程中的几个习惯。第一个习惯是“每写一个函数立刻测试”。不要等把所有接口写完再一起编译。链表这类指针密集的代码一次写完几十个函数再调试报错信息会叠在一起你根本分不清是哪一步把指针关系搞坏了。我通常写一个push_back就立刻用遍历把它打出来再写insert再测。第二个习惯是“大胆用for (const auto e : lt)来遍历const容器”。很多初学者以为范围for只能在真正的数组上用其实只要类提供了begin()和end()成员函数以及迭代器的!、、*就能被范围for识别。用const迭代器测试一遍是检验模板Ref、Ptr设计是否正确的有效手段。第三个习惯是“画图优先”。我调试链表时90%的问题最后都是靠纸笔定位的。看到指针乱了先画出插入/删除前后应有的状态再对比你的代码在哪一步把指针覆盖了问题基本秒解。代码里加std::cout打印每个结点的三个字段也是一种办法但打印一堆地址远不如图形直观。如果你严格按照这个思路写一遍list完成后最大的收获不是“我会手写链表了”而是你对STL容器、迭代器、模板和内存管理这几个C核心概念的理解会突然串成一条线。之前零散的知识会在这次模拟中完成第一次真正的缝合。