☰
C++中的循环引用
2026/10/20 20:52:49 网站建设 项目流程

前言


「循环引用」在 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的个数,不分析对象之间的引用关系。看下面这个双向链表节点:


// 适用于 C++17
#include <memory>
#include <string>
#include <iostream>

struct Node {
std::string name;
std::shared_ptr<Node> next;
std::shared_ptr<Node> prev;
~Node() { std::cout << "destroy " << name << '\n'; }
};

int main() {
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->name = "a";
b->name = "b";
a->next = b;
b->prev = a; // 形成环:a 持有 b,b 持有 a
return 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,环就被打破:


// 适用于 C++17
#include <memory>
#include <string>
#include <iostream>

struct Node {
std::string name;
std::shared_ptr<Node> next; // 拥有下一个节点
std::weak_ptr<Node> prev; // 观察上一个节点,不拥有
~Node() { std::cout << "destroy " << name << '\n'; }
};

int main() {
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
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,每个子对象都会把父对象钉住。正确写法是父持有子、子弱引用父:


// 适用于 C++17
#include <cstddef>
#include <memory>
#include <string>
#include <utility>
#include <vector>

class Parent;

class Child {
public:
explicit Child(std::string name, std::weak_ptr<Parent> parent)
: name_(std::move(name)), parent_(std::move(parent)) {}

const std::string& name() const noexcept { return name_; }

std::shared_ptr<Parent> parent() const {
return parent_.lock();
}

private:
std::string name_;
std::weak_ptr<Parent> parent_;
};

class Parent : public std::enable_shared_from_this<Parent> {
public:
void addChild(const std::string& name) {
children_.push_back(
std::make_shared<Child>(name, shared_from_this()));
}

std::size_t childCount() const noexcept { return children_.size(); }

private:
std::vector<std::shared_ptr<Child>> children_;
};

这里Parent继承std::enable_shared_from_this<Parent>,从而能在成员函数里通过shared_from_this()取得指向自身的shared_ptr,再以weak_ptr形式交给子对象。使用shared_from_this()有一个前提:对象必须已经由某个shared_ptr管理。对栈上对象或由裸指针管理的对象调用它,C++17 起会抛出std::bad_weak_ptr;更早的标准里行为未定义。因此Parent应当通过std::make_shared<Parent>()创建。


观察者模式也常踩这个坑。被观察者保存一列观察者,观察者又持有被观察者的shared_ptr以便回调,于是双方互强引用。解决办法同样是单向所有权:被观察者持有观察者的weak_ptr,通知时逐个lock(),失败的说明观察者已销毁,顺手清理。这样观察者的生命周期由外部决定,被观察者不会阻止其析构。


异步回调是另一个隐蔽来源。比如定时器持有shared_ptr<Self>的 lambda:


// 适用于 C++17,示意生命周期,不是完整定时器实现
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_ptr<B>这类不需要完整类型的成员,可以在 A 中写class B;前置声明,把#include "b.h"移到实现文件。只有在需要按值成员、继承、访问成员或调用内联函数时,才必须包含完整定义。


// 适用于 C++17
// a.h
#pragma once
#include <memory>

class B; // 前置声明,足以声明 shared_ptr<B> 成员

class A {
public:
void setB(std::shared_ptr<B> b);
void useB();
private:
std::shared_ptr<B> b_;
};

std::shared_ptr<B>允许B不完整,因为删除器信息在控制块里;但std::unique_ptr<B>的析构需要完整类型,若在头文件中定义析构函数,就必须看到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构造;C++17 起否则抛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的语义是「我引用它,但不为它的生命周期负责」——这句话既是约束,也是设计文档。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询