如果仔细研究std::weak_ptr的对外提供接口,会发现既没办法对它解引用,也没办法对它判空(因为没有: operator bool(){})。这看起来一点也不智能。这是因为std::weak_ptr并不是一个独立的智能指针,它是std::shared_ptr的扩充。
这种关系在它诞生时就已存在。std::weak_ptr通常是通过std::shared_ptr来构建的。当通过std::shared_ptr创建完std::weak_ptr时,std::weak_ptr就与std::shared_ptr指向同一个被管理的资源了,只是它不参与引用计数的操作:
auto spw = // after spw is constructed,
std::make_shared<Widget>(); // the pointed-to Widget's
// ref count (RC) is 1. (See
// Item 21 for info on
// std::make_shared.)
…
std::weak_ptr<Widget> wpw(spw); // wpw points to same Widget
// as spw. RC remains 1
…
spw = nullptr; // RC goes to 0, and the
// Widget is destroyed.
// wpw now dangles
std::weak_ptr的空悬,也被称作失效(expired)。可以直接检查:
if (wpw.expired()) … // if wpw doesn't point
// to an object…
通常我们想要的效果是,先检查std::weak_ptr是否失效,如果没有失效,就去访问它所指向的对象。这个想起来容易,做起来难啊。因为std::weak_ptr没有解引用操作,所以写不出来这样的代码,即便能写出这样的代码,将检查是否失效和解引用分开也会产生竞争条件:在expired()调用和解引用动作中间,其他线程可能对std::shared_ptr重新赋值或销毁最后一个指向对象的std::shared_ptr,这会导致被管理的对象被析构掉。那么,我们的解引用就可能会产生未定义行为。
其实我们想要的是这样一个原子操作:“检查std::weak_ptr是否失效,如果没失效,就去访问它所指向的对象”。这个我们想要的操作,可以通过std::weak_ptr创建的std::shared_ptr来实现。这个操作有两种形式,选择哪一种形式取决于当通过std::weak_ptr构建一个std::shared_ptr时,如果std::weak_ptr已经失效,我们希望发生什么。一种形式是调用weak_ptr::lock,它将返回一个std::shared_ptr。如果std::weak_ptr已经失效了,则std::shared_ptr为空:
std::shared_ptr<Widget> spw1 = wpw.lock(); // if wpw's expired,
// spw1 is null
auto spw2 = wpw.lock(); // same as above,
// but uses auto
另一种形式是利用std::weak_ptr作为实参来构造std::shared_ptr,对于这种形式,如果the std::weak_ptr已经失效,将会抛出一个异常:
std::shared_ptr<Widget> spw3(wpw); // if wpw's expired,
// throw std::bad_weak_ptr
介绍了这么多,std::weak_ptr到底有啥用呢?考虑一个工厂函数,它根据唯一的ID生成指向只读对象的智能指针。根据Item 18关于工厂函数返回类型的建议,它返回std::unique_ptr:
std::unique_ptr<const Widget> loadWidget(WidgetID id);
如果loadWidget是一个耗时的调用(比如,执行文件或数据库I/O),并且经常重复使用id,那么一个合理的优化方法是编写一个函数来完成loadWidget所做的工作,并缓存它的结果。但如果缓存所有用过的Widget,可能会引起缓存的性能问题,所以另一个合理的优化是,当Widget不在使用时将其删掉。
对于这个缓存工厂函数,返回std::unique_ptr类型显然不适合。调用者接收到指向缓存对象的智能指针,并且由调用者确定这些对象的生命周期,但是缓存也需要一个指向对象的指针。缓存的指针需要能够被检测何时失效,因为当工厂的客户端使用完工厂返回的对象时,该对象将被销毁,对应的缓存项就会失效。因此,缓存的指针应该是std::weak_ptrs。同时工厂的返回类型应该是std::shared_ptr。比较粗糙的实现写法如下:
std::shared_ptr<const Widget> fastLoadWidget(WidgetID id)
{
static std::unordered_map<WidgetID, std::weak_ptr<const Widget>> cache;
auto objPtr = cache[id].lock(); // objPtr is std::shared_ptr
// to cached object (or null
// if object's not in cache)
if (!objPtr) { // if not in cache,
objPtr = loadWidget(id); // load it
cache[id] = objPtr; // cache it
}
return objPtr;
}
上述实现忽略了一种情况,就是当对应的Widget不再使用时,缓存中失效的std::weak_ptr会越积越多,当然,这部分是可以被优化的。现在,我们考虑另外一种场景:观察者模式,这种模式的主要组件是主题(subject,状态可能变化的对象)和观察者
(observer,发生状态更改时通知的对象)。在大多数的实现中,每个主题包含一个指向所有观察者对象的指针的数据成员,使得subject发生变化时可以方便的发出通知。subject不关心observers的生命周期,但是subject要确保当某个observer失效时,就不要去访问它了。所以,合理的设计是让subject持有一个保存指向observers的std::weak_ptr的容器。这样在发出通知前,就可以知道具体的observer是否有效了。
接下来,是std::weak_ptr的最后一个应用场景。假设一个含有A, B, C三个对象的数据结构,A和C共享B的所有权,所以各持有一个指向B的std::shared_ptr:
stateDiagram-v2
state if_state <>
A --> B: shared_ptr
C --> B: shared_ptr
假设为了使用方便,有一个指针从B指回A,那么这个指针应该用啥类型呢?
stateDiagram-v2
state if_state <>
A --> B: shared_ptr
C --> B: shared_ptr
B --> A: ???
有三种选择:
- 裸指针。这种情况下,若A被析构,而C仍然指向B,B还保存着指向A的悬挂指针。但是B检测不到A被析构了,所以有可能去解引用A,就会产生未定义行为了;
- std::shared_ptr。这种情况下,A和B都包含指向彼此的std::shared_ptr。这种循环引用(A points to B and B points to A)A和B无法被正常析构。即使A和B无法从其他程序数据结构中访问(例如,由于C不再指向B),它们的引用计数都是1。这其实算是内存泄漏了,因为程序已经无法访问到A和B了,但是他们资源却一直未被回收;
- std::weak_ptr。避免了上述两个问题。如果A被析构,B的回指针将会空悬,且B将能够探测到该情况。此外,虽然A和B指向彼此,但B持有的指针不会影响A的引用计数,因此在std::shared_ptrs不再指向A时,不会阻止A被析构。
对比下来,std::weak_ptr显然是最好的选择。但是我们必须指出,通过std::weak_ptr解决std::shared_ptr并不常见。在类似树这种严格继承图谱的数据结构中,子节点通常只被其父节点拥有,当父节点被析构时,子节点也应该被析构。因此,从父结点到子节点的链接可以用std::unique_ptr来表示,而由子节点到父节点的反向链接可以用裸指针安全实现,因为子节点的生存期不会比父节点更长,所以不会出现子节点去解引用父节点空悬指针的风险。
当然,并不是所有的基于指针的数据结构都是严格的继承谱系。那么,在非严格继承谱系的数据结构、缓存以及观察者模式下,std::weak_ptr就非常使用了。
从效率的角度来看,std::weak_ptr和std::shared_ptr本质上是一致的。std::weak_ptr对象与std::shared_ptr对象的尺寸大小时一样的,它和std::shared_ptr使用同样的control block,其构造,析构,赋值操作都包含了对引用计数的原子操作。这可能有点让人迷糊,因为开头的地方我们提到过,std::weak_ptr不参与引用计数操作。但我们的意思时说std::weak_ptr不干涉对象的共享所有权,所以不会影响到所指对象的引用计数。实际上control block还有第二个引用计数,std::weak_ptr操作的就是第二个引用计数。更多细节,参看Item 21。
Things to Remember
- 使用std::weak_ptr替代可能空悬的std::shared_ptr;
- std::weak_ptr主要用于缓存、观察者列表、以及避免循环引用;