《Effective Modern C++》学习笔记 - Item 19: 使用 std::shared_ptr 管理共享性资源(附MSVC源码解析)

  • 很多编程语言中垃圾回收(GC)的确是非常便利的特性,但它们执行的时间往往不可预期;而C++98的全手动资源管理又显得过于“原始”了一点。将能够自动进行操作(GC)以及操作的时机可预期(析构函数)这两个优势结合在一起的,就是C++11的 std::shared_ptr

  • 任一个 std::shared_ptr 都不拥有它管理的对象;但它们共同确保对象不再被需要时会被销毁。其中主要的实现机制是 引用计数(reference count):一个资源被多少个 std::shared_ptr 指向的计数。std::shared_ptr 的大部分构造函数会使该值增加1(移动构造除外,左右抵消不需要增减,因此也比复制更快),析构函数会使该值减小1,复制赋值运算符同时增减(左减右加)。如果 std::shared_ptr 发现经自减后引用计数变为0,则销毁该对象。

  • 性能上来说:

    • std::shared_ptr 的大小是两倍裸指针,一个指针指向资源,一个指向引用计数(严格说是包含引用计数的 control block,见下文)。
    • 引用计数占用的内存必须是动态分配的,因为被指向的对象本身对引用计数毫不知情,因此必须由 std::shared_ptr 对引用计数进行动态分配和管理。(笔者注:C++的对象模型不像Java等有一个公共基类Object,因此如果采取将实现引用计数的责任甩给用户类编写者的设计,则会大大限制 std::shared_ptr 的使用场景,也无法支持 built-in types。)
    • 引用计数的自增和自减必须是原子操作,否则线程不安全,而原子操作相对较慢。
  • 笔者注:到这里你可能会质疑循环引用的问题——的确,当出现循环引用时引用计数永远不会归零,造成内存泄漏。C++的解决办法是引入 std::weak_ptr,见下一节 Item 20 的讲解。

  • std::shared_ptr 默认也使用 delete 作为资源释放方式,支持自定义 deleter。但与 std::unique_ptr 设计不同的是,std::shared_ptr 的 deleter 不属于其类型的一部分,这样的设计使其更加灵活,例如可以将多个持有对象种类相同而 deleter 不同的对象互相赋值,或放在同一个容器中。(注:而 std::unique_ptr 的根本设计理念是 lightweight,zero-overhead,使用默认deleter还多占一个指针的空间是不可接受的。)

std::unique_ptr<Widget, decltype(customDeleter1)> upw(new Widget, customDeleter1);
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::shared_ptr 是将引用计数,weak count(见 Item21),自定义 deleter 等内容打包在了一个名为 控制块(control block) 的数据结构中,其自身保存两个指针:一个指向管理对象,一个指向控制块
    在这里插入图片描述

  • 控制块理应由管理某对象的第一个 std::shared_ptr 创建;后续的 std::shared_ptr 只需修改其中的数据。然而 std::shared_ptr 无法知道自己是否是第一个指向某对象的,因此有必要应用以下规则:

    • std::make_shared(见 Item 21)总会创建一个控制块。它生成一个新对象,肯定是该对象的第一个所有者,所以应该生成控制块。
    • std::shared_ptr 从一个独占性指针中构建时(即 std::unique_ptrstd::auto_ptr)创建控制块。因为这些独占性指针不使用控制块。
    • std::shared_ptr 从一个裸指针中构建时,创建控制块;而从另一个 std::shared_ptrstd::weak_ptr 构建时不会创建控制块
  • 问题可能出现在第三条规则中:如果使用同一个裸指针创建了多个 std::shared_ptr,就会对同一个对象创建多个控制块,最终导致对象被 delete 多次——undefined behavior!因此记住两点:(1)尽可能避免将裸指针传入 std::shared_ptr 的构造函数;使用 std::make_unique。但这样无法使用自定义 deleter;(2)如果是这种情况,new 的结果直接传入构造函数,不要用一个中间裸指针变量存储

  • 与此有关的一个坑点出现在 this 指针上。例如你要在一个类的函数中将当前对象的指针加到一个容器,如 vector<std::shared_ptr<Widget>> v中,可能自然会写出 v.push_back(this) 的语句。如果在外面还有其他语句用当前对象创建过 std::shared_ptr,就会导致 undefined-bahavior。为此C++11提供了一个辅助性模板基类 enable_shared_from_this<T>。使用方法见以下代码。其中派生类作为基类模板类型的设计称为 奇异递归模板模式(Curiously Recurring Template Pattern,CRTP),我曾经在C#编程系列中单例模板类的设计中使用过:Unity之C#学习笔记(16):单例模式及单例模板类 Singleton and MonoSingleton

