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_ptrstd::shared_ptr之间的联系自后者的诞生就建立了,前者一般是后者来创建的。两者指向同一片资源,但是前者不会影响共享引用计数:

1
2
3
4
5
6
7
auto spw=std::make_shared<Widget>();

...
std::weak_ptr<Widget>wpw(spw);// 指向spw管理的资源,但是共享引用计数仍然为1.
...

spw=nullptr;//共享引用计数变为0,Widget对象被销毁,wpw变成悬空指针

我们称已经成为悬空指针的std::weak_ptr实例为失效( expired )。可以通过函数调用expired()来测试:

1
2
3
4
if (wpw.expired()){
//如果wpw已经悬空
...
}

更多的场景下,我们希望检测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::shared_ptr<Wiget> spw1 = wpw.lock();

第二种是,将std::weak_ptr扔到std::shared_ptr的构造函数中去,如果前者已经失效,构造函数会抛异常std::bad_weak_ptr

使用场景

在缓存设计中使用

你可能仍然想知道,到底std::weak_ptr有什么用?考虑以下场景,我们通过工厂方法,基于一个唯一的ID,来生成管理只读对象的智能指针。根据Item18中的讨论,此处应该返回std::unique_ptr

1
std::unique_ptr<const Widget> loadWidget(WidgetID id);

进一步思考,如果loadWidget函数调用代价比较高昂——比如这会引起文件或者数据库的I/O,并且ID重复使用很频繁,一种比较合理的优化措施是,重写一个函数,不仅完成loadWidget函数的工作,还暂时将loadWidget的结果缓存起来。

如果将每一个被访问过的Widget都塞到缓存中去,这会导致缓存自身的性能问题,我们还需要进一步地,将不再被使用的Widget实例销毁掉。

对于这个带缓存的工厂函数而言,std::unique_ptr作为其返回值类型就不是那么合适了。函数的调用者应该收到指向被缓存的资源的智能指针,并且决定资源的生命周期,但是,缓存cache也需要有一个指向资源的指针,同时这种指针还得能够检测到自己已经悬空了——因为是外部代码决定资源的生命周期,当外部使用资源完毕,资源就被释放了,缓存中的对应的指针也就悬空了。这种场景下就应该使用std::weak_ptr,它完全满足我们的要求:既不决定资源的生命周期,又能返回管理资源的std::shared_ptr,还能自动检测自己是不是悬空了。这里给出一种粗糙的实现方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
std::shared_ptr<const Widget>fastLoadWidget( WidgetID id){
static std::unordered_map<WidgetID, std::weak_ptr<const Widget>> cache;
std::shared_ptr<const WidgetID> ptr = cache[id].lock();
//从缓存中获取shared_ptr

if(!ptr){
//如果缓冲中取到的是nullptr,再重新load进来
ptr=loadWidget(id);
cache[id]=ptr;
}
return ptr;
}
// hashing and euqlity-comparison functions of WidgetID omitted

这种缓存很粗糙,因为没有定时清除已经悬空的指针,不过这和我们的主题没有关系,也就不加以讨论了。

在观察者模式中使用

我们来看另一种使用场景:观察者模式。

这种设计模式中,最重要的部件是:被观察者,也就是主体subject,即状态可以改变的对象,以及观察者,也就是当状态改变事件发生时会被通知到的对象。在绝大多数的实现中,每一个主体都有成员变量,此变量保存一个指向它的观察者的指针。

这种设计让主体可以方便地发出状态变化通知。主体毫不关心它们的观察者的生命周期,但是非常希望能确保,当观察者已经被销毁了之后,自己不再时不时地向它发通知。可以让主体保存一个装有std::weak_ptr的容器,每一个std::weak_ptr指向一个观察者,这样每次发通知之前就可以知道其是否已经被销毁了。

用于解决循环引用

作为最后一个例子,也可能是很多面试中会问到的,用于解决循环引用。

考虑一个数据结构X,它内部包含有A, B, C三个对象,并且AC内部都通过std::shared_ptr的方式共享B的所有权。

X的数据结构示意图

假设由于业务需求,我们需要从B里面也通过一个指针指向A,那么我们应该用什么指针?

新增一个从B指回A的指针

有三种选择:

  • 裸指针

    如果A已经被释放了,但因为C还指向了B,所以B仍然保有指向之前的A的指针,并且此时这个指针已经悬空了。B不能察觉到指针悬空的发生,B很有可能进行与之相关的指针操作,引发undefined behavior

  • std::shared_ptr

    这样的设计下,AB内部都包含指向对方的std::shared_ptr

    这会导致循环引用,最终AB都得不到析构。

    请注意,这里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
    27
    class 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指向AB的引用链条消失,但是由于AB内部存在相互引用关系,所以此时AB都不会被析构,而外部已经无法再访问它俩了:

    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_ptrstd::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 协议 ,转载请注明出处!