Effective Modern C++ Item 19 使用std::shared_ptr管理具备共享所有权的资源

std::shared_ptr让C++程序猿有了垃圾回收机制,std::shared_ptr采用的引用计数法做的垃圾回收。虽然不如根搜索算法那样,可以防止相互引用无法释放,但比起之前已经是飞跃了。

引用计数法:通常构造的时候+1引用计数,析构的时候-1引用计数。引用计数为0的时候,真正释放此对象。

引用计数法会带来一些性能影响:

  • std::shared_ptr的尺寸是裸指针的两倍。它们内部包含一个直射到该资源的裸指针,也包含一个指涉到该资源引用计数的裸指针。

  • 引用计数的内存必须动态分配。智能指针的设计方式是指到资源的指针和指到引用计数的指针是相互独立的,所以被指涉的对象并不知道引用计数的位置。虽然可以由std::make_ptr系列方法创建避免动态分配的成本,但有些情况下std::make_ptr没办法使用,但引用计数总是会作为动态分配的数据来存储。

  • 引用计数的递增和递减必须是原子操作。因为在不同线程中观察std::shared_ptr的引用数值必须保持原子性,否则会造成数据竞态。而原子性必定带来更高开销。

这里解释一下为何通常构造函数会增加引用

因为移动构造函数和移动赋值函数不需要修改引用,所以在可以对std::shared_ptr进行移动操作时,开销会降低很多。

std::shared_ptr的自定义析构器特点1

std::shared_ptrstd::unique_ptr都可以自定义析构器,但前者型别中不包括自定义析构器,而后者包括。

auto loggingDel = [](Widget *pw){
                    makeLogEntry(pw);
                    delete pw;
                };
std::unique_ptr<Widget, decltype(loggingDel)>   //std::unique_ptr
    upw(new Widget, loggingDel);        //型别中需要包含自定义析构器

//std::shared_ptr型别中不需要包含自定义析构器
std::shared_ptr<Widget> spw(new Widget, loggingDel);

对比代码可以发现,std::shared_ptr的设计更具弹性。因为自定义析构器型别不在类中体现的优势有两点:

  1. 可以把一系列不同析构器的对象放入容器中,但std::unique_ptr就办不到。
  2. 两个std::shared_ptr可以相互赋值,即便析构器类型不同。

std::shared_ptr的自定义析构器特点2

无论自定义析构器是函数对象,还是函数指针,甚至是捕获了很多变量的lambda表达式, std::shared_ptr的尺寸是固定的裸指针的两倍。

来看具体 std::shared_ptr的实现方式:

std::shared_ptr<T>
控制块
指向控制块首地址
指涉到T型别的对象的指针
指涉到控制块的指针
引用计数
弱计数
其他数据,例如自定义删除器,分配器等
T型别的对象

std::shared_ptr和裸指针相比不得不分配更多的内存,我们且取名叫控制块,这个控制块分配在堆上,或者按照自定义分配器分配的内存空间中。每一个由std::shared_ptr管理的对象都有一个控制块。控制块里面不仅仅有引用计数,还包含自定义析构器的一个复制(在指定了自定义析构器的前提下),还有可能包含弱计数的次级引用计数。弱计数在Item 21中会被讲解。

控制块相关知识

一个对象的控制块由创建首个指涉该对象的std::shared_ptr的函数来确定。

控制块的创建遵循以下规则:

  • std::make_shared总是创建一个控制块。

  • 从具备专属所有权的指针(std::unique_ptr或std::auto_ptr[这个不要用拉])出发构造一个std::shared_ptr时,会创建一个。

  • std::shared_ptr构造函数使用裸指针作为实参来调用时,会创建一个。

以上规则实际上会造成一个不好的结果:

从同一个裸指针出发构造不止一个std::shared_ptr的话,会出现多重控制块。

这是一个非常危险的问题,多重控制块意味着析构的时候,每个控制块会析构一次。这是很危险的行为。以下的代码是一定不行的!

auto pw = new Widget;       //pw是一个裸指针
...
std::shared_ptr<Widget> spw1(pw, loggingDel); //为*pw创建了一个控制块
...
std::shared_ptr<Widget> spw2(pw, loggingDel); //为*pw创建了又一个控制块

以上代码在spw1和spw2析构的时候会引发多重析构行为,而这无疑会造成未指定行为的错误。

教训:

  • 尽可能避免把裸指针传递给std::shared_ptr的构造函数,请使用std::make_shared,但std::make_shared不支持传入自定义析构函数。

  • 如果只能用裸指针,请按照如下方式写:

std::shared_ptr<Widget> spw1(new Widget, loggingDel);

这种写法大大降低了用裸指针创建多个std::shared_ptr的可能性,即便要使用第二个,也只能写成这样:

std::shared_ptr<Widget> spw2(spw1);

而这种方式并不会增加控制块,是调用的std::shared_ptr的复制构造函数。

shared_ptr构造碰上this指针而引发的多重控制块问题

试想这样一个场景:

std::vector<std::shared_ptr<Widget>> processedWidgets;

class Widget {
public:
    ...
    void process();
    ...
};

对于Widget::process而言,有一种看似合理的方法来完成跟踪操作

void Widget::process()
{
    ...     //处理对象本身
    processedWidgets.emplace_back(this);    // 将处理完的Widget加入链表
                                            // 这种做法大错特错
}

这种行为看似合理,但极有可能在已经指涉到Widget型别的对象成员函数外部在套一层std::shared_ptr,未定义行为已经悄悄跟过来了。

有幸的是,标准库为解决上述问题提供了一种解决思路。并且使用了最古怪的一个名字,std::enable_shared_from_this。这个类的创作目的是:

