c++:智能指针
1.智能指针使用场景与优势
void Func() { int* array1 = new int[10]; int* array2 = new int[10]; try { int len, time; cin >> len >> time; cout << Divide(len, time) << endl; } catch (...) { cout << "delete []" << array1 << endl; cout << "delete []" << array2 << endl; delete[] array1; delete[] array2; throw; // 异常重新抛出,捕获到什么抛出什么 } cout << "delete []" << array1 << endl; delete[] array1; cout << "delete []" << array2 << endl; delete[] array2; }
我们上面这段代码利用catch(...)处理了divide抛异常的处理情况,但是如果我们的异常是在第二个new的过程中抛出的,我们就需要再套一层catch(...)去处理释放array1的内存空间,一两个new还好,如果我们写了很多个new,此时再套catch(...)就会让代码逻辑很混乱
在这种时候我们就可以使用智能指针(是一个对象)来托管新申请的空间,通过对象的自动析构来完成自动资源释放
2.RAII设计与智能指针生效过程
RAII的核心思想:利用对象的生命周期来管理获取到的动态资源,避免资源泄露
在获取资源时将资源委托给一个对象,在对象的生命周期内资源始终有效,直到对象销毁的时候将资源释放
智能指针除了满足RAII之外,还需要方便对资源的访问,于是还要重载 operator*/
operator->/operator[] 等运算符。
接下来我们看看智能指针的简单实现:
template<class T> class SmartPtr { public: // RAII SmartPtr(T* ptr) :_ptr(ptr) {} ~SmartPtr() { cout << "delete[] " << _ptr << endl; delete[] _ptr; } // 重载运算符,模拟指针的⾏为,⽅便访问资源 T& operator*() { return *_ptr; } T* operator->() { return _ptr; } T& operator[](size_t i) { return _ptr[i]; } private: T* _ptr; };
成员变量只有一个指针,该指针由new出来的地址初始化。
他的析构就是将接收的地址空间释放掉。
然后我们看看实际使用场景的用法:
double Divide(int a, int b) { // 当b == 0时抛出异常if (b == 0){throw "Divide by zero condition!";}else{ return (double)a / (double)b;} } void Func() {SmartPtr<int> sp1 = new int[10];SmartPtr<int> sp2 = new int[10];for (size_t i = 0; i < 10; i++){sp1[i] = sp2[i] = i;}int len, time;cin >> len >> time;cout << Divide(len, time) << endl; }
在新的空间申请出来之后我们立刻用智能指针对象接收,此时相当于将资源交给智能指针对象托管。
当divide函数抛异常之后,我们跳转到main函数的catch语句中,在此之前我们先将divide和Func的函数栈帧释放,此时智能指针对象就自动调用析构函数,从而将申请的动态空间都释放掉
3.库中收录的智能指针
所处头文件:<memory>
第一种:auto_ptr(不使用)
他也是一个支持自动调用析构的对象,但是他有一个巨大的缺陷,那就是在进行拷贝构造的
时候,为了不进行多次析构,资源的管理权会转移到新拷贝出来的对象中,而旧的只智能指针对象会指向nullptr。如果在一个项目中,后面的程序员不知道原本的智能指针对象已经指向空了,接着使用旧的智能指针对象,就会导致程序终止。
也正是因为这个原因,他已经被c++11标准中的unique_ptr完全替代了
第二种:unique_ptr(用于不用拷贝的情况)
为了解决auto_ptr的问题,unique_ptr的办法是:难办,就不办了!
也就是将拷贝构造和赋值重载都delete掉了,直接不支持拷贝左值对象了。
不过右值的拷贝是支持的,右值的拷贝走的是移动构造。
int* a = new int(1);unique_ptr<int> p(move(a));
第三种:shard_ptr(支持拷贝场景)
他是依据引用计数实现的,且支持拷贝和赋值的场景,不过引用计数这种实现方法也会导致shard_ptr会有额外的开销,所以我们在不用支持拷贝的场景中一般会使用unique_ptr,而不是shard_ptr
图示:
第四种:weak_ptr
主要用于解决shard_ptr的循环引用的问题
4.智能指针的原理
auto_ptr的核心逻辑是将管理权转移,会导致智能指针悬空,所以不使用。
unique_ptr的核心逻辑是不支持拷贝和赋值,在确实不需要拷贝和赋值的场景推荐使用
shard_ptr的核心逻辑是使用引用计数的方式支持拷贝和赋值的操作
下面我们重点实现shard_ptr:
(1)框架搭建:
template<class T> class Shared_ptr { public:Shared_ptr(T* ptr = nullptr):_ptr(ptr){}~Shared_ptr(){if(_ptr)delete _ptr;}T& operator*(){return *_ptr;}T* operator->(){return _ptr;} private:T* _ptr; };
这里我们只实现了针对new一个变量的空间的析构释放,而没有实现new[]的析构释放,因为new[]的释放需要用到定制删除器。为了和库中方法名字区分开,我们将s大写
(2)引用计数设计逻辑
因为一个资源空间需要一个对应的引用计数管理,所以我们需要的是实现部分智能指针对象可以共用一个引用计数,却不是所有智能指针对象共用一个引用计数,于是我们不考虑使用静态成员变量来实现引用计数。
那么我们还有什么办法实现引用计数?
可以使用整型指针!
template<class T> class Shared_ptr { public://构造Shared_ptr(T* ptr = nullptr):_ptr(ptr),_pcount(new int(1)){}//拷贝构造Shared_ptr(const Shared_ptr<T>& cp):_ptr(cp._ptr),_pcount(cp._pcount){++(*_pcount);}//析构~Shared_ptr(){if (--(*_pcount) == 0){delete _ptr;delete _pcount;}}T& operator*(){return *_ptr;}T* operator->(){return _ptr;} private:T* _ptr;int* _pcount; };
拷贝构造的实现:
拷贝后相当于我们新的智能指针也要管理和被拷贝的智能指针一样的空间,此时我们需要将资源地址和引用计数地址都给到新的智能指针对象,并让引用计数++
析构函数的实现:
每次调用析构的时候说明有一个智能指针对象不再管理该资源空间,所以我们在_pcount不为0的时候不用释放资源空间,只需要让_pcount--即可下面我们分不同的情况分类讨论该方法的可行性:
(1)当我们有一个新的资源空间需要智能指针管理,我们构造一个智能指针,同时也就会申请一块4字节的空间用于存放引用计数的整型数据,并将地址给到_pcount。
这说明单智能指针管理情景逻辑合理
(2)当我们需要两个智能指针共同管理一个资源空间的时候,可以通过让不同的智能指针对象指向同一个引用计数对象和资源空间来实现共同管理
(3)赋值重载
//赋值重载Shared_ptr<T>& operator=(const Shared_ptr<T>& cp){if (_ptr != cp._ptr){release();_ptr = cp._ptr;_pcount = cp._pcount;++(*_pcount);}return *this;}
当我们两个智能指针对象指向的资源空间是不一样的时候,我们需要让被赋值智能指针的ptr和pcount都指向赋值的智能指针的资源和引用计数,引用计数++,并在更改指向之前先调用一次release(表示不再控制旧的资源空间),最后返回智能指针对象。
当我们智能指针指向的资源是一样的时候,我们不用对两个智能指针做任何操作。
release:
void release(){if (--(*_pcount) == 0){delete _ptr;delete _pcount;}}
release其实就是析构的代码封装起来了
上面的实现只能支持new ,而不能支持new [ ]/malloc多段空间。
库中使用的方法是使用定制删除器
对于shard_ptr我们可以直接在构造的时候将定制删除器作为第二个参数传递给它,其中定制删除器可以是lambda,函数指针,仿函数等。
对于unique_ptr我们需要现在模板参数部分将定制删除器类型作为第二个模板参数传递,然后再在第二个参数位置传递定制删除器
综上:shard_ptr的定制删除器对其类型没有影响,他是对象的一部分
unique_ptr的定制删除器类型是其类型的一部分,不同的定制删除器类型对应的unique_ptr是不一样的
接下来我们实现允许shard_ptr使用定制删除器的结构
template<class T> class Shared_ptr { public:void release(){if (--(*_pcount) == 0){_del(_ptr);delete _pcount;}}//构造Shared_ptr(T* ptr = nullptr):_ptr(ptr), _pcount(new int(1)){}//定制删除器构造template<class D>Shared_ptr(T* ptr, D del):_ptr(ptr),_pcount(new int(1)),_del(del){}//拷贝构造Shared_ptr(const Shared_ptr<T>& cp):_ptr(cp._ptr),_pcount(cp._pcount),_del(cp._del){++(*_pcount);}//赋值重载Shared_ptr<T>& operator=(const Shared_ptr<T>& cp){if (_ptr != cp._ptr){release();_ptr = cp._ptr;_pcount = cp._pcount;++(*_pcount);}return *this;}//析构~Shared_ptr(){release();}T& operator*(){return *_ptr;}T* operator->(){return _ptr;} private:T* _ptr;int* _pcount;function<void(T*)> _del = [](T* ptr) { delete ptr; }; };
变动点1:参数部分
因为我们需要接受的定制删除器是像lambda,仿函数,函数指针这样的可调用对象,且定制删除器的返回值都是void,参数都是指向资源的指针,而function包装器恰好就可以用来接收这些可调用对象,所以这里我们就创建一个新的包装器成员变量_del。
变动点2:构造函数
我们重载一个支持定制删除器的构造函数
变动点3:release函数
我们不再直接delete指向资源的指针,而是调用定制删除器进行资源释放
注意:如果我们的构造是不使用定制删除器的,此时我们应该使用delete ptr来释放资源,此时我们就可以在private部分给_del一个缺省值,而这个缺省值是一个可以实现delete ptr的可调用对象,这里我们用的是lambda
make_shared
作用:创建并返回一个将引用计数和资源空间紧贴着申请空间的智能指针
shared_ptr<int> s(new int(1));//直接构造shared_ptr<int> s1 = make_shared<int>(1);//先make_shared再赋值
我们知道普通的shared_ptr的引用计数和资源空间的申请是分开的,也就是他们的申请空间不是连续的,这会导致堆空间有很多碎片化占用,会让连续空间的大小减少。且这种频繁向内存申请空间的行为也会导致效率降低。
图示:空间碎片化
我们有如下方法解决这些问题:
1.传递一个内存池减少频繁向内存申请空间的行为2.使用make_shared,通过将引用计数申请的空间和资源空间申请为连续的空间,从而减少碎片化空间申请
图示:
第一个图是直接构造的情况,引用计数的空间和资源空间地址是非连续的
第二个图是make_shared的情况,他会将引用计数的地址和资源空间的地址连续开辟,从而达到减少碎片空间的目的
5.shard_ptr与weak_ptr
5.1shard_ptr的循环引用问题
我们思考一个场景:比如我们现在有一个链表类,它有一个next指针,一个prev指针,其中next指针指向该节点的后一个节点,而prev指针执行指向该节点的前一个节点。
我们需要用智能指针来托管它的对象的资源
思考:我们的next指针和prev指针的类型是list还是智能指针?
如果是list的类型,那么我们后续用智能指针托管的时候就无法用指针指向节点对象了// 循环引⽤ -- 内存泄露 std::shared_ptr<ListNode> n1(new ListNode); std::shared_ptr<ListNode> n2(new ListNode);n1->_next = n2; n2->_prev = n1;
这里我们用智能指针n1和n2托管链表节点,而我们用next和prev指向的就是托管了链表节点的智能指针对象,所以要将next和prev类型设置为智能指针类型的指针。
我们分析一下当前的情况:
图示:
目前第一块资源有两个shared_ptr指向,分别是n1和prev。第二块资源分别由n2和next指向。
假设我们现在程序结束了,分别对第一个块资源空间和第二块资源空间调用析构,引用计数都变为1,但是由于有next指向第二块资源,prev指向第一块资源,此时形成了循环引用,所以实际上资源空间没有被释放,造成资源泄露
疑问:为什么会造成循环引用?
1.右节点由next指针管着,所以右节点的释放需要next对象析构
2.next在左节点中,next析构需要左节点释放
3.左节点由prev对象管着,左节点释放需要prev对象析构
4.prev对象在右节点中,prev析构需要右节点释放
这四点分析下来我们发现形成了闭环,也就是无法进行释放
此时我们就需要将next和prev的智能指针类型改为weak_ptr来解决这个问题了
5.2weak_ptr
weak_ptr又叫弱指针,这里的弱可以理解为他的功能比较单一,他就是创建出来解决shared_ptr的循环引用问题的。
它不支持直接的访问数据,也不支持直接构造,他的构造就是用于绑定shared_ptr
std::weak_ptr<ListNode> _next; n1->_next = n2;
第一行代码表示weak_ptr的定义,然后第二行就是weak_ptr绑定shared_ptr,也就是初始化的过程。
疑问:weak_ptr解决循环引用的原理是什么?
循环引用出现的原因就是调用一次析构之后,正常来说应该直接释放空间资源,但是由于next和prev都指向了对位资源空间,导致引用计数都加一了,所以最终释放的时候因为引用计数不为0,没有释放。那么weak_ptr就是让next和prev指向空间资源的时候不让引用计数++,从而达到避免循环引用的目标
总结:循环引用的出现场景需要满足的条件
两个被智能指针托管的对象中,存在成员变量也是会导致引用计数++类型的智能指针,且这两个智能指针互相指向另一个空间。此时就会导致循环引用