1. 项目概述与核心思路看到这个标题我先说一句把STL的list用shared_ptr重写一遍这事看起来有点“脱裤子放屁”的嫌疑——明明标准库已经给你实现好了自己再造轮子图什么但真正动手做过的朋友会明白这个项目最大的价值不在“写一个能用的链表”而在借此把C11之后引入的智能指针体系、RAII资源管理思想、以及最折磨人的循环引用问题一次性地串起来。我之所以强调“C11现代特性”是因为老式的C98写链表人人都要手动new/delete稍不留神就是内存泄漏或者悬垂指针。凡是写过链表的人都体验过那种痛苦以为自己在脑海里有清晰的指针拓扑图结果删除一个节点之后另一个迭代器还在孤零零地引用一块已经释放的内存。使用shared_ptr之后资源释放交给引用计数来管这本身不值钱的准确来说这个项目真正需要动脑筋的地方是解决“循环引用”。循环引用是什么简单说就是A节点持有一个shared_ptr指向BB节点持有一个shared_ptr指向A。两个节点的引用计数都永远是1因为谁也不会主动释放对方。于是这两个节点就成了“互相拉扯的连体婴儿”谁也死不了。GC语言里这叫“对象图成环”有垃圾回收器兜底C没有全局GCshared_ptr的引用计数在成环的时候会失效内存就泄漏了。本项目里解决循环引用的方案是让其中一个方向的指针改用weak_ptr。shared_ptr负责“拥有”资源weak_ptr负责“观察”资源但不增加引用计数。需要访问的时候通过weak_ptr临时提升一个shared_ptr出来用用完即走。这个套路在缓存系统、观察者模式、树形结构里都很常见用链表来做载体非常合适。这个内容适合谁来学两类读者。一类是正在啃《深度探索C对象模型》或者Effective Modern C的进阶学习者你需要一个完整的不带UI干扰的实例来消化智能指针的语义另一类是工作了两三年的C开发者日常工作可能主要在写业务逻辑很少有机会把智能指针的所有特性在一个项目里集中演练这个list就是一个很好的演练场。我自己在做这个项目的时候最大的体会是真正难的从来不是“让代码跑起来”而是理解“为什么这样设计”。比如为什么头节点要单独用unique_ptr为什么反向迭代器里的current要存weak_ptr而不是shared_ptr这些问题的答案只有踩过坑才能真正记住。2. 设计拆解为什么选择shared_ptr而非unique_ptr先回答一个最直接的问题为什么不用unique_ptr来实现listunique_ptr是完全独占所有权的智能指针它确实也能表达链表节点的所有权关系——每个节点都指向下一个节点前一个节点死亡时后一个节点也活不了。从内存布局上看unique_ptr的方案其实更贴近原始裸指针的语义而且零额外开销。但unique_ptr方案有一个致命的麻烦它禁止拷贝。链表需要拷贝构造、拷贝赋值如果把节点里的next定义成unique_ptr那么深拷贝一颗链表会变得非常别扭。因为你不能“shared_ptr一样直接复制指针”你只能通过递归的方式new出整条链或者用移动语义把整条链表“搬走”。如果你想实现list的拷贝构造函数当list的节点内包含unique_ptr 就必须遍历原链表的每个节点逐个new新节点出来这个过程中原链表完全不能被改动。这当然能做但代码会非常啰嗦。shared_ptr则不一样。拷贝一个shared_ptr只是增加一次引用计数所以链表节点天然支持拷贝语义。你甚至可以在一瞬间生成一个“临时别名链表”两个list对象共享同一批节点改一个另一个也能看到。这种语义在一些需要共享数据结构的场景下非常有用。还有一个隐蔽的问题unique_ptr实现的链表在做“翻转”或者“排序”操作时涉及大量指针的转移。unique_ptr只能move不能copy所以在迭代遍历中临时保存一个“下一个节点”的指针时你得用裸指针来观察否则std::move来move去很容易把自己绕晕。shared_ptr则宽松得多临时多保存一份引用计数完全合法也不会造成问题。那么shared_ptr就没有代价吗当然有。每个shared_ptr内部包含两个指针——一个指向对象一个指向控制块。多一次引用计数增减就有一次原子操作。在现代CPU上原子操作的成本远高于普通指针赋值。所以用shared_ptr写出来的list在性能上跟标准库的list完全没法比。标准库list用裸指针加分配器单线程下无锁无原子操作性能优势非常明显。这就引出了这个项目的定位它是一个教学项目、语义实验项目不是生产环境的高性能容器。如果项目目标是性能那就直接用std::list如果目标是理解智能指针和RAII用shared_ptr重写一遍是最好的方式。2.1 链表节点的结构设计在设计节点结构的时候我经历了三次重构。第一版是最直观的写法template typename T struct ListNode { T data; std::shared_ptrListNodeT next; std::shared_ptrListNodeT prev; };写完之后我把链表构造出来打印节点数量再销毁链表结果发现节点数量计数根本降不到0。原因就是循环引用——头节点的next指向第二个节点第二个节点的prev又指回头节点在只有两个节点的链表里就已经出现了环。第二个版本直接把prev去掉改成单向链表。这下没有环了但失去了list最核心的特性——双向遍历和O(1)的插入删除。拿一个只能单向遍历的东西去冒充list不给别人骂死才怪。第三个版本就是最终采用的方案template typename T struct ListNode { T data; std::shared_ptrListNodeT next; std::weak_ptrListNodeT prev; };next用shared_ptrprev用weak_ptr。头节点持有的prev是空weak_ptr尾节点的next是一个空shared_ptr。边界条件干净利落。再说说为什么prev用weak_ptr而不是也改用shared_ptr然后在某个时机手动reset。手动reset确实可以打破环但“手动”两个字就注定是反模式了。RAII的精神始终是资源的释放不应该依赖程序员在正确的时间记得做正确的事情。用weak_ptr是从类型层面杜绝了“忘记”这个选项prev指针不会增加引用计数所以从语义上讲前一个节点“不拥有”后一个节点“只观察”它。只有后一个节点死亡时prev才需要通过weak_ptr的expired机制来感知。2.2 头节点设计的思考标准库的std::list使用一个哨兵节点sentinel node这个节点不存数据它的next指向第一个数据节点prev指向最后一个数据节点。空链表时它的next和prev都指向自己。这种设计让边界情况大大简化insert和erase不需要特殊判断链表是否为空。我在自己实现时也保留了这个哨兵节点。它的所有权怎么管理答案是哨兵节点由list对象唯一持有使用unique_ptr或裸指针都可以。但我选择了裸指针手动管理的方式这样在析构函数里可以严格控制释放时机。等一下用裸指针是不是违背了“现代C”的初衷这里要解释一下。哨兵节点是整个链表拓扑的“根”它的生命周期由list对象全权负责构造时创建析构时销毁。它不会被其他节点引用因为prev指向最后一个数据节点而不是哨兵也不需要在多个list之间共享。所以它就是典型的“独占所有权”场景实际上用unique_ptr更规范。但我在具体实现里用了裸指针目的是为了让析构逻辑更清晰更直观地展示节点链是怎么被递归释放的。读者如果介意可以自行替换为unique_ptr逻辑不变。2.3 迭代器设计与weak_ptr的配合链表和数组最大的不同在于迭代器失效规则。vector的迭代器在扩容后可能全部失效list的迭代器只要不删除它指向的那个节点就永远有效。用shared_ptr实现链表后这个“永远有效”变得更加彻底——哪怕原链表被销毁了迭代器手里的shared_ptr还维持着那个节点存活。这个特性是有危险的。举个例子auto it myList.begin(); myList.clear(); // 此时it指向的节点仍然活着因为你手上还有一个shared_ptr这是好事还是坏事从安全性角度来说是好事不会出现悬垂指针从语义一致性来说是个坑迭代器指向的不再是原链表里的节点数据节点孤零零地存在内存里。所以我给迭代器设计了一个改造迭代器内部的current不再存shared_ptr而是存weak_ptr。每次访问迭代器时先尝试lock提升为shared_ptr如果提升失败说明节点已经不存在直接断言或抛出异常。这样迭代器就和链表保持了同步——链表销毁迭代器随即失效跟标准库语义保持一致。代价是每次迭代器解引用都要做一次weak_ptr的lock多一次原子操作。但这正是这个项目想展示的用weak_ptr表达非拥有关系用lock获得临时的安全访问权。3. 核心细节解析与实操要点3.1 智能指针的循环引用为什么会发生想彻底理解这个项目必须先弄懂shared_ptr的“阿喀琉斯之踵”——循环引用。shared_ptr的机制说白了就是给堆对象配一个“计数器”每次拷贝就1每次析构就-1计数器归零时释放对象。这个设计的问题在于当两个对象互相持有对方的shared_ptr它们的计数器形成了闭环——A的计数器依赖B释放B的计数器依赖A释放而两者都在等对方先动手结果谁也不会动手内存就这么漏下去了。在单链表里不会出现这个问题因为单向链表的指针只有一个方向拓扑结构是有向无环图从head开始遍历走到nullptr就结束了整个链可以自然析构。但如果给节点加上prev指针双向链表立刻成为了一个“有环”的图结构——每个内部节点既被前驱的next引用又对后驱进行引用。这种情况下如果prev和next都用shared_ptr链表内部任意相邻节点的循环就让整条链的内存无法释放。有个朋友问我那我把整条链表的head也存成shared_ptr从外部引用计数拉高到2然后手动断开某个节点的指针是不是就能释放了答案是能但这是灾难性的设计。因为你必须精确记住“断开哪个指针”一旦记错链表中某一部分的引用计数始终不为零那一截节点就永远留在内存里。这就是典型的“我比编译器更懂资源管理”的翻车现场。正确路线只有一条把prev改成weak_ptr让环上的所有权方向统一。每个节点只拥有下一个节点不拥有上一个节点。这样任何相邻节点之间不存在“双向拥有”关系链表从根节点开始按next方向形成一个单向所有权链可以自然释放。3.2 weak_ptr的lock机制与原理解读weak_ptr本身不参与所有权它只是观察shared_ptr控制的那个对象是否还活着。想从weak_ptr获取一个可用指针必须调用lock()成员函数std::shared_ptrT sp weak.lock();lock()函数内部做了两件事第一步读取控制块中的引用计数判断对象是否仍然存活第二步如果活着把引用计数1并返回一个shared_ptr如果已经死了返回一个空的shared_ptr。整个过程有原子操作保护没有竞态条件。需要注意的是lock()返回的shared_ptr是临时的用完之后如果没有额外保存析构时引用计数会减回去。所以在手动迭代过程中应该把这个临时shared_ptr保存在局部变量里用完再丢弃。我特别喜欢用一个比喻来解释weak_ptr它就像查地图。你只是一张地图不会因为你看了地图那座楼就凭空多了一个人住。地图过期了你去查发现地址已经不存在那你拿到的就是一个空指针。这个过程是安全的代价是可能白跑一趟。在list的实现里什么时候必须lock答案是任何需要访问节点数据的时候。比如operator*要返回T第一步必须把weak_ptr提升为shared_ptr确保节点在访问的整个过程中不会因为其他线程的修改而死亡。3.3 深拷贝与引用计数借力打力链表有个麻烦操作是深拷贝。手写裸指针链表时拷贝构造要遍历原链、逐个new新节点还要处理异常安全——中途new失败怎么办已经new出来的节点怎么回滚用shared_ptr之后这活变得极其轻松list(const list other) { // 创建哨兵 // 遍历other的每个节点 // 每new一个节点用shared_ptr接住挂到新链表尾 }这里的逻辑跟裸指针版本一样但有一个关键差异当new抛异常时局部已经构建好的shared_ptr会立即析构并释放自己持有的节点。假设你已经构建了3个节点第4个节点new失败那3个shared_ptr析构时会依次拖垮后续的整个链。异常安全自动达成。RAII在这里的价值体现得淋漓尽致。深拷贝时的性能损耗主要在于prev是weak_ptr赋值时不需要原子递增只是普通赋值。next是shared_ptr每一次“把new出的节点挂到链表尾部”的操作都涉及一次引用计数的赋值。好在这个过程中引用计数从1变2再变回1两次原子操作可以接受。3.4 move语义与“窃取”shared_ptr天然支持移动语义。移动一个shared_ptr不会增加引用计数只是把所有权从源转移到目标源变为空指针。这个特性让链表的移动构造近乎零成本list(list other) noexcept : head(other.head), tail(other.tail), sz(other.sz) { other.head other.tail nullptr; other.sz 0; }如果这个move用了shared_ptr本质上只是复制了内部指针然后置空源对象没有增加任何计数也没有原子操作。好处是什么move之后的源链表是一个“空壳”析构时不会影响到已经移走的节点。整个操作的时间复杂度O(1)爽得不行。所以这个项目里有一个经验是能用移动就用移动。拷贝构造留给真正需要“深拷贝”的场景。4. 实操过程与核心代码实现4.1 总体代码骨架我先给出一个整体结构读者可以顺着往下看实现细节。这个版本为了直观起见没有把所有代码塞进类定义里而是按模块拆开讲。#include memory #include stdexcept #include utility template typename T class mylist { private: struct Node { T data; std::shared_ptrNode next; std::weak_ptrNode prev; template typename... Args explicit Node(Args... args) : data(std::forwardArgs(args)...) {} }; std::shared_ptrNode head; // 哨兵节点不存数据 std::shared_ptrNode tail; // 尾部哨兵还是直接指向最后一个节点 size_t sz 0;这里有一个设计分岔标准库list的哨兵节点既是头也是尾空的list里哨兵节点自己指向自己。用shared_ptr之后想表达“自己指向自己”会形成循环引用这是绝对不可取的。所以我的设计是单哨兵位于头部它不存数据它的next指向第一个数据节点如果有它的prev是nullptr。tail成员直接指向最后一个数据节点但tail本身不拥有节点它只是观察。让我再想清楚一点。如果tail是一个shared_ptr它指向最后一个数据节点那么它会对最后一个节点的引用计数1。也就是说链表中最后一个节点会被两个shared_ptr引用倒数第二个节点的next以及list的tail。当链表析构时tail的shared_ptr析构会让引用计数减1倒数第二个节点的next析构再减1最终归零释放。按这个设计tail也需要在每次操作中正确维护。如果在push_back时更新tailpop_back时也要更新tail麻烦是麻烦了点但可以接受。我实际采用的设计是tail采用weak_ptr。理由跟prev用weak_ptr完全相同——list对象不应该“拥有”最后一个节点它只是给迭代器提供一个O(1)的访问入口。最后一个节点的所有权应当属于倒数第二个节点的next如果链表只有一个节点则属于哨兵的next。这个选择还带来一个额外的好处在增加tail指针时用weak_ptr不会让链表尾部形成所有权环。std::weak_ptrNode tail_weak; public: mylist() { head std::make_sharedNode(); // 哨兵data默认构造为空 tail_weak head; // 空链表时tail就是哨兵本身 }4.2 迭代器实现weak_ptr安全访问的关键细节迭代器是这个项目里最容易出bug的地方。我的迭代器分为正向迭代器和反向迭代器它们都基于同一个内部结构只是operator和operator--的方向不同。正向迭代器的成员很简单一个weak_ptr cur指向当前节点。begin()时cur指向哨兵的nextend()时cur为空。class iterator { private: std::weak_ptrNode cur; public: using iterator_category std::bidirectional_iterator_tag; using value_type T; using difference_type std::ptrdiff_t; using pointer T*; using reference T; iterator() default; explicit iterator(const std::shared_ptrNode node) : cur(node) {} T operator*() const { auto sp cur.lock(); if (!sp) throw std::runtime_error(iterator is dangling); return sp-data; } iterator operator() { auto sp cur.lock(); if (!sp) throw std::runtime_error(cannot increment past end); cur sp-next; return *this; } // ... };这里要特别注意的是操作。如果直接存shared_ptr那么自增一次就把 shared_ptr 赋值给下一个节点不会出问题。但存weak_ptr之后必须先lock拿到当前节点的shared_ptr再从当前节点的next构造下一个节点的weak_ptr。为什么要这样因为不能只凭weak_ptr直接访问nextweak_ptr没有operator-。必须提升为shared_ptr才能操作。这个“每次自增都要lock一次”的开销不小一个简单的遍历循环里就多了一堆原子操作。但教学项目就是如此——用清晰换性能。反向迭代器怎么办如果只是把变成--就写着iterator operator--() { auto sp cur.lock(); cur sp-prev.lock(); // 取出prev }这里prev是weak_ptr需要先lock才能用在构造weak_ptr的地方吗不可以直接把sp-prev这个weak_ptr赋值给cur的weak_ptr类型完美匹配。不需要锁。这是weak_ptr到weak_ptr的复制不会增加引用计数也不会触发原子递增。但这里有个前提确保sp-prev不是空的。空链表或链表头部的哨兵它的prev是空weak_ptr所以rbegin()必须暴露为“指向最后一个数据节点”的迭代器rend()暴露为“哨兵节点”。这一点在实现反向迭代器时要格外注意直接把正向迭代器的--逻辑反过来用会踩坑。4.3 push_back与pop_back的实现细节push_back是所有操作里面最直观的。先创建新节点然后调整指针。void push_back(const T value) { auto newNode std::make_sharedNode(value); auto tailSp tail_weak.lock(); if (!tailSp) { // 链表为空哨兵的next指向新节点 head-next newNode; newNode-prev head; } else { tailSp-next newNode; newNode-prev tailSp; } tail_weak newNode; sz; }注意这里哨兵节点的prev始终为空哨兵节点没有被任何节点反向引用。整个链表的所有权方向head - 节点1 - 节点2 - ... - 节点n每个节点的prev只是一个观察。pop_back就有点讲究了。想删除最后一个节点必须知道“倒数第二个节点”是谁。我写了一个辅助函数来找到它void pop_back() { if (empty()) throw std::runtime_error(pop_back on empty list); auto oldTail tail_weak.lock(); if (sz 1) { head-next nullptr; tail_weak head; } else { auto newTail oldTail-prev.lock(); newTail-next nullptr; tail_weak newTail; } --sz; }当pop_back之后oldTail这个局部shared_ptr析构时如果引用计数归零也就是说没有其他shared_ptr还在引用它节点就会被释放。这里的释放时机和引用计数关系要特别清晰pop_back的返回值如果没人接住那oldTail析构就是释放节点的瞬间。4.4 insert与erase的节点挂接插入和删除的核心是四步建立新连接、断开旧连接、处理边界。跟裸指针版差不多但多了一个“不要弄丢shared_ptr”的告诫。iterator insert(iterator pos, const T value) { auto posSp pos.cur.lock(); if (!posSp) { // 在end处插入相当于push_back push_back(value); return iterator(tail_weak.lock()); } auto newNode std::make_sharedNode(value); auto prevSp posSp-prev.lock(); // 连接新节点到前驱 newNode-prev posSp-prev; prevSp-next newNode; // 连接新节点到后继 newNode-next posSp; posSp-prev newNode; sz; return iterator(newNode); }erase时要注意返回值应该指向被删除节点的后继。如果删除的是最后一个节点返回值就是end()。iterator erase(iterator pos) { auto posSp pos.cur.lock(); if (!posSp) throw std::runtime_error(erase invalid iterator); auto prevSp posSp-prev.lock(); auto nextSp posSp-next; prevSp-next nextSp; if (nextSp) { nextSp-prev prevSp; } else { tail_weak prevSp; } --sz; return iterator(nextSp); }这里的核心逻辑是posSp是一个局部shared_ptr在函数返回时会析构。如果nextSp为空说明删的是最后一个节点那posSp析构后这个节点没有任何shared_ptr引用了直接释放。如果nextSp非空那posSp的析构不会导致节点释放因为prevSp已经被重新挂接到nextSp上面了。4.5 析构函数与递归释放list析构时哨兵head是list的成员shared_ptr析构会让哨兵引用计数减1如果归零就释放哨兵。释放哨兵时哨兵的next如果有的话的shared_ptr引用计数减1这会让第一个数据节点释放。释放第一个数据节点时它的next的shared_ptr引用计数减1释放第二个数据节点……如此递归下去整条链都能被正确释放不会因为prev是weak_ptr而影响任何引用计数。等等还有tail_weak。tail_weak是list的成员它析构时不会减少任何引用计数weak_ptr不参与计数。所以析构过程完全是靠哨兵head的next链式释放一路从左往右“推倒”。这种链式析构有一个明显的缺点它是递归的。链表有十万个节点时析构会递归十万层耗尽调用栈。标准库的list是迭代式的析构时按顺序依次释放节点不会爆栈。所以我额外写了一个clear()函数采用迭代式的析构遍历节点时先保存下一个节点的shared_ptr再把当前节点的next清空这样每次只释放一个节点栈上不会堆积void clear() noexcept { auto cur head-next; while (cur) { auto next cur-next; cur-next nullptr; // 断开让当前节点析构 cur next; } tail_weak head; sz 0; }这是一个很重要的工程细节。虽然标准库list不这么干但用shared_ptr写的链表如果不处理这个爆栈风险总有一天会被用户一份“析构十万节点链表”的测试用例炸掉。5. 常见问题与排查技巧实录5.1 明明用了shared_ptr为什么内存还在涨这是我在这个项目里遇到最多的一个问题。很多初学者把节点结构设计成next和prev都是shared_ptr测了几个操作后发现内存持续增长就开始怀疑shared_ptr有bug。其实不是。shared_ptr没有bug是使用者的心智模型错了。任何双向结构只要两端的指针都是“拥有”语义那就是个环。不用等到链表很长只要有两个节点就能形成互相引用。查这种问题的方法很简单在析构函数和节点析构函数里加打印看看到底哪一步没有释放。我习惯用一个辅助类来统计存活节点数struct Track { inline static int alive 0; Track() { alive; } Track(const Track) { alive; } ~Track() { --alive; } };然后mylist操作完之后打印alive。如果alive不为0说明有节点还活着。这时候逐一检查prev是shared_ptr吗tail是shared_ptr吗仅仅一个tail指针都会让最后一个节点无法释放。5.2 为什么迭代器的operator*拿到的数据永远是脏的这是weak_ptr的经典误用。如果你在operator*里写T operator*() const { return *cur.lock(); // 错误临时shared_ptr在表达式结束后析构返回的引用绑定的对象也随即释放 }实际上这个写法在“节点存活”的情况下不会出问题因为临时shared_ptr析构时只是引用计数减1只要节点的引用计数在表达式结束后仍然大于0对象就不会被释放。但如果这个节点只被当前这个迭代器引用而链表已经销毁那临时shared_ptr析构后节点立即释放返回的引用就是一个悬垂引用。所以正确做法是在operator内部先lock并将shared_ptr保存在局部变量返回data引用然后离开函数时局部变量析构。这个局部变量保证了在函数调用期间节点不会被释放。但注意一旦operator返回的引用被使用方保存再访问可能就悬垂了。这个没有特别好的解决办法——除非用operator-返回指针并配合shared_ptr的别名构造但那会很复杂。作为教学项目接受这个限制即可。5.3 迭代器自增时抛异常怎么办以前写裸指针的链表迭代器就是cur cur-next没有任何异常。但我的weak_ptr版本必须lock如果lock失败说明迭代器所指向的节点已经被其他人释放了此时抛出异常。这在单线程环境里几乎不会发生因为你在使用迭代器的时候不会同时去删除它所指向的节点。但多线程环境下如果两个线程共享一个list一个线程持迭代器访问另一个线程删除节点就可能导致迭代器失效并触发lock失败。结论多线程环境下list和迭代器必须由外部锁来保护weak_ptr只是让“失效”变得可检测而不是让“失效”不发生。想真正并发安全得用std::list加mutex或者直接用无锁容器。5.4 为什么clear()之后size不为0这是因为sz的更新和节点断开没有同步。我在早期版本里clear()遍历完了才把sz0如果遍历过程中抛异常sz就崩了。后来改进为先置sz0再断开所有节点。顺序很重要void clear() noexcept { sz 0; auto cur head-next; while (cur) { auto next cur-next; cur-next nullptr; cur next; } tail_weak head; }5.5 跨出底线迭代器的生命周期最后再强调一个普遍的原则这个项目的迭代器是弱引用它的生命周期严格受限于其内部的weak_ptr能不能成功lock。如果迭代器是默认构造的空对它做operator*就会抛异常。这比裸指针版本的“未定义行为”要友好得多但代价是每次解引用都多了一个分支判断。在实际工程里如果性能要求苛刻可以提供一个“检查模式”和“快速模式”的开关快速模式不做lock检查直接裸指针访问检查模式在debug下开启。这种设计参考了STL的debug模式思路。5.6 使用shared_ptr时常见的性能误区有人会觉得链表里的每个节点都是一个shared_ptr操作时频繁递增递减引用计数性能一定差得离谱。确实差但要分场景看。在push_back中新建一个节点引用计数是1挂到链表尾部后可能变成2如果tail是shared_ptr。pop_front/Pop_back时又要递减。一次push_backpop_back总共4次原子操作在单线程下这4次原子操作的成本大约是几十纳秒。对于教学项目完全没有问题但如果在网络转发的热路径上用这个容器可能成为性能瓶颈。所以如果你真想把这个list用于生产环境建议把next换回裸指针保留shared_ptr的管理交给更精细的层面。但那是另一个项目了。6. 项目扩展建议与进阶方向做完这个基础版本还不过瘾的话可以往几个方向深挖。6.1 添加自定义分配器标准库容器的一大优势是支持自定义分配器。我的list目前用的都是std::make_shared它走的是默认分配器。如果要支持自定义分配器可以改成std::shared_ptrNode node std::allocate_sharedNode(alloc, args...);allocate_shared能把控制块和节点对象放在同一块内存里减少一次堆分配。这个工程量不大但对理解分配器的“无状态/有状态”区别非常有帮助。6.2 实现splice操作splice是把一个list的节点区间“搬”到另一个list里不拷贝数据不额外分配节点。如果用shared_ptr来实现需要修改节点的prev和next但不需要动shared_ptr本身的引用计数。这是很典型的有意思的操作值得实现一遍。注意同一个list给自己splice时要处理循环移动的情况处理不好会死锁甚至形成环。6.3 用原子引用计数的shared_ptr做线程安全的list这个听起来很诱人但实际上不行。标准库的list迭代器本身就不同时支持多线程写shared_ptr的线程安全只保证“控制块”的计数安全不保证链表节点之间的指针操作安全。真要线程安全需要对整个list加锁这反而比shared_ptr本身更麻烦。6.4 对比std::list的实现手工实现完之后强烈建议你用调试器打开std::list的内部结构看看。标准库的实现通常用裸指针哨兵节点节点里只有data和两个指针不存任何引用计数。这一对比就能看出shared_ptr版本的代价每个节点多了控制块的开销至少两个指针大小每个操作多两次原子操作每多一次拷贝就多一次引用计数增减。但代价换来的是“所有权显式化”。裸指针版本里谁拥有谁根本不知道只能靠注释和约定。shared_ptr版本里next的类型告诉你“我拥有下一个节点”prev的类型告诉你“我只是观察上一个节点”这对代码的可维护性有着不可替代的价值。6.5 扩展到其他容器用同样的思路可以改造std::map的节点结构吗可以。但map用shared_ptr会有更大的开销因为红黑树节点间的旋转操作频繁每次旋转都要调整引用计数。如果只是想要解决循环引用weak_ptr的引入会让代码非常复杂。所以通常的做法是树结构用unique_ptr持有子节点父节点用裸指针维护然后整体容器用shared_ptr对外暴露。这是工程上常见的折中。7. 从shared_ptr的list到现代C的思考做完这个项目我有一个非常强烈的感受智能指针不是银弹它只是在帮我们把“所有权”这个抽象概念变成可描述、可检查的类型约束。原始指针的问题从来不在于它“慢”而在于它什么信息都不携带——一个Node*无法告诉你这个指针是拥有、是观察、还是悬垂。shared_ptr告诉别人“我拥有它”weak_ptr告诉别人“我在观察它”unique_ptr告诉别人“我独占它”。当所有权信息被类型系统编码之后编译器就能在编译期帮我们检查很多资源管理错误这是工具层面的进步。但智能指针并不能消灭所有内存bug。它消灭的是“忘记释放”这一类却放大了“循环引用”这一类。现代C开发者在拥抱智能指针时最需要建立的思维习惯是画所有权图。每次写一个新的类先把“谁拥有谁”画清楚再决定用什么指针类型。这个习惯一旦养成绝大多数跟生命周期相关的设计问题都可以在动手写代码之前被消灭掉。有朋友问过我你以后还会不会用裸指针写链表我用不用其实无所谓但你如果在新的代码里还在手动管理链表节点的生命周期我建议你认真考虑一下是否真的无法避免。今天写的每一行手动delete都是在给未来的维护者埋雷。最后欢迎大家动手实现一套自己的版本实现完请务必写一写测试用例空链表的插入删除、头删尾删、中间插入、迭代器失效场景、大链表析构、拷贝和移动混合使用。这套测试跑完你基本能cover住绝大多数智能指针的坑。