C++——智能指针

智能指针

为什么需要智能指针?
由于裸指针很危险,忘记释放很容易造成内存泄露,所以自动垃圾回收就显得十分重要,C++虽然并没有像java、python那样完全支持自动垃圾回收,但是C++11 增添了智能指针来实现堆内存的自动回收,不再需要手动delete。

智能指针的实现原理:
利用代理模式,把裸指针包装起来,在构造函数里初始化,在析构函数里释放。这样当对象使用完了时,C++ 就会自动调用析构函数,完成内存释放、资源回收等清理工作。

和 Java、Go 相比,这算是一种“微型”的垃圾回收机制,而且回收的时机完全“自主可控”,非常灵活。

智能指针本质是对象
智能指针虽然名字叫指针,用起来也很像,但它实际上是一个对象(当然指针本来就是对象)。
所以不要企图对它调用 delete,它会自动管理初始化时的指针,在离开作用域时析构释放内存。

shared_ptr

shared_ptr 与 unique_ptr 的最大不同点:
shared_ptr 指针指向的堆内存可以同其它 shared_ptr 共享

shared_ptr和unique_ptr支持的常用操作
在这里插入图片描述
shared_ptr独有的操作:
在这里插入图片描述

使用:

int main() {
    shared_ptr<int> q= shared_ptr<int>(new int(10));
    shared_ptr<int> p= make_shared<int>(10); // 工厂函数创建智能指针
    
    if(p) {
        cout << p << endl; //0xf06100
        cout << *p << endl;//10
        cout << q << endl;//0xf06110
        cout << *q;//10
    }
}

shared_ptr自动销毁所管理的对象

当指向一个对象的最后一个shared_ptr被销毁时,shared_ptr类会通过析构函数自动销毁此对象。

shared_ptr还会自动释放相关联的内存,当动态对象不再被使用时,shared_ptr类会自动释放动态对象

要小心使用析构函数
因为我们把指针交给了 shared_ptr 去自动管理,但在运行阶段,引用计数的变动是很复杂的,很难知道它真正释放资源的时机,无法像 Java、Go 那样明确掌控、调整垃圾回收机制。所以要特别小心对象的析构函数,不要有非常复杂、严重阻塞的操作。一旦 shared_ptr 在某个不确定时间点析构释放资源,就会阻塞整个进程或者线程,“整个世界都会静止不动”( Go 也是)。排查起来费了很多功夫,真的是“血泪教训”。

引用计数

引用计数:shared_ptr 支持安全共享的原理在于内部使用了“引用计数”
原理:如果发生拷贝赋值,引用计数就增加,而发生析构销毁的时候,引用计数就减少。当引用计数减少到 0,即没有任何人使用这个指针的时候,会调用 delete 释放内存。

引用计数存在循环引用的问题:
shared_ptr 的引用计数也导致了一个新的问题,就是“循环引用”,这在把 shared_ptr 作为类成员的时候最容易出现,典型的例子就是链表节点,看下面的例子:

auto n1 = make_shared<Node>();   // 工厂函数创建智能指针
auto n2 = make_shared<Node>();   // 工厂函数创建智能指针

n1->next = n2;                 // 两个节点互指,形成了循环引用
n2->next = n1;

assert(n1.use_count() == 2);    // 引用计数为2
assert(n2.use_count() == 2);    // 无法减到0,无法销毁,导致内存泄漏

在这里,两个节点指针刚创建时,引用计数是 1,但指针互指(即拷贝赋值)之后,引用计数都变成了 2。

这个时候,shared_ptr 就“犯傻”了,意识不到这是一个循环引用,多算了一次计数,后果就是引用计数无法减到 0,无法调用析构函数执行 delete,最终导致内存泄漏。

想要从根本上杜绝循环引用,光靠 shared_ptr 是不行了,必须要用到它的“小帮手”:weak_ptr。

shared_ptr的线程安全问题

1、引用计数的加减操作是否线程安全?
安全,因为是原子操作
2、shared_ptr指向的对象是否线程安全?
不安全
当智能指针发生拷贝的时候,会先拷贝智能指针,再拷贝对象,这两个操作并不是原子的,所以不安全。

make_shared

为什么可以直接创建智能指针,却还要设计一个make_shared呢?
使用shared_ptr直接创建智能指针存在内存泄露的风险。

使用shared_ptr直接创建智能指针:

auto px = shared_ptr<int>(new int(100));

上面代码会执行下面两个过程:

  • new int申请内存,并把指针传给px
  • 为shared_ptr 的控制块另外申请一块内存,用来存放shared_ptr的控制信息,比如shared_ptr引用计数,weak_ptr引用计数。

所以new方式创建的shared_ptr,内存地址空间和引用计数地址空间不在一块。所以当 new int 申请内存成功,但引用计数内存申请失败时,很可能造成内存泄漏

