Item 19: Use std::shared_ptr for shared-ownership resource management.
Effective Modern C++ Item 19 的学习和解读。
上文中介绍了 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 实际是通控制块的引用计数(reference counter)来管理对象的生命周期。一个 std::shared_ptr 可以通过查看引用计数知道有多少个 std::shared_ptr 指向该对象。引用计数更新如下:
- std::shared_ptr 的构造函数会通常增加引用计数。但是对于 move 构造函数:从一个std::shared_ptr 移动构造一个std::shared_ptr 会将源 std::shared_ptr 设置为 nullptr,源 std::shared_ptr 不再指向资源,并且新的 std::shared_ptr 开始指向资源。所以,它不需要维护引用计数。
- std::shared_ptr 的析构函数会减少引用计数。
- 拷贝 operator= 既增加也减少引用计数:如果 sp1 和 sp2 是指向不同对象的 std::shared_ptr,赋值操作 “sp1 = sp2” 会修改 sp1 来让它指向 sp2 指向的对象。这个赋值操作的效果就是:原本被 sp1 指向的对象的引用计数减一,同时被 sp2 指向的对象的引用计数加一。
如果一个std::shared_ptr 查询到一个引用计数在一次自减后变成 0 了,这就意味着没有别的 std::shared_ptr 指向这个资源了,所以 std::shared_ptr 就会销毁这个资源。
#include <iostream>
#include <memory>
int main() {
std::shared_ptr<int> p1 = std::make_shared<int>(3);
std::shared_ptr<int> p2 = std::move(p1);
std::cout << "==== p1.use_count() = " << p1.use_count() << std::endl;
std::cout << "==== p2.use_count() = " << p2.use_count() << std::endl;
std::shared_ptr<int> p3 = std::make_shared<int>(4);
std::shared_ptr<int> p4(p2);
std::cout << "==== p3.use_count() = " << p3.use_count() << std::endl;
std::cout << "==== p2.use_count() = " << p2.use_count() << std::endl;
p4 = p3;
std::cout << "==== p3.use_count() = " << p3.use_count() << std::endl;
std::cout << "==== p2.use_count() = " << p2.use_count() << std::endl;
return 0;
}
// output
==== p1.use_count() = 0
==== p2.use_count() = 1
==== p3.use_count() = 1
==== p2.use_count() = 2
==== p3.use_count() = 2
==== p2.use_count() = 1
另外,为了保证多线程安全,引用计数的增加和减少操作必须是原子操作。
自定义deleter
上一篇文章介绍过 std::unique_ptr 可以自定义 deleter,并且会增加 std::unique_ptr 占用内存大小。std::shared_ptr 默认也使用 delete 来销毁资源,也支持自定义 deleter,但是其实现机制和 std::unique_ptr 不同。std::unique_ptr 的 deleter 是智能指针的一部分,但是对于 std::shared_ptr 并非如此,它的 deleter 是属于 control block,因此 std::shared_ptr 占用内存大小不会因为自定义 deleter 而改变。
auto loggingDel = [](Widget *pw)
{
makeLogEntry(pw);
delete pw;
};
std::unique_ptr<Widget, decltype(loggingDel)> // deleter type is
upw(new Widget, loggingDel); // part of ptr type
std::shared_ptr<Widget> // deleter type is not
spw(new Widget, loggingDel); // part of ptr type
std::shared_ptr 这样的设计更加灵活。看下面的例子:
auto customDeleter1 = [](Widget *pw) { … }; // custom deleters,
auto customDeleter2 = [](Widget *pw) { … }; // each with a different type
std::shared_ptr<Widget> pw1(new Widget, customDeleter1);
std::shared_ptr<Widget> pw2(new Widget, customDeleter2);
std::vector<std::shared_ptr<Widget>> vpw{ pw1, pw2 };
pw1 和 pw2 具有相同的类型,可以放到一个容器中。它们能互相赋值,并且它们都能被传给一个函数作为参数,只要这个函数的参数是std::shared_ptr类型。但是对于有自定义 deleter 的两个 std::unique_ptr,因为类型不同,无法做到这些功能。
控制块
上面介绍的引用计数和自定义 deleter 都是存在 std::shared_ptr 指向的控制块中。一个对象的控制块应该被指向这个对象的第一个 std::shared_ptr创建。通常,一个创建 std::shared_ptr 的函数不可能知道是否有其他 std::shared_ptr 已经指向这个对象,所以控制块的创建需要遵循以下规则:
- std::make_shared 总是创建一个控制块,它制造一个新对象,所以当 std::make_shared 被调用的时,这个对象没有控制块。
- 当一个 std::shared_ptr 的构造来自一个独占所有权的智能指针(std::unique_ptr 或 std::auto_ptr)时,创造一个控制块。独占所有权的指针不使用控制块,所以原来的被指向的对象没有控制块。
- 当使用一个原始指针构造 std::shared_ptr 时,它创造一个控制块。如果你想使用一个已有控制块的对象来创建一个std::shared_ptr 的话,你可以传入一个 std::shared_ptr 或一个 std::weak_ptr 作为构造函数的参数,但不能传入一个原始指针。使用 std::shared_ptr 或 std::weak_ptr 作为构造函数的参数不会创建一个新的控制块,因为它们能依赖传入的智能指针来指向必要的控制块。
这些规则产生一个结果:用一个原始指针来构造超过一个的 std::shared_ptr 的对象时,会让这个对象拥有多个控制块。
auto pw = new int;
std::shared_ptr<int> spw1(pw);
std::shared_ptr<int> spw2(pw);
spw1 和 spw2 分别针对资源 pw 创建了一个控制块,这在析构的时候会导致资源 pw 被释放两次。上述行为给了我们两个启示:
- 尽量避免使用一个原始指针构造 std::shared_ptr。通最好是使用 std::make_shared,但是若使用了自定义的 deleter,那就不能使用 std::make_shared 了。
- 如果你必须传入一个原始指针给 std::shared_ptr 的构造函数,那最好也是直接传入“new xx”。
std::shared_ptr<Widget> spw1(new Widget, loggingDel); // direct use of new
std::shared_ptr<Widget> spw2(spw1); // spw2 uses same control block as spw1
使用原始指针变量作为 std::shared_ptr 构造函数的参数时,有一个特别让人惊奇的方式(涉及到 this 指针)会产生多个控制块。
std::vector<std::shared_ptr<Widget> processedWidget;
class Widget {
public:
...
void process();
...
};
void Widget::process() {
processedWidget.emplace_back(this); // add it to list of processed Widgets; this is wrong!
}
这段代码能编译,但是它传入一个原始指针(this)给一个 std::shared_ptr 的容器。因此 std::shared_ptr 的构造函数将为它指向的 Widget(*this)创建一个新的控制块。但是,如果在成员函数外面已经有 std::shared_ptr 指向这个 Widget,则会导致资源的 double free。例如如下代码:
std::shared_ptr<Widget> w(new Widget, loggingDel);
w->process();
如果你的类被 std::shared_ptr 管理,你可以继承 std::enable_shared_from_this,这样就能用this指针安全地创建一个std::shared_ptr。
class Widget;
std::vector<std::shared_ptr<Widget>> processedWidget;
auto loggingDel = [](Widget *pw) {
delete pw;
};
class Widget : public std::enable_shared_from_this<Widget> {
public:
void process();
};
void Widget::process() {
processedWidget.emplace_back(shared_from_this());
}
int main() {
{
std::shared_ptr<Widget> w(new Widget, loggingDel);
w->process();
}
return 0;
}
在使用 shared_from_this 返回 this 指针的 std::shared_ptr 的时候 shared_from_this 会先搜索当前对象的控制块,如果有就不会再创建控制块了。所以以上代码就不会产生 double free 的问题了。
但是,这个设计依赖于当前的对象已经有一个相关联的控制块了。也就是说,必须已经有一个 std::shared_ptr 指向当前的对象。如果没有,shared_from_this 也会抛出异常,它的行为还将是未定义的。
为了防止用户在一个 std::shared_ptr 指向这个对象前,调用成员函数(这个成员函数调用了 shared_from_this),继承自std::enable_shared_from_this 的类通常将它们的构造函数为申明为 private,并且让用户通过调用一个返回 std::shared_ptr 的工厂函数来创建对象。
class Widget: public std::enable_shared_from_this<Widget> {
public:
// factory function that perfect-forwards args
// to a private ctor
template<typename... Ts>
static std::shared_ptr<Widget> create(Ts&&... params);
…
void process(); // as before
…
private:
… // ctors
};
此外,std::shared_ptr 另外一个和 std::unique_ptr 不同的地方是:std::shared_ptr 的 API 被设计为只能作为单一对象的指针。没有 std::shared_ptr<T[]>,但是使用 std::array,std::vector 和 std::string 可以满足这样的需求。