条款20:在std::shared_ptr类似指针可以悬挂时使用std::weak_ptr

优质
小牛编辑
131浏览
2023-12-01

说起来有些矛盾,可以很方便的创建一个表现起来想std::shared_ptr的智能指针,但是它却不会参于被指向资源的共享式管理。换句话说,一个类似于std::shared_ptr的指针不影响它所指对象的引用计数。这种类型的智能指针必须面临一个std::shared_ptr未曾面对过的问题:它所指向的对象可能已经被析构。一个真正的智能指针通过持续跟踪判断它是否已经悬挂(dangle)来处理这种问题,悬挂意味着它指向的对象已经不复存在。这就是std::weak_ptr的功能所在

你可能怀疑std::weak_ptr怎么会有用,当你检查了下std::weak_ptr的API之后,你会觉得更奇怪。它的API看起来一点都不智能。std::weak_ptr不能被解引用,也不能检测判空。这是因为std::weak_ptr不能被单独使用,它是std::shared_ptr作为参数的产物。

这种关系与生俱来,std::weak_ptr通常由一个std::shared_ptr来创建,它们指向相同的地方,std::shared_ptr来初始化它们,但是std::weak_ptr不会影响到它所指向对象的引用计数:

auto spw = std::make_shared<Widget>();//spw 被构造之后
                            //被指向的Widget对象的引用计数为1
                            //(欲了解std::make_shared详情,请看Item21)
...
std::weak_ptr<Widget> wpw(spw);//wpw和spw指向了同一个Widget,但是RC(这里指引用计数,下同)仍旧是1
...
spw = nullptr;//RC变成了0,Widget也被析构,wpw现在处于悬挂状态

悬挂的std::weak_ptr可以称作是过期了(expired),可以直接检查是否过期:

if(wpw.expired())...        //如果wpw悬挂...

但是我们最经常的想法是:查看std::weak_ptr是否已经过期,如果没有过期的话,访问它所指向的对象。想的容易做起来难啊。因为std::weak_ptr缺少解引用操作,也就没办法写完成这样操作的代码。即使又没法做到,将检查和解引用分开的写法也会引入一个竞态存在:在调用expired以及解引用操作之间,另外一个线程可能对被指向的对象重新赋值或者摧毁了最后一个指向对象的std::shared_ptr,这样就导致了被指向的对象的析构。这种情况下,你的解引用操作会产生未定义行为。

我们需要的是将检查std::weak_ptr是否过期,以及如果未过期的话获得访问所指对象的权限这两种操作合成一个原子操作。这是通过由std::weak_ptr创建出一个std::shared_ptr来完成的。根据当std::weak_ptr已经过期,仍以它为参数创建std::shared_ptr会发生的情况的不同,这种创建有两种方式。一种方式是通过std::weak_ptr::lock,它会返回一个std::shared_ptr,当std::weak_ptr已经过期时,std::shared_ptr会是null:

std::shared_ptr<Widget> spw1 = wpw.lock();//如果wpw已经过期
//spw1的值是null
auto spw2 = wpw.lock();//结果同上,这里使用了auto

另外一种方式是以std::weak_ptr为参数,使用std::shared_ptr构造函数。这种情况下,如果std::weak_ptr过期的话,会有异常抛出:

std::shared_ptr<Widget> spw3(wpw);//如果wpw过期的话
//抛出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所做的事情,而且要缓存loadWidget的返回结果。把每一个请求过的Widget对象都缓存起来肯定会导致缓存自身的性能出现问题,所以,一个合理的做法是当被缓存的Widget不再使用时将它销毁。

对于这样的一个带有缓存的工厂函数,返回std::unique_ptr类型不是一个很好的选择。可以确定的两点是:调用者接收指向缓存对象的智能指针,调用者来决定这些缓存对象的生命周期;但是,缓存也需要一个指向所缓存对象的指针。因为当工厂函数的调用者使用完了一个工厂返回的对象,这个对象会被销毁,对应的缓存项会悬挂,所以缓存的指针需要有检测它现在是否处于悬挂状态的能力。因此缓存使用的指针应该是std::weak_ptr类型,它有检测悬挂的能力。这就意味着工厂函数的返回类型应该是std::shared_ptr,因为只有当一个对象的生命周期被std::shared_ptr所管理时,std::weak_ptr才能检测它自身是否处于悬挂状态。

