智能指针shared_ptr的原理、用法和注意事项

智能指针shared_ptr的原理、用法和注意事项

  • 1 前言
  • 2 shared_ptr原理
  • 3 shared_ptr的基本用法
    • 3.1 初始化
    • 3.2 获取原始指针
  • 4 智能指针和动态数组
    • 4.1 c++17前需指定删除器
    • 4.2 `c++17`增加了`opreator[]`和使用`int[]`类的数组类型做模板参数
    • 4.3 `c++20`支持`std::make_shared`
  • 5 使用`shared_ptr`需要注意的问题

1 前言

在实际的C++开发过程中,我们经常会遇到诸如程序运行中突然崩溃、程序运行所用的内存越来越多最终不得不重启等问题,这些问题往往都是内存管理资源不当造成的。比如:
①有些内存资源已经释放,但指向它的指针并没有改变指向,最终成为了野指针,并且后续还在使用;
②有些内存资源已经被释放,后期又试图再释放一次,最终导致重复释放同一块内存会使程序运行崩溃;
③没有及时释放不再使用的内存资源,造成内存泄漏,程序占用的内存资源越来越多。
针对以上情况,C++提供了更友好的内存管理机制,让程序员更专注于开发项目的各个功能上,而不是自己进行内存管理。事实上,显示内存管理的替代方案很早就有了,早在1959年前后,就有人提出“垃圾自动回收”机制,“垃圾”指的是那些不再使用或者没有任何指针指向的内存空间,而“回收”指的是将这些“垃圾”收集起来以便再次利用。
在C++98/03标准中,支持使用anto_ptr智能指针来实现堆内存的自动回收,C++11新标准在废弃auto_ptr的同时,增加了nique_ptrshared_ptr 以及 weak_ptr 这 3 个智能指针来实现堆内存的自动回收。
智能指针和普通指针用法相似,智能指针的本质是一个模板类,对普通指针进行了封装,通过在构造函数中初始化分配内存,在析构函数中释放内存,达到自己管理内存,不需要手动管理内存的效果,因此智能指针可以在适当时机自动释放分配的内存,即使用智能指针可以很好地避免“忘记释放内存而导致内存泄漏”问题,由此可见,C++开始支持了垃圾回收机制,但是目前支持的程度有限。
接下来对shared_ptr的原理和用法做详细的解释说明。

2 shared_ptr原理

shared_ptr是以类模板的方式实现的,shared_ptr(其中 T 表示指针指向的具体数据类型)的定义位于头文件,并位于 std 命名空间中,因此在使用该类型指针时,程序中应包含如下 2 行代码:

#include 
using namespace std; //这一行代码不是必须的,如果不添加则在后续使用shared_ptr 智能指针时,就需要明确指明std::

shared_ptr的简单实现如下:

#include
#include
#include
using namespace std;

template<class T>  //模板类
class Shared_Ptr{
public:
    //以普通指针进行构造
	Shared_Ptr(T* ptr = nullptr)
		:_pPtr(ptr)
		, _pRefCount(new int(1))
		, _pMutex(new mutex)
	{}
    
    //析构函数
	~Shared_Ptr()
	{
		Release();
	}
    
    //拷贝构造函数
	Shared_Ptr(const Shared_Ptr<T>& sp)
		:_pPtr(sp._pPtr)
		, _pRefCount(sp._pRefCount)
		, _pMutex(sp._pMutex)
	{
		AddRefCount();
	}
    
    //重载赋值号,使得同一类型的shared_ptr智能指针可以相互赋值
	Shared_Ptr<T>& operator=(const Shared_Ptr<T>& sp)
	{
		if (_pPtr != sp._pPtr)
		{
			// 释放管理的旧资源
			Release();
			// 共享管理新对象的资源,并增加引用计数
			_pPtr = sp._pPtr;
			_pRefCount = sp._pRefCount;
			_pMutex = sp._pMutex;
			AddRefCount();
		}
		return *this;
	}
    
    //	重载 * 号,获取当前 shared_ptr 智能指针对象指向的数据
	T& operator*(){
		return *_pPtr;
	}
    
    //重载 -> 号,当智能指针指向的数据类型为自定义的结构体时,通过 -> 运算符可以获取其内部的指定成员
	T* operator->(){
		return _pPtr;
	}
    
