C++智慧指针shared_ptr和unique_ptr

Aki 发布于 2022-09-23 449 次阅读


介绍:

从比较简单的层面来看,智慧指针是RAII(Resource Acquisition Is Initialization,资源获取即初始化)机制对普通指针进行的一层封装。这样使得智能指针的行为动作像一个指针,本质上却是一个对象,这样可以方便管理一个对象的生命周期。

原始指针的问题大家都懂,就是如果忘记删除,或者删除的情况没有考虑清楚,容易造成悬挂指针(dangling pointer)或者说野指针(wild pointer)。

智慧指针就可以方便我们控制指针对象的生命周期。在智能指针中,一个对象什么情况下被析构或被删除,是由指针本身决定的,并不需要用户进行手动管理,是不是瞬间觉得幸福感提升了一大截,有点幸福来得太突然的意思,终于不用我自己手动删除指针了。

C++标准库中定义了weak_ptr,shared_ptr,unique_ptr三种智慧指针,有利于管理内存,这三个智慧指针都用来管理单个元素,而不是数组。

还有一个boost库里也定义了许多的智慧指针,但boost库并不是C++官方库,所有没有引入C++标准库中。boost库里有auto_ptr,shared_ptr,scope_ptr,scope_array,shared_array,intrusive_ptr等智慧指针,这些智慧指针分别有着不同的用途。

shared_ptr(一种强引用指针):

多个shared_ptr指向同一处资源,当所有shared_ptr都全部释放时,该处资源才释放。(有某个对象的所有权(访问权,生命控制权) 即是 强引用,所以shared_ptr是一种强引用型指针)

我们提到的智慧指针,很大程度上就是指的shared_ptr,shared_ptr也在实际应用中广泛使用。它的原理是使用引用计数实现对同一块内存的多个引用。在最后一个引用被释放时,指向的内存才释放,这也是和 unique_ptr 最大的区别。当对象的所有权需要共享(share)时,share_ptr可以进行赋值拷贝。

shared_ptr使用引用计数,每一个shared_ptr的拷贝都指向相同的内存。每使用他一次,内部的引用计数加1,每析构一次,内部的引用计数减1,减为0时,删除所指向的堆内存

shared_ptr初始化:

1.裸指针直接初始化,但不能通过隐式转换来构造

2.允许移动构造,也允许拷贝构造

3.通过make_shared构造

//shared_ptr是一个类模板,使用时需要指明类型,初始化使用一个指针或者一个make_shared对象

shared_ptr<int> ptr(new int(10));             //第一种创建shared_ptr方法

shared_ptr<int> ptr2(move(new int(100)));     //第二种创建shared_ptr方法

shared_ptr<int> ptr3(make_shared<int>(20));   //第三种创建shared_ptr方法

//make_shared也是一个类模板,它返回一个指针,实现类似于下面这样,shared_ptr实现了移动构造,赋值方法,会使用移动构造函数接管指针所有权和引用计数,同时也在类中重载了*,->等运算符,使其表现起来像指针一样。

template<class _Ty>
shared_ptr<_Ty> make_shared(const _Ty& value)
{
	return shared_ptr<_Ty>(new _Ty(value));
}

shared_ptr注意事项:

1.尽量避免将一个裸指针传递给std::shared_ptr的构造函数,常用的替代手法是使用std::make_shared。如果必须将一个裸指针传递给shared_ptr的构造函数,就直接传递new运算符的结果,而非传递一个裸指针变量。

2.不要将this指针返回给shared_ptr。当希望将this指针托管给shared_ptr时,类需要继承自std::enable_shared_from_this,并且从shared_from_this()中获得shared_ptr指针。

3.不要使用相同的原始指针作为实参来创建多个shared_ptr对象,具体原因见下面讲的shared_ptr内存模型。可以使用拷贝构造或者直接使用重载运算符=进行操作

shared_ptr常用成员函数:

get():返回shared_ptr中保存的裸指针;

reset(…):重置shared_ptr;

  • reset( )不带参数时,若智能指针s是唯一指向该对象的指针,则释放,并置空。若智能指针P不是唯一指向该对象的指针,则引用计数减少1,同时将P置空。
  • reset( )带参数时,若智能指针s是唯一指向对象的指针,则释放并指向新的对象。若P不是唯一的指针,则只减少引用计数,并指向新的对象。如:

