news 2026/10/14 6:40:04

用shared_ptr实现list:打破循环引用与内存泄漏的现代C++实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用shared_ptr实现list:打破循环引用与内存泄漏的现代C++实践

用shared_ptr写一个能用的list,听起来是件挺酷的事:再也不用在析构函数里写while循环手动delete节点了。但真正动手之后你会发现,它带来的麻烦远比想象中多,最典型的就是循环引用。两个节点互相用shared_ptr指来指去,引用计数永远降不到零,内存直接泄漏,而你对“现代C++”的信任感也会瞬间崩塌。

这篇文章把我实现“具备C++11现代特性的STL list(shared_ptr版)”这个练手项目的全过程拆开,包括为什么用shared_ptr、怎么用weak_ptr打破循环引用、迭代器怎么写、深拷贝怎么做,以及那些只有踩过坑才知道的细节。适合已经会用shared_ptr但想真正理解引用计数、想读懂智能指针源码、或者打算自己造一个像样容器的C++学习者。

1. 为什么要用shared_ptr写list

1.1 传统链表的所有权痛点

手写过原生指针双向链表的人应该都有体会,最烦的不是插入删除的指针调整,而是内存释放。析构函数里要写一个循环,从头节点开始逐个delete,还得小心翼翼避免重复释放。一旦插入、删除、拷贝的逻辑里有一处忘记new或者多了一次delete,轻则内存泄漏,重则悬垂指针崩溃,排查起来相当折磨。

传统std::list内部用原生指针管理节点所有权,容器本身是唯一的“所有者”。它析构时用迭代遍历逐个释放节点,这是一个线性过程,不存在递归调用的问题。但代价是所有生命周期管理都得手写,一旦容器发生拷贝、移动、异常抛出,很容易踩坑。

C++11带来的shared_ptr正好把“所有权”这个语义包装成了类型系统的一部分。节点被谁持有,谁就在计数上增加一,谁释放了,计数就减一。这个概念用在链表上,理论上可以让“节点A的next持有一个节点B的shared_ptr”来表达B的生命期,容器只管持有头节点,后续节点自动链式存活,释放的问题似乎不再需要人肉关心。

1.2 shared_ptr方案的核心收益

用shared_ptr实现链表的第一个直接好处是代码显著简洁。传统写法中,插入函数需要new Node,删除函数需要delete p,而shared_ptr版本里只需要构造shared_ptr<Node>,剩下的事情由控制块的引用计数接管。比如push_back的尾部链接,只需要调整前一个节点的next和当前节点的prev,完全不用考虑被替换掉的旧指针内存去哪了。

第二个好处是异常安全。传统写法里,如果你在new Node之后、真正链入链表之前发生了异常,这个节点就泄漏了。甚至链入链表的过程中,如果后续操作抛出异常,链表结构可能已经处于半损坏状态。shared_ptr的局部变量会在栈展开时自动析构,节点尚未入链就被安全释放,不会出现泄漏。

第三个好处是可以利用make_shared一次分配节点和控制块,减少一次堆分配。原生实现里一个节点通常只是一次new,shared_ptr方案如果用make_shared,节点对象和控制块是连续内存,缓存友好度更高,分配次数反而少于“裸指针节点 + 独立计数”的组合。

1.3 必须跨过的一道坎:循环引用

shared_ptr方案最大的坑,也是整个项目最值得写的部分,就是循环引用。

如果简单地把Node的prev和next都设计成shared_ptr,会出现什么情况?头节点被外部持有,它的next指向第二个节点,第二个节点的prev指回头节点,强引用环就形成了。这个环的引用计数永远不会降到零。即使外部的头节点引用被释放,节点之间依然互相拉扯,控制块计数不为零,于是整个链表的内存全部泄漏。

解决方案看起来很简单:把prev从shared_ptr换成weak_ptr。next保持强引用,prev使用弱引用,不参与计数。这样整条链表只有一个强所有权方向,从头到尾,加上尾节点的next为空,整个强引用图是有向无环的,不会形成循环。

真正实现的时候,比这个一句话方案要复杂得多,比如哨兵节点怎么设计、迭代器怎么判断尾后、erase的时候怎么安全地获取当前节点的shared_ptr。这些细节我会在后面逐个拆开讲。

2. 核心设计:节点结构、哨兵节点与weak_ptr破环