当你希望一个托管到std::shared_ptr的类能够安全的由this指针创建一个std::shared_ptr时,它将为你继承而来的基类提供一个模板。

那么上述代码会呈现为如下形式:

class Widget : public std::enable_shared_from_this<Widget> {
    public:
    ...
    void process();
    ...
}

std::enbale_shared_from_this 特点

std::enbale_shared_from_this是一个基类模板,其型别形参总是其派生类的类名。注意这是一个古怪的设定,你细品,一个派生类的基类是用该派生类作为模板形参具现的型别。如果不好理解,就死记吧。这代码完全合法,其背后的设计模式也已得到普遍认可,还有一个标准的名字:奇妙递归模版模式(The Curiously Recurring Template Pattern,CRTP)。

std::enbale_shared_from_this定义了一个成员函数,它会创建一个std::shared_ptr指涉到当前对象,但同时不会重复创建控制块。这个成员函数的名字是shared_from_this,每当你需要一个和this指针指涉到相同对象的std::shared_ptr时,都可以在成员函数中使用它。那么上述代码实现会是这样:

void Widget::process()
{
    //同前,处理对象本身
    ...
    //将指涉到当前对象的std::shared_ptr加入processWidgets
    processedWidgets.emplace_back(shared_from_this());
}

std::enbale_shared_from_this 的实现机理

shared_from_this查询当前对象的控制块,并创建一个指涉到该控制块的新std::shared_ptr。为了实现这一点,就必须有一个已经存在的指涉到当前对象的std::shared_ptr。如果这样的std::shared_ptr不存在,那么该行为未定义,一般会由shared_from_this抛出异常。

std::enbale_shared_from_this的注意事项

为了避免上述异常情况的发生,即用户在std::shared_ptr指涉到该对象前就调用了shared_from_this问题,一般而言,继承自std::enbale_shared_from_this的类通常会将其构造函数声明为private访问层级。所以代码可能会是这样的:

class Widget :public std::enbale_shared_from_this<Widget> {
public:
    //将实参完美转发给private构造函数的工厂函数
    template<typename ... Ts>
    static std::shared_ptr<Widget> create(Ts&& ... params);
    ...
    void process();     //同前
    ...
private:
    ...                 //构造函数
}

重回控制块

一个控制块的尺寸通常只有几个字长,尽管自定义析构器和内存分配器可能会使其变得更大。但通常的控制块的实现比你语气的要更加复杂。它使用了继承,甚至会用到虚函数( 用以确保所指涉到的对象被适当地析构)。这就意味着,使用std::shared_ptr也会带来控制块用到的虚函数带来的成本。

重回std::shared_ptr成本探究

之前说了那么多std::shared_ptr的开销,但实际上std::shared_ptr的开销和他提供的功能是匹配的。经典场景下,默认析构器和默认内存分配器,并且std::shared_ptr是由std::make_shared创建的前提下,控制块的尺寸只有3个字长,而且分配操作实质上没有任何成本(因为控制块随对象一起分配,而不会分开两次分配)。这意味着,使用std::shared_ptr并不比使用一个裸指针开销更多。

进行一项要求引用计数的操作(如复制构造或者复制赋值,析构)需要一个或两个原子化操作,但这些操作通常会映射到单个机器指令。尽管原子化指令相对成本较高,但仍旧是单指令。

控制块中的虚函数机制,通常只被每个托管给std::shared_ptr的对象使用一次:在该对象被析构的时候。

重回std::shared_ptr应该合适使用

付出了这样温和的成本后,得到的却是动态分配资源的自动生存期管理。绝大多数时候,std::shared_ptr用来管理共享所有权的对象生命期是十分可取的。但如果发现所管理对象是独占的,那么请使用std::unique_ptr,它的表现和裸指针非常相似,并且也能够很方便的转换至std::shared_ptr

std::shared_ptr并不能反转至std::unique_ptr。资源和指涉到它的std::shared_ptr之间的契约是“永续的”,不可废止,也不能免除。

std::shared_ptrstd::unique_ptr的不同

std::shared_ptr不能做的事情,包括处理数组。

std::unique_ptr的另一个不同是,std::shared_ptr的API仅被设计用来处理指涉到单个对象的指针。并没有所谓的std::shared_ptr<T[]>

有些“聪明”的程序员会误打误撞地发现可以使用std::shared_ptr<T>来指涉到一个数组,并通过指定一个自定义析构器来完成数组删除操作(即delete[])。这种做法能够通过编译,却是一个糟糕透顶的主意

  • 一方面,std::shared_ptr<T>未提供operator[],这么一来要取得数组的下标,就要用基于指针算数的笨拙表达式。

  • 另一方面,std::shared_ptr支持从派生类到基类的指针型别转换,这对单个数组而言是有意义的,但是应用到数组的时候,会在型别系统上开天窗。也正是因为如此,std::unique_ptr<T[]>的API禁止此型别转换。

  • C++11本身就设置了比数组更好用的array,vector,string。如果在这个前提下,还非要用智能指针指向非智能的数组,那么多半是个拙劣的设计。

要点速记
1. std::shared_ptr提供方便的手段,实现了任意资源在共享所有权语义下进行生命周期管理的垃圾回收。
2. 与std::unique_ptr相比,std::shared_ptr的尺寸通常是裸指针尺寸的两倍,它还会带来控制块的开销,并要求原子化的引用计数操作。
3. 默认的资源析构通过delete运算符进行,但同时也支持定制删除器。删除器的型别对std::shared_ptr的型别没有影响。
4. 避免使用裸指针型别的变量来创建std::shared_ptr指针。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值