对于共享资源使用std::shared_ptr
std::shared_ptr
指针访问的对象采用共享所有权来管理其生存期。没有特定的std::shared_ptr
拥有该对象。相反,所有指向它的std::shared_ptr
都能相互合作确保在它不再使用的那个点进行析构。当最后一个std::shared_ptr
到达那个点,std::shared_ptr
会销毁它所指向的对象。就垃圾回收而言,客户端不需要关心指向对象的声明周期,而对象的析构是确定的。
std::shared_ptr
通过引用计数来确保它是否是最后一个指向某种资源的指针,引用计数关联资源并跟踪有多少std::shared_ptr
指向该资源。std::shared_ptr
构造函数递增引用计数值(通常),析构函数递减值,拷贝赋值运算符可能递增也可能递减值(如果sp1
和sp2
是std::shared_ptr
并且指向不同的对象,赋值运算符sp1 = sp2
会使sp1
指向sp2
指向的对象,直接效果就是sp1
引用计数减一,sp2
引用计数加一)。如果std::shared_ptr
发现引用计数值为零,没有其他std::shared_ptr
指向该资源,它就会销毁资源。
引用计数会带来性能问题:
std::shared_ptr
大小是原始指针的两倍,因为它内部包含一个指向资源的原始指针,还包含一个资源的引用计数值。- 引用计数必须动态分配。理论上,引用计数与所指对象关联起来,但是被指向的对象不知道这个事情(不知道有指向自己的指针)。因此它们没有办法存放一个引用计数值。使用
std::make_shared
创建的std::shared_ptr
可以避免引用计数的动态分配,但是还存在一些std::make_shared
不能使用的场景,这时候引用计数就会动态分配 - 递增递减引用计数必须是原子性的。因为多个reader,writer可能在不同的线程。比如,指向某种资源的
std::shared_ptr
可能在一个线程执行析构(所指资源的引用计数会递减),在另一个不同的线程,std::shared_ptr
指向相同的对象可能同时正在被赋值(因此该引用计数又会递增)。原子操作一般都比非原子操作慢,所以即使引用计数通常只有一个字长,也应该假设读写它们成本比较高昂。
前面说到,std::shared_ptr
构造函数只是“通常”递增指向对象的引用计数会不会让你有点好奇?创建一个指向对象的std::shared_ptr
至少产生了一个指向对象的智能指针。为什么我没说总是增加引用计数值呢?
原因是移动构造函数的存在。从另一个std::shared_ptr
移动构造新的std::shared_ptr
会将原来的std::shared_ptr
设置为空。这意味着一旦新的std::shared_ptr
产生后,原有的std::shared_ptr
将不再指向资源,结果是不需要进行任何引用计数操作。因此,移动std::shared_ptr
比复制它们要快:复制要求递增引用计数,而移动则不需要。这一点对于构造和赋值操作同样成立。所以,移动构造函数比拷贝构造函数要快,移动赋值比拷贝赋值要快。
类似std::unique_ptr
,std::shared_ptr
使用delete
作为资源的默认销毁器,但是它也支持自定义的销毁器,这种支持不同于std::unique_ptr
。对于std::unique_ptr
来说,销毁器类型是智能指针类型的一部分,对于std::shared_ptr
则不是。
auto loggingDel = [](Widget* pw) //自定义销毁器
{
makeLogEntry(pw);
delete pw;
};
std::unique_ptr<Widget, decltype(loggingDel)>
upw(new Widget, loggingDel); //销毁器类型是ptr类型的一部分
std::shared_ptr<Widget>
spw(new Widget, loggingDel); //销毁器类型不是ptr类型的一部分
std::shared_ptr
的设计更为灵活。考虑有两个std::shared_ptr
,每个自带不同的析构器(比如通过lambda
表达式自定义析构器):
auto customDeleter1 = [](Widget* pw){ ... };
auto customDeleter2 = [](Widget* pw){ ... };
std::shared_ptr<Widget> pw1(new Widget, customDeleter1);
std::shared_ptr<Widget> pw2(new Widget, customDeleter2);
因为pw1
和pw2
具有同一类型,所以它们都可以放到存放那个类型的对象的容器中;
std::vector<std::shared_ptr<Widget>> vpw{ pw1, pw2 };
它们也能相互赋值,也可以传入形参为std::shared_ptr<Widget>
的函数,但是std::unique_ptr
就不行,因为std::unique_ptr
把析构器视为类型的一部分。
另一个不同于std::unique_ptr
的地方是,自定义析构器不会改变std::shared_ptr
的尺寸,无论析构器是怎么的类型,std::shared_ptr
对象的尺寸都相当于裸指针的两倍。自定义析构器有可能是函数对象,而函数对象可以包含任意数量的数据,这意味着它们的尺寸可能是任意大小。std::shared_ptr
如何能够在不使用更多内存的前提下,指涉到任意尺寸的析构器?
std::shared_ptr
不得不使用更多内存。然而,该部分内存却不属于std::shared_ptr
对象的一部分,它位于堆上,又或是std::shared_ptr
的创建者利用了std::shared_ptr
堆自定义内存分配器,则它会在托管给该分配器的内存位置。前文中,我们提到std::shared_ptr
对象包含了所指对象的引用计数。这一点没错,但是可能会误导人。因为该引用计数是控制块这样一个更大的数据结构的一部分,每一个由std::shared_ptr
管理的对象都有一个控制块。除了包含引用计数之外,该控制块还包含自定义析构器的一个复制,如果该自定义析构器被指定的话。如果指定了一个自定义内存分配器,控制块也会包含一份它的复制。控制块还有可能包含其他附加数据。
一个对象的控制块由创建首个指向该对象的std::shared_ptr
的函数来确定。
控制块的创建遵循了以下规则:
std::make_shared
总是创建一个控制块。它创建一个指向新对象的指针,因此在调用std::make_shared
的时刻,显然不会有针对该对象的控制块存在。- 从具备专属所有权的指针(即
std::unique_ptr
或std::auto_ptr
)出发构造一个std::shared_ptr
时,会创建一个控制块。专属所有权指针不使用控制块,所以指针指向的对象没有关联其他控制块(作为构造过程的一部分,std::shared_ptr
被指定了其所指向的对象的所有权,因此那个专属所有权的智能指针会被置空)。 - 当
std::shared_ptr
构造函数使用裸指针作为实参来调用时,它会创建一个控制块。如果你想从一个早已存在控制块的对象上创建std::shared_ptr
,你将假定传递一个std::shared_ptr
或者std::weak_ptr
作为构造函数实参,而不是原始指针。用std::shared_ptr
或者std::weak_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创建了第二个控制块
创建裸指针pw,使其指向一个动态分配对象,这本身就是不好的。spw1的构造函数调用时传入了一个裸指针,因此它为所指向的对象创建一个控制块(并同时创建一个引用计数)。在这种情况下,就是*pw
(即被pw指向到的对象)。spw2的构造函数在调用时传入了同一个裸指针,它也为*pw
创建了一个控制块(同时创建了又一个引用计数)。这么一来,*pw
既有了两个引用计数,而每个引用计数最终都会变为零,从而导致*pw
被析构两次,第二次析构将会引发未定义行为。
首先,尽可能避免将裸指针传递给一个std::shared_ptr
的构造函数。常用的替代手法是使用std::make_shared
。不过在上面的例子中,我们使用了自定义析构器,std::make_shared
无法做到。第二,如果你必须传给std::shared_ptr
构造函数原始指针,直接传new
出来的结果,而非传递一个裸指针变量。
std::shared_ptr<Widget> spw1(new Widget, loggingDel); //直接使用new的结果
这将大大降低试图由相同的裸指针创建第二个std::shared_ptr
的可能。取而代之的是,代码撰写者在创建spw2时会自然地使用spw1作为初始化实参(亦即,会调用std::shared_ptr
的估值构造函数),这将不会产生任何诸如此类的问题。
std::shared_ptr<Widget> spw2(spw1); //spw2使用spw1一样的控制块
一个尤其令人意外的地方是使用this
原始指针作为std::shared_ptr
构造函数实参的时候可能导致创建多个控制块。假设我们的程序使用std::shared_ptr
管理Widget对象,我们有一个数据结构用于跟踪已经处理过的Widget对象
std::vector<std::shared_ptr<Widget>> processdWidgets;
又假设Widget有个成员函数用来做这种处理:
class Widget{
public:
...
void process();
...
};
对Widget::process
而言,有一种看似合理的方法来完成跟踪操作。
void Widget::process()
{
... //处理Widget
processWidgets.emplace_back(this); //然后将它加入到已处理过的Widget列表中
//这是错的
}
关于错误部分的注释说明了一切,或至少说明了大部分(错误部分在于this
指针的传递,而不是emplace_back
的使用)。该代码能够通过编译,然而它把一个裸指针this
传入了一个std::shared_ptr
容器,由此构造的std::shared_ptr
将为其所指向的Widget类型的对象创建一个新的控制块。这似乎没什么危害,直到你意识到,如果在已经指向到该Widget类型的对象的成员函数外部再套一层std::shared_ptr
的话,未定义行为就会发生。
std::shared_ptr
的API为类似这种情况提供了一种基础设施,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::enable_shared_from_this
是一个用作基类的模板类。它的模板参数总是某个继承自它的类。所以Widget
继承自std::enable_shared_from_this<Widget>
。这种设计模板有个标准名字叫奇异递归模板模式。
std::enable_shared_from_this
定义了一个成员函数,成员函数会创建指向当前对象的std::shared_ptr
却不创建多余控制块。这个成员函数就是shared_from_this
,无论在哪当你想使用std::shared_ptr
指向this
所指对象时都请使用它。
void Widget::process()
{
//和之前一样,处理Widget
...
//把指向当前对象的shared_ptr加入processWidgets
processedWidgets.emplace_back(shared_from_this());
}
shared_from_this
查找当前对象控制块,然后创建一个新的std::shared_ptr
指向这个控制块。设计的依据是当前对象已经存在一个关联的控制块。为了实现这一点,就必须有一个已经存在的指向当前对象的std::shared_ptr
(即调用shared_from_this
的成员函数外面已经存在一个std::shared_ptr
)。如果没有std::shared_ptr
指向当前对象(即当前对象没有关联控制块),行为是未定义的,shared_from_this
通常抛出一个异常。
要想防止客户端在调用std::shared_ptr
前先调用shared_from_this
,继承自std::enable_shared_from_this
的类通常将它们的构造函数声明为private
,并让客户端通过工厂方法创建std::shared_ptr
。以Widget为例,代码可以是这样:
class Widget : public std::enable_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::unique_ptr
升级到std::shared_ptr
,因为std::shared_ptr
可以从std::unique_ptr
上创建。
反之不行,不能从std::shared_ptr
转换为std::unique_ptr
。因为std::shared_ptr
指向的资源之前签订的协议是“除非死亡否则永不分离”。
std::shared_ptr
不能处理数组。和std::unique_ptr
不同的是,std::shared_ptr
的API设计之初就是针对单个对象的,没有办法std::shared_ptr<[]>
。
要点速记
std::shared_ptr
提供方便的手段,实现了任意资源在共享所有权语义下进行生命周期管理的垃圾回收- 与
std::unique_ptr
相比,std::shared_ptr
的尺寸通常是裸指针的两倍,它还会带来控制块的开销,并要求原子化的引用计数操作 - 默认的资源析构通过
delete
运算符进行,但同时也支持定制析构器,析构器的类型对于std::shared_ptr
的类型没有影响 - 避免使用裸指针类型的变量来创建
std::shared_ptr
指针