20.类shared_ptr但可空悬的智能指针——std::weak_ptr

前言

 
有时我们会想,如果有一种行为类似于shared_ptr,但不会参与资源共享权的智能指针该有多好。换而言之,我们需要一种不改变引用计数的shared_ptr。这种智能指针主要负责解决一样问题:指出shared_ptr指向的资源是否已遭到破坏。一个真正智能的指针可以通过跟踪它何时空悬来处理这个问题,这正是weak_ptr的精义所在。你可能想知道std::weak_ptr究竟有何用途,但你在查看其API时会发现它既不能解引用也不能检测资源是否为NULL,因为weak_ptr并非是一个独立的智能指针,它是shared_ptr的扩充形式。


weak_ptr

 
weak_ptr通常由shared_ptr创建,它们指向同样的资源,其区别在于weak_ptr不会改变该资源的引用计数:

1
2
3
auto spw = std::make_shared<Widget>();// RC is 1
std::weak_ptr<Widget> wpw(spw);// RC remains 1
spw = nullptr;// RC goes to 0, and the Widget is destroyed.wpw now dangles

你可以通过weak_ptr的成员函数来查看其是否空悬:
1
if (wpw.expired()) … // if wpw doesn't point to an object…

但通常我们不仅仅想要查看其是否处于空悬状态,而是需要如果其不处于空悬状态,则直接使用它所指向的资源。但说起来容易做起来难,原因在于weak_ptr不存在解引用操作,就算它有解引用操作,如上文一般将空悬检查和解引用相分离也可能会导致问题产生:例如当前存在另一个线程对指向资源的最后一个std::shared_ptr进行了赋值或析构操作,从而导致资源释放。在此环境下,解引用操作将导致未定义行为。

为了确保线程安全,我们真正需要完成的是一个原子操作,它检查std::weak_ptr是否空悬,如果没有,则访问它指向的对象。 访问对象这一行为可以通过从std::weak_ptr创建std::shared_ptr来完成。该操作有两种形式,具体采用哪一种形式取决于你在明确指针空悬后会采取何种措施。
一种形式是std::weak_ptr::lock,它返回一个std::shared_ptr。如果std::weak_ptr已经空悬,则std::shared_ptr为null:

1
2
std::shared_ptr<Widget> spw1 = wpw.lock(); // if wpw's expired,spw1 is null
auto spw2 = wpw.lock(); // same as above,but uses auto

另一种形式是以weak_ptr为实参构造shared_ptr,如果weak_ptr已然空悬,则抛出异常:

1
std::shared_ptr<Widget> spw3(wpw); // if wpw's expired,throw std::bad_weak_ptr


问题实例一(工厂模式)

 
我们将以实例分析和探讨weak_ptr的真正使用场景,假设当前一个工厂函数,它根据唯一ID生成只读对象的智能指针。根据Item 18关于工厂函数返回类型的建议,它返回一个std::unique_ptr:

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

如果loadWidget调用成本较为昂贵(例如执行文件或数据库I/O),并且重复使用ID这一行为较为常见(即经常需要获取指定ID的对象),那么我们可以采用一种合理的优化方式:cache。将所有Widget都置入缓存也可能会导致性能问题,因此我们也应当保证在适当时段删除缓存。
对于缓存式工厂函数,其返回值仍采用unique_ptr已然不再合适,调用者当然需要指向资源的智能指针,但调用者也需要了解资源是否已经遭到释放,cache里存放的智能指针应当具备检测空悬的能力(以便及时地清空缓存),因此缓存中存储的智能指针应当为weak_ptr类型,而工厂函数返回值应当为shared_ptr类型,因为weak_ptr只能检测shared_ptr所管理资源的空悬性,这是loadWidget的缓存版本的快速实现:
1
2
3
4
5
6
7
8
9
std::shared_ptr<const Widget> fastLoadWidget(WidgetID id){
static std::unordered_map<WidgetID,std::weak_ptr<const Widget>> cache;
auto objPtr = cache[id].lock();
if (!objPtr) {// not in cache
objPtr = loadWidget(id);//load it
cache[id] = objPtr;//cache it
}
return objPtr;
}

这个实现显然可以进一步改进,因为我们的cache目前只做到了增加元素,实际上还可以在检测到资源破坏后从cache中删除指定元素,但这个实现与本节主题无关,因此有兴趣者可自行尝试。


问题实例二(观察者模式)

 
让我们现在将目光转向第二个实例:Observer设计模式。该模式的主要组成部分是主体(状态可能发生变化的对象)和观察者(发生状态变化时要通知的对象)。在该模式的大多数实现中,每个主体包含一个数据成员,该成员持有指向其观察者的指针,这使得主体可以轻松发出当前状态已更改的通知。主体对观察者的生命周期毫无兴趣(即主体并不需要了解当前观察者的状态),但我们至少需要撰写某种操作保证当某观察者已被破坏时,主体从观察者列表中将其删除。一个合理的设计是每个主体均持有一个容器,其容器内部元素为指向观察者的std::weak_ptrs,从而使得主体可以判断观察者是否已经失效。


问题实例三

 
考虑一个包含对象A,B,C的数据结构,其中A和C共享B的所有权,因此我们使用shared_ptr:
image_1chkntif01p2nnas112q152r1hc229.png-14.3kB
假设我们又发现B中也需要存在一个指向A的指针,那它应当是何种类型?
image_1chkohht0hdb1p4d71e1rl41c562m.png-20kB
主要有三种选择:

  1. 原始指针
    如果使用原始指针,如果A已被销毁,但仍存在C继续指向B,而B中又包含指向A的指针。B无法确定A是否已经析构,因此可能会触发解引用空悬指针的行为,这将导致未定义后果。
  2. std::shared_ptr
    在此设计中,A和B中包含指向彼此的std::shared_ptrs。这种彼此指向的行为将阻止A和B被析构。即使A和B无法被其他数据结构访问(例如C不再指向B),A与B仍然具备引用计数为1。这种情况也是一种内存泄漏,因为A与B永远无法被访问,但分配给它们的资源将永远无法回收。
  3. std::weak_ptr
    使用weak_ptr完美避免了上述两个问题。如果A已被析构,B指向它的指针将保持空悬状态,并且B能够检测空悬。此外,虽然A和B指向彼此,但B并不对A存有任何引用计数,因此仍然可以析构自如。

值得注意的是,需要使用std::weak_ptr取代std::shared_ptr的常见并不多见。在严格的分层数据结构(例如树)中,子节点通常仅由其父节点拥有。当父节点被销毁时,其子节点也应该被销毁。因此,父母与孩子之间的链接通常最好由std::unique_ptrs表示。此外,从子节点到父节点的反向链接可以安全地使用原始指针表示,因为子节点的生命周期永远不会超过其父节点。 因此,没有子节点解引用空悬父指针的风险。


weak_ptr的效率

 
从效率的角度而言,std::weak_ptr与std::shared_ptr基本保持一致。std::weak_ptr对象与std::shared_ptr对象大小相同,并且它们也涉及control block,构造,析构和赋值等操作也需要atomic引用计数操作。在本节开头我们曾说weak_ptr不使用引用计数,这说明的是weak_ptr不涉及shared_ptr的引用计数,实际上control block中有专门关于weak_ptr的引用计数(详见Item21)。


总结

  1. 你可以将weak_ptr视为可空悬的shared_ptr。
  2. weak_ptrs的使用场合包括cache、观察者模式、以及打破shared_ptr循环。