    //返回同当前 shared_ptr 对象(包括它)指向相同的所有 shared_ptr 对象的数量
	int UseCount() { return *_pRefCount; }
    
    //返回 shared_ptr 对象内部包含的普通指针
	T* Get() { return _pPtr; }
    
	void AddRefCount()
	{
		_pMutex->lock();
		++(*_pRefCount);
		_pMutex->unlock();
	}
private:
	void Release()
	{
		bool deleteflag = false;
		_pMutex->lock();
		if (--(*_pRefCount) == 0)
		{
			delete _pRefCount;
			delete _pPtr;
			deleteflag = true;
		}
		_pMutex->unlock();
		if (deleteflag == true)
			delete _pMutex;
	}
private:
	int *_pRefCount;  //定义一个引用计数指针
	T* _pPtr;   //定义一个存储指针
	mutex* _pMutex;  //定义一个锁指针,为了保证线程安全,防止资源未释放或程序崩溃
};

shared_ptr源码可以看出模板类Shared_Ptr有一个存储指针( _pPtr),一个锁指针( _pMutex)和一个引用计数指针( _pRefCount),共三个成员。为了方便用户使用 shared_ptr 智能指针,shared_ptr 模板类还提供有一些实用的成员方法,它们各自的功能如下:

成员方法名 功 能
operator=() 重载赋值号,使得同一类型的 shared_ptr 智能指针可以相互赋值。
operator*() 重载 * 号,获取当前 shared_ptr 智能指针对象指向的数据。
operator->() 重载 -> 号,当智能指针指向的数据类型为自定义的结构体时,通过 -> 运算符可以获取其内部的指定成员。
swap() 交换 2 个相同类型 shared_ptr 智能指针的内容。
reset() 当函数没有实参时,该函数会使当前 shared_ptr 所指堆内存的引用计数减 1,同时将当前对象重置为一个空指针;当为函数传递一个新申请的堆内存时,则调用该函数的 shared_ptr 对象会获得该存储空间的所有权,并且引用计数的初始值为 1
get() 获得 shared_ptr 对象内部包含的普通指针。
use_count() 返回同当前 shared_ptr 对象(包括它)指向相同的所有 shared_ptr 对象的数量。
unique() 判断当前shared_ptr对象指向的堆内存,是否不再有其它 shared_ptr 对象再指向它。
operator bool() 判断当前 shared_ptr 对象是否为空智能指针,如果是空指针,返回 false;反之,返回 true。
make_shared(args) 返回一个shared_ptr,指向一个动态分配的类型为T的对象。使用args初始化次对

想系统的了解share_ptr的详细功能,可见shared_ptr官网:share_ptr官网

shared_ptr的原理:是通过引用计数的方式来实现多个shared_ptr对象之间共享资源
shared_ptr存储指针和引用计数指针是一一对应的,即shared_ptr里存的是存储指针,对应的引用计数指针就是对stored pointer的加一,因此shared_ptr在其内部,给每个资源都维护着一份计数,用来记录该份资源被几个对象共享
②在对象被销毁时,即调用了析构函数,说明自己不使用该资源了,对象的引用计数减一;
③如果引用计数是0,说明自己是最后一个使用该资源的对象,必须释放该对象
④如果不是0,说明除了自己还有其他对象在使用该份资源,不能释放资源,否则其他对象就成了野指针。
锁指针是为了保证线程安全。 智能指针对象中引用计数是多个智能指针对象共享的,两个线程中智能指针的引用计数同时++--,这个操作不是原子的,引用计数原来是1,如果两个线程同时访问,++了两次可能还是2,这样的引用计数都是错乱的,会导致资源未释放或程序崩溃的问题。因此智能指针引用计数++--是需要加锁的,这样能够保证引用计数的操作是线程安全的。

3 shared_ptr的基本用法

3.1 初始化

可以通过构造函数、std::make_sharedreset初始化三种初始化方式

#include "stdafx.h"
#include 
#include 
#include 

using namespace std;
class Person
{
public:
    Person(int v) {
        value = v;
        std::cout << "Cons" <<value<< std::endl;
    }
    ~Person() {
        std::cout << "Des" <<value<< std::endl;
    }
    int value;
};

