尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++手写list容器:哨兵节点、迭代器与insert/erase核心设计

C++手写list容器:哨兵节点、迭代器与insert/erase核心设计 手写list容器几乎是我面试候选人时的必考题。说实话我自己第一次在45分钟内从头写一个能跑的list时也磕磕绊绊——不是不会用std::list而是一旦要自己从空链表开始设计节点、迭代器、insert/erase返回值时才发现我对STL的理解有多少是“用过”多少是“真的懂”。这篇把从零实现C自定义list容器的完整思路、可运行代码和踩坑记录都放出来重点讲清楚每个设计决策背后的理由而不是贴一段代码让你抄完就忘。1. 动手之前先把这几个设计问题想清楚1.1 为什么要自己写一个list而不是直接用std::list这个问题很多人没认真想过。用std::list确实简单但你写一遍自定义list之后会对几个底层问题有完全不同的体感第一迭代器为什么能一直持有一个元素而不失效。vector的迭代器在扩容后会全部失效list的迭代器却可以长期保存原因是每个节点独立堆分配地址稳定。自己实现一遍你对“迭代器失效”这件事的理解就不再是背规则而是看得到根因。第二insert和erase为什么是O(1)操作。链表插入只需要改相邻节点的指针不涉及数据搬移。这个特性看起来简单但只有自己写过四步链接、处理过空链表边界才会真正意识到它和vector的insert在底层逻辑上的天壤之别。第三面试场景非常爱考手写容器。不止是list还包括vector、map、shared_ptr。你如果只是会用STL面试官很难判断你的C功底但你能当堂画出一个节点结构、解释哨兵节点的作用、写出erase后返回新迭代器的代码这本身就是一种能力证明。1.2 设计范围与三个关键取舍动手前我先列了接口清单决定做到什么程度构造/析构默认构造、拷贝构造、拷贝赋值、析构。元素访问front、back。修改操作push_back、push_front、pop_back、pop_front、insert、erase、clear。容量与状态size、empty。迭代器begin、end以及对应的const版本。我没有去做emplace、splice、sort等进阶接口原因是想把核心骨架讲透。splice本质是“摘节点再挂节点”理解指针操作后就是体力活emplace则涉及完美转发和就地构造是另一个层面的复杂度。先把基础版本吃透再往外扩会轻松很多。设计上有三个关键取舍我特意在这里说明理由内存策略直接用new/delete不用std::allocator。这样代码最直观核心是展示链表指针操作而不是STL内存池的复杂机制。size维护方式用一个size_t成员实时维护计数而不是每次调用size()时遍历整条链。原因很简单list的size()是O(1)这是标准要求遍历得出结果的做法写出来会被面试官直接挂掉。异常安全级别拷贝赋值采用copy-and-swap手法保证强异常安全插入/删除操作保证基本异常安全。具体原理在第4节展开。1.3 一个重要的简化说明我实现的哨兵节点也持有一个T对象这意味着T需要可默认构造。标准库通常用双层节点结构node_base只存指针value_node再挂数据来规避这个问题但那样代码复杂度会明显上升。为了把核心逻辑讲清楚我做这个取舍并在实际使用时留意。如果你对这个问题在意可以在看完本文骨架后自己把Node结构改成“基类指针 派生类数据”的方式这是个不错的进阶练习。2. 哨兵节点与迭代器先把骨架搭对2.1 节点结构prev、data、next双向链表的节点设计是所有操作的基础struct Node { T data; Node* prev; Node* next; explicit Node(const T value T()) : data(value), prev(nullptr), next(nullptr) {} };每个节点在堆上独立new出来三个成员分别是数据、前驱指针、后继指针。这里有个新手容易忽略的点Node构造函数里必须把prev和next初始化为nullptr否则会出现野指针。很多崩溃都出在这种看似不起眼的初始化上。我见过有人这样写节点struct Node { T data; Node* prev; Node* next; };然后在使用时忘记初始化后果就是后续所有指针操作全部读到垃圾地址。记住任何指针成员要么在构造函数里初始化要么在创建后立刻赋值没有第三种选择。2.2 哨兵节点为什么能救空链表很多初版实现会在list类里放head和tail两个指针然后所有操作都要特判“链表为空”的情况。空链表时head和tail都是nullptrpush_back你得特殊处理pop_back又得特殊处理遍历还得换一种方式。代码里到处都是if (head nullptr)又容易漏又难读。std::list的标准解法是引入哨兵节点sentinel node。它的核心思想是链表里永远有一个节点存在这个节点不存实际业务数据只是作为链表的“边界标识”。设计成环形_sentinel-next _sentinel; _sentinel-prev _sentinel;空链表就是一个指向自己的哨兵节点而哨兵的地址永远不变。这样一来begin()就是_sentinel-next不需要判断是否为空。end()就是_sentinel本身迭代器到哨兵即结束。第一个元素的prev永远指向_sentinel最后一个元素的next永远指向_sentinel。哨兵节点的好处是把所有边界情况都统一成了普通情况。你可以把链表想象成一个圆环哨兵就是环上的一个固定标记点业务数据节点都排在它两边。遍历时从标记点出发转一圈回到标记点就结束。空链表就是环上只有标记点自己。2.3 迭代器实现只存节点指针就够了吗迭代器本质上是节点指针的封装但只存指针还不够还要重载那一组操作符让它具备“像个指针”的语义class iterator { public: explicit iterator(Node* node nullptr) : _node(node) {} T operator*() const { return _node-data; } T* operator-() const { return (_node-data); } iterator operator() { _node _node-next; return *this; } iterator operator--() { _node _node-prev; return *this; } bool operator(const iterator other) const { return _node other._node; } bool operator!(const iterator other) const { return _node ! other._node; } Node* node() const { return _node; } private: Node* _node; };注意operator和operator--只操作内部指针不做任何边界检查。这是因为我们约定当迭代器到达哨兵节点时循环自然结束也就是it ! end()的判断会返回false。如果用户手动把迭代器加过头那属于未定义行为标准库同样不做检查。operator和operator--返回的是引用还是值有讲究。前置版本返回引用避免拷贝后置版本因为要返回修改前的状态必须返回一个临时对象。我实现了后置版本是因为范围for循环和某些通用代码会用到它。很多人手写迭代器时只写前置结果发现编译不过就是因为漏了后置版本。const_iterator的原理和iterator一样区别只在于解引用返回const T并且存储的是const Node*。当list对象是const的时候begin()和end()要返回const_iterator否则外部就无法通过const对象获得只读访问能力。3. 核心接口实现从push_back到insert/erase3.1 push系列四步链接法的顺序是命门push_back的完整实现如下void push_back(const T value) { Node* node new Node(value); node-prev _sentinel-prev; node-next _sentinel; _sentinel-prev-next node; _sentinel-prev node; _size; }这四步链接的顺序很关键。我的习惯是先把新节点自己的prev和next都设置好。再让旧最后一个节点_sentinel-prev的next指向新节点。最后更新哨兵的prev指向新节点。这个顺序能保证任何时刻不会出现“悬空指针”也不会丢失链表尾部的引用。如果你乱改顺序比如先把_sentinel-prev node做了然后才去设置_sentinel-prev-next那这时_sentinel-prev已经是新节点了你等于是把新节点的next指向了它自己链表直接断掉。push_front就是镜像操作把_sentinel-next相关的链路反过来处理void push_front(const T value) { Node* node new Node(value); node-prev _sentinel; node-next _sentinel-next; _sentinel-next-prev node; _sentinel-next node; _size; }因为有了哨兵push_back和push_front甚至不需要判断链表是不是空。这就是前面说的“哨兵统一了边界情况”。pop操作则要反过来先把目标节点的前后节点链接好再delete掉目标节点void pop_back() { if (empty()) throw std::out_of_range(pop_back on empty list); Node* old _sentinel-prev; old-prev-next _sentinel; _sentinel-prev old-prev; delete old; --_size; } void pop_front() { if (empty()) throw std::out_of_range(pop_front on empty list); Node* old _sentinel-next; old-next-prev _sentinel; _sentinel-next old-next; delete old; --_size; }注意我在空链表pop时抛异常。标准库这里其实是未定义行为用户自己保证不越界但作为一个教学容器抛出异常能让错误提前暴露调试的时候友好很多。3.2 insert把“前插”抽象成通用操作insert的语义是在指定迭代器之前插入新节点返回指向新节点的迭代器iterator insert(iterator pos, const T value) { Node* current pos.node(); Node* node new Node(value); node-prev current-prev; node-next current; current-prev-next node; current-prev node; _size; return iterator(node); }写成“在pos前插入”是因为它同时支持头插和尾插插入到begin()位置等价于push_front。插入到end()位置等价于push_back。插入到中间就是真正意义的insert。所以你不需要单独为头插和尾插各写一套插入逻辑只要旋转换算成对应的迭代器位置就行。为什么要返回新节点的迭代器因为调用者插入之后很可能马上要操作新节点比如更新数据、再插一个等。如果insert返回void调用者要么重新查找要么无法访问新节点非常别扭。3.3 erase返回值为什么标准库非要给你一个新迭代器erase的实现如下iterator erase(iterator pos) { if (pos end()) throw std::out_of_range(erase on end()); Node* current pos.node(); Node* next current-next; current-prev-next next; next-prev current-prev; delete current; --_size; return iterator(next); }返回值是被删除节点的下一个节点。这样设计是因为erase之后传入的pos迭代器已经指向被释放的内存继续使用就是未定义行为。但调用者通常还需要继续遍历链表或者需要一个有效的迭代器来判断循环是否结束。如果erase返回void调用者就必须在erase前手动保存next迭代器否则循环就会崩// 错误写法erase后继续用it for (auto it lst.begin(); it ! lst.end(); it) { if (*it % 2 0) lst.erase(it); // it已失效it是未定义行为 }正确写法应该是auto it lst.begin(); while (it ! lst.end()) { if (*it % 2 0) { it lst.erase(it); } else { it; } }这个返回值不是可有可无的优化而是保证链表删除操作可用的必要条件。3.4 拷贝构造、赋值与析构哨兵初始化顺序拷贝构造最容易翻车的点是忘记初始化哨兵就直接push。如果_sentinel没有分配内存push_back里的_sentinel-prev就是访问野指针程序直接崩。正确做法是先调用默认构造函数完成哨兵初始化再把other里的数据逐个push_backlist(const list other) : list() { for (const auto value : other) { push_back(value); } }这样写简洁且安全。如果T的拷贝构造抛出异常当前对象的构造也失败了但已经分配的资源会被析构函数回收不会泄漏。拷贝赋值我使用copy-and-swaplist operator(const list other) { if (this other) return *this; list tmp(other); std::swap(_sentinel, tmp._sentinel); std::swap(_size, tmp._size); return *this; }这个手法的原理是先拷贝一份临时对象再把临时对象的内部状态和当前对象交换。这样一来赋值操作要么完全成功要么当前对象保持原样不会出现“改了一半”的中间状态。临时对象析构时会带走旧数据所以原来的节点也被正确释放了。clear和析构的实现则是把整条链上的数据节点全部delete最后再删除哨兵void clear() { Node* cur _sentinel-next; while (cur ! _sentinel) { Node* next cur-next; delete cur; cur next; } _sentinel-next _sentinel; _sentinel-prev _sentinel; _size 0; } ~list() { clear(); delete _sentinel; }这里有个细节clear里用next提前保存下一个节点地址防止delete当前节点后无法再访问后继指针。这是遍历删除节点的标准姿势写顺手之后基本不会错但手撕代码时容易因为紧张漏掉。4. 迭代器失效与异常安全最容易翻车的两个点4.1 失效规则list的优势和隐藏陷阱写自定义list时迭代器失效问题必须内化成直觉。list的失效规则和vector完全不同操作listvectorinsert不影响任何已有迭代器扩容后所有迭代器失效erase仅被删除元素的迭代器失效被删除元素之后的迭代器全部失效push_back/push_front不影响已有迭代器扩容后所有迭代器失效end()不会被push/insert改变扩容后可能改变根因就在于list的节点是独立堆分配的。insert只是原地插入一个新节点指针旧节点的地址没变erase虽然释放了目标节点内存但其他节点的地址也没变。而哨兵节点的地址在整个容器生命周期内固定不变所以end()也一直稳定。但这里有一个隐藏陷阱erase后被删除节点的迭代器失效了可它看起来可能还“能访问”。因为内存释放后这块堆内存可能暂时还没被重新分配你读原来的data还能读出旧值。这种“幽灵读”极具误导性尤其在debug版本和release版本行为不一致。所以写代码时不要依赖失效迭代器去读取数据来验证是否被删除那是未定义行为迟早踩坑。另外还要注意erase的返回值在极端边界下的行为如果删除的是最后一个有效节点返回值是end()也就是哨兵。循环判断时会自然退出。4.2 单元操作的异常安全new失败不能破坏链表说到异常安全就要引出C的一个经典哲学异常是常态不是事故。以push_back为例Node* node new Node(value); // 如果这里抛出 bad_alloc node-prev _sentinel-prev; node-next _sentinel; _sentinel-prev-next node; _sentinel-prev node; _size;我把new放在所有链表操作之前。这样如果new抛出bad_alloc链表完全没被改动整个容器保持原状。这就是基本异常安全——至少不会让容器处在半破坏状态。但如果我在链接操作做到一半的时候T的拷贝构造抛异常情况就复杂了。严格来说要保证强异常安全操作要么完全成功要么容器保持原状需要在链接前先完成所有可能抛异常的操作链接阶段只做不会抛异常的指针赋值。T的拷贝构造发生在new Node(value)这一步而这一步在链接之前所以我们的实现实际上是能保证push_back的强异常安全性的。erase和pop操作更有意思它们内部不构造新对象理论上不会抛异常。delete操作本身不抛异常而前置的empty检查抛出的out_of_range是调用者自己触发的。所以在“参数合法”的前提下erase和pop都不会抛异常这一点对写无异常环境下的代码很重要。5. 完整可运行代码与测试结果5.1 完整实现C11可直接编译下面这版代码我把上面所有设计串起来放在一个头文件里可以直接编译使用。为了简洁Node定义成了public嵌套类真实项目里通常会藏到private并用friend解决迭代器访问问题但这里是教学场景可读性优先。#include iostream #include utility #include stdexcept templatetypename T class list { public: struct Node { T data; Node* prev; Node* next; explicit Node(const T value T()) : data(value), prev(nullptr), next(nullptr) {} }; class iterator { public: explicit iterator(Node* node nullptr) : _node(node) {} T operator*() const { return _node-data; } T* operator-() const { return (_node-data); } iterator operator() { _node _node-next; return *this; } iterator operator(int) { iterator tmp *this; _node _node-next; return tmp; } iterator operator--() { _node _node-prev; return *this; } iterator operator--(int) { iterator tmp *this; _node _node-prev; return tmp; } bool operator(const iterator other) const { return _node other._node; } bool operator!(const iterator other) const { return _node ! other._node; } Node* node() const { return _node; } private: Node* _node; }; class const_iterator { public: explicit const_iterator(const Node* node nullptr) : _node(node) {} const T operator*() const { return _node-data; } const T* operator-() const { return (_node-data); } const_iterator operator() { _node _node-next; return *this; } const_iterator operator(int) { const_iterator tmp *this; _node _node-next; return tmp; } const_iterator operator--() { _node _node-prev; return *this; } const_iterator operator--(int) { const_iterator tmp *this; _node _node-prev; return tmp; } bool operator(const const_iterator other) const { return _node other._node; } bool operator!(const const_iterator other) const { return _node ! other._node; } const Node* node() const { return _node; } private: const Node* _node; }; list() : _sentinel(new Node()), _size(0) { _sentinel-next _sentinel; _sentinel-prev _sentinel; } list(const list other) : list() { for (const auto value : other) { push_back(value); } } list operator(const list other) { if (this other) return *this; list tmp(other); std::swap(_sentinel, tmp._sentinel); std::swap(_size, tmp._size); return *this; } ~list() { clear(); delete _sentinel; } void push_back(const T value) { Node* node new Node(value); node-prev _sentinel-prev; node-next _sentinel; _sentinel-prev-next node; _sentinel-prev node; _size; } void push_front(const T value) { Node* node new Node(value); node-prev _sentinel; node-next _sentinel-next; _sentinel-next-prev node; _sentinel-next node; _size; } void pop_back() { if (empty()) throw std::out_of_range(pop_back on empty list); Node* old _sentinel-prev; old-prev-next _sentinel; _sentinel-prev old-prev; delete old; --_size; } void pop_front() { if (empty()) throw std::out_of_range(pop_front on empty list); Node* old _sentinel-next; old-next-prev _sentinel; _sentinel-next old-next; delete old; --_size; } iterator insert(iterator pos, const T value) { Node* current pos.node(); Node* node new Node(value); node-prev current-prev; node-next current; current-prev-next node; current-prev node; _size; return iterator(node); } iterator erase(iterator pos) { if (pos end()) throw std::out_of_range(erase on end()); Node* current pos.node(); Node* next current-next; current-prev-next next; next-prev current-prev; delete current; --_size; return iterator(next); } void clear() { Node* cur _sentinel-next; while (cur ! _sentinel) { Node* next cur-next; delete cur; cur next; } _sentinel-next _sentinel; _sentinel-prev _sentinel; _size 0; } iterator begin() { return iterator(_sentinel-next); } iterator end() { return iterator(_sentinel); } const_iterator begin() const { return const_iterator(_sentinel-next); } const_iterator end() const { return const_iterator(_sentinel); } const_iterator cbegin() const { return const_iterator(_sentinel-next); } const_iterator cend() const { return const_iterator(_sentinel); } bool empty() const { return _size 0; } size_t size() const { return _size; } T front() { if (empty()) throw std::out_of_range(front on empty list); return _sentinel-next-data; } const T front() const { if (empty()) throw std::out_of_range(front on empty list); return _sentinel-next-data; } T back() { if (empty()) throw std::out_of_range(back on empty list); return _sentinel-prev-data; } const T back() const { if (empty()) throw std::out_of_range(back on empty list); return _sentinel-prev-data; } private: Node* _sentinel; size_t _size; };5.2 测试用例与预期输出测试代码覆盖了空链表、push系列、insert、erase、拷贝、异常路径这些核心场景#include cassert #include iostream int main() { listint lst; assert(lst.empty()); assert(lst.size() 0); lst.push_back(10); lst.push_front(20); lst.push_back(30); assert(lst.size() 3); assert(lst.front() 20); assert(lst.back() 30); std::cout 初始元素: ; for (int v : lst) std::cout v ; std::cout std::endl; auto it lst.begin(); it; lst.insert(it, 25); std::cout insert 25 后: ; for (int v : lst) std::cout v ; std::cout std::endl; it lst.begin(); it; auto er lst.erase(it); std::cout erase 后返回: *er std::endl; std::cout 最终 list: ; for (int v : lst) std::cout v ; std::cout std::endl; listint emptyList; try { emptyList.pop_back(); assert(false); } catch (const std::out_of_range) { std::cout 空链表 pop 抛出预期异常 std::endl; } listint copy(lst); assert(copy.size() lst.size()); copy.push_back(99); assert(copy.size() lst.size() 1); listint assigned; assigned lst; assert(assigned.size() lst.size()); std::cout 拷贝和赋值测试通过 std::endl; std::cout 所有测试通过 std::endl; return 0; }预期输出初始元素: 20 10 30 insert 25 后: 20 25 10 30 erase 后返回: 10 最终 list: 20 10 30 空链表 pop 抛出预期异常 拷贝和赋值测试通过 所有测试通过你再手动推一遍erase的结果删除的是25所以返回25的下一个节点10最终剩下20、10、30和预期一致。6. 按踩坑频率排序的易错点避坑清单6.1 速查表症状到根因到修复典型症状根因修复思路push_back后链表断链遍历丢失尾部链接顺序错比如先改_sentinel-prev再访问旧prev先设新节点prev/next再动旧节点的next最后更新哨兵空链表push直接崩溃哨兵没有初始化_sentinel-prev是野指针构造函数里必须让_sentinel-next和prev都指向自己对空链表pop能编译但运行崩没有检查emptydelete了哨兵或野指针操作前加empty检查或抛出可预期异常erase后继续it导致无限循环/崩溃erase后原迭代器失效但代码还在使用使用erase返回的新迭代器或者先保存next拷贝构造后源对象也被改动拷贝时没有先初始化哨兵直接push到未初始化的_sentinel使用初始化列表调用默认构造list()频繁调用size()很慢size()里写循环遍历每次都O(n)size()返回_size成员增删时同步维护clear后哨兵指针还指着已释放节点clear里没把哨兵的next/prev重置循环删除后再让_sentinel指向自己6.2 三个“看起来对实际错”的经典写法第一个经典错误是erase循环里使用被删除的迭代器// 错 for (auto it lst.begin(); it ! lst.end(); it) { if (*it % 2 0) lst.erase(it); }原因前面已经说过erase后it失效再执行it就是未定义行为。正确写法是用erase的返回值或者先保存nextauto it lst.begin(); while (it ! lst.end()) { if (*it % 2 0) it lst.erase(it); else it; }第二个经典错误是忽略insert的返回值。很多人insert完想继续操作新插入的节点但没接收返回值导致又要从头遍历auto it std::find(lst.begin(), lst.end(), 10); lst.insert(it, 25); // 想继续操作25这个新节点但找不到它的迭代器正确写法auto inserted lst.insert(it, 25);第三个经典错误是试图对list的迭代器使用std::sort。std::sort要求随机访问迭代器list::iterator是双向迭代器传到std::sort里大概率编译失败std::sort(lst.begin(), lst.end()); // 错误list有成员函数sort应当用它。这个坑的根源在于没有区分“容器接口”和“迭代器分类”理解了list迭代器的双向性就能理解为什么不能配std::sort。6.3 调试技巧与个人体会手写链表的调试最忌讳的就是“靠猜”。我自己早期调这种代码九成崩溃都是指针问题而单纯靠看代码往往看不出问题。后来我养成一个习惯在关键操作前后打印每个节点的地址、prev、next把链表结构画出来。比如push_back前先打印old last的地址push后打印_sentinel-prev的新地址确认和new出来的节点地址一致。看起来原始但非常有效。链表这种结构一旦画出来指针问题是透明的——哪里断了、哪里指向自己一眼就能看出来。如果是用gdb可以设置断点观察内存地址。不需要掌握复杂命令只需打印指针值就行。更进阶一点的做法是把自定义list和一个裸指针版本做对比测试确保每次操作后链表节点数、顺序、头尾元素都一致再逐步加入const迭代器、拷贝赋值、异常路径测试。回头来看从零实现list容器这件事最大的价值不是让你在工作中重写一遍STL而是让你彻底理解为什么std::list的接口长这样。为什么erase要返回迭代器为什么insert是O(1)为什么迭代器不怕插入操作——这些问题只有自己写过一遍才会真正变成肌肉记忆。建议你也可以从最简单的单链表开始先用裸指针实现一遍再加上哨兵节点最后用模板和迭代器封装每步都用测试驱动。这个练法我试过虽然过程不短但对C理解提升的回报非常直接。
返回列表