问小白 wenxiaobai
资讯
历史
科技
环境与自然
成长
游戏
财经
文学与艺术
美食
健康
家居
文化
情感
汽车
三农
军事
旅行
运动
教育
生活
星座命理

C++23中智能指针的使用:共享指针详解

创作时间:
作者:
@小白创作中心

C++23中智能指针的使用:共享指针详解

引用
CSDN
1.
https://m.blog.csdn.net/IncrediBuild/article/details/145700447

C++以具有难以处理的内存模型而闻名,尤其是对于来自托管内存语言的程序员。它因越界引用错误和内存泄漏而被吐槽。尽管如此,现代C++比以前安全得多,现在甚至比托管内存模型更安全(性能更高)。本文将介绍C++23中智能指针的使用,特别是shared_ptr的详细讲解。

共享所有权的概念,以及开发者为什么需要它

在C++中,智能指针的主要功能是在不再需要时释放已在堆(和其他资源)上分配的对象。unique_ptr引入了所有权的概念,它将所指对象的生命周期与所有者的生命周期(unique_ptr本身)绑定在一起。但是,如果所指对象的生命周期不是那么可预测的,会发生什么呢?在多线程或异步程序中,一个进程可能会设置一个对象,另一个进程可能会填充它,第三个进程可能会将其转发到外部端点。只有当每个进程都已完成对象时,才能将其删除。

这就是shared_ptr的用途。从表面上看,它就像一个unique_ptr,因为所指对象的一生与其所有者的一生息息相关。但最大的区别在于,一个共享指针可以有多个所有者,并且在删除引用对象之前,他们都必须放弃自己的所有权。

计算参考文献

shared_ptr使用一种称为引用计数的技术工作,该技术是为了管理函数式语言中的内存而发明的。除了每个引用对象之外,还有一条记录,用于计算有多少其他对象对它感兴趣。每次创建链接到引用对象的共享指针时,它都会在后台递增计数器。如果共享指针被删除或与引用对象断开连接,则共享指针将再次递减计数器。最终,当计数器达到零时,离开的指针将删除引用并回收其内存。

图 1:参考计数策略

这个过程显然比unique_ptr稍微复杂一些,因此使用过程中涉及的开销非常小。首先,管理计数器涉及空间开销。如果分配的资源很大且复杂,则计数器的开销最小,但如果分配大量微小的对象,则开销可能会变得很大。根据counter的分配方式,也可能有时间开销,因为counter是与引用对象一起分配和解除分配的。随着堆的碎片化程度越来越高,这也可能变得很重要。

当心循环引用

尽管引用计数指针看起来与托管内存语言中的引用非常相似,但开发者需要注意一个很大的区别。考虑一个shared_ptr拥有的对象,该对象本身包含shared_ptrs。当对象被销毁时,包含的shared_ptrs也会被销毁,从而销毁它们的引用。整个树将一次性全部删除。

图 2:级联删除

但是假设shared_ptrs在一个循环中相互引用。在这种情况下,每个对象都由链中的前一个共享指针保持活动状态,即使没有对整个结构的引用。那是内存泄漏!


图 3:对象 1 仍归对象 3 所有,因此不会被删除。

这里有几点需要注意:

  • 不变性是安全的。如果shared_ptr仅引用const对象(或者至少引用实现不可变接口的对象),则无法创建这些循环。不可变对象不受此问题的影响。
  • Pining。这是一种非常好的技术,允许对象将自身固定在内存中。假设开发者有一个对象正在参与一个长时间运行的异步进程。它可以包含一个引用自身的共享指针,当该过程完成时,它会重置指针。如果没有其他人关心,该对象将被删除。
  • 弱引用。最后,如果自引用指针是必不可少的,则可以创建一个weak_ptr。我们稍后会探讨这个问题。

基本用法

与unique_ptr类似,创建shared_ptr的最简单方法是使用make_shared:

#include <memory>
shared_ptr<MyClass> c = make_shared<MyClass>(p1, p2);

以这种方式创建指针和引用对象时,共享指针在堆上只分配一个内存块,同时包含引用对象和计数器。


图 4:make_shared 后的内存布局

与unique_ptr不同,我们可以将一个shared_ptr分配给另一个:

shared_ptr<MyClass> d = c;

这将生成如下所示的内存布局:


