在本篇博客中,作者将会带领你理解并自己手动实现简单的智能指针,以加深对智能指针的理解。
一.什么是智能指针,为什么需要智能指针
智能指针是一种基于RAII思想实现的一种资源托管方式,至于什么是RAII,后面会讲到。
对于C++语言来说,内存泄漏是一个避不开的话题,但对于内存的安全管理来说,在智能指针出现之前,只能我们程序员自己来管理,自己决定什么时候释放,这样就很容易因为程序员的疏忽导致内存泄漏,所以为了解决这个问题,发明了智能指针帮我们托管资源。
二.内存泄漏
对于内存泄漏问题来说,一个普通的程序员在庞大的项目中,很难做到对每一块申请的内存资源都做到合理的释放,所以需要用到智能指针帮助我们托管资源,即使程序员记得将资源delete掉了,也有可能因为一些其他的因素导致资源没有释放。
例如下面这种情况,即使在代码中,主动的进行了delete,也有可能因为其他的原因导致内存泄漏。
我们来看一下例子。
#include <iostream>
using namespace std;
void Div()
{
int x, y;
cin >> x >> y;
if (y == 0)
throw invalid_argument("除数为0");
else
cout << x / y << endl;
}
void Func()
{
int* a = new int(10);
Div();
delete a;
}
int main()
{
try
{
Func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
在上面这个程序中,在Func函数里面,我们new出来了新空间,也进行了delete操作,正常情况下,程序会像我们预想的那样,正确的将资源释放掉。
但是当在Div函数里面进行除0操作的时候,会直接抛异常,导致程序运行直接跳到catch那行进行捕获错误,从而没有运行delete导致内存泄漏。
所以对于内存泄漏问题来说,我们很难很好的真正做到管理好资源,所以这个时候,我们需要用到智能指针。
三.智能指针的使用以及原理
1.RAII技术
RAII技术是一种利用对象生命周期来控制程序资源。
在对象构造时获取资源,保证对象在整个生命周期内都是有效的。
在对象析构时释放资源,保证对象生命周期结束后,资源一定能释放。
这种资源的管理释放就是将资源托管给一个对象,这个对象就是智能指针对象
RAII的好处是,我们可以不需要显示的释放资源,而且资源在生命周期内始终有效。
2.智能指针原理
基于上面的原理,我们可以来试着实现一下。
#include <iostream>
using namespace std;
//智能指针类
template<class T>
class SmartPtr
{
public:
//构造函数
SmartPtr(T* ptr)
:_ptr(ptr)
{}
//析构函数
~SmartPtr()
{
delete _ptr;
cout << "资源已被释放" << endl;
}
private:
T* _ptr;//一个指针指向new出来的空间
};
void Div()
{
int x, y;
cin >> x >> y;
if (y == 0)
throw invalid_argument("除数为0");
else
cout << x / y << endl;
}
void Func()
{
SmartPtr<int> sp(new int(10));//将new出来的空间的地址传参进去
Div();
}
int main()
{
try
{
Func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
运行结果:
在上面的代码中,我们定义了一个SmartPtr类来管理我们的资源。
当我们new一个int的时候,将这块内存托管给SmartPtr类对象,让这个对象帮助我们托管资源,当这个SmartPtr类对象的生命周期到了的时候,就会自动的去调用它的析构函数,从而帮助我们释放资源,即使在这个代码中Div函数除0了,导致直接跳到catch捕获错误,这个sp对象也会因为出了作用域(即Func函数),从而帮助我们释放资源。
这就是智能指针的原理,但是这个SmartPtr类智能指针是很不完善的,虽然它可以帮助我们管理资源,但是它没有指针的原生行为,即*解引用操作以及->解引用操作。
所以我们来实现一些原生指针的行为。
#include <iostream>
using namespace std;
template<class T>
class SmartPtr
{
public:
//构造函数
SmartPtr(T* ptr)
:_ptr(ptr)
{}
//析构函数
~SmartPtr()
{
delete _ptr;
cout << "资源已被释放" << endl;
}
//实现可以模拟指针的原生行为
//重载operator*
T& operator*()
{
return *_ptr;
}
//重载operator->
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
struct Data
{
Data(int year,int month,int day)
:_year(year)
,_month(month)
,_day(day)
{}
int _year;
int _month;
int _day;
};
int main()
{
SmartPtr<Data> sp(new Data(2024,12,28));
cout << (*sp)._year << endl;
cout << sp->_month << endl;
return 0;
}
SmartPtr的缺陷
这个时候我们的SmartPtr至少能像原生指针一样去使用。
但是,这个SmartPtr还是很有问题!!!
如果我去调用这个SmartPtr的拷贝构造会出现什么问题呢?
如下面的代码所示:
#include <iostream>
using namespace std;
template<class T>
class SmartPtr
{
public:
//构造函数
SmartPtr(T* ptr)
:_ptr(ptr)
{}
//析构函数
~SmartPtr()
{
delete _ptr;
cout << "资源已被释放" << endl;
}
//实现可以模拟指针的原生行为
//重载operator*
T& operator*()
{
return *_ptr;
}
//重载operator->
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
struct Data
{
Data(int year,int month,int day)
:_year(year)
,_month(month)
,_day(day)
{}
int _year;
int _month;
int _day;
};
int main()
{
SmartPtr<Data> sp(new Data(2024, 12, 28));
SmartPtr<Data> sp1(sp);//这里进行拷贝构造
cout << (*sp)._year << endl;
cout << sp->_month << endl;
return 0;
}
运行结果:
这是什么原因呢?
当我们进行拷贝构造的时候,就会有两个智能指针指向同一块资源,即sp和sp1指向同一块内存资源,当它们的生命周期结束的时候,会去调用它们两个的构造函数,导致同一块资源被释放了两次。
所以对于智能指针的一系列问题,从而诞生了多种智能指针:
auto_ptr、unique_ptr、shared_ptr、weak_ptr。
接下来我将逐一解析。
3.auto_ptr智能指针
auto_ptr是C++98提供的一种智能指针,所以下面来简单的实现一下auto_ptr,以及演示auto_ptr的问题。
auto_ptr的实现原理是:管理权转移思想。
template<class T>
class auto_ptr
{
public:
//构造函数
auto_ptr(T* ptr)
:_ptr(ptr)
{}
//析构函数
~auto_ptr()
{
if (_ptr != nullptr)
{
delete _ptr;
std::cout << "auto_ptr所指向资源已被释放" << std::endl;
}
}
//拷贝构造,将原来的auto_ptr所管理的资源转移给新的auto_ptr对象
auto_ptr(auto_ptr<T>& ap)
:_ptr(ap._ptr)
{
ap._ptr = nullptr;
}
//operator=重载
auto_ptr<T>& operator=(auto_ptr<T>& sp)
{
if (this != &sp)//防止自己给自己赋值
{
if (_ptr != nullptr)//如果_ptr有指向的资源,要先将_ptr所指向的资源释放掉,否则会出现内存泄漏
{
delete _ptr;
}
//转移资源
_ptr = sp._ptr;
sp._ptr = nullptr;
}
return *this;
}
//实现可以模拟指针的原生行为
//重载operator*
T& operator*()
{
return *_ptr;
}
//重载operator->
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
如上面代码所示,auto_ptr的实现是基于管理权转移的思想,当进行拷贝构造和operator=重载的时候,会将资源的管理权转移出去,如下图所示:
对于这种做法,auto_ptr的设计是很失败的,因为当进行拷贝构造或者operator=赋值后,原来的sp会指向nullptr,这就很容易引发对空指针访问的问题,如下面代码所示:
同时这样的做法非常的不好用。
4.unique_ptr智能指针
所以为了解决auto_ptr所遗留的问题,设计出了unique_ptr,暴力的进行防拷贝和防operator=运算符重载。
template<class T>
class unique_ptr
{
public:
unique_ptr(const T* ptr)
:_ptr(ptr)
{}
~unique_ptr()
{
if (_ptr != nullptr)
{
delete _ptr;
std::cout << "unique_ptr所指向资源已被释放" << std::endl;
}
}
//非常简单粗暴的将拷贝构造和operator=重载函数删除掉
unique_ptr(const unique_ptr<T>& up) = delete;
unique_ptr<T>& operator=(const unique_ptr<T>& up) = delete;
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
对于unique_ptr的实现,很简单,直接将拷贝构造和operator=重载delete掉即可,这样就可以解决auto_ptr的问题。
5.shared_ptr智能指针
对于unique_ptr来说,虽然解决了auto_ptr的问题,但是这导致unique_ptr也有一定的缺陷。
因为unique_ptr所管理的资源是unique_ptr独占的,别的智能指针不能再管理这块资源,导致这块资源不能被共享使用,使得在操作这块内存上很不方便。
所以为了解决这个问题,又发明出了shared_ptr。
shared_ptr在unique_ptr的基础上增加了引用计数,当一块资源被多个shared_ptr管理的时候,shared_ptr里面的引用计数会记录着有几个shared_ptr管理着这块资源。
当引用计数为0的时候,才会去释放资源。
如下图所示:
代码实现:
template<class T>
class shared_ptr
{
//成员变量
private:
T* _ptr;
int* _pRefCount;//记录引用计数
std::mutex* _pmtx;//需要锁,是因为操作_pRefCount的时候,需要保证是线程安全的
public:
//构造函数
shared_ptr(T* ptr)
:_ptr(ptr)
, _pRefCount(new int(1))
, _pmtx(new std::mutex)
{
if (_ptr == nullptr)//防止拿nullptr构造智能指针
{
*_pRefCount = 0;
}
}
//析构函数
~shared_ptr()
{
Release();
}
//拷贝构造
shared_ptr(shared_ptr<T>& sp)
{
std::unique_lock<std::mutex> lock(*sp._pmtx);//加锁保护
_ptr = sp._ptr;
_pRefCount = sp._pRefCount;
_pmtx = sp._pmtx;
if (_ptr != nullptr)
{
(*_pRefCount)++;
}
}
//operator=重载
shared_ptr<T>& operator=(shared_ptr<T>& sp)
{
if (this != &sp)//防止自己给自己赋值
{
//先释放原来所指向的资源
Release();
{
std::unique_lock<std::mutex> lock(*sp._pmtx);
_ptr = sp._ptr;
_pRefCount = sp._pRefCount;
_pmtx = sp._pmtx;
if (_pRefCount != nullptr)
{
(*_pRefCount)++;
}
}
}
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
int use_count()
{
return *_pRefCount;
}
T* get() const
{
return _ptr;
}
private:
//释放资源
void Release()
{
if (_pmtx != nullptr)
{
_pmtx->lock();
bool flag = false;//辅助判断是否需要delete掉_pmtx
if (_pRefCount != nullptr && --(*_pRefCount) == 0)
{
std::cout << "shared_ptr所指向的资源已被释放" << std::endl;
delete _ptr;
delete _pRefCount;
flag = true;
//delete _pmtx;这里不能直接delete,是因为锁还在使用
}
_pmtx->unlock();
if (flag == true)
{
delete _pmtx;
}
}
_ptr = nullptr;
_pRefCount = nullptr;
_pmtx = nullptr;
}
};
在shared_ptr的实现中,最重要的是加锁,因为要保证是线程安全的。
shared_ptr的缺陷
尽管shared_ptr解决了unique_ptr的问题,但是shared_ptr它也有自己的缺陷。
就是循环引用的问题。
我来写一个代码演示一下。
struct Node
{
Node(int val = 0)
:_val(val)
,_next(nullptr)
,_prev(nullptr)
{}
int _val;
shared_ptr<Node> _next;
shared_ptr<Node> _prev;
};
void test5()
{
shared_ptr<Node> sp1(new Node(10));
shared_ptr<Node> sp2(new Node(20));
sp1->_next = sp2;
sp2->_prev = sp1;
}
int main()
{
test5();
return 0;
}
运行结果:
从运行结果可以看到,程序运行结束后,sp1和sp2所指向的空间并没有释放。
那是因为这两句代码造成的:
sp1->_next = sp2;
sp2->_prev = sp1;如果没有这两句代码,资源还是会正常的释放,那是因为什么原因造成的呢?
我们先来分析一下为什么会这样。
我们先来看一下图,当new出来了空间后是怎样的,以及开始连接结点后又是怎样的。
这里就会出现循环引用的问题sp1里面管理着sp2的内容,而sp2又管理着sp1的内容,出现了循环,导致出现问题。
当析构的时候,sp1和sp2的_count都只会变成1,导致资源没有释放。
6.weak_ptr智能指针
所以为了解决shared_ptr的循环引用的问题,又诞生出了wear_ptr(弱指针)。
这个weak_ptr是没有引用计数的,而且它的构造函数是由shared_ptr来构造的,同时weak_ptr不支持*重载和->重载,而且weak_ptr不具备RAII的功能,它仅仅是用来辅助shared_ptr来解决循环引用问题而已。
实现如下:
template<class T>
class weak_ptr
{
public:
//构造函数
weak_ptr(const shared_ptr<T>& sp)
:_ptr(sp.get())
{}
//operator=重载
weak_ptr<T>& operator=(const shared_ptr<T>& sp)
{
_ptr = sp.get();
return *this;
}
private:
T* _ptr;
};
当weak_ptr实现后,我们再来修改一下之前有问题的代码,如下面代码所示:
struct Node
{
Node(int val = 0)
:_val(val)
,_next(nullptr)
,_prev(nullptr)
{}
int _val;
weak_ptr<Node> _next;
weak_ptr<Node> _prev;
};
void test5()
{
shared_ptr<Node> sp1(new Node(10));
shared_ptr<Node> sp2(new Node(20));
sp1->_next = sp2;
sp2->_prev = sp1;
}
int main()
{
test5();
return 0;
}
运行结果:
这个时候,我们的资源就能被正常的释放了。