C++中的循环引用

发布时间:2026/10/20 20:53:01

C++中的循环引用 前言「循环引用」在 C 语境里有两个常见所指一是智能指针之间的所有权成环导致引用计数永远降不到零二是头文件互相包含导致类型定义顺序或编译依赖出问题。两者都叫循环引用但机制和解法完全不同。本文以第一类为主因为它是使用std::shared_ptr时最容易造成内存泄漏的坑最后一节再简要说明头文件循环包含的处理方式。先澄清一个误解循环引用并不等于「程序一定崩溃」。它通常表现为内存缓慢增长、对象析构函数不执行、文件句柄或网络连接迟迟不释放。程序可能长时间看起来正常直到资源耗尽。另一个误解是「用了智能指针就不会泄漏」——std::shared_ptr解决的是「谁负责释放」解决不了「所有权成环」环上的对象互相持有强引用引用计数始终至少为 1。本文讲清shared_ptr的计数模型说明std::weak_ptr如何打破环给出父子关系、观察者模式等实际场景的写法并补充检测思路和头文件循环包含的处理。一、引用计数为什么会被环卡住std::shared_ptr由两部分组成指向对象的指针以及指向控制块control block的指针。控制块里维护强引用计数strong count与弱引用计数weak count。拷贝一个shared_ptr会让强计数加一销毁一个会让它减一强计数归零时销毁被管理对象弱计数也归零时释放控制块本身。关键点在于销毁被管理对象的条件是强计数归零而强计数只统计shared_ptr的个数不分析对象之间的引用关系。看下面这个双向链表节点// 适用于 C17#include memory#include string#include iostreamstruct Node {std::string name;std::shared_ptrNode next;std::shared_ptrNode prev;~Node() { std::cout destroy name \n; }};int main() {auto a std::make_sharedNode();auto b std::make_sharedNode();a-name a;b-name b;a-next b;b-prev a; // 形成环a 持有 bb 持有 areturn 0;}main返回时局部变量a和b被销毁强计数各减一但a的计数仍为 1被b-prev持有b的计数也仍为 1被a-next持有。两个对象都达不到零析构函数不会执行两块内存泄漏。注意这里~Node的输出来自代码逻辑实际运行时是否打印取决于是否成环按标准语义成环时不会打印。这个例子说明引用计数是一种局部规则它只回答「还有多少个强引用」不回答「这些引用是否构成可达的环」。垃圾回收器会做可达性分析引用计数不会。二、用weak_ptr打破环std::weak_ptr是对shared_ptr管理对象的非拥有non-owning引用。它不增加强计数只增加弱计数因此不会延长对象的生命周期。使用时通过lock()提升为shared_ptr对象还活着就返回有效指针已经销毁就返回空指针。也可以用expired()判断是否失效。把双向链表中的一条边改成weak_ptr环就被打破// 适用于 C17#include memory#include string#include iostreamstruct Node {std::string name;std::shared_ptrNode next; // 拥有下一个节点std::weak_ptrNode prev; // 观察上一个节点不拥有~Node() { std::cout destroy name \n; }};int main() {auto a std::make_sharedNode();auto b std::make_sharedNode();a-name a;b-name b;a-next b;b-prev a;if (auto p b-prev.lock()) {std::cout prev of b is p-name \n;}return 0;}现在a只被局部变量和b-prev弱引用指向b只被a-next和局部变量指向。main结束时局部变量销毁a的强计数归零a析构a-next随之销毁b的强计数也归零b析构。整条链正常释放。选择哪条边用弱引用取决于所有权语义单向链表用next拥有、prev观察是常见约定树结构中父节点拥有子节点子节点只观察父节点图结构则要给每条边明确方向避免所有边都是强引用。一个实用原则是让所有权形成有向无环图DAG环上的「回边」用weak_ptr。特性shared_ptrweak_ptr是否影响对象生命周期是增加强计数否只增加弱计数能否直接解引用能operator*、operator-不能必须先lock()典型用途表达所有权表达观察、打破环、缓存失效后的行为对象在计数归零时销毁lock()返回空shared_ptr构造方式make_shared、从其他shared_ptr拷贝从shared_ptr或weak_ptr构造weak_ptr的lock()在并发场景下是安全的它原子地检查对象是否存活并增加强计数因此不会出现「检查时还活着、使用时已被销毁」的竞态。但要注意lock()成功只保证在你持有返回的shared_ptr期间对象存活释放这个shared_ptr后对象可能立即销毁。需要多次访问时把lock()的结果存下来复用而不是每次访问都重新lock()。weak_ptr也有代价它需要额外的原子操作且控制块必须等到弱计数也归零才释放。因此不要为了「看起来安全」把所有shared_ptr都换成weak_ptr它表达的是「我不拥有它」滥用会让所有权关系变得含糊。三、典型场景与排查思路父子关系是循环引用最高频的来源。父对象持有子对象的容器子对象又保存指向父对象的指针如果两边都用shared_ptr每个子对象都会把父对象钉住。正确写法是父持有子、子弱引用父// 适用于 C17#include cstddef#include memory#include string#include utility#include vectorclass Parent;class Child {public:explicit Child(std::string name, std::weak_ptrParent parent): name_(std::move(name)), parent_(std::move(parent)) {}const std::string name() const noexcept { return name_; }std::shared_ptrParent parent() const {return parent_.lock();}private:std::string name_;std::weak_ptrParent parent_;};class Parent : public std::enable_shared_from_thisParent {public:void addChild(const std::string name) {children_.push_back(std::make_sharedChild(name, shared_from_this()));}std::size_t childCount() const noexcept { return children_.size(); }private:std::vectorstd::shared_ptrChild children_;};这里Parent继承std::enable_shared_from_thisParent从而能在成员函数里通过shared_from_this()取得指向自身的shared_ptr再以weak_ptr形式交给子对象。使用shared_from_this()有一个前提对象必须已经由某个shared_ptr管理。对栈上对象或由裸指针管理的对象调用它C17 起会抛出std::bad_weak_ptr更早的标准里行为未定义。因此Parent应当通过std::make_sharedParent()创建。观察者模式也常踩这个坑。被观察者保存一列观察者观察者又持有被观察者的shared_ptr以便回调于是双方互强引用。解决办法同样是单向所有权被观察者持有观察者的weak_ptr通知时逐个lock()失败的说明观察者已销毁顺手清理。这样观察者的生命周期由外部决定被观察者不会阻止其析构。异步回调是另一个隐蔽来源。比如定时器持有shared_ptrSelf的 lambda// 适用于 C17示意生命周期不是完整定时器实现auto self shared_from_this();registerCallback([self]() {self-onTimer();});如果回调注册后永远不触发、也不被注销self就永远活着如果对象内部又持有这个回调句柄就形成环。正确做法是捕获weak_ptr回调触发时lock()失败就直接返回。这样对象销毁后回调自然失效不会延长生命周期。循环引用造成的泄漏不会报错只能靠工具和代码审查发现。常用手段有三类。第一在关键类型析构函数里打日志或设置断点观察对象是否按预期销毁。这是最直接的办法适合定位「哪个对象没释放」。第二用use_count()观察计数。它适合调试但要注意多线程环境下返回值只是瞬时快照不能作为同步依据某些实现包括标准库的优化可能不精确标准只保证它返回当前的强引用数近似值。第三使用 AddressSanitizer 的泄漏检测LeakSanitizer或 Valgrind 的泄漏检查。它们能在程序退出时报告未释放的堆块配合调试符号可以定位分配栈。具体命令行与平台支持以工具官方文档为准。代码审查时可以问三个问题这个shared_ptr成员真的需要拥有对方吗两个对象之间是否存在双向的强引用回调捕获的是shared_ptr还是weak_ptr大部分环都能在这三个问题里暴露。四、另一种循环引用头文件互相包含当头文件 A 包含 B、B 又包含 A 时包含卫士include guard或#pragma once只能防止同一文件被重复展开不能解决类型不完整的问题。典型症状是「unknown type name」或在某个头文件里看到另一个类只声明未定义。处理原则是能前置声明就不包含头文件。如果 A 的类只用到B*、B或std::shared_ptrB这类不需要完整类型的成员可以在 A 中写class B;前置声明把#include b.h移到实现文件。只有在需要按值成员、继承、访问成员或调用内联函数时才必须包含完整定义。// 适用于 C17// a.h#pragma once#include memoryclass B; // 前置声明足以声明 shared_ptrB 成员class A {public:void setB(std::shared_ptrB b);void useB();private:std::shared_ptrB b_;};std::shared_ptrB允许B不完整因为删除器信息在控制块里但std::unique_ptrB的析构需要完整类型若在头文件中定义析构函数就必须看到B的定义通常把析构函数声明在头文件、定义在实现文件pimpl 手法。这些规则与智能指针的循环引用是两回事但都源于「依赖关系成环」设计时减少双向依赖两类问题都会少很多。常见坑点❌ 父子对象互相用shared_ptr持有。 ✅ 父持有子的shared_ptr子持有父的weak_ptr用lock()临时提升。❌ 用weak_ptr后直接-访问成员。 ✅ 先lock()得到shared_ptr并判空再访问weak_ptr没有解引用运算符。❌ 每次访问都调用lock()用完立刻释放中间对象被销毁。 ✅ 把lock()的结果保存为局部shared_ptr覆盖整个使用区间。❌ 对未被shared_ptr管理的对象调用shared_from_this()。 ✅ 确保对象由make_shared或shared_ptr构造C17 起否则抛std::bad_weak_ptr。❌ 异步回调捕获shared_ptr自身且回调永不注销。 ✅ 捕获weak_ptr触发时lock()判空避免回调延长对象生命周期。❌ 用同一个裸指针构造两个独立的shared_ptr。 ✅ 用make_shared或从一个已有的shared_ptr拷贝重复构造会产生两个控制块导致重复释放。❌ 以为use_count()在多线程下是精确的同步依据。 ✅ 只把它当调试参考同步用互斥量或原子操作。❌ 头文件互相包含靠 include guard 硬扛。 ✅ 用前置声明加实现文件包含减少编译期依赖必要时用 pimpl 隐藏实现。总结主题关键结论成环原因强引用计数只看数量不做可达性分析破环工具std::weak_ptr不增加强计数lock()提升所有权设计让强引用构成有向无环图回边用弱引用父子关系父持子强引用子持父弱引用回调捕获weak_ptr避免异步任务钉住对象头文件循环前置声明、实现文件包含、pimpl循环引用的根源不是智能指针本身而是所有权关系没有设计成单向。写代码时先问「谁拥有谁、谁只观察谁」再决定用shared_ptr还是weak_ptr比事后用工具找泄漏更省事。记住weak_ptr的语义是「我引用它但不为它的生命周期负责」——这句话既是约束也是设计文档。
延伸阅读

