Item 19: Use std::shared_ptr for shared-ownership resource management.

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 beforeprivate:// ctors
};

此外,std::shared_ptr 另外一个和 std::unique_ptr 不同的地方是:std::shared_ptr 的 API 被设计为只能作为单一对象的指针。没有 std::shared_ptr<T[]>,但是使用 std::array,std::vector 和 std::string 可以满足这样的需求。

  • 8
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
`std::vector` 是 C++ 标准库中用于存储动态数组的一种容器,而 `std::shared_ptr` 则是一个智能指针,它允许你管理内存,并自动处理资源的生命周期,避免了内存泄露的问题。 当你将 `std::shared_ptr` 放入 `std::vector` 中时,可能会遇到一些异常情况: ### 1. 内存泄漏风险 如果在向 `std::vector` 添加元素的过程中,由于某种原因导致 `std::shared_ptr` 的构造函数抛出异常,则该元素不会被添加到 `std::vector` 中,但是分配给这个 `std::shared_ptr` 的内存仍然会被保留。这可能导致内存泄漏,因为你实际上无法访问或利用这部分内存。 ```cpp #include <iostream> #include <vector> #include <memory> int main() { try { std::vector<std::shared_ptr<int>> vec; std::shared_ptr<int> ptr(new int(42)); // 这里假设有一个函数 create_shared_ptr() 可能会抛出异常 std::shared_ptr<int> ptr_err = create_shared_ptr(); vec.push_back(ptr); vec.push_back(ptr_err); // 抛出异常后,ptr_err 未加入到 vector 中 for (const auto& p : vec) { std::cout << *p << std::endl; } } catch(...) { std::cerr << "Caught an exception!" << std::endl; } return 0; } ``` ### 2. 异常传播 当在循环中或在其他需要遍历 `std::vector` 的操作中发生异常时,程序会立即停止运行,即使有部分数据已经成功插入。这种情况下,未处理的部分 `std::shared_ptr` 也有可能导致后续操作出现问题,尤其是涉及所有权转移的操作(如 `std::make_shared()` 或者通过 `std::weak_ptr` 访问时)。 ### 解决方案 为了避免上述问题,可以采用几种策略: 1. **使用 `std::optional`**:`std::optional` 提供了一种更安全的方式来存放可能存在的值,它可以捕获并忽略掉异常。 ```cpp #include <optional> // 使用 std::optional 替代 std::shared_ptr,在向 std::vector 插入前检查是否有效 ``` 2. **使用 `try-catch` 来捕捉异常**:在向 `std::vector` 添加元素之前尝试捕获可能出现的异常,并适当地处理它们,例如记录错误信息或跳过异常。 ```cpp try { vec.push_back(std::move(ptr)); // 将 ptr 移动至 vector 而不是拷贝 vec.push_back(std::move(ptr_err)); } catch(...) { // 处理异常,比如记录日志、跳过异常等 } ``` 3. **使用 RAII 管理资源**:确保所有资源都通过 RAII(Resource Acquisition Is Initialization)原则进行管理,即资源在离开作用域时自动释放。 ```cpp if(auto ptr = create_shared_ptr()) { vec.push_back(std::move(ptr)); } ``` ### 相关问题: 1. 如何在使用 `std::vector` 和 `std::shared_ptr` 结合时有效地管理异常? 2. 何时应该考虑使用 `std::optional` 而非 `std::shared_ptr` 存储变量? 3. 如何设计程序结构以避免因 `std::shared_ptr` 引发的内存泄漏和异常传播?
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值