智能指针的使用
问题
我们在平时写程序的时候,有些情况下不可避免地会遇见内存泄露的情况。内存泄露是指因为疏忽或错误,造成程序未能释放已经不再使用的内存的情况。例如下面这个例子,内存泄漏不易被察觉。
int div()
{
int a, b;
cin >> a >> b;
if(b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void func()
{
int* p = new int[10];
cout << div() << endl;
delete[] p;
}
int main()
{
try
{
func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
程序首先会调用func函数,当func函数中执行到“ cout << div() << endl ”这句代码时,就会跳转到div函数中依次执行,当我们把b设置为0时,这时就会抛异常。程序的执行流就直接跳转到主函数的catch块中执行,最终导致func函数中申请到的资源没有被释放,造成内存泄露。
解决方案一
我们可以利用异常的重新捕获来解决,在func函数中先对div函数中抛出的异常进行捕获,捕获后先将之前申请的内存资源释放,然后再将异常重新抛出。
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void func()
{
int* p = new int[10];
try
{
cout << div() << endl;
}
catch (...)
{
delete[] p;
throw;
}
delete[] p;
}
int main()
{
try
{
func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
解决方案二
上述问题也可以使用智能指针进行解决
template<class T>
class SmartPtr
{
public:
SmartPtr(T* ptr)
:_ptr(ptr)
{}
~SmartPtr()
{
cout << "delete: " << _ptr << endl;
delete _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void func()
{
SmartPtr<int> sp(new int[10]);
//...
cout << div() << endl;
//...
}
int main()
{
try
{
func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
代码中将申请到的内存空间交给了一个SmartPtr对象进行管理。
- 在构造SmartPtr对象时,SmartPtr将传入的需要被管理的内存空间保存起来。
- 在SmartPtr对象析构时,SmartPtr的析构函数中会自动将管理的内存空间进行释放。
- 此外,为了让SmartPtr对象能够像原生指针一样使用,还需要对
*
和->
运算符进行重载。
这样一来,无论程序是正常执行完毕返回了,还是因为某些原因中途返回了,或是因为抛异常返回了,只要SmartPtr对象的生命周期结束就会调用其对应的析构函数,进而完成内存资源的释放。
智能指针的原理
实现智能指针时需要考虑以下三个方面的问题:
- 在对象构造时获取资源,在对象析构的时候释放资源,利用对象的生命周期来控制程序资源,即RAII特性。
- 对
*
和->
运算符进行重载,使得该对象具有像指针一样的行为。 - 智能指针对象的拷贝问题。
RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、互斥量等等)的简单技术。 简而言之就是把资源交给对象管理,对象生命周期内,资源有效,对象生命周期到了,释放资源
但是我们对于当前实现的SmartPtr类,如果用一个SmartPtr对象来拷贝构造另一个SmartPtr对象,或是将一个SmartPtr对象赋值给另一个SmartPtr对象,都会导致程序崩溃。
int main()
{
SmartPtr<int> sp1(new int);
SmartPtr<int> sp2(sp1); //拷贝构造
SmartPtr<int> sp3(new int);
SmartPtr<int> sp4(new int);
sp3 = sp4; //拷贝赋值
return 0;
}
这是由于编译器默认生成的拷贝构造函数和赋值重载函数对内置类型完成的是浅拷贝,若将一个SmartPtr对象原封不动地拷贝给另外一个SmartPtr对象,这时两个SmartPtr对象就指向同一块内存空间。当SmartPtr对象销毁时就会调用析构函数,这样就导致了同一块空间被析构了两次,程序因而会崩溃。
需要注意的是,智能指针就是要模拟原生指针的行为,当我们将一个指针赋值给另一个指针时,目的就是让这两个指针指向同一块内存空间,所以这里本就应该进行浅拷贝,但单纯的浅拷贝又会导致空间被多次释放,因此根据解决智能指针拷贝问题方式的不同,从而衍生出了不同版本的智能指针。
C++中的智能指针
auto_ptr
auto_ptr是C++98中引入的智能指针,auto_ptr通过管理权转移的方式解决智能指针的拷贝问题,保证一个资源在任何时刻都只有一个对象在对其进行管理,这时同一个资源就不会被多次释放了。比如:
int main()
{
// C++98 一般实践中,很多公司明确规定不要用这个
auto_ptr<A> ap1(new A(1));
auto_ptr<A> ap2(new A(2));
auto_ptr<A> ap3(ap1);
// 崩溃
//ap1->_a++;
ap3->_a++;
return 0;
}
发生管理权转移后,再进行拷贝时就会把被拷贝对象的资源管理权转移给拷贝对象,导致被拷贝对象悬空,这也就造成了很大的隐患,当访问被拷贝对象时程序就会造成程序崩溃。
auto_ptr的模拟实现
auto_ptr的实现步骤如下:
- 在构造函数中获取资源,在析构函数中释放资源,利用对象的生命周期来控制资源。
- 对*和->运算符进行重载,使auto_ptr对象具有像原生指针一样的行为。
- 在拷贝构造函数中,通过传入对象管理的资源来构造当前对象,并将传入对象管理资源的指针置空。
template<class T>
class auto_ptr
{
public:
auto_ptr(T* ptr)
:_ptr(ptr)
{}
~auto_ptr()
{
cout << "delete:" << _ptr << endl;
delete _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
//管理权转移
auto_ptr(auto_ptr<T>& a)
:_ptr(a._ptr)
{
a._ptr = nullptr;
}
private:
T* _ptr;
};
unique_ptr
unique_ptr是C++11中引入的智能指针,unique_ptr通过禁止拷贝的方式解决智能指针的拷贝问题,它是个简单粗暴的办法,这样做也能保证资源不会被多次释放。但是禁止拷贝的办法也不是一个万全的办法,因为有些场景就是要用到拷贝。
int main()
{
unique_ptr<int> up1(new int(0));
//std::unique_ptr<int> up2(up1); //会报错
return 0;
}
unique_ptr的模拟实现
unique_ptr的实现步骤如下:
- 在构造函数中获取资源,在析构函数中释放资源,利用对象的生命周期来控制资源。
- 对*和->运算符进行重载,使unique_ptr对象具有像原生指针一样的行为。
- 用C++11的方式在拷贝构造和赋值重载函数后面加上=delete。
template<class T>
class unique_ptr
{
public:
unique_ptr(T* ptr)
:_ptr(ptr)
{}
~unique_ptr()
{
cout << "delete:" << _ptr << endl;
delete _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
unique_ptr(unique_ptr<T>& ap) = delete;
unique_ptr<T>& operator=(unique_ptr<T>& ap) = delete;
private:
T* _ptr;
};
shared_ptr
shared_ptr是C++11中引入的智能指针,shared_ptr通过引用计数的方式解决智能指针的拷贝问题。
- 每一个被管理的资源都有一个对应的引用计数,通过这个引用计数记录着当前有多少个对象在管理着这块资源。
- 当新增一个对象管理这块资源时,则将该资源对应的引用计数进行++,当一个对象不再管理这块资源或该对象被析构时则将该资源对应的引用计数进行--。
- 当一个资源的引用计数减为0时说明已经没有对象在管理这块资源了,这时就可以将该资源进行释放了。
通过这种引用计数的方式就能支持多个对象一起管理某一个资源,也就是支持了智能指针的拷贝,并且只有当一个资源对应的引用计数减为0时才会释放资源,因此保证了同一个资源不会被释放多次。
class A
{
public:
A(int a = 0)
:_a(a)
{
cout << "A(int a = 0)" << endl;
}
~A()
{
cout << this;
cout << " ~A()" << endl;
}
//private:
int _a;
};
注:A类的具体内部结构如上述代码所示。
int main()
{
a::shared_ptr<A> sp1(new A(1));
a::shared_ptr<A> sp2(new A(2));
a::shared_ptr<A> sp3(sp1);
sp1->_a++;
sp3->_a++;
a::shared_ptr<A> sp4(sp2);
a::shared_ptr<A> sp5(sp4);
return 0;
}
其关系图如下图所示,其中红色方框代表的是引用计数
shared_ptr的模拟实现
shared_ptr的实现步骤如下:
- 首先要在shared_ptr类中增加一个成员变量_pcount,代表引用计数。
- 在构造函数中获取资源,并将该资源对应的引用计数设置为1,表示当前只有一个对象在管理资源。
- 在拷贝构造函数中,我们让传入的对象与之前管理资源的对象共同管理对应的资源,同时将该资源的引用计数加一。
- 在赋值重载函数中,先将当前对象管理的资源对应的引用计数--(如果减为0则需要释放),然后再与传入对象一起管理它管理的资源,同时需要将该资源对应的引用计数++。
举例:例如将sp5赋值给sp1,那么sp1原来指向的那块资源的引用计数就要减一,否则的话就会内存泄漏,因为无论如何引用计数都减不到为0,那么这块空间也就不会释放。并且原先sp1指向的这块资源如果只有sp1在管理的话,那么就直接将其释放即可。还有一个细节问题,例如遇到“ sp1=sp1 ”自己给自己赋值这种情况的话,我们就直接返回*this即可。
- 在析构函数中,将管理资源对应的引用计数--,如果减为0则需要将该资源释放。
- 对*和->运算符进行重载,使shared_ptr对象具有像原生指针一样的行为。
template<class T>
class shared_ptr
{
public:
// RAII
// 像指针一样
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
, _pcount(new int(1))
{}
~shared_ptr()
{
if (--(*_pcount) == 0)
{
cout << "delete:" << _ptr << endl;
delete _ptr;
delete _pcount;
}
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
// sp3(sp1)
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
, _pcount(sp._pcount)
{
++(*_pcount);
}
// sp6 = sp6
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
if (_ptr == sp._ptr)
return *this;
if (--(*_pcount) == 0)
{
delete _ptr;
delete _pcount;
}
_ptr = sp._ptr;
_pcount = sp._pcount;
++(*_pcount);
return *this;
}
int use_count() const
{
return *_pcount;
}
T* get() const
{
return _ptr;
}
private:
T* _ptr;
int* _pcount;
};
这里有个问题,为什么引用计数要放在堆区呢?
如果将引用计数设置为int类型时,那么每个对象都有自己独立的引用计数,而我们希望的是不同的对象管理同一份资源 。其次,shared_ptr中的引用计数count也不能定义成静态成员变量,因为静态成员变量是所有类型对象共享的,这会导致管理相同资源的对象和管理不同资源的对象用到的都是同一个引用计数。
weak_ptr
虽然shared_ptr非常好用,看似完美无缺,但是它在某个场景中却有致命的缺陷。
我们先定义一个节点类,并且将_next和_prev成员变量的类型改为shared_ptr类型。
struct Node
{
A _val;
a::shared_ptr<Node> _next;
a::shared_ptr<Node> _prev;
~Node()
{
cout<<"~Node"<<endl;
}
};
接着我们再将sp1和sp2相互链接,然后运行程序观察结果。
int main()
{
// 循环引用
shared_ptr<Node> sp1(new Node);
shared_ptr<Node> sp2(new Node);
cout << sp1.use_count() << endl;
cout << sp2.use_count() << endl;
sp1->_next = sp2;
sp2->_prev = sp1;
cout << sp1.use_count() << endl;
cout << sp2.use_count() << endl;
return 0;
}
我们发现这时程序运行结束后两个结点都没有被释放,但如果去掉链接节点时的两句代码中的任意一句,那么这两个节点就都能够正确释放,根本原因就是因为这两句链接节点的代码导致了循环引用。
出现循环引用的原因
当以new的方式申请到两个Node节点并交给两个智能指针管理后,这两个资源对应的引用计数都为1。接着我们将这两个节点链接起来后,资源1当中的next成员与sp2一同管理资源2,资源2中的prev成员与sp1一起管理资源1,此时这两个资源对应的引用计数都被加到了2。
当出了main函数的作用域后,sp1和sp2的生命周期也就结束了,因此这两个资源对应的引用计数最终都减到了1
根据上图可知_prev管着左边的节点,_next管着右边的节点。当右边的节点析构时,_prev才会析构,而右边的节点是当_next析构它才会析构,而_next析构需要左边的节点析构才能析构。这时就形成了一个死循环,最终导致资源无法被释放。
而如果链接节点时只进行一个链接操作,那么当sp1和sp2的生命周期结束时,就会有一个资源对应的引用计数被减为0,此时这个资源就会被释放,当这个释放后另一个资源的引用计数也会被减为0,最终两个资源就都会被释放了。这就是为什么只进行一个连接操作时这两个节点就都能够正确释放的原因。
针对上述问题,C++11引入了weak_ptr。其中我们最需要注意的一点是weak_ptr不是用来管理资源的释放的,它主要是用来解决shared_ptr的循环引用问题的。
weak_ptr支持用shared_ptr对象来构造weak_ptr对象,构造出来的weak_ptr对象与shared_ptr对象管理同一个资源,但不会增加这块资源对应的引用计数。
我们将_next和_prev的类型改为weak_ptr
struct Node
{
A _val;
weak_ptr<Node> _next;
weak_ptr<Node> _prev;
~Node()
{
cout<<"~Node"<<endl;
}
};
接着再执行上述代码就会发现两个节点能正确释放,并且根据use_count链接前后两次打印的内容发现weak_ptr不会增加对应资源的引用计数
weak_ptr的模拟实现
weak_ptr的实现步骤如下:
- 提供一个无参的构造函数,比如new Node这句代码就会调用weak_ptr的无参的构造函数。
- 支持用shared_ptr对象拷贝构造weak_ptr对象,构造时获取shared_ptr管理的资源。
- 支持用shared_ptr对象赋值给weak_ptr对象,赋值时获取shared_ptr管理的资源。
- 对*和->运算符进行重载,使weak_ptr对象具有像原生指针一样的行为。
template<class T>
class weak_ptr
{
public:
weak_ptr()
:_ptr(nullptr)
{}
weak_ptr(const shared_ptr<T>& sp)
:_ptr(sp.get())
{}
weak_ptr<T>& operator=(const shared_ptr<T>& sp)
{
_ptr = sp.get();
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};