大多数情况下,在new完资源后,只要记得在不用的地方delete释放掉就好,但是有了异常之后,可能情况就不是像期望的那样发生了,比如:
int main() {
try {
int* p1 = new int;
fun();
delete p1;
}
catch (...) {
//...
}
return 0;
}
若没有异常情况,上述代码是不会出现内存泄漏问题的,但有了异常后fun函数可能会抛异常,这就导致执行流不会按照上面的情况继续向下执行,若处理该异常的catch子句的代码中也没有释放该资源时,这就导致了内存泄漏问题。
还有其它更为复杂的场景,针对这些若要实现绝对的安全,那就需要写出很冗余的代码… ,此时就可以使用智能指针来解决上述情况。
什么是内存泄漏:内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。
简单来说,把new出来的资源不在交给该资源类型的指针,而是交给一个类类型的对象来管理,这就是智能指针的做法。
//支持泛型
template <class T>
class SmtPtr {
public:
SmtPtr(T* ptr) :_ptr(ptr) {
}
~SmtPtr() {
delete _ptr;
}
private:
T* _ptr;
};
int main() {
SmtPtr<int> p1 = new int;
return 0;
}
这种做法充分利用了类中构造和析构函数的特点,这样不管程序是正常还是非正常结束,申请的资源都可以自动完成释放,这种思想就是RAII。
RAII(Resource Acquisition Is Initialization/内存申请即初始化)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。
在对象构造时获取资源,控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:
上面的还不能称之为智能指针,因为它还不具备指针的行为,要让其能够像指针一样解引用访问指向空间中的数据,因此需要重载*
和->
运算符:
T& operator*() {
return *_ptr;
}
//当T是自定义类型时
T* operator->() {
return _ptr;
}
除此之外,还有对象之间的赋值拷贝的行为等。
//拷贝
int main() {
SmtPtr<int> p1 = new int;
SmtPtr<int> p2(p1);
return 0;
}
//赋值
int main() {
SmtPtr<int> p1 = new int;
SmtPtr<int> p2 = new int;
p1 = p2;
return 0;
}
上面这两种赋值拷贝会导致程序报错,不难发现,如果类中不显式地写拷贝构造或者赋值重载那么就是一种浅拷贝,也就是按字节进行拷贝,这种行为会造成两个对象中的指针指向同一块空间,那么后续在调用它们的析构函数时会对同一块空间释放两次,因此会报错,与此同时第二种写法还会导致内存泄漏,因为p1还没释放它就去指向其它空间了。
既然浅拷贝不行,那么应该深拷贝吗?其实也不行,因为智能指针就是模拟的原生指针,而原生指针之间的赋值行为本质都是浅拷贝,就是要让两个指针指向同一块空间。
针对这类问题,官方的做法是设计出了几种赋值功能不同的智能指针。
在C++98中,就设计出了一个叫做auto_ptr
的智能指针,它的用法如下:
//智能指针头文件
#include
struct A {
A(int a = 0) :_a(a) {
cout << "A(int a = 0) :_a(0)" << endl;
}
~A() {
cout << "~A()" << endl;
}
int _a;
};
int main() {
auto_ptr<A> p1(new A);
return 0;
}
进行拷贝构造时,它的做法如下:
此时分别对_a进行自增操作时程序会崩溃:
可以通过调试查找崩溃原因,进行拷贝前:
拷贝后:
可以发现,拷贝后,p1的资源现在由p2进行管理,而p1自身却悬空了(被置空),因此当用p1去访问_a时对空指针解引用导致的崩溃。
而它的这种行为被称为管理权转移:拷贝时,会把被拷贝对象的资源管理权转移给拷贝对象,转移后被拷贝的对象悬空,访问它就会出问题。
一般实践中,很多公司明确规定禁止使用auto_ptr
模拟实现:
template<class T>
class auto_ptr {
public:
auto_ptr(T* ptr) :_ptr(ptr)
{}
~auto_ptr() {
delete _ptr;
}
T& operator*() {
return *_ptr;
}
T* operator->() {
return _ptr;
}
//资源管理权转移
auto_ptr(auto_ptr<T>& ap) :_ptr(ap._ptr)
{
//转移后将被拷贝的对象置空
ap._ptr = nullptr;
}
auto_ptr<T>& operator=(auto_ptr<T>& ap) {
//先释放原来的对象
delete _ptr;
资源管理权转移
_ptr = ap._ptr;
//转移后将被拷贝的对象置空
ap._ptr = nullptr;
return *this;
}
private:
T* _ptr;
};
由于这个指针拷贝赋值功能的设计非常尴尬,处处被人唾弃,因此在C++11中,又设计出了unique_ptr
、shared_ptr
和weak_ptr
三个智能指针。
顾名思义,该智能指针的针对拷贝问题的做法非常简单粗暴,直接禁止拷贝构造和赋值,模拟实现:
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;
}
//C++98的做法是只声明不实现
//并且将其声明为私有即可防拷贝
//而C++11则是通过delete关键字将其声明为删除函数即可
//这样编译器便不会生成这两个拷贝函数
unique_ptr(unique_ptr<T>& ap) = delete;
unique_ptr<T>& operator=(unique_ptr<T>& ap) = delete;
private:
T* _ptr;
};
但是禁止拷贝这个行为是不符合指针的性质的,指针之间可以相互赋值,因此支持拷贝的智能指针shared_ptr就诞生了。
它的做法是采用引用计数(计数器)的方式来控制对象之间的拷贝行为,在申请一个资源的同时为它配备一个计数器,有几个对象指向这块空间时,该计数器的值就是几,当计数器的值是0时代表没有对象再指向这块空间,析构时可以进行释放。
下面这种设计计数器的方式可以吗?
private:
T* _ptr;
int _cnt;
这样方式会让每个对象中都会有一个独立的计数器,即使当它们指向同一块空间时,计数器中的值都不相同,这样便会影响最后资源的释放,而原本是期望它们共用一个计数器,因此很明显不可以。
既然是希望共用一个计数器,那用static修饰它可以吗?其实也是不可以的,用了static修饰后,所有的类对象都会共用这一个static计数器,但大部分情况并不是所有对象都指向同一块空间,也可以n个对象指向这块空间,m个对象指向那块空间(m != n),因此用static是无法解决这种情况的。
既然对象可以指向同一块空间,那能否再为计数器动态开辟一块空间,让它们再共同指向同一个计数器呢?是没问题的,正确的做法是动态开辟一块空间,用作计数器,拷贝赋值时让其指向同一个计数器并且自增即可。
模拟实现:
template<class T>
class shared_ptr {
public:
//计数器初始值为1
//代表有一个对象指向这块空间
shared_ptr(T* ptr) :_ptr(ptr), _pcnt(new int(1))
{}
~shared_ptr() {
//先-1后当计数器为0时
//代表没有对象指向申请的资源了
//把连同计数器一起释放
if (--(*_pcnt) == 0) {
delete _ptr;
delete _pcnt;
}
}
T& operator*() {
return *_ptr;
}
T* operator->() {
return _ptr;
}
//让其指向同一块空间和计数器
shared_ptr(const lzh::shared_ptr<T>& sp) :_ptr(sp._ptr), _pcnt(sp._pcnt)
{
//计数器自增1即可
++(*_pcnt);
}
lzh::shared_ptr<T>& operator=(const lzh::shared_ptr<T>& sp) {
//自己给自己赋值是没有意义的
//常规写法只能规避 sp1 = sp1这种情况
/*if (this == &sp) {
return *this;
}*/
//若是sp1 = sp2,且sp1和sp2指向同一块空间
//上面的写法就不行了,因为两个对象的地址是不一样的
//因此可以使用下面这种方法,检查对象指向的空间或计数器
if (_ptr == sp._ptr) {
return *this;
}
//在赋值之前检查被赋值的对象的计数器
//若为0代表只有一个对象指向这块空间
//可以直接释放
//否者说明有多个对象指向这块空间
//无需释放,-1即可
if (--(*_pcnt) == 0) {
delete _ptr;
delete _pcnt;
}
//然后赋值,计数器自增
_ptr = sp._ptr;
_pcnt = sp._pcnt;
++(*_pcnt);
return *this;
}
int use_count() const {
return *_pcnt;
}
T* get() const {
return _ptr;
}
private:
T* _ptr;
int* _pcnt;
};
shared_ptr近似完美,但存在循环引用的问题,下列就是会触发的一种情况:
struct Node {
Node() {}
A _a;
lzh::shared_ptr<Node> _next;
lzh::shared_ptr<Node> _prev;
};
int main() {
lzh::shared_ptr<Node> p1(new Node);
lzh::shared_ptr<Node> p2(new Node);
p1->_next = p2;
p2->_prev = p1;
return 0;
}
运行后:
可以发现两个对象都没有释放资源,造成了内存泄漏,为什么呢?
析构后,左边结点中的next指向右边的结点,而右边结点中的prev指向左边的结点,这就导致计数器没有归0,不为0就表示还有对象管理资源,无法释放。
若要释放资源,就需要让两边其中一个先析构,假设左结点先析构,那么左结点什么时候析构呢?prev析构左节点就析构,prev什么时候析构?右结点析构prev就析构,右节点什么时候析构?next析构右节点就析构,next什么时候析构?左节点析构next就析构,左节点什么时候析构?…
这就是循环引用,谁也无法先析构,就导致资源无法释放了,可以使用weak_ptr来解决这个问题。
weak_ptr它不是RAII性质的智能指针,只是专门设计用来解决shared_ptr循环引用的问题,无法单独使用。
原理是它不会修改引用计数,不参与资源的管控,只是可以访问资源。
模拟实现:
template<class T>
class weak_ptr {
public:
weak_ptr() :_ptr(nullptr)
{}
//不能单独使用
//支持shared_ptr构造
weak_ptr(const shared_ptr<T>& sp) :_ptr(sp.get())
{}
//支持shared_ptr赋值
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;
};
使用weak_ptr后:
由于不会影响到引用计数,因此析构时可以正常释放资源。
上面析构函数的写法都是支持释放单个对象,若是是申请多个时释放会有什么问题吗?
int main() {
lzh::shared_ptr<A> p1(new A[10]);
return 0;
}
运行后:
程序会崩溃,因为申请时使用的是new [],那么释放时就需要使用配套的delete [],如果不配套就可能会引起如崩溃等一些系列问题,除此之外,还可能是用malloc或者fopen等方式来申请资源,那么在释放时就需要使用对应配套的方式来释放资源,针对这些情况,就需要用到定制删除器来解决。
如何能在结构外部按照自己的想法来控制内部代码的运行逻辑呢?其实就是用仿函数或其它可调用类型来解决:
template<class T>
struct DeleteArray {
void operator()(T* ptr) {
delete[] ptr;
}
};
void fclo(FILE* fp) {
cout << "fclose(fp);" << endl;
fclose(fp);
}
int main() {
//传递仿函数对象
lzh::shared_ptr<A> p1(new A[10], DeleteArray<A>());
//传递lambda
lzh::shared_ptr<A> p2((A*)malloc(sizeof(A)), [](A* ptr) {
cout << "free(ptr)" << endl;
free(ptr);
});
//传递函数指针
lzh::shared_ptr<FILE> p3(fopen("test.cpp", "r"), fclo);
return 0;
}
运行结果:
上面的这种行为就称为定制删除器,不管用什么方式申请资源,最终通过它都可以正确释放。
为了能支持定制删除器,需要对shared_ptr的代码实现进行修改,修改后如下:
template<class T>
class shared_ptr {
public:
shared_ptr(T* ptr = nullptr) :_ptr(ptr), _pcnt(new int(1))
{}
//新增一个模板构造函数
template<class D>
//用D类型创建可调用对象
//由于不是类模板
//那么析构函数那里该如何拿到这个对象呢?
//可以使用包装器,知道它的参数和返回值
//就可以在类中定义一个包装器对象来接收它
shared_ptr(T* ptr, D del) : _ptr(ptr), _pcnt(new int(1)),
_del(del)
{}
~shared_ptr() {
if (--(*_pcnt) == 0) {
//使用删除器释放
_del(_ptr);
delete _pcnt;
}
}
T& operator*() {
return *_ptr;
}
T* operator->() {
return _ptr;
}
shared_ptr(const lzh::shared_ptr<T>& sp) :_ptr(sp._ptr), _pcnt(sp._pcnt)
{
++(*_pcnt);
}
lzh::shared_ptr<T>& operator=(const lzh::shared_ptr<T>& sp) {
if (_ptr == sp._ptr) {
return *this;
}
if (--(*_pcnt) == 0) {
//使用删除器释放
_del(_ptr);
delete _pcnt;
}
_ptr = sp._ptr;
_pcnt = sp._pcnt;
++(*_pcnt);
return *this;
}
int use_count() const {
return *_pcnt;
}
T* get() const {
return _ptr;
}
private:
T* _ptr;
int* _pcnt;
//包装器用来接收构造函数中的可调用对象
//而申请单个对象时不需要传递删除器
//所以针对最基本的情况要给它一个缺省值
//当释放单个对象时直接使用默认的删除器即可
function<void(T*)> _del = [](T* ptr) { delete ptr; };
};