Item 20: Use std::weak_ptr for std::shared_ptr - like pointers that can dangle

如果仔细研究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主要用于缓存、观察者列表、以及避免循环引用;
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值