10. shared_ptr 详解(下):循环引用问题、enable_shared_from_this 与 weak_ptr 的引入
上一讲我们把 shared_ptr 的引用计数原理、自定义删除器、线程安全边界都聊透了。今天要聊的这三个话题,说白了就是 shared_ptr 在实际项目中「翻车」最多的场景。
我个人习惯把这一讲称为「救火指南」。因为循环引用、裸指针返回、以及 weak_ptr 的正确用法,几乎每个 C++ 项目做到中期都会踩一遍。我自己就曾在线上服务里被循环引用搞出过内存泄漏,查了两天才定位到根因。
一、循环引用:shared_ptr 的阿喀琉斯之踵
先看一个最经典的场景:两个对象互相持有对方的 shared_ptr。
#include <memory>
#include <iostream>
struct B; // 前向声明
struct A {
std::shared_ptr<B> b_ptr;
~A() { std::cout << "A destroyed\n"; }
};
struct B {
std::shared_ptr<A> a_ptr;
~B() { std::cout << "B destroyed\n"; }
};
int main() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->b_ptr = b;
b->a_ptr = a;
// 离开作用域,a 和 b 都不会被销毁!
return 0;
}
运行这段代码,你会发现控制台没有任何输出。A 和 B 的析构函数都没被调用。
为什么会这样?
我们来画一下引用计数的变化:
- 创建 a 时,A 的引用计数 = 1
- 创建 b 时,B 的引用计数 = 1
- 执行
a->b_ptr = b,B 的引用计数变成 2 - 执行
b->a_ptr = a,A 的引用计数变成 2
当 main 函数结束,局部变量 a 和 b 被销毁。此时:
- A 的引用计数从 2 减到 1(还有 b 的 a_ptr 指着它)
- B 的引用计数从 2 减到 1(还有 a 的 b_ptr 指着它)
两边引用计数都没归零,所以两个对象都「赖着不走」。这就是循环引用导致的内存泄漏。
核心结论:shared_ptr 的引用计数只能处理「无环」的指向关系。一旦出现环,引用计数永远不会归零。
我的经验:我曾经在一个消息中间件项目里,用 shared_ptr 管理连接对象和会话对象。连接持有会话,会话也持有连接。结果服务跑了一周后内存暴涨,最后用 Valgrind 一查,全是循环引用。从那以后,我只要看到两个类互相包含对方的 shared_ptr,就会立刻警觉。
二、weak_ptr:打破循环的钥匙
weak_ptr 是 shared_ptr 的「小弟」。它不参与引用计数,只提供一种「弱引用」的观察能力。
用 weak_ptr 改写上面的例子:
struct A {
std::weak_ptr<B> b_ptr; // 改成 weak_ptr
~A() { std::cout << "A destroyed\n"; }
};
struct B {
std::weak_ptr<A> a_ptr; // 改成 weak_ptr
~B() { std::cout << "B destroyed\n"; }
};
int main() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->b_ptr = b;
b->a_ptr = a;
// 现在可以正常析构了!
return 0;
}
运行结果:
A destroyed
B destroyed
原理很简单:weak_ptr 赋值时不会增加引用计数。所以 a 和 b 的引用计数始终是 1。main 结束时,两个 shared_ptr 销毁,引用计数归零,对象正常释放。
但这里有个问题——weak_ptr 不能直接访问对象。你需要先把它「升级」成 shared_ptr:
if (auto sp = weak_b_ptr.lock()) {
// 安全使用 sp
sp->doSomething();
} else {
// 对象已经被销毁了
}
lock() 方法会尝试创建一个新的 shared_ptr。如果原对象还活着,就成功返回;如果已经销毁,就返回空指针。
注意:千万不要直接调用 weak_ptr::expired() 判断后再 lock()。因为判断和 lock 之间,对象可能被其他线程销毁。正确的做法是直接 lock,然后检查返回值是否为空。
三、enable_shared_from_this:安全地返回 this
这是另一个高频翻车点。假设你有一个类,它需要把自己的 this 指针作为 shared_ptr 返回给调用方:
struct BadExample {
std::shared_ptr<BadExample> getSelf() {
return std::shared_ptr<BadExample>(this); // 危险!
}
};
这段代码有什么问题?
如果同一个对象被多个 shared_ptr 管理,每个 shared_ptr 都会认为自己是「唯一的管理者」。当其中一个 shared_ptr 销毁时,它会 delete 对象。然后其他 shared_ptr 就成了悬空指针。
更糟糕的是,如果外部已经有一个 shared_ptr 管理着这个对象,你再用 this 构造一个新的 shared_ptr,就会有两个独立的控制块指向同一块内存——double free 等着你。
解决方案:让类继承 std::enable_shared_from_this<T>。
struct GoodExample : std::enable_shared_from_this<GoodExample> {
std::shared_ptr<GoodExample> getSelf() {
return shared_from_this(); // 安全!
}
};
shared_from_this() 会从对象内部已有的控制块中创建一个新的 shared_ptr,而不是重新创建一个。这样就保证了所有 shared_ptr 共享同一个控制块。
重要限制:必须在对象已经被 shared_ptr 管理之后,才能调用 shared_from_this()。如果对象是栈上分配的,或者还没有被任何 shared_ptr 接管,调用 shared_from_this() 会抛出 std::bad_weak_ptr 异常。
我踩过的坑:有一次我在工厂函数里返回了 std::make_shared<MyClass>(),然后在 MyClass 的构造函数里调用了 shared_from_this()。结果程序直接崩溃。原因就是:构造函数执行时,shared_ptr 还没完全构造好,控制块还没关联到对象上。正确的做法是在构造完成后,再调用需要 shared_from_this() 的方法。
四、知识体系总览
下面这张图把 shared_ptr、weak_ptr、enable_shared_from_this 的关系梳理清楚了:
五、最佳实践总结
聊了这么多,最后给几条我自己的铁律:
| 场景 | 推荐做法 | 原因 |
|---|---|---|
| 明确的所有权(父子关系) | unique_ptr | 零开销,语义清晰 |
| 共享所有权,无环 | shared_ptr | 引用计数自动管理 |
| 共享所有权,有环 | 一方用 weak_ptr 打破环 | 避免内存泄漏 |
| 需要返回 this 的 shared_ptr | 继承 enable_shared_from_this | 避免 double free |
| 观察者模式 | weak_ptr | 不延长对象生命周期 |
再强调一次:weak_ptr 的 lock() 和 expired() 不是原子组合。永远先 lock 再检查,不要先 expired 再 lock。
嗯,shared_ptr 的核心内容到这里就差不多了。循环引用、weak_ptr、enable_shared_from_this 这三个知识点,说白了就是「怎么安全地处理 shared_ptr 之间的复杂关系」。你在实际项目中只要记住一条原则:能用 unique_ptr 就别用 shared_ptr,必须用 shared_ptr 时警惕循环引用,就能避开 90% 的坑。
公众号:蓝海资料掘金营,微信deep3321