2.1 节点结构设计:NodeBase与Node分离

一个干净的链表节点设计,应该把“指针链接”和“数据存储”分开。传统std::list内部就是这么干的:一个NodeBase只负责prev/next指针,真正的数据节点继承它并附加数据。这样做的原因很简单——哨兵节点只需要链接关系,不应该被迫存一份T类型数据。

如果直接把T塞进节点里,那么哨兵节点也必须构造一个T。这会导致两个问题:第一,T必须有默认构造函数;第二,链表还没放任何元素,就要白白构造一个无意义对象,对于T昂贵的情况是浪费。

所以我的设计是:

struct NodeBase { std::shared_ptr<NodeBase> next; std::weak_ptr<NodeBase> prev; }; template <typename T> struct Node : NodeBase { T data; template <typename... Args> explicit Node(Args&&... args) : data(std::forward<Args>(args)...) {} };

这里有一个重要细节:NodeBase里的next类型是shared_ptr<NodeBase>,而不是shared_ptr<Node<T>>。因为哨兵节点是NodeBase类型,next可能指向NodeBase,也可能指向Node 。通过基类指针管理派生类对象,这在shared_ptr里完全安全,因为make_shared创建Node 时,控制块里保存的是Node 的删除器,即使通过shared_ptr<NodeBase>去析构,最终调用的仍然是Node 的析构函数,不需要虚析构。

2.2 哨兵节点与迭代器协作

链表容器需要一个哨兵节点来统一处理空链表和迭代器的end()。传统std::list用哨兵节点当一个环形连接的锚点,尾节点的next指向哨兵,哨兵的prev指向尾节点。但shared_ptr方案里,如果让尾节点的next也指向哨兵,就又会形成强引用环:哨兵强引用头节点,尾节点强引用哨兵,链表越长这个环越结实。真要这么干,内存泄漏又回来了。

我的做法是:哨兵节点仍然存在,但尾节点的next永远为空,不再回指哨兵。哨兵只负责两件事:m_sentinel->next强引用头节点,m_sentinel->prev弱引用尾节点。这样强引用方向永远是单向的,从哨兵开始,依次经过每个节点的next链到底尾。

迭代器的end()就表示为空指针,因为对最后一个节点执行++会得到nullptr,恰好和end()相等。这个设计让迭代器实现更简单,代价是需要额外记录哨兵裸指针,以便在--end()时能从哨兵的prev拿到尾节点。

2.3 为什么prev必须是weak_ptr:完整推理

我们来写一组引用计数推导。假设三个节点A、B、C,分别被哨兵S的next、A的next、B的next强引用,计数都是1。如果C的prev也是shared_ptr,指向B,那么B的计数是2。但A、B、C之间没有形成双向强引用?先看A和B:A.next指向B,B.next指向C,B.prev指向A。B同时被A.next和C.prev强引用,C同时被B.next和...这里C.prev指向B,所以B计数+1。但这还不是环,真正的问题在于:B.prev指向A,如果A的next也指向B,那么A计数加1;而A.next指向B,所以B计数加1。A被谁持有?哨兵S的next强引用A。所以目前的强引用图是:S->A,A->B,B->C,C->B(因为C.prev是shared_ptr指向B),B->A(B.prev是shared_ptr指向A)。C同时被B.next持有,而B被A.next和C.prev持有,A被S.next和B.prev持有。如果S被释放,A的计数仍然被B.prev持有,B又被C.prev持有,C被B.next持有,B又要A...这就是一团互相引用的强引用环。

把prev改成weak之后,所有反向指针都不参与计数。强引用关系变成:S.next->A,A.next->B,B.next->C,C.next空。这个图无环,任何节点释放时,计数都能正确归零。核心思想是:双向链表的“反向链接”在语义上只是查找辅助,不应该对节点的生命周期产生所有权影响。

2.4 什么样的环会泄漏:边界情况

理解了prev用weak_ptr的原理之后,还有一个容易被忽略的边界:如果用户在容器外部额外持有某个节点的shared_ptr,会发生什么?

比如把一个节点N的shared_ptr<NodeBase>从容器里复制出来保存。这时即使容器析构,N的计数也不会归零,N和它后续的节点都不会被释放。这其实是shared_ptr本身语义决定的,不算是循环引用的锅,但值得知道。如果N的prev是weak,指向N的强引用只有它的前驱节点的next,而那个前驱节点可能已经被释放了,于是N被外部持有,整个链表尾巴都挂在它后面。

