《Effective Modern C++》学习笔记 - Item 19: 使用 std::shared

《Effective Modern C++》学习笔记 - Item 19: 使用 std::shared,第1张

《Effective Modern C++》学习笔记 - Item 19: 使用 std::shared
  • 很多编程语言中垃圾回收(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 upw(new Widget, customDeleter1);
std::shared_ptr pw1(new Widget, customDeleter1);
std::shared_ptr pw2(new Widget, customDeleter2);

std::vector> 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_ptr 或 std::auto_ptr)创建控制块。因为这些独占性指针不使用控制块。
    • 当 std::shared_ptr 从一个裸指针中构建时,创建控制块;而从另一个 std::shared_ptr 或 std::weak_ptr 构建时不会创建控制块。
  • 问题可能出现在第三条规则中:如果使用同一个裸指针创建了多个 std::shared_ptr,就会对同一个对象创建多个控制块,最终导致对象被 delete 多次——undefined behavior!因此记住两点:(1)尽可能避免将裸指针传入 std::shared_ptr 的构造函数;使用 std::make_unique。但这样无法使用自定义 deleter;(2)如果是这种情况,将 new 的结果直接传入构造函数,不要用一个中间裸指针变量存储。

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

class Widget : public std::enable_shared_from_this {    // CRTP
public:
    static std::shared_ptr create() {       // 仅允许使用工厂函数创建shared_ptr对象,见下面解释
        return std::shared_ptr(new Widget);
    }
    using spwVector = std::vector>;
    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 的工厂函数创建,而通过 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 a(new T[N]) 将数组伪装成一个普通对象的指针,但这样的设计非常糟糕(默认 deleter 是错误的,应该提供用 delete [] 的 deleter;没有索引运算符 operator[];std::shared_ptr 的派生类向基类转换的行为是错误的),完全没有使用的理由。C++17中用于数组的创建方式为 shared_ptr 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_base 或 shared_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_ptr 从 std::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 中完成。

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/zaji/5681954.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-12-17
下一篇 2022-12-17

发表评论

登录后才能评论

评论列表(0条)

保存