class Widget : public std::enable_shared_from_this<Widget> {    // CRTP
public:
    static std::shared_ptr<Widget> create() {       // 仅允许使用工厂函数创建shared_ptr<Widget>对象,见下面解释
        return std::shared_ptr<Widget>(new Widget);
    }
    using spwVector = std::vector<std::shared_ptr<Widget>>;
    void addToVector(spwVector& v)
    {
        v.push_back(shared_from_this());            // 返回从this指针构建的shared_ptr
    }
private:
    Widget() = default;                             // 禁止直接创建Widget对象,见下面解释
};
  • shared_from_this() 不会创建控制块,而是在既有的控制块上创建 std::shared_ptr(查看源码发现其内部实际使用 std::weak_ptr 存储派生类 this 指针)。因此必须保证调用它之前至少用当前对象创建过一个 std::shared_ptr,否则C++17之后会抛出 std::bad_weak_ptr 异常,C++17之前是 undefined behavior。 良好的方式是使该类对象仅能用返回 std::shared_ptr<Widget> 的工厂函数创建,而通过 private 阻止外部调用该类的构造函数。

  • 小结——使用 std::shared_ptr 的得失:
    • 失:std::shared_ptr 本体一般占用两个字长;控制块使用默认 deleter 和 allocator 时占用三个字长。引用计数涉及两个(自增、自减)原子操作,稍微有一些性能损失。
    • 得:解引用效率基本与裸指针相同,动态分配资源生命周期的全自动管理。

对大多数情况来说这是一笔很划算的买卖。当然,如果可能使用独占性拥有的设计,std::unique_ptr 是更佳的选择。它的性能更好,而且从 std::unique_ptr “升级”到 std::shared_ptr 是很简单的(一个构造函数或等号赋值即可),反之则不成立。

  • C++17之前,std::shared_ptr 不能用于数组。虽然也可以用 std::shared_ptr<T> a(new T[N]) 将数组伪装成一个普通对象的指针,但这样的设计非常糟糕(默认 deleter 是错误的,应该提供用 delete [] 的 deleter;没有索引运算符 operator[]std::shared_ptr 的派生类向基类转换的行为是错误的),完全没有使用的理由。C++17中用于数组的创建方式为 shared_ptr<T[]> sp(new T[N]);

总结

  1. std::shared_ptr 提供了一种便利的任意共享资源生命期管理(垃圾回收)方式。
  2. std::unique_ptr 相比,std::shared_ptr 通常占用两倍大小,有使用控制块的额外代价,以及引用计数需要使用原子操作。
  3. 默认的资源释放方式是通过 delete,但可以使用自定义 deleter。deleter 的类型不影响 std::shared_ptr 的类型。
  4. 避免从裸指针变量创建 std::shared_ptr

附:对MSVC的STL中 std::shared_ptr 实现的一些分析
此部分网络上基本没有参考资料,主要基于笔者阅读源码的推测,可能有错误,欢迎各位大神指出。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  • shared_ptr 继承于 _Ptr_base 类(也是 weak_ptr 的基类),后者包含两个主要数据成员 _Ptr_Rep,分别是管理对象的指针和引用计数的控制块指针(不过MSVC和GCC都没有使用 control block 的名称)。remove_extent_t 处理数组类型,移除长度标识(_Ty 非数组类型时返回值仍为_Ty)。_Ptr_base 定义了:
    • _Copy_construct_from:复制构造,将 _Ptr_Rep 换为参数 _Other 的,并将计数+1。
    • _Move_construct_from:移动构造,将 _Ptr_Rep 换为参数 _Right 的,并将 _Right 的置为 nullptr,计数不变。
    • _Incref_Decref:增加和减少计数,真正的实现在 _Rep 中。