所以用shared_ptr写容器,要有清晰的“所有权边界”意识:如果一个节点被外部强引用,它的生命周期就脱离了容器控制。这是合理的,但使用迭代器和裸指针时要格外注意,绝不能假设“容器析构了节点就一定全部释放”。

3. 从零实现:现代C++特性全部用上

3.1 emplace系列与完美转发

既然标题说了“具备C++11现代特性”,那emplace系列必然不能少。emplace_back和emplace的核心是用完美转发把参数直接传给Node的构造函数,在堆上就地构造T对象,避免先构造一个临时T再拷贝进节点。

template <typename... Args> void emplace_back(Args&&... args) { auto node = std::make_shared<Node<T>>(std::forward<Args>(args)...); link_back(node); }

这里的关键是std::forward<Args>(args)...。如果外面传进来一个左值,实际传到Node构造函数的是左值引用,data会走拷贝构造;如果传进来右值或临时对象,走的是移动构造。如果T的移动构造函数是noexcept的,整个过程不会有多余的拷贝,性能很好。

Node构造函数的写法需要同时兼容无参、左值、右值、多参数构造。使用变参模板加完美转发,一个构造函数搞定所有情况:

template <typename... Args> explicit Node(Args&&... args) : data(std::forward<Args>(args)...) {}

这个构造函数配合make_shared,是emplace系列的核心。传统写法里,push_back往往要先构造临时对象,再复制进节点。有了完美转发,list.emplace_back(1, 2, 3)这种多参数构造也能直接支持,前提是T的构造函数接受这些参数。

3.2 双向迭代器:为什么用裸指针而不是shared_ptr

迭代器设计上,我选择保存裸指针NodeBase*,而不是shared_ptr。原因有两个。

一个是性能。shared_ptr的拷贝构造函数和析构函数都要操作引用计数的原子变量,如果迭代器频繁复制、递增、比较,每一次都会触发原子操作。裸指针拷贝只是一个指针宽度赋值,成本几乎为零。另一个是语义。std::list的迭代器失效规则是erase之后指向该节点的迭代器失效,其他迭代器不受影响。裸指针迭代器恰好符合这个语义,因为它的有效性直接取决于底层节点是否还存在于链表中。

但裸指针迭代器有一个麻烦:operator--时,如果当前是end()(即空指针),需要从哨兵的prev拿到尾节点。这要求迭代器内部除了当前节点指针外,还要保存哨兵指针。

class iterator { NodeBase* cur = nullptr; NodeBase* sen = nullptr; public: iterator(NodeBase* c, NodeBase* s) : cur(c), sen(s) {} using iterator_category = std::bidirectional_iterator_tag; using value_type = T; using difference_type = std::ptrdiff_t; using pointer = T*; using reference = T&; reference operator*() const { return node()->data; } pointer operator->() const { return std::addressof(node()->data); } iterator& operator++() { assert(cur != nullptr); cur = cur->next.get(); return *this; } iterator& operator--() { if (cur == nullptr) { cur = sen->prev.lock().get(); } else { cur = cur->prev.lock().get(); } return *this; } bool operator==(const iterator& other) const { return cur == other.cur; } bool operator!=(const iterator& other) const { return !(*this == other); } private: Node<T>* node() const { assert(cur != nullptr); return static_cast<Node<T>*>(cur); } };

node()函数里的static_cast是关键。因为cur的类型是NodeBase*,但实际指向的对象是Node 。这个转换是受控的,只有当cur指向的是哨兵时才是非法转换,而哨兵永远不会通过begin()返回,也不允许解引用end(),所以在正确使用迭代器的前提下是安全的。

3.3 insert和erase的核心技巧:不要从裸指针构造shared_ptr

这是整个项目最容易踩坑的地方。很多人在实现insert或erase时,拿到一个NodeBase*,第一反应是std::shared_ptr<NodeBase> p(cur)。这么写是灾难级的,等于给这个节点创建了一个全新的控制块。一旦这个局部shared_ptr析构,它会尝试释放一个已经被链表持有的节点,随即触发双重释放。

获取节点对应shared_ptr的正确方式,是从真正持有它的成员那里拷贝一份。比如当前节点cur,它的持有者是谁?是它的前驱节点的next成员。所以应该先通过cur->prev.lock()拿到前驱的shared_ptr,再从prev->next拷贝出当前节点的shared_ptr。