use_count():返回shared_ptr的强引用计数

unique():若use_count()为1,返回true,否则返回false。

swap():与另一个shared_ptr交换管理的指针。

shared_ptr内存模型:

由图可以看出,shared_ptr包含了一个指向对象的指针和一个指向控制块的指针。每一个由shared_ptr管理的对象都有一个控制块,它除了包含强引用计数、弱引用计数之外,还包含了自定义删除器的副本和分配器的副本以及其他附加数据。

控制块的创建规则:

  • std::make_shared总是创建一个控制块;
  • 从具备所有权的指针出发构造一个std::shared_ptr时,会创建一个控制块(如std::unique_ptr转为shared_ptr时会创建控制块,因为unique_ptr本身不使用控制块,同时unique_ptr置空);
  • 当std::shared_ptr构造函数使用裸指针作为实参时,会创建一个控制块。这意味从同一个裸指针出发来构造不止一个std::shared_ptr时会创建多重的控制块,也意味着对象会被析构多次。如果想从一个己经拥有控制块的对象出发创建一个std::shared_ptr,可以传递一个shared_ptr或weak_ptr而非裸指针作为构造函数的实参,或者直接使用重载运算符=,这样则不会创建新的控制块。

因此,更好的解决方式是尽量避免使用裸指针作为共享指针的实参,而是使用make_shared,此外,make_shared相比直接new还具有以下好处

优点:

  • 避免代码冗余:创建智能指针时,被创建对象的类型只需写1次,而用new创建智能指针时,需要写2次;
  • 异常安全:make系列函数可编写异常安全代码,改进了new的异常安全性;
  • 提升性能:编译器有机会利用更简洁的数据结构产生更小更快的代码。使用make_shared时会一次性进行内存分配,该内存单块(single chunck)既保存了T对象又保存与其相关联的控制块。而直接使用new表达式,除了为T分配一次内存,还要为与其关联的控制块再进行一次内存分配。

缺点:

  • 所有的make系列函数都不允许自定义删除器
  • make系列函数创建对象时,不能接受{}初始化列表(这是因为完美转发的转发函数是个模板函数,它利用模板类型进行推导。因此无法将{}推导为initializer_list)。换言之,make系列只能将圆括号内的形参完美转发;
  • **自定义内存管理的类(如重载了operator new和operator delete),不建议使用make_shared来创建。**因为:重载operator new和operator delete时,往往用来分配和释放该类精确尺寸(sizeof(T))的内存块;而make_shared创建的shared_ptr,是一个自定义了分配器(std::allocate_shared)和删除器的智能指针,由allocate_shared分配的内存大小也不等于上述的尺寸,而是在此基础上加上控制块的大小;
  • 对象的内存可能无法及时回收。因为:make_shared只分配一次内存,减少了内存分配的开销,使得控制块和托管对象在同一内存块上分配。而控制块是由shared_ptr和weak_ptr共享的,因此两者共同管理着这个内存块(托管对象+控制块)。当强引用计数为0时,托管对象被析构(即析构函数被调用),但内存块并未被回收,只有等到最后一个weak_ptr离开作用域时,弱引用也减为0才会释放这块内存块。原本强引用减为0时就可以释放的内存, 现在变为了强引用和弱引用都减为0时才能释放, 意外的延迟了内存释放的时间。这对于内存要求高的场景来说, 是一个需要注意的问题。

比较运算符

所有比较运算符都会调用共享指针内部封装的原始指针的比较运算符;支持==、!=、<、<=、>、>=;同类型的共享指针才能使用比较运算符。

unique_ptr(独占指针):

unique_ptr 不共享它的指针。它无法复制到其他 unique_ptr,无法通过值传递到函数,也无法用于需要副本的任何标准模板库 (STL) 算法。只能移动unique_ptr。这意味着,内存资源所有权将转移到另一 unique_ptr,并且原始 unique_ptr 不再拥有此资源。我们建议你将对象限制为由一个所有者所有,因为多个所有权会使程序逻辑变得复杂。因此,当需要智能指针用于纯 C++ 对象时,可使用 unique_ptr,而当构造 unique_ptr 时,可使用make_unique 函数。unique_ptr具有->*运算符重载符,因此它可以像普通指针一样使用。