更多相关文章

2026/10/20 20:53:01

NLP数据增广实战:基于Transformers的四种方法详解

1. 为什么要在 NLP 里做数据增广先把话说在前面:很多人一听到“数据增广”,第一反应是计算机视觉那套——翻转、裁剪、调亮度,一张图变十张图。这个思路放到 NLP 里,天然就容易踩坑。原因很简单:图像是连续的像素&…

2026/10/20 20:53:01

AI爬虫技能实战:从反爬识别到合规数据采集

做数据采集的人,几乎都逃不过“反爬”这两个字。就在上个月,我给自己那个只会聊天的AI助手——代号“小龙虾”——加了一套爬虫技能,让它能自己看网页、提取数据、清洗入库。整个过程踩了不少坑,但也整理出了一套相对完整的思路。…

2026/10/20 20:53:01

TransNeXt聚合注意力在医学图像分割中的原理与实战

1. 从一张病理切片说起:为什么传统卷积网络在医学图像上总差那么一口气第一次接触医学图像分割,是帮一个做病理分析的朋友处理一批肝脏切片。那批数据里,肿瘤区域和正常组织的边界模糊得像水墨画晕开的边缘,用当时主流的卷积网络跑…

2026/10/20 21:49:13

AI女装模特视频生成实战:六款工具对比与完整制作链路