插入逻辑:

template <typename... Args> iterator emplace(iterator pos, Args&&... args) { if (pos == end()) { auto node = std::make_shared<Node<T>>(std::forward<Args>(args)...); link_back(node); return iterator(node.get(), m_sentinel.get()); } NodeBase* cur = pos.cur; std::shared_ptr<NodeBase> prev = cur->prev.lock(); std::shared_ptr<NodeBase> next = prev->next; // 这就是cur的强引用 auto node = std::make_shared<Node<T>>(std::forward<Args>(args)...); prev->next = node; node->prev = prev; node->next = next; next->prev = node; ++m_size; return iterator(node.get(), m_sentinel.get()); }

删除逻辑也是同理:

iterator erase(iterator pos) { assert(pos != end()); NodeBase* cur = pos.cur; std::shared_ptr<NodeBase> prev = cur->prev.lock(); std::shared_ptr<NodeBase> next = cur->next; // 可能是nullptr,删除尾节点 if (next) { next->prev = prev; } else { m_sentinel->prev = prev; } prev->next = next; --m_size; return iterator(next.get(), m_sentinel.get()); }

这段代码里,next = cur->next这行很重要。如果不先把next备份成局部shared_ptr,直接把prev->next改掉,cur可能就已经释放了,而cur->next也随之析构,整个链表断掉。有了局部next保命,cur析构时它的next成员析构也只会让next的计数从2变成1,不会真正释放next,因为局部还持有一份。

这就是shared_ptr容器和裸指针容器在写法上的最大差异:裸指针版本只管改指针,不用考虑“释放顺序”;shared_ptr版本必须保证每个节点在调整期间始终是“有主”的,不能被中途释放。诀窍是:在破坏一条引用链之前,先用局部shared_ptr把即将失去引用的对象保住。

3.4 clear与析构:线性断开防止栈溢出

再强调一遍:如果不显式写析构函数,shared_ptr链表的析构会沿next链递归释放。一万个节点就是一万层嵌套析构,栈会直接爆掉。所以析构函数必须显式clear,并且clear的处理方式也有讲究。

一个看似合理的实现是m_sentinel->next.reset(),以为reset头节点后,头节点的析构会触发它的next析构,依次传递。这确实会释放链表,但这是递归式释放,同样会栈溢出。

正确的clear要线性地“断开”链条:

void clear() noexcept { while (m_sentinel->next) { m_sentinel->next = m_sentinel->next->next; } m_sentinel->prev.reset(); m_size = 0; }

这个循环看起来只有一行赋值,实际每轮做了三件事:第一,m_sentinel->next->next先把下一个节点的shared_ptr拷贝出来,让它在赋值过程中存活;第二,给m_sentinel->next赋新值,旧的头节点引用计数减一,如果没有其他强引用,它会被析构;第三,头节点析构时,它内部的next成员会析构,但因为我们已经有一份临时拷贝持有下一个节点,所以计数不会归零。

每一轮循环只释放一个节点,循环执行n次,没有任何递归,栈空间占用是常量的。这是shared_ptr容器里必须掌握的“线性断开”技巧。

3.5 深拷贝、移动语义与swap

shared_ptr会共享所有权,默认的拷贝构造函数会让两个list共享同一组节点。但list是值语义容器,拷贝构造应该产生一份独立的新链表。所以必须实现深拷贝。

List(const List& other) : m_sentinel(std::make_shared<NodeBase>()) { for (const auto& v : other) { push_back(v); } }

如果other里有一万个节点,这里会构造一万个Node ,并且每个T对象都从源节点拷贝一次。这是必要的语义,同时也是shared_ptr方案相对std::list多出来的明显成本:std::list深拷贝只需要new节点加拷贝data;shared_ptr方案里每个节点还要额外做一次控制块引用计数操作。对于学习项目来说没问题,但应用在性能敏感的场合要慎重。

移动构造和移动赋值可以做到O(1),直接交换哨兵指针:

List(List&& other) noexcept : List() { swap(other); } List& operator=(List&& other) noexcept { if (this != &other) { clear(); swap(other); } return *this; }

这里移动赋值先clear自己,再交换。交换后,other把空链表换走了,它析构时clear空链表是安全的。如果直接用copy-and-swap处理移动,会多分配一个空哨兵,不够清爽。这个写法更贴合真实STL容器的移动语义。

swap本身也简单:

void swap(List& other) noexcept { m_sentinel.swap(other.m_sentinel); std::swap(m_size, other.m_size); }

交换的是shared_ptr本身,不是它指向的内容,所以O(1),且不会影响节点引用计数。

4. 完整可复现代码与测试

4.1 完整源码

把上面所有设计整合成一份可直接编译的头文件实现,我贴在这里。代码精简掉了const_iterator的一些重复细节,主体逻辑完整:

#include <cassert> #include <cstddef> #include <memory> #include <utility> template <typename T> class List { private: struct NodeBase { std::shared_ptr<NodeBase> next; std::weak_ptr<NodeBase> prev; }; template <typename U> struct Node : NodeBase { U data; template <typename... Args> explicit Node(Args&&... args) : data(std::forward<Args>(args)...) {} }; std::shared_ptr<NodeBase> m_sentinel; std::size_t m_size = 0; template <typename U, typename... Args> void link_back(Args&&... args) { auto node = std::make_shared<Node<U>>(std::forward<Args>(args)...); auto last = m_sentinel->prev.lock(); if (!last) { m_sentinel->next = node; } else { last->next = node; } node->prev = m_sentinel; m_sentinel->prev = node; ++m_size; } public: List() : m_sentinel(std::make_shared<NodeBase>()) {} List(const List& other) : m_sentinel(std::make_shared<NodeBase>()) { for (const auto& v : other) push_back(v); } List(List&& other) noexcept : List() { swap(other); } List& operator=(const List& other) { if (this != &other) { List tmp(other); swap(tmp); } return *this; } List& operator=(List&& other) noexcept { if (this != &other) { clear(); swap(other); } return *this; } ~List() { clear(); } class iterator { NodeBase* cur = nullptr; NodeBase* sen = nullptr; public: explicit iterator(NodeBase* c, NodeBase* s) : cur(c), sen(s) {} using iterator_category = std::bidirectional_iterator_tag; using value_type = T; using difference_type = std::ptrdiff_t; using pointer = T*; using reference = T&; reference operator*() const { return node()->data; } pointer operator->() const { return std::addressof(node()->data); } iterator& operator++() { assert(cur != nullptr); cur = cur->next.get(); return *this; } iterator operator++(int) { iterator tmp(*this); ++(*this); return tmp; } iterator& operator--() { if (cur == nullptr) cur = sen->prev.lock().get(); else cur = cur->prev.lock().get(); return *this; } iterator operator--(int) { iterator tmp(*this); --(*this); return tmp; } bool operator==(const iterator& other) const { return cur == other.cur; } bool operator!=(const iterator& other) const { return !(*this == other); } private: Node<T>* node() const { assert(cur != nullptr); return static_cast<Node<T>*>(cur); } friend class List; }; iterator begin() { return iterator(m_sentinel->next.get(), m_sentinel.get()); } iterator end() { return iterator(nullptr, m_sentinel.get()); } bool empty() const noexcept { return m_size == 0; } std::size_t size() const noexcept { return m_size; } void push_back(const T& value) { link_back<T>(value); } void push_back(T&& value) { link_back<T>(std::move(value)); } void push_front(const T& value) { emplace_front(value); } void push_front(T&& value) { emplace_front(std::move(value)); } template <typename... Args> void emplace_back(Args&&... args) { link_back<T>(std::forward<Args>(args)...); } template <typename... Args> void emplace_front(Args&&... args) { auto node = std::make_shared<Node<T>>(std::forward<Args>(args)...); auto first = m_sentinel->next; if (first) { first->prev = node; } else { m_sentinel->prev = node; } node->next = first; node->prev = m_sentinel; m_sentinel->next = node; ++m_size; } template <typename... Args> iterator emplace(iterator pos, Args&&... args) { if (pos == end()) { link_back<T>(std::forward<Args>(args)...); return iterator(m_sentinel->prev.lock().get(), m_sentinel.get()); } NodeBase* cur = pos.cur; std::shared_ptr<NodeBase> prev = cur->prev.lock(); std::shared_ptr<NodeBase> next = prev->next; auto node = std::make_shared<Node<T>>(std::forward<Args>(args)...); prev->next = node; node->prev = prev; node->next = next; next->prev = node; ++m_size; return iterator(node.get(), m_sentinel.get()); } iterator insert(iterator pos, const T& value) { return emplace(pos, value); } iterator insert(iterator pos, T&& value) { return emplace(pos, std::move(value)); } iterator erase(iterator pos) { assert(pos != end()); NodeBase* cur = pos.cur; std::shared_ptr<NodeBase> prev = cur->prev.lock(); std::shared_ptr<NodeBase> next = cur->next; if (next) next->prev = prev; else m_sentinel->prev = prev; prev->next = next; --m_size; return iterator(next.get(), m_sentinel.get()); } void pop_front() { erase(begin()); } void pop_back() { erase(iterator(m_sentinel->prev.lock().get(), m_sentinel.get())); } void clear() noexcept { while (m_sentinel->next) { m_sentinel->next = m_sentinel->next->next; } m_sentinel->prev.reset(); m_size = 0; } void swap(List& other) noexcept { m_sentinel.swap(other.m_sentinel); std::swap(m_size, other.m_size); } }; template <typename T> void swap(List<T>& a, List<T>& b) noexcept { a.swap(b); }

4.2 基础行为测试

用一个简单的测试程序验证插入、删除、遍历、深拷贝、移动等行为:

#include <iostream> struct Print { static void show(const List<int>& lst) { for (auto it = lst.begin(); it != lst.end(); ++it) { std::cout << *it << ' '; } std::cout << '\n'; } }; int main() { List<int> lst1; lst1.push_back(1); lst1.push_back(2); lst1.push_front(0); lst1.emplace_back(3); lst1.insert(++lst1.begin(), 100); Print::show(lst1); // 0 100 1 2 3 lst1.erase(lst1.begin()); Print::show(lst1); // 100 1 2 3 lst1.pop_back(); Print::show(lst1); // 100 1 2 List<int> lst2(lst1); // 深拷贝 lst2.push_back(99); Print::show(lst1); // 100 1 2 Print::show(lst2); // 100 1 2 99 List<int> lst3(std::move(lst2)); Print::show(lst3); // 100 1 2 99 std::cout << lst3.size() << '\n'; return 0; }

实测输出符合预期。重点验证了insert在begin()位置插入、在中间位置插入,以及erase头节点时迭代器返回新头节点这几个容易出错的分支。

4.3 析构顺序与内存行为观察

循环引用是否真正解决,靠肉眼不容易看出来。最直接的方法是让节点元素在析构时打印一行日志,看它是否在容器析构时被完整调用。

用一个简单的辅助类型:

struct Trace { int v; explicit Trace(int x) : v(x) { std::cout << "ctor " << v << '\n'; } Trace(const Trace& o) : v(o.v) { std::cout << "copy ctor " << v << '\n'; } Trace(Trace&& o) noexcept : v(o.v) { std::cout << "move ctor " << v << '\n'; } ~Trace() { std::cout << "dtor " << v << '\n'; } };

测试代码如下:

{ List<Trace> traces; traces.emplace_back(1); traces.emplace_back(2); traces.emplace_back(3); std::cout << "--- clear ---\n"; traces.clear(); std::cout << "--- destruct ---\n"; }

输出里每个Trace都会恰好析构一次,和构造次数一一对应。没有出现“构造了3个但析构了0个”的情况,说明prev换成weak_ptr之后,节点在clear和容器析构时都能被正常释放。如果把prev改回shared_ptr再跑一次,输出会明显缺少析构日志,内存泄漏就直观暴露出来了。

4.4 const迭代器与补充说明

上面代码里我只写了iterator。真要作为完整容器,还需要const_iterator,以及begin/end的const重载。const_iterator的写法与iterator几乎一样,只是解引用返回const T&,递增递减逻辑完全复用。篇幅关系我没有全部贴出来,但接口设计上应该补上。学习的时候可以把这个作为练习,自己加上去,顺便理解const传播对迭代器类型的影响。

5. 踩坑实录与排查技巧

5.1 深链表析构栈溢出:最容易踩的坑

我第一次写完这个list时,没有显式写析构函数,默认析构会让m_sentinel析构,m_sentinel->next也会析构,递归地释放每一个节点。

链表不长的时候完全正常。直到我用它装了十几万个元素,程序直接崩溃。崩溃信息指向深拷贝的递归析构,栈完全耗尽。

解决办法就是我前面写的clear()加上~List(){ clear(); }。这个坑是shared_ptr容器特有的,原生指针容器不会有这个问题,因为原生析构天然是循环。你可以试着在NodeBase的析构函数里打印信息,观察析构顺序,就会看到默认析构是一层一层的递归调用。

5.2 怎么确认循环引用真的解决了

有三种手段可以验证。

第一种是析构日志法,构造一个带日志的节点类型,插入几个元素,然后观察容器析构时析构函数是否被完整调用。这种方法直观且零依赖。

第二种是内存检测工具。用内存检测工具跑一遍测试用例,如果工具报了内存泄漏,说明还有环没破;如果没有报告,就说明析构路径是干净的。这种方法的优点是可以检测出程序内部其他地方的泄漏。

第三种是引用计数法,在NodeBase的析构函数里加一个全局计数器,程序结束时检查计数器是否为0。如果计数器不为0,说明还有节点对象或控制块没有被释放。

实际排查时,我建议三种一起上:先用日志快速定位路径,再用内存检测工具确认全局没有泄漏。注意shared_ptr的循环引用很多时候不是立刻表现出来,而是等到大量创建销毁时内存持续增长,对线上服务影响非常隐蔽。

5.3 shared_ptr(裸指针)的死亡陷阱:重复释放

这个坑我在写insert时几乎踩进去。如果当前节点是cur,千万不要写:

// 错误写法:创建一个新的控制块 std::shared_ptr<NodeBase> cur_sp(cur);

这个cur目前的所有权在前驱节点的next成员里。如果你凭空又建了一个shared_ptr包裹同样的裸指针,等于有两个控制块认为自己拥有同一个对象。等这个临时shared_ptr析构,它会直接释放cur,链表里还有一个shared_ptr指向一块已经释放的内存,然后访问时就是悬垂,析构时又释放一次,双重释放。

正确做法永远是从“持有者”那里拷贝。要拿cur的shared_ptr,就看它的前驱节点的next,cur->prev.lock()->next。这条规则适用于所有需要获取节点shared_ptr的场景。永远不要试图把一个裸指针包成新的shared_ptr。

这也是为什么我要先把prev = cur->prev.lock()写出来,而不是直接访问cur->prev.lock()->next。前者把shared_ptr保存在局部变量里,中途不会失效;后者如果把lock()的临时对象用完就释放,再访问next时可能出错。

5.4 weak_ptr的lock()为什么返回空

用weak_ptr做prev,遍历时调用cur->prev.lock()。如果这个节点是链表的头节点,prev指向哨兵,哨兵永远存活,lock永远成功。如果一个节点已经被外部释放,那么它的weak_ptr会变成悬空状态,lock返回空指针。

常见错误是在迭代器内部没有对lock结果判空就直接get()。比如遍历过程中erase了某个节点,又继续使用指向它的旧迭代器,那么it->prev对应的weak_ptr很可能已经失效,lock返回空指针,get()得到nullptr,再往下走就是空指针解引用。

解决方法有两个层面。第一,遵循迭代器失效规则,erase之后不使用旧迭代器,这样的问题根本不会出现。第二,如果你确实需要在可能失效的weak_ptr上操作,先lock再检查是否为空,这是weak_ptr的标准用法。

5.5 迭代器失效规则与std::list的一致性

这套实现里,迭代器用裸指针保存节点地址,失效规则和std::list基本一致:erase掉的节点的迭代器失效,其他节点的迭代器继续保持有效。insert操作不会让现有迭代器失效,因为插入不改变已有节点的地址。

有一个细节和std::list不同:因为end()是用空指针表示的,erase返回的迭代器是下一个节点的迭代器,这没问题。但对于尾节点,erase返回的是end(),也就是空指针,这和std::list返回哨兵节点迭代器在逻辑上等价,但比较方式不同。要注意不能对这个返回值继续做--运算,否则会得到未被定义的指针操作,虽然理论上--end()是合法的,但在空链表上--end()本身就是未定义行为。

5.6 其他零碎经验

make_shared一定要优先用。它一次分配节点和控制块,减少堆分配次数,对缓存更友好。如果写成shared_ptr<Node<T>>(new Node<T>(...)),会有两次分配:一次new节点,一次分配控制块。虽然这两块的分配器可以被定制,但额外的分配开销和内存碎片都是实打实的。

考虑异常安全时,emplace后如果链表结构更新中抛出异常(比如T的移动构造函数抛出),节点可能已经分配但未入链。使用局部shared_ptr可以保证异常路径上节点被自动释放,不会泄漏。但链表结构的一致性仍然需要靠异常规范来保证,这个练习里没有刻意处理,足够日常学习使用。

6. 一些真实体会

这套list写下来,我最大的感受是:shared_ptr不是万能的,但用对了地方确实能让代码语义清晰很多。循环引用不是shared_ptr的问题,而是“强引用方向设计”的问题。只要你想清楚谁拥有谁,明确weak_ptr只做辅助查找,它就能成为一把锋利的手术刀。

再分享一个小技巧:练习的时候可以把NodeBase的析构函数加上一个静态计数器,然后跑一千次插入、删除、清空,最后打印计数。这个“计数归零”实验比任何文字说明都有说服力,能让你直观地看到智能指针在底层做了什么。做完这个实验,你对shared_ptr的引用计数机制、控制块生命周期、weak_ptr的失效行为都会有全新的理解。

如果接下来想继续扩展,可以在这份代码的基础上加const_iterator、reverse_iterator、支持自定义分配器,或者尝试把next也换成unique_ptr,看看所有权语义会产生什么变化。每种尝试都会让你对C++内存模型的理解更扎实一层。这份list的代码量不大,但值得反复读几遍,每一遍都能抠出新东西。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 6:39:48

Python门诊挂号系统:号源动态管理与高并发实战

简介&#xff1a;本资源是一套完整的医院门诊挂号与预约系统毕业设计级源码&#xff0c;面向计算机专业本科生、医疗信息化初学者及全栈开发学习者&#xff0c;旨在解决传统挂号流程效率低、信息不透明、系统耦合度高等现实问题。压缩包共220个文件&#xff0c;总大小8.39MB&am…

作者头像 李华
网站建设 2026/10/14 6:39:28

Vibe Coding工具选型与四步实施框架:效率提升27%与成本差24倍背后的工程实践

1. 效率数字背后的真实账本先把标题里两个数字拆开看。效率提升27%这个量级&#xff0c;放在任何研发团队里都算得上可观&#xff0c;但它不是凭空冒出来的——它通常来自编码环节中重复劳动被压缩、上下文切换次数减少、以及试错周期缩短这三块的叠加。而成本差24倍这个说法&a…

作者头像 李华
网站建设 2026/10/14 6:39:21

基于YOLOv8的外墙裂缝检测系统:从数据标注到界面部署全解析

外墙裂缝检测这几年在建筑安全评估、老旧小区改造、物业管理里需求增长很快&#xff0c;很多读者私信问有没有现成的方案能直接改一改用于课程设计或者小规模工程试点。我最近把一套基于YOLOv8的外墙裂缝检测识别系统完整落地了&#xff0c;做了中英文双版界面&#xff0c;包含…

作者头像 李华
网站建设 2026/10/14 6:39:04

抢券脚本开发实战:从抓包逆向到高并发请求调度

简介&#xff1a;本资源是一份面向前端开发者与自动化脚本初学者的可运行抢券工具包&#xff0c;聚焦电商平台&#xff08;如某宝&#xff09;限时抢券场景&#xff0c;解决人工手动刷新易错过时机、操作重复性高、页面响应不稳定等实际痛点。压缩包共3个文件&#xff0c;含1个…

作者头像 李华
网站建设 2026/10/14 6:39:03

联邦学习与区块链结合:构建可信去中心化AI协作系统

联邦学习这几年不算新鲜词了&#xff0c;但真正上手去搭一个跨机构协作系统的人应该都有同感&#xff1a;算法层其实挺成熟的&#xff0c;难的是怎么让参与各方真正信任这个协作过程。我去年跟着一个多机构联合建模的试点项目做技术调研&#xff0c;越往下看越觉得&#xff0c;…

作者头像 李华
网站建设 2026/10/14 6:38:48

文件上传服务端接口设计:对象存储与文件管理实战

这一期聊聊服务端接口开发里最容易翻车的一块&#xff1a;文件上传。咱们这个零代码实战项目做到第五期&#xff0c;前几期把产品定位、页面结构、数据模型都理清了&#xff0c;从这期开始正式写服务端接口。这次要处理的是一套组合拳&#xff1a;文件上传&#xff08;File Upl…

作者头像 李华