unique_ptr被设计成为一个零额外开销的智能指针,使用它,应该相比你手工写newdelete没有额外开销,不管是时间还是空间上。

std::unique_ptr 实现了独享所有权的语义。一个非空的 std::unique_ptr 总是拥有它所指向的资源。转移一个 std::unique_ptr 将会把所有权也从源指针转移给目标指针(源指针被置空)。拷贝一个 std::unique_ptr 将不被允许,因为如果你拷贝一个 std::unique_ptr ,那么拷贝结束后,这两个 std::unique_ptr 都会指向相同的资源,它们都认为自己拥有这块资源(所以都会企图释放)。因此 std::unique_ptr 是一个仅能移动(move_only)的类型。当指针析构时,它所拥有的资源也被销毁。默认情况下,资源的析构是伴随着调用 std::unique_ptr 内部的原始指针的 delete 操作的。

unique_ptr构造方法:

1.裸指针直接初始化,但不能通过隐式转换来构造,因为unique_ptr构造函数被声明为explicit。
2.允许移动构造,但不允许拷贝构造,因为unique_ptr是个只移动类型。
3.通过make_unique构造,但这是C++14才支持的语法。需要注意的是:make_unique不支持添加删除器,或者初始化列表。

unique_ptr<int> ptr(new int(10));             //使用裸指针初始化

unique_ptr<int> ptr2(make_unique<int>(20));    //使用make_unique

unique_ptr<int> ptr3(move(ptr));
               //使用移动构造

ptr3 = move(ptr2);                //移动赋值

//unique_ptr中的源代码(MSVC),拷贝构造函数和拷贝赋值运算符均为delete:

unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr&) = delete;

unique_ptr注意事项:

unique_ptr不支持拷贝构造和拷贝赋值,即说明了unique_ptr不能拷贝和赋值,但支持移动构造和移动赋值。

unique_ptr内存模型:

unique_ptr成员函数:

函数API含义
uptr.get()返回管理的指针(不能delete),所有权仍归uptr
uptr.release()释放管理指针所有权、并返回原生指针(要负责delete)
uptr.reset()uptr = nullptr等价,delete管理的指针对象,同时置空指针
uptr.reset(p)先delete uptr,再将所有权指向p指针
uptr.swap(uptr2)交换两个智能指针

使用unique_ptr来管理数组:

unique_ptr也可以用来管理数组,但是shared_ptr 在默认情况下是不能指向数组的,那是为什么呢?原因是因为我们的 shared_ptr 默认的删除器是使用 Delete 对智能指针中的对象进行删除,而 delete 要求 new 时是单一指针 Delete时也应该是指针 new时是数组 delete 也应该用数组类型去delete。当创建一个uniqie_ptr对象时,会进行下面的判断:

1.先判断是不是数组,不是数组就直接创建 unique_ptr

2.是数组:

2.1 先判断是否为定长数组,如果是定长数组则编译不通过,因为不能这样调用make_unique<T[10]>(10), 应该 make_unique<T[ ]>(10)。

2.2 若非定长数组,则获取数组种的元素类型,再根据传入参数size 创建动态数组的unique_ptr

相比与shared_ptr unique_ptr对于动态数组的管理就轻松多了 我们只需要直接使用即可,而且unique_ptr是重载了下标运算符的,意味着我们可以方便把其当数组一样使用。

unique_ptr<int[]> ptr(make_unique<int[]>(10));   //创建 new int[10]()
unique_ptr<int[]> ptr2(new int[5]());            //创建 new int[5]()

for (int i = 0; i < 10; ++i)
{
	ptr[i] = i;
}

for (int i = 0; i < 10; ++i)
{
	cout << ptr[i] << endl;
}
	

即使unique_ptr能够管理动态数组,但还是不推荐使用任何的智能指针来管理数组,原因是这个数组不是动态扩展的,没有标准库中的vector好用,且没有迭代器之类的东西,不好搭配stl算法使用。在需要动态数组的情况下我们应该使用std::vector,它提供了更多的灵活性,而只付出了很小的代价。