使用 std::shared_ptr 的第一天,大多数人都觉得它实现了“自动垃圾回收”。但用到第三个月,你可能开始怀疑:为什么析构函数永远不被调用?
循环引用从来不是停留在教材里的“理论问题”,而是 C++ 项目中最为隐蔽、最难排查的一类内存泄漏。更麻烦的是,它通常不会导致程序崩溃或告警,只会悄无声息地吞噬内存。
下面这 5 种典型场景,几乎都在真实的工程代码中出现过。
一、最经典的双向引用(教科书案例)
这是 shared_ptr 第一个也是最容易被忽略的坑。
struct B;
struct A {
std::shared_ptr<B> b;
~A() { std::cout << "~A\n"; }
};
struct B {
std::shared_ptr<A> a;
~B() { std::cout << "~B\n"; }
};
int main() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->b = b;
b->a = a;
}
程序运行结束,你将看不到任何析构函数的输出。
原因很直观:A 和 B 对象的引用计数都至少为 1,永远无法归零。
正确做法
在双向关系中,必须有一方是“观察者”,应使用 std::weak_ptr:
struct B {
std::weak_ptr<A> a;
};
这是 C++ 标准库官方文档明确推荐的所有权模型。
二、父子对象模型中“想当然”的 shared_ptr
许多开发者在编写树形结构(如 DOM、AST、场景图)时会这样设计:
struct Node {
std::shared_ptr<Node> parent;
std::vector<std::shared_ptr<Node>> children;
};
从数据结构上看很对称,但从语义上就是错误的。
children 容器拥有其子节点。
parent 指针只是一个用于回溯的导航指针。
一旦 parent 也使用 shared_ptr,整棵树将被锁定,无法释放。
行业共识
拥有关系用 shared_ptr,回溯关系用 weak_ptr。
struct Node {
std::weak_ptr<Node> parent;
std::vector<std::shared_ptr<Node>> children;
};
这不是简单的编码习惯问题,而是对象所有权建模的核心问题。
三、回调或观察者模式中的隐式循环
这是实际项目里最容易被忽略的一类循环引用。
class Service : public std::enable_shared_from_this<Service> {
public:
void registerCallback() {
callback_ = [self = shared_from_this()] {
self->doSomething();
};
}
private:
std::function<void()> callback_;
};
代码看起来很合理,甚至很“现代 C++”。但关系链条是:
Service 对象拥有 callback_ 成员。
callback_ 的 lambda 表达式捕获了一个 shared_ptr<Service>。
对象自己把自己给“锁住”了,析构函数永远不会被调用。
修复方式
在捕获时使用 weak_ptr:
void registerCallback() {
std::weak_ptr<Service> weakSelf = shared_from_this();
callback_ = [weakSelf] {
if (auto self = weakSelf.lock()) {
self->doSomething();
}
};
}
这是 GUI 框架、网络库、事件系统中最常见的循环引用来源之一。
四、enable_shared_from_this 在构造期间的误用
这个陷阱更为隐蔽,常常在代码重构后才暴露出来。
class Foo : public std::enable_shared_from_this<Foo> {
public:
Foo() {
auto self = shared_from_this(); // 未定义行为!
}
};
shared_from_this() 只能在对象已经被一个 shared_ptr 管理之后调用。
虽然这不是传统意义上的“循环引用”,但很多开发者为了修复这个问题,会在外层额外包裹一层 shared_ptr,结果反而制造出真正的循环。
正确模式
将初始化逻辑移出构造函数:
auto foo = std::make_shared<Foo>();
foo->init(); // 在 init() 方法中使用 shared_from_this
构造函数里,应避免执行任何与对象生命周期和所有权相关的操作。
五、容器缓存导致的“永久存活”
最后一个陷阱常见于缓存、对象池、单例管理器等场景。
class Manager {
public:
std::unordered_map<int, std::shared_ptr<Item>> items;
};
struct Item {
std::shared_ptr<Manager> mgr;
};
Manager 通过 items 映射拥有 Item,而 Item 又反过来拥有 Manager。
这类问题的危险性在于:程序运行一切正常,功能无误,只是内存占用永不下降。
解决方案依然是明确语义:
- 管理器拥有对象 → 使用
shared_ptr。
- 对象需要回指管理器 → 使用
weak_ptr。
写在最后:shared_ptr 不是“免死金牌”
C++ 没有垃圾回收机制,std::shared_ptr 只是一个基于引用计数的工具。
判断是否该用 shared_ptr,其实只需问一个核心问题:
这个指针,是否真的“拥有”它所指向的对象?
- 是,表示拥有所有权 → 使用
shared_ptr。
- 否,只是观察、引用或访问 → 使用
weak_ptr 或裸指针。
如果你在项目中遇到析构函数“莫名其妙不执行”,第一反应不应是怀疑编译器或 STL 的 bug,而应该从画出 shared_ptr 的关系图开始。将对象间的引用关系可视化,问题往往就解决了一半。
理解并避免这些常见的 std::shared_ptr 陷阱,是掌握现代 C++ 内存管理的关键一步。想要深入探讨更多 C++ 高级话题与实践,欢迎访问 云栈社区 与更多开发者交流。