在说明这个问题之前,我们可以先看一看下面这段代码所引发的问题:
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void Func()
{
// 1、如果p1这里new 抛异常会如何?
// 2、如果p2这里new 抛异常会如何?
// 3、如果div调用这里又会抛异常会如何?
int* p1 = new int;
int* p2 = new int;
cout << div() << endl;
delete p1;
delete p2;
}
int main()
{
try
{
Func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
分析:
当p1抛出异常过后,程序不会出现任何问题,但是如果是p2抛出异常了,那么执行流会直接跳到catch的地方,那么这就会造成我们之前开辟的空间p1没有得到释放,这回造成p1空间的内存泄漏,同理当div也抛出异常了的话,执行流也会跳到catch的地方,这就会造成p1、p2的空间没有得到释放,同样会造成内存泄漏!为此,为了避免这种情况的发生,我们需要重新捕获这个异常,然后释放空间过后,在重新抛出!
因此,我们的代码可以改为如下样子:
可是这样的作法终究是治标不治本,如果在Func函数中有多条会抛出异常的语句呢?我们岂不是需要为每一条都可能抛出异常的语句重新捕捉然后重新抛出,这不仅会大大增加代码量也会显得比较繁琐!
有没有一种方式能够让执行流离开作用域过后,我们之前所开辟的资源都会被自动回收呢?这样我们就不用再每次try、catch,然后再重新抛出了?
为了解决这样的问题,智能指针这类产物就出来了,我们只需要将我们申请的指针交给智能指针管理,就可以保证我们执行流再离开作用域过后,我们所开辟的空间能够被自动释放!
在具体介绍智能指针之前,我们需要了解RAII思想,这个思想非常重要,诸如lock_gard、智能指针等产物都是在该思想下指导出来的!
RAII主要思想: 就是利用对象的生命周期来控制资源的释放;
我们都知道一个对象生命周期到了的时候编译器会自动调用其析构函数,来清理这个对象的内部数据,那么我们可不可以在构造对象的时候将我们所申请的资源交给对象管理,在对象调用析构函数的时候让其顺便释放一下我们交给它的资源,调用析构函数是编译器自动的,那么我们只需要将资源交给对象,是不是就能完成资源的自动回收?就是这样的!再将资源交给对象过后,我们只需要控制对象的生命周期即可,这样就能控制资源的回收时机!这就是RAII都主要思想!
为此根据上面的思想,我们可以实现一个简单的RAII产物:就比如说设计一个可以管理独立空间的类吧:
template<class T>
class SmartStuff
{
public:
SmartStuff(T* ptr) :_ptr(ptr)
{
cout << "开始管理..." << endl;
}
~SmartStuff()
{
cout << "结束管理..." << endl;
delete _ptr;
}
private:
T* _ptr;
};
我们现在只需要将我们开辟的资源交给它,就可以不用在手动管理我们之前所申请的资源了:
为此,我们开头的代码还可以可以这么改,将我们申请的p1、p2资源交给这个智能的东西管理:
实际上上面的SmartStuff就是智能指针的一个简单原理,但是实际上SmartStuff还并不能被称为智能指针,因为上面的SmartStuff还不具备指针的行为,比如利用->、对其解引用都不行就只能简单的实现资源管理!
但是如果我们想让其具备指针的行为也还是比较容易的,我们只需要重载以下operator->、operator*等符号就可以了:
我们来看看实际效果:
总结:
什么是智能指针?
- 具有RAII风格;
- 可以像指针一样使用;
auto_ptr智能指针是在C++98的时候被提出来的,该智能指针具有RAII风格、同时也具有像指针一样使用的能力,但是该智能指针的拷贝实现不是特别理想,为了保持原生态指针一样的风格,智能指针之间也必须能够互相拷贝,并且还只能是浅拷贝,如果是深拷贝的话,在拷贝过后两个智能指针就指向两个不同的空间了这是不符合常理的,可是如果使用浅拷贝的话,那么智能指针最后在析构的时候就会对同一块空间释放两次,会造成程序崩溃,这是我们不愿意看到的;那么究竟该如何做才能实现智能指针之间的浅拷贝同时在释放的时候又能避免多次释放同一块空间呢?
我们来看看auto_ptr给出的方案:
auto_ptr给出的方案是:管理权限的转移:画个图来理解:
这样的话,就能避免对于同一块空间的多次释放,但是这样做的话危害比较大,熟悉一点auto_ptr拷贝原理的用户还好,要是用户不太熟悉auto_ptr那么用户可能在将sp1拷贝给sp2后对sp1进行解引用操作,由于sp1在完成拷贝过后已经被置空,用户此时再对sp1解引用势必会造成空指针的解引用从而导致程序的崩溃!为此auto_ptr这个智能指针在日常使用中并不常见,甚至被某些公司明令禁止使用!
但是作为学习的角度我们需要学习它,了解它,并且简单模拟实现一下它:
namespace MySpace
{
template<class T>
class auto_ptr
{
public:
auto_ptr(T* ptr) :_ptr(ptr) {}
auto_ptr( auto_ptr<T>& ap) :_ptr(ap._ptr)
{
ap._ptr = nullptr;
}
~auto_ptr()
{
delete _ptr;
}
auto_ptr<T>& operator=( auto_ptr<T>& ap)
{
if (&ap != this)
{
if (_ptr)
{
delete _ptr;
}
_ptr = ap._ptr;
ap._ptr = nullptr;
}
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
const T& operator*()const
{
return *_ptr;
}
const T* operator->()const
{
return _ptr;
}
private:
T* _ptr;
};
}
unque_ptr是C++11提出来的智能指针,该智能指针对于拷贝问题的处理就比较简单粗暴,它直接禁止了智能指针之间的拷贝,直接从根源解决问题,狠行!
为此我们也简单模拟实现一下:
namespace MySpace
{
template<class T>
class unique_ptr
{
public:
unique_ptr(T* ptr) :_ptr(ptr) {}
~unique_ptr()
{
delete _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
const T& operator*()const
{
return *_ptr;
}
const T* operator->()const
{
return _ptr;
}
//C++11禁止拷贝的做法:
//使用关键字delete不让其生成默认拷贝函数和默认赋值运算符
unique_ptr(const unique_ptr<T>&) = delete;
unique_ptr& operator=(const unique_ptr<T>&) = delete;
private:
//C++98禁止拷贝的作法:
//将拷贝构造和赋值运算符直接设为私有
//unique_ptr(const unique_ptr&);
//unique_ptr& operator=(const unique_ptr&);
T* _ptr;
};
}
都介绍了两个智能指针了,可是它们对于拷贝的处理是否都有点不尽人意,为此为了让用户用的放心用的舒心,C++11又提出了一个智能指针shared_ptr,该智能指针在拷贝过后,还会指向原来的空间,同时在释放的时候也不会对同一块空间进行多次释放!
那么它是如何做到的?
通过对于一块资源进行引用计数,该引用计数表示由多少的智能指针管理着这块空间,同时当我们智能指针析构的时候会先减减引用计数,然后检查引用计数是否为0才决定是否释放这块空间,只有当这块空间的引用计数为0过后,智能指针在析构的时候才会delete这块空间,否则则只会将这块空间的引用计数减减;
这大概就是shared_ptr的大致原理;
为此当shared_ptr在接手管理资源时,还需要为这个资源单独开辟一块空间专门用来记录该资源的引用计数;
同时每一份资源都有着自己单独发引用计数,因此智能指针不仅要看到资源同时也要看到这个资源的引用计数,因为最后析构的时候是根据引用计数来决定是否需要释放资源的!
同时在拷贝的时候拷贝对象在完成拷贝过后不仅要看到同一份资源,还需要看到该资源对应引用计数;
为此shared_ptr的设计思路如下:
但是上面的智能指针还不是安全的,为什么?
你想哈,假设现在有一个智能指针sp1,线程1会拿着sp1去拷贝了100次,线程2也拿着sp1去拷贝了100次,我们知道拷贝是会增加引用计数的,要是线程1和线程2同时对sp1进行拷贝是不是就相当于同时对引用计数进行++,这可能会造成引用计数精度的丢失,会让引用计数少记录智能指针的管理次数,是会引发线程安全的;同时当线程1和线程2同时对刚才拷贝的智能指针析构的时候有没有可能同时对引用计数–,从而又引发线程安全?
答案是肯定的,那么我们应该如何解决这个问题?
既然会引发线程安全,我们就为引用计数配一把锁了,当我们想要访问引用计数的时候先申请锁!
同时每个智能指针要看到这把锁,同时拷贝过后的智能指针也要看到这把锁!
为此我们在开辟引用计数空间的时候还需要为引用计数分配一把锁!
namespace MySpace
{
template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr) :_ptr(ptr), _pcount(new int(1)), _pmux(new mutex)
{
cout << "开始管理" << endl;
}
~shared_ptr()
{
Release();
}
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr),_pcount(sp._pcount),_pmux(sp._pmux)
{
//看到同一份资源过后,需要将引用计数+1;
_pmux->lock();
*_pcount += 1;
_pmux->unlock();
}
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
if (_ptr != sp._ptr)
{
//先解脱this本身管理的资源
Release();
_ptr = sp._ptr;
_pcount = sp._pcount;
_pmux = sp._pmux;
_pmux->lock();
*_pcount += 1;
_pmux->unlock();
}
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
const T& operator*()const
{
return *_ptr;
}
const T* operator->()const
{
return _ptr;
}
private:
void Release()
{
_pmux->lock();
(*_pcount) -= 1;
if (*_pcount == 0)//表示没有智能指针在管理该资源了,本次是最后一个管理资源的智能指针,负责释放资源
{
delete _pcount;
delete _ptr;
delete _pmux;
cout << "结束管理" << endl;
}
_pmux->unlock();
}
T * _ptr;//用于记录需要管理的资源
int* _pcount;//用于记录管理的资源的引用计数
mutex* _pmux;//该锁用来保护引用计数
};
}
这样才是一个安全有用的智能指针;
那么shared_ptr是不是就是完美的呢?
那肯定不是,shared_ptr还是有一些场景解决不了的,比如:
根据上面的经验,我们可可以发现,假设n1->_next = n2; n2->_prev = n1;抛出了异常,那么会造成n1、n2内存泄漏,为此,我们需要将n1、n2托付给智能指针管理,我们采用shared_ptr来管理:
可是这样会造成类型不匹配的错误,因为n1->_nexthen2->_prev是ListNode*类型的,而n2、n1是shared_ptr类型的,我们无法正常完成赋值,为了完成正常赋值,我们可以将ListNode节点中的_next和_prev也改成shared_ptr类型:
我们再来继续看一看运行结果:
什么也没有,不对啊,我们不是已经两块ListNode空间交给智能指针管理了吗,那么自然的最后出了test10的作用域n1、n2所控制两块空间也会被释放啊,也就会调用ListNode的析构函数啊,可是为什么程序结束以后什么也没输出?
我们可以尝试着屏蔽一条赋值语句:
n1、n2所控制的资源成功释放!
又或者屏蔽另一句赋值语句:
还是成功释放了!
可是为什么两条赋值语句在一起的时候就不行了呢?就会造成内存泄漏了呢?
我们分析一下具体原因:
此时,A资源的引用计数为1,B资源的引用计数也为1,因此最后析构的时候先析构n2,引用计数- -然后n2发现B资源的引用计数为0了就会deleteB资源,因此会调用一次ListNode的析构函数;同理,最后析构n1对象,n1对象将引用计数减减过后发现A资源的引用计数也为1,n1对象也会deleteA资源,也会调用一次ListNode的析构函数,因此,我们会在屏幕上看见两次析构:
我们在来看另一种情况:
首先n1、n2分别管理中A、B两块空间,因此A、B两块资源的引用计数分别是1、1,再着编译器接下来执行n2->_prev=n1,别忘了n2->_prev返回的是ListNode对象中的_prev,而_prev的类型是什么?
是shared_ptr也是一个智能指针唉!现在我将n1拷贝构造给n2->_prev这个智能指针,那么说明参与管理A资源的智能指针数变为了2,也就是A资源的引用计数是2!最后老样子先析构n2,n2对象先将引用计数减减,发现他所管理的B资源的引用计数已经为0了,为此它多做了一件事,delete了B资源,因此会调用一次ListNode的析构函数,来析构B资源,可是 B资源中又有其它自定义类型的成员:_prev、_next,对于自定义类型成员,在析构的时候会自动调用它们自己的析构函数来进行析构,为此编译器会调用两次shared_ptr的析构 ,一次用来析构_next、一次用来析构_prev,只不过析构_next没什么用而已,因为_next根本就没有管理资源,我们只需要看_prev的表演就可以了,在析构_prev的时候,_prev先将它所管理的A资源的引用计数减减,然后检查发现A资源的引用计数还不是0,而是1说明还有一个智能指针管理着A资源,为此它不会在做其它事情了,就这样平平淡淡的走完了一生;最后时间的齿轮来到了n1对象,n1对象被析构,n1对象先减减A资源的引用计数,然后发现其引用计数变为了0,说明它是最后一个管理A资源的智能指针,它需要对A资源负责,于是它delete了A资源,于是A资源的析构又被调用了一次,为此综上所述,两块堆上开辟出来的ListNode资源都得到了释放,为此我们最后可以看到两次析构:
有了以上的两种情况解释,这种情况解释起来就方便多了,自然的刚开始的时候A、B资源的引用计数分别为1、1,接着执行完n1->_next=n2过后,B资源的引用计数为2;执行完n2->_prev=n1过后,A资源的引用计数也变为了2;
接着最先析构n2对象,n2对象先将引用计数减减,发现B资源的引用计数并未变成0,为此它什么也不会做!接着析构n1对象,n1对象也是先将引用计数减减,发现A资源的引用计数也未变成0,为此他也什么也不会做!最后整个程序结束A、B资源都没得到释放!为此我们在屏幕上看不到析构或者说什么也看不到!
接着现在我们来看啊,想要释放A资源,那么就必须先析构n2->_prev,想要析构n2->_prev就要先释放B资源,而想要释放B资源就必须先析构n1->_next,想要析构n1->_next就必须先释放A资源,而要释放A资源就必须先析构n2->_prev…像这样无穷无尽的下去最后A、B两个资源始终不会得到释放,最后造成内存泄漏,像上面的闭环现象,叫做循环引用; 着也是shared_ptr智能指针无法处理的一个缺陷吧!那么如何解决这个缺陷呢?打破闭环!实际上,我们是并不希望_prev、_next这些成员变量也参与资源的管理的,我们只需要它们能够指向它,能够使用这块资源就够了,这样就不会造成引用计数的增加了,也就不会造成最后的闭环问题了,因此C++11专门提出了weak_ptr智能指针来专门解决shared_ptr的循环引用问题!记住weak_ptr是专门用来解决shared_ptr的循环引用问题的,别乱用!
为此我们只需要将上面的_next、_prev成员类型改为weak_ptr就可以了:
为此顺便,我们也可以把weak_ptr的简单实现也贴出来,就不参与循环计数就可以了,当然我这里也是简单说说而,库里面的weak_ptr实现肯定更为复杂:
由于weak_ptr是用来专门解决shared_ptr循环引用问题的,因此weak_ptr智能指针不具备RAII风格!它不参与资源的管理但是却能像指针一样使用!
1、shared_ptr是线程安全的吗?
当然!引用计数是加锁保护的!但是智能指针所指向的资源不是线程安全的!
通过多线程同时对智能指针所管理的资源进行访问来看,智能指针所管理的资源并不是线程安全的,要想保证智能指针所管理的资源是线程安全的,那么我们可以考虑在访问智能指针所管理的资源的时候进行加锁访问!当然不是智能指针不想管,而是它想管也管不到啊!它也不知道你在哪里会访问智能指针所指向的资源啊!
2、智能指针只能管理堆上开辟的独立空间吗?可不可以管理一片连续的空间?或者FILE类型的空间或者栈上的空间呢?
常规思路来看,似乎是不行的,因为智能指针在析构的时候是用的delete来释放资源的,但是C++11为了满足用户的需求,运行用户为智能指针定制删除器,将如何释放资源的权力交给用户!
就比如:我们想要智能指针管理一片连续的空间,那么我们就需要定制一个删除连续空间的删除器了:
unique_ptr也具有定制删除器的功能但似乎要麻烦一点,总之不要局限于智能管理一块独立的对空间,有了定制删除器的能力,智能指针那块空间都能管理!