为了解决直接使用shared_ptr创建智能指针带来的内存泄露问题,C++11标准库引入了make_shared

auto p = make_shared<int>(100);

make_shared只会申请一次内存,这块内存会大于int所占用的内存,多出的部分用于存放智能指针引用计数。这样就避免了直接使用shared_ptr带来的问题。

不过make_shared并不是完美的:
因为当强引用计数为0时,会释放引用的对象的内存,但是只有当弱引用计数为0时,才释放引用计数所占用的内存。
现在make_shared申请的内存中同时存放对象数据和引用计数,所以只有当强/弱引用都为0时,才能释放make_shared申请的一整块内存。造成对象数据占用的内存无法得到及时的释放。

shared_ptr和new结合使用

new的三个知识点:
1、如果我们不初始化一个智能指针,它就会被初始化为一个空指针
我们还可以用new返回的指针来初始化智能指针:
在这里插入图片描述
2、接受指针参数的智能指针的构造函数是explicit修饰的
因此,我们不能将一个内置指针隐式转换为一个智能指针,必须使用直接初始化形式来初始化一个智能指针:
在这里插入图片描述
3、p1的初始化隐式地要求编译器用一个new返回的int*来创建一个shared_ptr。由于我们不能进行内置指针到智能指针间的隐式转换,因此这条初始化语句是错误的。
出于相同的原因,一个返回shared_ptr的函数不能在其返回语句中隐式转换一个普通指针:
在这里插入图片描述
我们必须将shared_ptr显式绑定到一个想要返回的指针上:
在这里插入图片描述

delete的知识点:
默认情况下,一个用来初始化智能指针的普通指针必须指向动态内存,因为智能指针默认使用delete释放它所关联的对象。
我们可以将智能指针绑定到一个指向其他类型的资源的指针上,但是与此同时必须提供自己的操作来替代delete、
在这里插入图片描述

智能指针保证发生异常时释放内存

当程序发生异常时,需要确保资源能被正确地释放。一个简单的确保资源被释放的方法是使用智能指针。

如果使用智能指针,即使程序块过早结束,智能指针类也能确保在内存不再需要时将其释放

在这里插入图片描述
与之相对的,当发生异常时,我们直接管理的内存是不会自动释放的。如果使用内置指针管理内存,且在new之后在对应的delete之前发生了异常,则内存不会被释放,比如下面这样:在这里插入图片描述
如果在new和delete之间发生异常,且异常未在f中被捕获,则内存就永远不会被释放了。在函数f之外没有指针指向这块内存,因此就无法释放它了。

注意事项

智能指针可以提供对动态分配的内存安全而又方便的管理,但这建立在正确使用的前提下。为了正确使用智能指针,我们必须坚持一些基本规范:
· 不使用同一个内置指针初始化(或reset)多个智能指针
· 不delete get()返回的指针。
· 不使用get()初始化或reset另一个智能指针。
· 如果你使用get()返回的指针,记住当最后一个对应的智能指针销毁后,你的指针就变为无效了。
· 如果你使用智能指针管理的类没有析构函数,可以定义一个删除器函数,然后当我们创建一个shared_ptr时,可以传递一个(可选的)指向删除器函数的参数
举个例子,end_connection就是一个删除器函数:
在这里插入图片描述
在这里插入图片描述

当p被销毁时,它不会对自己保存的指针执行delete,而是调用end_connection。接下来,end_connection会调用disconnect关闭连接,从而确保连接被关闭。如果f正常退出,那么p的销毁会作为结束处理的一部分。如果发生了异常,p同样会被销毁,从而连接被关闭。

unique_ptr

与shared_ptr不同,某个时刻只能有一个unique_ptr指向一个给定对象。当unique_ptr被销毁时,它所指向的对象也被销毁

下表列出unique_ptr独有操作:
在这里插入图片描述

由于一个unique_ptr独享它指向的对象,因此unique_ptr不支持普通的拷贝或赋值操作:
在这里插入图片描述

unique_ptr的对象是独享的,不能拷贝或赋值,但可以实现对象所有权的转移,

方式一:移动语义move
利用move之后,指针的所有权就被转走了,原来的 unique_ptr 变成了空指针,新的unique_ptr 接替了管理权,保证所有权的唯一性:

auto ptr1 = make_unique<int>(42); // 工厂函数创建智能指针
auto ptr2 = std::move(ptr1); // 使用move()转移所有权

方式二:调用release或reset将指针的所有权从一个(非const)unique_ptr转移给另一个unique:
在这里插入图片描述
release成员返回unique_ptr当前保存的指针并将其置为空。因此,p2被初始化为p1原来保存的指针,而p1被置为空。

reset成员接受一个可选的指针参数,令unique_ptr重新指向给定的指针。如果unique_ptr不为空,它原来指向的对象被释放。因此,对p2调用reset释放了用"Stegosaurus"初始化的string所使用的内存,将p3对指针的所有权转移给p2,并将p3置为空。