图 5:shared_ptr 分配后的内存布局。

我们可以使用插桩类来证明这是可行的,如下所示:

class Test {
public:
    Test() {
        cout << "Test ctor" << endl;
    }
    ~Test() {
        cout << "Test dtor" << endl;
    }
};

shared_ptr<Test> s;
{
    cout << "Creating t" << endl;
    shared_ptr<Test> t = make_shared<Test>();
    s = t; // The reference count is now 2
}
// When t goes out of scope, the reference count falls to 1
cout << "t has been destroyed" << endl;

当程序结束并且S被销毁时,计数将降至零。这将产生输出:

Creating t
Test ctor
t has been destroyed
Test dtor

我们在介绍中看到,shared_ptrs的周期会将它们的内存固定在适当的位置,我们顺便提到weak_ptr可以帮助解决这个问题。我们现在可以探索weak_ptr的实际作用。

实际上,weak_ptr就像一个shared_ptr,但引用计数器不计算weak_ptrs。更重要的是,开发者无法取消引用weak_ptr:创建该shared_ptrs可能已被销毁,但即使该weak_ptr仍然存在,所引用对象也将消失。

要使用weak_ptr,开发者首先必须尝试将其转换为shared_ptr:

weak_ptr<Test> w;
{
    cout << "Creating t" << endl;
    shared_ptr<Test> t = make_shared<Test>();
    w = t;
    shared_ptr<Test> s = w.lock(); // try to get the shared_ptr
    if (!s) cout << "t has expired" << endl;
    // otherwise use s
}
cout << "t has been deleted" << endl;
shared_ptr<Test> s = w.lock(); // try to get the shared_ptr
if (!s) cout << "t has expired" << endl;

这将生成输出:

Creating t
Test ctor
Test dtor
t has been deleted
t has expired

高级用法

就像唯一指针一样,共享指针可以使用预先存在的引用进行初始化:

MyClass *c = new MyClass();
shared_ptr<MyClass> p = new shared_ptr<MyClass>(c);

当开发者像这样构造shared_ptr时,引用计数器和引用需要位于两个不同的分配块中。

图 6:使用新的MyClass构建shared_ptr时的内存布局

这种不同的布局具有一些含义。首先,每个shared_ptr都需要两个堆分配:一个用于引用,一个用于计数器。这是make_shared的两倍。再次销毁它们时,堆活动也是两倍。

最后,weak_ptr的处理存在显著差异。要使弱指针工作,它需要访问reference counter。一旦最后一个shared_ptr被销毁,就可以立即删除referent,但只有当没有更多的共享指针或弱指针使用它时,计数器才会被删除:

图 7:weak_ptr保存引用计数器时的内存布局

但是,当使用make_shared创建shared_ptr时,其工作方式略有不同。开发者已经看到counter和referent是在同一个内存块中创建的。因此,在上面的weak_ptr演示中删除shared_ptr时,会调用其析构函数,但直到weak_ptr也超出范围,内存才会被回收。所指对象的记忆可能比你预期的要长得多!

结论

乍一看,shared_ptr看起来像是托管内存指针的近似值。但存在重要差异。

你真的需要shared_ptr吗?共享所有权就其性质而言,意味着开发者具有非本地效果:当每个所有者都具有同等权限时,任何所有者都可以随时更改正在共享的对象。这使得很难预测依赖于它们的任何特定函数的行为。共享状态是一种公认的反模式。

假设共享指针适合开发者的解决方案:

  • 仅共享不可变状态。为了缓解上述问题,开发者应该尽可能显式地创建shared_ptr对象或仅共享其接口为const的对象。
  • 明智地混合make_shared和new shared_ptr(new C)。与unique_ptr一样,开发者应该避免将new/delete与shared_ptr混用。相反,请使用其中一种。
  • 按值传递shared_ptr s。与unique_ptr相比,在中,开发者只应将引用传递给函数,而通常最好按值传递shared_ptrs。

如果shared_ptr<>&的源超出范围,则它(及其引用对象)将被删除,如果函数包含任何异步操作,则指针及其引用对象都将消失。复制shared_ptr实际上没有开销,但它会将引用保留在内存中,直到异步操作完成为止。

© 2023 北京元石科技有限公司 ◎ 京公网安备 11010802042949号