int main()
{
    //构造函数初始化
    std::shared_ptr<Person> p1(new Person(1));// Person(1)的引用计数为1
	
    //std::make_shared初始化
    std::shared_ptr<Person> p2 = std::make_shared<Person>(2);

    //reset初始化
    p1.reset(new Person(3));// 首先生成新对象,然后引用计数减1,引用计数为0,故析构Person(1)
                            // 最后将新对象的指针交给智能指针

    std::shared_ptr<Person> p3 = p1;//现在p1和p3同时指向Person(3),Person(3)的引用计数为2

    p1.reset();//Person(3)的引用计数为1
    p3.reset();//Person(3)的引用计数为0,析构Person(3)
    return 0;
}

初始化方式有很多,下面具体讲下每个初始化方式的用法
构造函数初始化方式

//1.通过如下两种方式,可以构造出 shared_ptr 类型的空智能指针,对于空的指针,其初始引用计数方式为0,不是1
std::shared_ptr<int> p1;             //不传入任何实参
std::shared_ptr<int> p2(nullptr);    //传入空指针 nullptr


//2.创建指针时,可以明确指向
std::shared_ptr<int> p3(new int(10)); //指向一个存有10这个int类型数据的堆内存空间

//3.调用拷贝构造函数
std::shared_ptr<int> p4(p3);//或者 std::shared_ptr p4 = p3; 如果P3为空,则P4也为空,其引用计数初始值为0,反之,则表明P4和P3指向同一块堆内存,同时堆内存的引用次数会加1.

//4.调用移动构造函数
std::shared_ptr<int> p5(std::move(p4)); //或者 std::shared_ptr p5 = std::move(p4); 即P5拥有了P4的堆内存,而P4则变成了空智能指针

std::make_shared 初始化方式,C++11 标准中提供了 std::make_shared 模板函数,其可以用于初始化 shared_ptr 智能指针

//1.定义一个空的智能指针
std::shared_ptr<int> p6 = std::make_shared<int>(); 

//2.创建指针,并明确指向
std::shared_ptr<int> p7 = std::make_shared<int>(10);

//3.auto关键字代替std::shared_ptr,p8指向一个动态分配的空vector
auto p8 = make_shared<vector<int>>();

reset初始化

//创建了一个指针,并明确指向
//调用reset(new xxx())重新赋值时,智能指针首先是生成新对象,然后将旧对象的引用计数减1(当然,如果发现引用计数为0时,则析构旧对象),然后将新对象的指针交给智能指针保管。
std::shared_ptr<int> p8 = nullptr;
p8.reset(new int(1));

//当智能指针中有值的时候,调用reset()会使引用计数减1,如果引用计数为0时,则析构旧对象
p8.reset();

3.2 获取原始指针

智能指针一般都提供了get()成员函数,用来执行显示转换,即返回智能指针内部的原始指针。

 std::shared_ptr<int> p9(new int(5));
 int *pInt = p9.get();

如果需要调用成员函数,由于几乎所有的智能指针都重载了 *->操作符,所以直接使用把智能指针当做一般的指针变量来使用就可以了。但是有时候需要传递参数,如果参数是 T*,那么传递一个智能指针类是无法识别的,因此需要使用原始指针。

4 智能指针和动态数组

c++17std::shared_ptr是不支持动态数组的,如下

#include

std::shared_ptr<int[]> sp1(new int[10]()); // 错误,c++17前不能传递数组类型作为shared_ptr的模板参数
std::unique_ptr<int[]> up1(new int[10]()); // ok, unique_ptr对此做了特化
 
std::shared_ptr<int> sp2(new int[10]()); // 错误,可以编译,但会产生未定义行为,请不要这么做

sp1错误的原因很明显,然而sp2的就没有那么好找了,究其原因,是因为std::shared_ptr对非数组类型都使用delete p释放资源,显然这对于new int[10]来说是不对的,对它应该使用delete [] p

其实c++17前的解决方案并不复杂,我们可以借助std::default_delete,它用于提供对应类型的正确的delete操作,即 指定删除器

4.1 c++17前需指定删除器

智能指针可以指定删除器,当智能指针的引用计数为0时,自动调用指定的删除器来释放内存。std::shared_ptr可以指定删除器的一个原因是在C++11标准中其默认删除器不支持数组对象,比如,对于申请的动态数组来说,shared_ptr 指针默认的释放规则是不支持释放数组的,只能自定义对应的释放规则,才能正确地释放申请的堆内存,这一点需要注意。对于申请的动态数组,释放规则可以使用C++11标准中提供的 default_delete 模板类,我们也可以自定义释放规则:

//指定 default_delete 作为释放规则
std::shared_ptr<int> p6(new int[10], std::default_delete<int[]>());

//自定义释放规则
void deleteInt(int*p) {
    delete []p;
}
//初始化智能指针,并自定义释放规则
std::shared_ptr<int> p7(new int[10], deleteInt);

以上,我们可以用默认的删除器,也可以用制定的删除器进行正确的delete操作,但是用默认删除器的缺点是明显的:

  • 我们想管理的值是int[]类型的,然而事实上传给模板参数的是int
  • 需要显示提供delete functor
  • 不能使用std::make_shared,无法保证异常安全
  • c++17shared_ptr未提供opreator[],所以当需要类似操作时不得不使用p7.get()[index]的形式

4.2 c++17增加了opreator[]和使用int[]类的数组类型做模板参数

以上的代码可以简化为:

std::shared_ptr<int[]> p7(new int[10]());

对于访问分配的空间,可以将p7.get()[index]替换为p7[index]。看个具体的例子:

#include 
#include 
 
int main()
{
    std::shared_ptr<int[]> p7(new int[5]());
    for (int i = 0; i < 5; ++i) {
        p7[i] = (i+1) * (i+1);
    }
 
    for (int i = 0; i < 5; ++i) {
        std::cout << p7[i] << std::endl;
    }
}

c++17缺点:无法使用std::make_shared,而我们除非指定自己的delete functor,否则我们应该尽量使用std::make_shared

4.3 c++20支持std::make_shared

auto up2 = std::make_unique<int[]>(10); // 从c++14开始,分配一个管理有10个int元素的动态数组的unique_ptr
 
// c++20中你可以这样写,与上一句相似,只不过返回的是shared_ptr
auto sp3 = std::make_shared<int[]>(10);

5 使用shared_ptr需要注意的问题

①不要用一个原始指针初始化多个shared_ptr,原因在于,会造成二次销毁,如下所示:

 int *p5 = new int;
 std::shared_ptr<int> p6(p5);
 std::shared_ptr<int> p7(p5);// logic error

②不要在函数实参中创建shared_ptr。因为C++的函数参数的计算顺序在不同的编译器下是不同的。正确的做法是先创建好,然后再传入。

 function(shared_ptr<int>(new int), g());

③看了很多书籍或博客,都说shared_ptr不支持动态数组,但是本地编译器由通过了,这是为啥?

随着标准越来越新,C++17及以后是支持动态数组的,C++11/14是不支持的,只要是最新的编译器是没问题的。shared_ptr动态数组定义如下:std::shared_ptr p(new int[10]);C++11shared_ptr默认调用的析构函数是default_delete(),而非default_delete<_Ty[]>,很显然,如果分配数组,当然应该使用delete[], 所以直到C++17才被支持。

④避免循环引用。智能指针最大的一个陷阱是循环引用,循环引用会导致内存泄漏。

⑤线程安全问题。
参考线程安全

假设一个简单的场景,有 3 个 shared_ptr 对象 x、g、n:

shared_ptr<Foo> g(new Foo); // 线程之间共享的 shared_ptr
shared_ptr<Foo> x; // 线程 A 的局部变量
shared_ptr<Foo> n(new Foo); // 线程 B 的局部变量

每个线程需要操作两个成员:new出来的对象以及计数器
智能指针shared_ptr的原理、用法和注意事项_第1张图片
智能指针shared_ptr的原理、用法和注意事项_第2张图片
智能指针shared_ptr的原理、用法和注意事项_第3张图片

因此,shared_ptr的线程不安全性在于它需要操作两个成员,即new出来的对象和计数器。在多线程下,不能保证new出来一个对象一定能被放入shared_ptr中,也不能保证智能指针管理的引用计数的正确性,这是因为shared_ptr操作不是一气呵成的。即存在以下情况:同一个shared_ptr对象可以被多线程同时读取。不同的shared_ptr对象可以被多线程同时修改。同一个shared_ptr对象不能被多线程直接修改,但可以通过原子函数完成。因此在创建一个shared_ptr时,需要使用C++11提供的make_shared模板,make_shared创建shared_ptr只申请一次内存,避免了上述错误,也提高了性能,同时在读写操作时,需要加锁。

你可能感兴趣的:(C++,c++)