Std::weak_ptr
本文最后更新于:April 20, 2022 am
本篇为智能指针系列第三篇,介绍std::weak_ptr
系列导航:智能指针
设计思路
设想有一种智能指针,它能和std::shared_ptr
做相似的工作,但不会影响被管理资源的共享引用计数(说共享是因为这个计数只是供std::shared_ptr
使用的)。虽然这种智能指针听起来会比较反常,但却是会真真切切带来使用上的方便。
这种智能指针在使用时会出现一种问题,虽然这对于std::shared_ptr
来说是完全不用担心的——即它所指向的资源可能已经被释放了,既然它本身的存在不会影响共享引用计数。真正智能的指针会通过追踪自己究竟于何时变成了一个悬空的指针来解决这个问题。
具备以上特点的指针,就是今天的主角std::weak_ptr
你可能会思考,std::weak_ptr
究竟有什么用?特别是当你去细看一下它的API之后:它既不支持解引用操作,也不能被测试是否是nullptr
这是因为std::weak_ptr
并不是独立使用的,它是std::shared_ptr
的一种补充。
与std::shared_ptr的联系
std::weak_ptr
和std::shared_ptr
之间的联系自后者的诞生就建立了,前者一般是后者来创建的。两者指向同一片资源,但是前者不会影响共享引用计数:
1 |
|
我们称已经成为悬空指针的std::weak_ptr
实例为失效( expired )。可以通过函数调用expired()
来测试:
1 |
|
更多的场景下,我们希望检测std::weak_ptr
是否已经悬空,已经如果不是的话,进一步去访问其指向的资源。这听起来很美好,但是并不好实现。首先,std::weak_ptr
并不支持解引用操作;其外,将判空和解引用分开进行将会引起竞争条件,即执行完判空之后,另一个线程的操作使得std::weak_ptr
指向的资源被释放了,然后本线程再执行解引用时,会引起undefined behavior
在此处我们需要将判空和访问资源揉成一个单独的原子动作——这通过从std::weak_ptr
构造std::shared_ptr
实例来实现。
有两种方式可以达到目的,这取决于当创建std::shared_ptr
时,如若std::weak_ptr
已经失效,你期望执行什么?
第一种方式,std::weak_ptr::lock
,这个函数返回一个std::shared_ptr
实例。如果std::weak_ptr
已经失效,std::shared_ptr
会指向nullptr
;
1 |
|
第二种是,将std::weak_ptr
扔到std::shared_ptr
的构造函数中去,如果前者已经失效,构造函数会抛异常std::bad_weak_ptr
使用场景
在缓存设计中使用
你可能仍然想知道,到底std::weak_ptr
有什么用?考虑以下场景,我们通过工厂方法,基于一个唯一的ID,来生成管理只读对象的智能指针。根据Item18中的讨论,此处应该返回std::unique_ptr
1 |
|
进一步思考,如果loadWidget
函数调用代价比较高昂——比如这会引起文件或者数据库的I/O,并且ID
重复使用很频繁,一种比较合理的优化措施是,重写一个函数,不仅完成loadWidget
函数的工作,还暂时将loadWidget
的结果缓存起来。
如果将每一个被访问过的Widget
都塞到缓存中去,这会导致缓存自身的性能问题,我们还需要进一步地,将不再被使用的Widget
实例销毁掉。
对于这个带缓存的工厂函数而言,std::unique_ptr
作为其返回值类型就不是那么合适了。函数的调用者应该收到指向被缓存的资源的智能指针,并且决定资源的生命周期,但是,缓存cache也需要有一个指向资源的指针,同时这种指针还得能够检测到自己已经悬空了——因为是外部代码决定资源的生命周期,当外部使用资源完毕,资源就被释放了,缓存中的对应的指针也就悬空了。这种场景下就应该使用std::weak_ptr
,它完全满足我们的要求:既不决定资源的生命周期,又能返回管理资源的std::shared_ptr
,还能自动检测自己是不是悬空了。这里给出一种粗糙的实现方式:
1 |
|
这种缓存很粗糙,因为没有定时清除已经悬空的指针,不过这和我们的主题没有关系,也就不加以讨论了。
在观察者模式中使用
我们来看另一种使用场景:观察者模式。
这种设计模式中,最重要的部件是:被观察者,也就是主体subject,即状态可以改变的对象,以及观察者,也就是当状态改变事件发生时会被通知到的对象。在绝大多数的实现中,每一个主体都有成员变量,此变量保存一个指向它的观察者的指针。
这种设计让主体可以方便地发出状态变化通知。主体毫不关心它们的观察者的生命周期,但是非常希望能确保,当观察者已经被销毁了之后,自己不再时不时地向它发通知。可以让主体保存一个装有std::weak_ptr
的容器,每一个std::weak_ptr
指向一个观察者,这样每次发通知之前就可以知道其是否已经被销毁了。
用于解决循环引用
作为最后一个例子,也可能是很多面试中会问到的,用于解决循环引用。
考虑一个数据结构X
,它内部包含有A, B, C
三个对象,并且A
和C
内部都通过std::shared_ptr
的方式共享B
的所有权。
假设由于业务需求,我们需要从B
里面也通过一个指针指向A
,那么我们应该用什么指针?
有三种选择:
裸指针
如果
A
已经被释放了,但因为C
还指向了B
,所以B
仍然保有指向之前的A
的指针,并且此时这个指针已经悬空了。B
不能察觉到指针悬空的发生,B
很有可能进行与之相关的指针操作,引发undefined behaviorstd::shared_ptr
这样的设计下,
A
和B
内部都包含指向对方的std::shared_ptr
这会导致循环引用,最终
A
和B
都得不到析构。请注意,这里
B
能够被左右的std::shared_ptr
所管理,就代表B
的内存是分配在堆上的。如果A
也能被管理,那证明A
的内存也是在堆上的。下面这段代码可以简要描述场景:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27class A{
private:
std::shared_ptr<B> ptr2B;
// ...
}
class B{
private:
std::shared_ptr<A> ptr2A;
// ...
}
class C{
private:
std::shared_ptr<B> ptr2B;
// ...
}
class X{
private:
std::shared_ptr<A> a;
std::shared_ptr<B> b;
C c;
//or std::shared_ptr<C> c;
}来看内存分布和引用链条:
flowchart TB subgraph 堆 A -->|std::shared_ptr| B B -->|std::shared_ptr| A end subgraph 栈 X -->|std::shared_ptr| A X -->|std::shared_ptr| B X -->|has| C end
当栈上的
X
被析构,X
指向A
和B
的引用链条消失,但是由于A
和B
内部存在相互引用关系,所以此时A
和B
都不会被析构,而外部已经无法再访问它俩了:flowchart TB subgraph 堆 A -->|std::shared_ptr| B B -->|std::shared_ptr| A end subgraph 栈 X -->|"×"| A X -->|"×"| B X -->|"×"| C end
std::weak_ptr
std::weak_ptr
在这里能同时检测自身是否已经悬空,也可以避免循环引用。上图中,如果B
内部的智能指针是std::weak_ptr
,那么在X
对象被析构之后,A
的共享引用计数为0,A
也将被析构,于是B
的共享引用计数也会被减为0,因而所有的资源都会被释放flowchart TB subgraph 堆 A -->|std::shared_ptr| B B -.->|std::weak_ptr| A end subgraph 栈 X -->|"×"| A X -->|"×"| B X -->|"×"| C end
显而易见,std::weak_ptr
是3个中最好的选择。尽管如此,这种技巧没什么用,因为必须要使用到std::weak_ptr
来打破引用循环的情况实在是太罕见了。
在具有严格层级的数据结构中,比如树,子节点一般只会被父节点拥有。当父节点被摧毁,子节点也会被摧毁,而这种场景下std::unique_ptr
最适合。从子节点指回父节点的指针可以直接被存储为裸指针,因为子节点绝不会存在得比其父节点还要久,所以子节点永远不会出现解引用一个悬空的指针的情况。
当然,不是所有基于指针的数据结构都是严格具有层次的,知道std::weak_ptr
的蓄势待发是很有必要的。
从效率的角度来看,std::weak_ptr
和std::shared_ptr
本质上一模一样,两者的实例大小一致,也使用同一个控制块,并且它们的构造、析构和赋值都包括对引用计数的原子操作。
当然,再次强调,此处的引用计数并不是std::shared_ptr
使用的那一个,而是在控制块中的一个第二个引用计数,这是被std::weak_ptr
所操作的。具体内容参见Item 21~
总结
- 当自身可能悬空时,使用
std::weak_ptr
来替代std::shared_ptr
std::weak_ptr
潜在的使用场景包括:缓存,观察者列表,以及打破std::shared_ptr
的循环引用
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!