下面是一个较快却欠缺完美的缓存版本的loadWidget的实现:

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是std::shared_ptr类型
    //指向了被缓存的对象(如果对象不在缓存中则是null)

    if(!objPtr){
        objPtr = loadWidget(id);
        cache[id] = objPtr;
    }//如果不在缓存中,载入并且缓存它
    return objPtr;
}

C++11利用了hash表容器(std::unordered_map),尽管它没有提供所需的WidgetID哈希算法以及相等比较函数。

我为啥要说fastLoadWidget实现欠缺完美,因为它忽略了一个事实,缓存可能把一些已经过期的std::weak_ptr(对应的Widget不会被使用了,已经被销毁了)。所以它的实现还可以再改善下,但是我们还是不要深究了,因为深究对我们继续深入了解std::weak_ptr没有用处。我们下面探究第二个使用std::weak_ptr的场景:在观察者模式中,主要的组成部分是:状态可能会发生变化的subjects,以及当状态变化时需要得到通知的observers.在大多数实现中,每一个subject包含了指向它的observers的数据成员.这就使得subject很容易发送出状态变化的通知。subject对于控制他们的observer的生命周期(observer何时被析构)毫无兴趣.但是,它们必须知道,如果一个observer析构了,subject就不能尝试去访问它了。一个合理的设计是:每一个subject拥有一个std::weak_ptr,指向了它的observer,这样在可以在访问之间,先检查一下指针是否处于悬挂状态。

下面讲到最后一个std::weak_ptr的例子,有这样一个数据结构,包含A,B和C。A和C共享B的所有权,它们各自包含了一个std::shared_ptr指向B

![20-1.png]

如果现在有需要使B拥有反向指针指向A,那么指针应该是什么类型?

![20-2.png]

下面有三种选择:

  • 一个原生指针。如果这么做,A如果被析构了,但是C会继续指向B,B包含的指向A的指针现在处于悬挂状态。而B对此毫不知情,所以B有可能不小心反引用了那个悬挂指针,这样会产生未定义的行为。
  • 一个std::shared_ptr。在这种设计下,A和B包含了std::shared_ptr互相指向对方。结果就引发了一个std::shared_ptr的环(A指向B,B指向A),这个环会使得A和B都不能得到析构。即使程序其他的数据结构都不能访问到A和B(例如,C如果不再指向B),A和B的引用计数仍然是1.如果这种情况发生了,A和B都会是内存泄露的情况,实际上,程序永远无法再访问到它们,它们也永远无法得到回收。
  • 一个std::weak_ptr。这样避免了以上所有的问题。如果A被回收,B指向它的指针将会悬挂,B也有能力检测到这一状态。此外,就算A和B互相指向对方,B的指针也不会影响到A的引用计数。当没有std::shared_ptr指向A时,也不会阻止A的析构。

使用std::weak_ptr毫无疑问是最好的选择。然而,值得注意的是,使用std::weak_ptr来破坏预期的std::shared_ptr形成的环不是那么普遍。在定义的比较严格的数据结构,比如说树,子节点一般被父节点所拥有。当父节点被析构时,子节点也应该会被析构。从父节点指向子节点的链接因此最好使用std::unique_ptr.因为子节点不应该比父节点存在的时间过长,从子节点指向父节点的链接可以安全的使用原生指针来实现。因此也不会出现子节点解引用一个指向父节点的悬挂指针。

当然,并不是所有的以指针为基础的数据结构都是严格的层级关系。如果不是的话,就像刚才所说的缓存以及观察者列表的情形,使用std::weak_ptr是最棒的选择了。

从效率的观点来看,std::weak_ptrstd::shared_ptr的情况基本相同,。std::weak_ptr对象的大小和std::shared_ptr对象相同,它们都利用了同样的控制块(请看Item 19),并且诸如构造,析构以及赋值都涉及到引用计数的原子操作。这可能让你吃了一惊,因为我在本章开始的时候说std::weak_ptr不参与引用计数的操作。可能没有表达完整我的意思。我要写的意思是std::weak_ptr不参与对象的共享所有权,因此不影响被指向对象的引用计数。但是,实际上在控制块中存在第二个引用计数,std::weak_ptr来操作这个引用计数。欲知详情,请看Item 21.

要记住的东西
std::weak_ptr用来模仿类似std::shared_ptr的可悬挂指针
潜在的使用std::weak_ptr的场景包括缓存,观察者列表,以及阻止std::shared_ptr形成的环