开年以来我一直在帮几个服装类目的店铺做视觉素材,最常被问的问题就是:现在AI生图、AI视频工具这么多,到底该用哪个?尤其最近大家都在聊AI女装模特视频,很多同行一上来就问我哪款工具最牛,我通常会反问一句…

2026/10/20 21:49:13

半监督学习实战指南:从伪标签到一致性正则化的算法与应用

半监督学习在业内一直是个“听着冷门、用着真香”的方向。核心一句话:手头只有少量带标签的数据,却有大量没标签的数据,怎么把模型练好?这几乎是工业界默认的常态——标注贵、标注慢、标注还容易出错,而最不缺的就是裸…

2026/10/20 21:49:13

Flutter工具类库实战复盘:从散装代码到团队基础设施

做Flutter开发这几年,我最怕的不是复杂的嵌套路由,也不是状态管理框架选型,而是那些散落在各个业务文件里的工具方法。早些年我们团队做一款面向多端的应用,每个人都在自己负责的页面里写过日期格式化、手机号脱敏、金额分位处理这…

2026/10/20 21:49:13

LLM服务优化:连续批处理、显存管理与调度策略解析

LLM服务(LLM Serving)大概是过去一年多里被讨论得最多、也最容易被低估的工程课题。训练一个模型可以慢慢磨,把模型跑起来也许只需要一条命令行,但当并发请求从几十涨到几百、承载的业务从聊天扩展到代码补全、RAG问答和生产级Age…

2026/10/20 21:49:13

context-mode:多场景上下文管理的模式化架构设计与实践

1. 从“context-mode”说起:一个被低估的上下文管理思路第一次看到“context-mode”这个词,很多人会下意识地把它归到某个具体框架的配置项里,觉得无非就是个开关或者枚举值。但如果你真正在复杂项目里摸爬滚打过一段时间,就会发现…

2026/10/20 21:43:57

工作区路径信任级别:构建Web管理界面的安全防线

1. 项目概述:当"路径信任"成为Web管理界面的安全命门先别急着看标题里的"工作区路径信任级别"这几个字觉得晦涩——说白了,这就是在回答一个问题:当你在浏览器里打开一个管理界面,操作着服务器上的文件、目录…

2026/10/19 18:06:35

金融AI落地指南:从调研报告到技术选型与工程实践

简介:这份PDF报告聚焦2025年金融服务业中国AI现状与趋势,面向金融科技从业者、AI战略规划人员及关注行业数字化转型的研究者,帮助读者把握生成式AI在银行、支付、交易等场景中的落地路径与投资方向。资源包内含1个PDF文件,大小约1…

2026/10/20 1:05:37

单片机C运行时从启动到多任务:中断安全与共享资源冲突

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:05:37

GCC编译四步详解:预处理、编译、汇编、链接实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:05:37

FIW线是什么?FEP注塑线与普通氟塑料线的区别及应用指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