该文章代码均在gitee中开源
C++智能指针hpphttps://gitee.com/Ehundred/cpp-knowledge-points/tree/master/%E6%99%BA%E8%83%BD%E6%8C%87%E9%92%88
智能指针
传统指针的问题
在C++自定义类型中,我们为了避免内存泄漏,会采用析构函数的方法释放空间。但是对于一些情况,系统往往并没有那么聪明,比如C语言里,我们malloc一块空间;C++里,我们new一块空间,系统不会对这些空间进行特别检查, 最后便造成了内存泄漏
void func()
{int* a = new int(1);//...一通操作if (true){return;}//如果程序在中途就终止了,那这段delete便不会执行,内存泄漏了delete a;
}
有的时候并不是我们不想释放或者忘了释放,而是经常会发生函数异常终止或者中途结束,导致某一块空间的释放被跳过了
并且,在一些较大的程序中,某一个类似的函数会调用成千上万次, 每一次去泄漏一点点内存,极少成多,渐渐内存便开始以肉眼无法看见的速度渐渐泄漏。
此时,C++便想出了一个C++独有的解决方案:智能指针
为什么是C++独有?因为只有C++才把这种史甩给程序员去自己解决
智能指针的原理
我们在文章刚开始便解释到,对于自定义类型,C++会通过析构函数的方式将其释放,但是new出来的空间并没有析构函数。那为什么我们就不能强行给他一个析构函数呢?
而这个想法的实现方法其实也很简单:只需要给一个类,让这个类来装这一个指针便可以了
template<class T>
class smart_ptr
{
public:smart_ptr(T* ptr=nullptr):_ptr(ptr){}~smart_ptr(){delete _ptr;}
private:T* _ptr;
};
我们在new一块空间之后,把这个指针装在smart_ptr这块盒子里,当函数结束时,smart_ptr会自动调用析构函数销毁,从而让这个野指针实现自动销毁的行为,这便是智能指针
void func()
{int* a = new int(1);smart_ptr<int> spa(a);//无论函数从哪里终止,只要函数被销毁,spa就会被销毁,从而释放a
}
同时,为了方便,我们完全可以改造一下只能指针,将智能指针改造成智能指针来使用
//改造后的智能指针,与普通指针的使用方法便一致了
template<class T>
class smart_ptr
{
public:smart_ptr(T* ptr=nullptr):_ptr(ptr){}~smart_ptr(){delete _ptr;}T& operator*(){return *ptr;}T* operator->(){return &_ptr;}
private:T* _ptr;
};
改造之后,不仅可以实现指针的所有功能,而且被指针指向的空间也可以自动释放,相当于指针plus
同时,我们在初始化时,不需要引入新变量了
void func()
{/*int* a = new int(1);smart_ptr<int> spa(a);*///直接简化成smart_ptr<int> spa(new int(1));}
智能指针的问题
智能指针虽然看着好用,但是还是有着很多大问题。其中最大的便是赋值问题,如果我们想用一个智能指针去赋值另一个智能指针,那我们会发现一个严重的问题:
那咋整?
而为了解决这一问题,C++标准库给出了三种解决方案,这也便是C++智能指针的发展历史。
std中的智能指针
其实智能指针的发展史很早。早在C++98中,std库中便有了一个智能指针名为auto_ptr,但是一个字便可以概括:
不仅被开发者们诟病,而且很多公司还明确要求:不许使用库中的智能指针。而这也导致了一个结果:不同的库智能指针千奇百怪,程序和程序间的兼容依托稀烂。
而后C++11,对备受诟病的智能指针进行了改造,产生了两种应用场景的智能指针:unique_ptr和shared_ptr,至此,智能指针的发展便已经完美画上了句号,而我们如今最常用也最需要去学习的便是在C++11新加入的两种智能指针
auto_ptr
C++98在刚开始接触智能指针这一问题的时候,可能是项目经理开始催命了,便展现出了及其离谱的操作:权限转移。这个操作虽然理论上确实解决了两次delete的问题,但是就相当于饿到没办法才去赤石,没有任何实际使用的价值
什么是权限转移?就是在a赋值b的时候,将a装着的指针清空,而原本的指针到了b身上,就相当于把a变成了b,然后a这一变量销毁掉。
下面只展示赋值的情况代码
template<class T>
class auto_ptr
{
public:auto_ptr(T* ptr=nullptr):_ptr(ptr){}auto_ptr(const auto_ptr& ptr){if (ptr != *this)swap(*this, ptr);}auto_ptr& operator=(const auto_ptr& ptr){if(ptr!=*this)swap(*this, ptr);return *this;}~auto_ptr(){delete _ptr;}
private:T* _ptr;
};
你说他赋值了吗?好像赋值了,但是又好像没有赋值
我们想要对智能指针进行赋值,为的就是产生两份智能指针,但是你这一通转移,最后还是只给了我一份智能指针,而且还到了最后连我自己都不知道转移到哪去了。解决问题了吗?好像解决了,但是实际上让问题变得更麻烦了,这也是auto_ptr一直被诟病的原因——为了修一个小bug,引入了一个更大的bug
unique_ptr
C++11里,为了解决掉auto_ptr乱赋值这一毛病,干脆采用了一个简单粗暴的方法——既然赋值会有bug,那就都别赋值了
unique_ptr在最初的智能指针上加了一个新特性:私有化operator=和赋值构造函数,让unique_ptr无法被赋值
template<class T>
class unique_ptr
{
public:unique_ptr(T* ptr=nullptr):_ptr(ptr){}~unique_ptr(){delete _ptr;}T& operator*(){return *_ptr;}T* operator->(){return _ptr;}
private:unique_ptr& operator=(const unique_ptr& ptr) = default;unique_ptr(const unique_ptr& ptr) = default;T* _ptr;
};
这样,和他的名字一样,unique_ptr就是独一无二的智能指针,只能产生一次,无法多次使用。
虽然这种方法听起来也是拖史,但是我们不可否认,unique_ptr解决了赋值的问题,而且也没有产生新的bug
shared_ptr
而从shared_ptr开始,才算是直视多次delete这一问题。既然不断去赋值会导致delete很多次,那我就记录一下指向某块空间的智能指针的个数,当最后一个智能指针也被销毁,我再去delete,这样就不会产生delete多次的问题了。而这实际也是引用计数的思想。
不过这种想法虽然看起来简单,真正实现起来却还是有着一些障碍:
- 引用计数怎么实现?
- 如果某一个智能指针已经指向了一块空间,之后再对其进行赋值,那原来被指向的空间怎么办?
- 自己赋值自己又是什么情况?
我们来一个一个看
引用计数怎么实现?
最直观直接的方法便是,在类中加入一个新变量count来记录指向这块空间的数量,如果有一个新的智能指针指向了这块空间,就将count++,然后将++后的count赋值给新的智能指针。虽然想法很好,但是也有着一个巨大的问题——count无法同步
比如count==3,表示有三个智能指针a,b,c指向了这块空间,我们再将c赋值给d,然后count++变成4, c和d中的count也变成了4,那a和b怎么办?a和b里的count还是3
此刻便可以想出一个很简单的解决方案——在类中存放一个count的地址,这样一个count改变,所有的count也便随之改变了。
如何赋值给已存放地址的智能指针
在之前,我们都只考虑了用智能指针进行初始化。但是其实赋值还有一种情况——改变智能指针的值。这种情况,如果我们直接修改,显然会导致原先的内存泄漏,所以我们在赋值的时候,还需要将原先的count--,不然会导致多出一次count 的问题。
如何自己赋值给自己
这是在所有类型的赋值中,我们都要考虑的情况。一般,如果自己赋值给自己,我们直接跳过就可以了,否则最好的情况是效率的损耗,而最坏的情况则会导致野指针。
举个例子,如果有一个智能指针sp,其中的count只有1,我们自己赋值给自己,上述情况是count--,最终count==0,sp指向的空间被销毁。然后再去赋值,指针指向了一块被销毁的空间,count++,就导致了指向野指针的问题。
所以,自己赋值给自己必须要进行判断并跳过,否则或大或小都会产生一些意料之外的问题。
而解决了上述的问题,shared_ptr也算是被暴力解决了
template<class T>
class share_ptr
{
public:share_ptr(T* ptr = nullptr):_ptr(ptr){_count = new int(1);}share_ptr(const share_ptr& ptr){_ptr = ptr._ptr;_count = ptr._count;++(*_count);}share_ptr& operator=(const share_ptr& ptr){if (_ptr != ptr._ptr){delete_ptr();_ptr = ptr._ptr;_count=ptr._count;++(*_count);}return *this;}~share_ptr(){delete_ptr();}T& operator*(){return *_ptr;}T* operator->(){return _ptr;}
private:void delete_ptr(){--(*_count);if (*_count == 0){delete _ptr;delete _count;}}T* _ptr;int* _count;
};
循环引用
shared_ptr虽然强大,但是shared_ptr也会有着内存泄漏的问题
我们来看双向链表
struct ListNode
{ListNode():_pre(nullptr),_next(nullptr){}share_ptr<ListNode> _pre;share_ptr<ListNode> _next;
};void func()
{share_ptr<ListNode> head(new ListNode);share_ptr<ListNode> tail(new ListNode);head->_next = tail;tail->_pre = head;
}int main()
{func();
}
一个很经典的双向链表问题,但是最终却暗藏玄机。我们来看func函数内部
void func()
{share_ptr<ListNode> head(new ListNode);share_ptr<ListNode> tail(new ListNode);head->_next = tail;tail->_pre = head;//赋值之后,很正常的head和tail指向的空间count都为2//但是到了最后,调用析构函数,head的count--,tail的count--,两个count都为1//最后head和tail都没有被清理掉,内存泄漏了
}
而导致这个问题的本质原因是什么?是智能指针指向的对象,其内部还有一个无法被自动释放的指针。
而为了避免这个问题,C++采用了一个新的指针——weak_ptr。
weak_ptr顾名思义,是弱指针,其特性和shared_ptr基本相同,只不过在赋值的时候,count并不会增加
也就是说,在类内部的智能指针,我们定义成weak_ptr,这样就可以避免count异常的问题
unique_ptr和shared_ptr
光看解说量,我们都会发现,unique_ptr已经被shared_ptr完爆了。虽然如此,我们仍还是让两个不同的智能指针都进入了std标准库,因为shared_ptr虽然在功能上远远战胜了unique_ptr,但是产生的性能代价仍是非常大的。unique_ptr简单粗暴,空间开销少,性能极高,所以在不同的场合还是会在两种智能指针之间取舍。
而auto_ptr
RAII
看看得了,经常看我文章的都知道,我最不喜欢甩概念。
简单说,RAII就是将空间的释放自动化,我们不需要特意去delete,也不需要检查内存是否泄漏,我们只需要把地址抛给一个对象,让这个对象帮我们干这些事情就可以了
其实在很多语言中,都有一个垃圾回收机制,定期去回收掉被泄露的内存,而C++将这个责任甩给了程序员。但是,这并不是C++没能力弄或者懒得弄,而是为了极致的性能,不得不去舍弃掉这个垃圾回收机制。往后无论C++如何发展,一些其他语言便捷的地方如果会导致性能的损耗,C++都不会去尝试利用他们,而是让我们程序员去想更好的解决方案,没办法,谁叫我们是站在语言歧视链顶端的程序员呢。