我们来深入剖析一下 std::weak_ptr
。它常常被视为 std::shared_ptr
的"配角",但理解它正是区分中级和高级C++使用者的关键之一。weak_ptr
的设计精巧而实用,解决了 shared_ptr
模型中的一个核心缺陷。
1. 解决了什么痛点? (The Problem)
std::shared_ptr
的共享所有权模型非常强大,但它引入了一个新的、特定于引用计数的问题:循环引用(Cyclic Reference)。
循环引用场景: 想象两个对象 A
和 B
相互持有对方的 shared_ptr
。
cpp
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"; }
};
void leak() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->b_ptr = b; // b 的 use_count -> 2
b->a_ptr = a; // a 的 use_count -> 2
}
// 函数结束:
// 1. 栈上的 `b` 析构,b 的 use_count 从 2 减为 1。不为零,B 对象存活。
// 2. 栈上的 `a` 析构,a 的 use_count 从 2 减为 1。不为零,A 对象存活。
// 3. 现在只剩下 A.b_ptr 和 B.a_ptr 相互指着对方,它们的 use_count 永远为 1。
// 4. 内存泄漏!A 和 B 都无法被释放。
std::weak_ptr
就是为了打破这种循环而生的。它提供了一种方式,可以"观察"或"引用"一个由 shared_ptr
管理的对象,但不参与所有权争夺,不贡献引用计数。
此外,它还解决了另一个问题: 提供一种临时共享的访问方式,而不影响对象的生命周期(例如用于缓存),避免"悬挂指针"问题。
2. 是什么? (What is it?)
std::weak_ptr
是一种弱引用智能指针 。它不独立管理对象生命周期,而是绑定到一个 std::shared_ptr
上,来观察其管理的对象。
你可以把它想象成一个"旁观者"或者"观察员证":
- 它不能直接进入场馆(访问对象)。
- 它只能告诉你场馆是否还在开放(对象是否存活)。
- 如果你想进去,你必须凭这个"观察员证"临时兑换一张正式门票 (
.lock()
方法得到一个shared_ptr
),然后才能进入。
它的核心特性是:
- 不增加引用计数:它的存在与否,不影响其所指对象的生命周期。
- 需要验证后使用 :不能直接通过
weak_ptr
访问对象,必须先将其"提升"为一个shared_ptr
。 - expired():可以快速检查其观察的对象是否已被销毁。
3. 内部是如何实现的? (Implementation)
std::weak_ptr
的实现与 std::shared_ptr
密不可分,它们共享同一个控制块(Control Block)。
回顾一下 shared_ptr
的控制块结构:
- use_count : 强引用计数(
shared_ptr
的数量) - weak_count : 弱引用计数(
weak_ptr
的数量 + 一些其他内部用途) - 其他数据:如删除器、分配器等。
weak_ptr
的实现简化概念如下:
cpp
template<typename T>
class weak_ptr {
private:
T* ptr; // 指向托管对象的指针(可能已失效)
ControlBlock* control_block; // 指向控制块的指针
public:
// 构造函数:通常由一个 shared_ptr 创建
weak_ptr(const std::shared_ptr<T>& sp) noexcept : ptr(sp.get()), control_block(sp.control_block) {
if (control_block) {
// 关键:只增加弱引用计数,不增加强引用计数!
++control_block->weak_count;
}
}
// 析构函数:减少弱引用计数
~weak_ptr() {
if (control_block) {
--control_block->weak_count;
// 如果强引用和弱引用都归零,则销毁控制块本身
if (control_block->use_count == 0 && control_block->weak_count == 0) {
delete control_block;
}
}
}
// 最重要的方法:尝试获取一个共享所有权的 shared_ptr
std::shared_ptr<T> lock() const noexcept {
if (control_block && control_block->use_count > 0) {
// 原子地检查use_count>0,如果成立则增加use_count并返回一个shared_ptr
// 这是一个原子操作,防止竞态条件:
// 线程1:判断 use_count > 0 成立
// 线程2:最后一个shared_ptr析构,use_count=0,对象被销毁
// 线程1:尝试增加use_count -> 因为use_count已为0,操作会失败或避免
return std::shared_ptr<T>(*this); // 使用 shared_ptr 的弱指针构造方式
} else {
return std::shared_ptr<T>(); // 返回一个空的 shared_ptr
}
}
bool expired() const noexcept {
// 检查是否过期(对象是否已被销毁)
return (control_block == nullptr) || (control_block->use_count == 0);
}
};
关键实现要点:
- 共享控制块 :
weak_ptr
和其来源shared_ptr
指向同一个控制块。 - 操作弱计数 :
weak_ptr
的构造和析构只影响控制块的weak_count
,不影响use_count
。因此它不会阻止对象的销毁。 lock()
的原子性 :lock()
方法的核心是原子地 检查use_count
是否仍大于零,如果是,则将其递增。这一步至关重要,它保证了在多线程环境中,即使对象正在被最后一个shared_ptr
释放,lock()
也能安全地返回一个空指针或有效的shared_ptr
,而不会导致竞态条件。- 控制块的生命周期 :对象内存(
T
)在use_count
降为 0 时被销毁。但控制块内存 会一直保留,直到use_count
和weak_count
都降为 0 。这是因为可能还有weak_ptr
存在,它们需要访问控制块来检查use_count
。这就是为什么使用std::make_shared
(对象和控制块一起分配)有时会导致内存延迟释放的原因。
4. 应该如何正确使用? (Best Practices)
基本用法:打破循环引用
这是 weak_ptr
最经典和重要的用途。
cpp
struct B;
struct A {
std::shared_ptr<B> b_ptr;
~A() { std::cout << "A destroyed\n"; }
};
struct B {
std::weak_ptr<A> a_ptr; // 关键修改:将强引用改为弱引用
~B() { std::cout << "B destroyed\n"; }
};
void no_leak() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->b_ptr = b; // b.use_count -> 2
b->a_ptr = a; // a.use_count 仍然为 1! (weak_ptr 不增加计数)
// 访问弱引用的对象
if (auto shared_a = b->a_ptr.lock()) { // 尝试提升为 shared_ptr
shared_a->doSomething(); // 安全使用
} // 临时 shared_ptr 析构,use_count 恢复为 1
}
// 函数结束:
// 1. 栈上 `b` 析构 -> b.use_count 从 2 -> 1 (因为 a->b_ptr 还在)
// 2. 栈上 `a` 析构 -> a.use_count 从 1 -> 0 -> A 对象被销毁!
// -> 导致 A 的成员 b_ptr 析构 -> b.use_count 从 1 -> 0 -> B 对象被销毁!
其他用法
-
缓存(Cache):
cppstd::weak_ptr<CacheEntry> cache_entry_weak; // 客户端代码想要获取缓存 std::shared_ptr<CacheEntry> get_cache() { if (auto entry = cache_entry_weak.lock()) { return entry; // 缓存命中,对象还在 } else { // 缓存未命中或已被清理,重新加载... auto new_entry = std::make_shared<CacheEntry>(...); cache_entry_weak = new_entry; // 存储弱引用,不影响对象生命周期 return new_entry; } } // 当所有使用者都忘记这个缓存项时,它会自动被释放。缓存系统只持有弱引用,不会阻止其释放。
-
观察者模式(Observer Pattern) : 主题(Subject)持有所有观察者(Observer)的
weak_ptr
。当主题要通知观察者时,它遍历列表,用.lock()
获取有效的shared_ptr
然后调用方法。如果某个观察者已经被销毁(.lock()
失败),主题可以安全地将该weak_ptr
从列表中移除。这避免了主题"持有"观察者导致其无法析构的问题。
重要准则与陷阱(Dos and Don'ts)
- DO : 在可能存在循环引用 的地方,毫不犹豫地使用
std::weak_ptr
来替代std::shared_ptr
,打破循环。 - DO : 使用
auto shared_ptr = weak_ptr.lock()
来安全地访问对象。永远不要假设weak_ptr
观察的对象仍然存活。 - DO : 使用
expired()
方法如果你只关心对象是否存在,而不需要立即使用它(但要注意,expired()
和lock()
之间可能有竞态条件,通常直接使用lock()
检查返回值是更好的模式)。 - DON'T : 不要直接解引用
weak_ptr
。weak_ptr
没有重载operator->
和operator*
。这是语法层面的保护,强制你进行安全检查。 - DON'T : 不要尝试从裸指针或另一个
weak_ptr
创建weak_ptr
。weak_ptr
必须总是从一个已存在的shared_ptr
创建,以确保它共享正确的控制块。 - DON'T : 注意控制块的生命周期。如果你非常关心内存的即时释放,并且使用了大量的
weak_ptr
,了解std::make_shared
会将对象和控制块内存捆绑这一点很重要。如果你想将对象内存和控制块内存分开释放,可以使用std::shared_ptr<T>(new T(...))
,但这牺牲了性能和异常安全性,通常不推荐。
总结
特性 | std::weak_ptr |
---|---|
所有权 | 无所有权(弱引用) |
用途 | 打破 shared_ptr 的循环引用、实现缓存、观察者列表 |
创建 | 必须从 std::shared_ptr 构造或赋值 |
访问对象 | 必须通过 .lock() 方法尝试获取一个 std::shared_ptr |
开销 | 很小(与控制块的原子操作) |
线程安全 | .lock() 操作是原子的,线程安全 |
核心思想 :std::weak_ptr
不是一种独立的智能指针,而是 std::shared_ptr
生态系统的一个安全补充 。它通过放弃所有权来换取避免循环引用的能力,并通过严格的"先检查后访问"机制保证了安全性。熟练运用 weak_ptr
是构建复杂且无内存泄漏的C++对象关系模型的必备技能。
点个关注不迷路,定时更新底层机制与算法通俗讲解
C++底层机制推荐阅读**
【C++基础知识】深入剖析C和C++在内存分配上的区别
【底层机制】【C++】vector 为什么等到满了才扩容而不是提前扩容?
【底层机制】malloc 在实现时为什么要对大小内存采取不同策略?
【底层机制】剖析 brk 和 sbrk的底层原理
【底层机制】为什么栈的内存分配比堆快?
【底层机制】右值引用是什么?为什么要引入右值引用?
【底层机制】auto 关键字的底层实现机制
【底层机制】std::unordered_map 扩容机制
【底层机制】稀疏文件--是什么、为什么、好在哪、实现机制
【底层机制】【编译器优化】RVO--返回值优化
【基础知识】仿函数与匿名函数对比
【底层机制】【C++】std::move 为什么引入?是什么?怎么实现的?怎么正确用?
【底层机制】emplace_back 为什么引入?是什么?怎么实现的?怎么正确用?
【底层机制】【编译器优化】循环优化--为什么引入?怎么实现的?流程啥样?
【底层机制】std::string 解决的痛点?是什么?怎么实现的?怎么正确用?
【底层机制】std::unique_ptr 解决的痛点?是什么?如何实现?怎么正确使用?
【底层机制】std::shared_ptr解决的痛点?是什么?如何实现?如何正确用?