调用release会切断unique_ptr和它原来管理的对象间的联系。release返回的指针通常被用来初始化另一个智能指针或给另一个智能指针赋值。在本例中,管理内存的责任简单地从一个智能指针转移给另一个。但是,如果我们不用另一个智能指针来保存release返回的指针,我们的程序就要负责资源的释放:
在这里插入图片描述

weak_ptr(解决循环引用)

weak_ptr专门为打破循环引用而设计
因为weak_ptr只观察指针,不会增加引用计数,但在需要的时候,可以调用成员函数 lock(),获取 shared_ptr(强引用)

weak_ptr支持的API:
在这里插入图片描述
当我们创建一个weak_ptr时,要用一个shared_ptr来初始化它:
在这里插入图片描述
本例中wp和p指向相同的对象。由于是弱共享,创建wp不会改变p的引用计数;wp指向的对象可能被释放掉。

由于对象可能不存在,我们不能使用weak_ptr直接访问对象,而必须调用lock。此函数检查weak_ptr指向的对象是否仍存在。如果存在,lock返回一个指向共享对象的shared_ptr。与任何其他shared_ptr类似,只要此shared_ptr存在,它所指向的底层对象也就会一直存在。例如:
在这里插入图片描述
在这段代码中,只有当lock调用返回true时我们才会进入if语句体。在if中,使用np访问共享对象是安全的。

weak_ptr打破循环引用

举例子:

#include <iostream>
#include <memory>

class CB;
class CA {
  public:
    CA() {
      std::cout << "CA()" << std::endl;
    }
    ~CA() {
      std::cout << "~CA()" << std::endl;
    }
    void set_ptr(std::shared_ptr<CB>& ptr) {
      m_ptr_b = ptr;
    }
  private:
    std::shared_ptr<CB> m_ptr_b;
};

class CB {
  public:
    CB() {
      std::cout << "CB()" << std::endl;
    }
    ~CB() {
      std::cout << "~CB()" << std::endl;
    }
    void set_ptr(std::shared_ptr<CA>& ptr) {
      m_ptr_a = ptr;
    }
  private:
    std::shared_ptr<CA> m_ptr_a;
};

int main()
{
  std::shared_ptr<CA> ptr_a(new CA());
  std::shared_ptr<CB> ptr_b(new CB());
  ptr_a->set_ptr(ptr_b);
  ptr_b->set_ptr(ptr_a);
  std::cout << ptr_a.use_count() << " " << ptr_b.use_count() << std::endl;

  return 0;
}

编译并运行结果,打印为:

CA()
CB()
2 2

为什么析构函数并没有调用呢? 例子中的引用情况如下:
在这里插入图片描述
当main函数运行结束时,对象ptr_a和ptr_b被销毁,也就是①、③两条引用会被断开,但是②、④两条引用依然存在,每一个的引用计数都不为0,结果就导致其指向的内部对象无法析构,造成内存泄漏。

利用weak_ptr解决
将CA或CB两个类中的一个成员变量改为weak_ptr对象即可解决,比如将CB中的成员变量改为weak_ptr对象,即CB类的代码如下:

class CB {
  public:
    CB() {
      std::cout << "CB()" << std::endl;
    }
    ~CB() {
      std::cout << "~CB()" << std::endl;
    }
    void set_ptr(std::shared_ptr<CA>& ptr) {
      m_ptr_a = ptr;
    }
  private:
    std::weak_ptr<CA> m_ptr_a;
};

编译并运行结果,打印为:

CA()
CB()
1 2
~CA()
~CB()

修改后例子中的引用关系如下图所示:
在这里插入图片描述
④这条引用是通过weak_ptr建立的,并不会增加引用计数。也就是说,CA的对象只有一个引用计数,而CB的对象只有2个引用计数
当main函数返回时,对象ptr_a和ptr_b被销毁,也就是①、③两条引用会被断开,此时CA对象的引用计数会减为0,对象被销毁,其内部的m_ptr_b成员变量也会被析构,导致CB对象的引用计数会减为0,对象被销毁,进而解决了引用成环的问题。

总结

智能指针是代理模式的具体应用,它使用 RAII 技术代理了裸指针,能够自动释放内存,无需程序员干预。

如果指针是“独占”使用,就应该选择 unique_ptr,它为裸指针添加了很多限制,更加安全。

如果指针是“共享”使用,就应该选择 shared_ptr,它的功能非常完善,用法几乎与原始指针一样。

应当使用工厂函数 make_unique()、make_shared() 来创建智能指针,强制初始化,而且还能使用 auto 来简化声明。

shared_ptr 有少量的管理成本,也会引发一些难以排查的错误,所以不要过度使用。


版权声明:本文为qq_40337086原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
THE END
< <上一篇
下一篇>>