在这里插入图片描述
在这里插入图片描述

  • 转到核心的引用计数实现部分:定义了一个基类 _Ref_count_base 和三个派生类 _Ref_count_Ref_count_resource_Ref_count_resource_alloc_Ty_Resource 是管理对象类型(为什么要取两个名字?),_Dx 是 deleter 类型,_Alloc 是 allocator 类型,分别代表 shared_ptr 的三种初始化方法。对象指针的销毁管理也是在这里而非 _Ptr_baseshared_ptr 中直接进行的。
    在这里插入图片描述
  • 基类 _Ref_count_base 定义了函数:
    • _Destroy_Delete_this:销毁管理对象的指针和自身,纯虚函数,在三个派生类中各自实现。
    • _Incref_Decref 等:引用计数增减的真正实现,具体代码太菜了看不懂(逃)。总之如果 _Uses 计数减为 0 则调用 _Destroy,如果 _Weaks 计数减为 0 则调用 _Delete_this

在这里插入图片描述
在这里插入图片描述

  • 三个派生类主要内含数据成员以及上面提到的 _Destroy_Delete_this 实现:
    • _Ref_count 类:仅存一个管理对象的指针 _Ty_Destroy 行为是 delete
    • _Ref_count_resource 类:与 unique_ptr 类似的实现手法,使用 _Compressed_pair<_Dx, _Resource> 存储 deleter 和管理对象,_Destroy 行为是从 _Compressed_pair 中取出 deleter 并在对象上调用。
    • _Ref_count_resource_alloc 类:套娃的 _Compressed_pair_Destroy 行为同上,_Alloc 的构造行为在 shared_ptr 中(见下文),析构行为调用 _Deallocate_plain(Allocator还没学,不懂,逃x2)

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  • 回到 shared_ptr 中,先看构造函数。使用裸指针,deleter 和 allocator 的构造函数如下:
    在这里插入图片描述
    在这里插入图片描述

  • 仅裸指针版本对于单个对象创建 _Temporary_owner,然后调用 _Set_ptr_rep_and_enable_shared;对于数组调用 _Setpd。加入 deleter 和 allocator 则分别调用 _Setpd_Setpda_Setpd_Setpda 内部实际上也先创建 _Temporary_owner_del,然后调用 _Set_ptr_rep_and_enable_shared
    在这里插入图片描述

  • _Temporary_owner_Temporary_owner_del 只是两个简单封装的结构体,笔者暂时没理解为什么在构造 shared_ptr 时要用这么一层结构。

  • _Set_ptr_rep_and_enable_shared 实际设置好继承自基类的 _Ptr_Rep
    在这里插入图片描述

  • shared_ptr 的复制/移动构造函数,调用前面讲到的基类的 _Copy_construct_from_Move_construct_from 实现。
    在这里插入图片描述

  • shared_ptrstd::unique_ptr 的构造函数:将对方的指针和 deleter 拿过来,类似裸指针+deleter的构造方式,然后将对方 release 掉。
    在这里插入图片描述

  • shared_ptr 的复制/移动赋值运算符copy-and-swap idiom,用 _Right 构造新的 shared_ptr ,然后与 *this 交换。swap 的实现就是分别对两个数据成员 _Ptr_Rep 调用 std::swap
    在这里插入图片描述

  • reset 函数,同样是 copy-and-swap idiom,对应三种裸指针,deleter,allocator 的版本。
    在这里插入图片描述

  • 析构函数,调用基类的 _Decref 函数足矣,因为 shared_ptr 自身中没有除基类 _Ptr_base 外的数据成员(因此其大小也与 _Ptr_base 相等,为两个指针)。对象销毁操作在 _Ptr_base_Rep_Decref 中完成。
    在这里插入图片描述

`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、付费专栏及课程。

余额充值