【C++】智能指针:解决内存泄漏、悬空指针等问题
目录
- 前言
- 一、RAII
- 二、智能指针原理
- 三、auto_ptr
- 四、unique_ptr
- 五、shared_ptr
- 第一步:实现出RAII的框架
- 第二步:如何实现引用计数
- 第三步:赋值重载
- 第四步:加定制删除器
- 第五步:解决循环引用的问题
- 六、weak_ptr
前言
什么是内存泄漏:内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。
解决内存泄漏的问题,使用智能指针管理是一个很好的选择。
一、RAII
RAII(Resource Acquisition Is Initialization)是C++中的一种资源管理技术,其核心思想是利用对象的生命周期来自动管理资源,因为对象的构造和析构是自动调用的。
在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:
- 不需要显式地释放资源
- 对象所需的资源在其生命期内始终保持有效
不管对象是生命周期正常结束还是抛了异常,最后都会自动释放掉:
//使用RAII思想设计的SmartPtr类
template<class T>
class SmartPtr
{
public:
SmartPtr(T* ptr = nullptr)
:_ptr(ptr)
{}
~SmartPtr()
{
if (_ptr)
delete _ptr;
}
private:
T* _ptr;
};
二、智能指针原理
上面的SmartPtr
还不能称为智能指针,因为它还不具有指针的行为。指针可以解引用,也可以通过->去访问所指空间中的内容,因此智能指针模板类中还得需要将* 、->重载下,才可让其像指针一样去使用。
template<class T>
class SmartPtr
{
public:
SmartPtr(T* ptr = nullptr)
:_ptr(ptr)
{}
~SmartPtr()
{
if (_ptr)
delete _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
struct Date
{
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
SmartPtr<int> sp1(new int);
*sp1 = 10;
cout << *sp1 << endl;
SmartPtr<Date> sp2(new Date);
sp2->_year = 2024;
sp2->_month = 10;
sp2->_day = 25;
}
智能指针的原理:RAII特性
+
重载operator*和operator->,具有像指针一样的行为
这么看智能指针好像还挺好理解挺简单的,不过先别高兴太早,智能指针麻烦的地方不在这里,在拷贝构造。
我们知道C++默认构造函数实现的是浅拷贝,而智能指针模拟的是原生指针的行为,我们期望它的拷贝就是浅拷贝,看似类的默认构造函数就很好的满足我们的需求,但是不要忘了让多个指针指向同一块空间,这样会导致同一块空间出现析构多次的情况,显然这里出现了矛盾。
三、auto_ptr
- C++智能指针都在头文件
<memory>
中定义。
auto_ptr
要求其对“裸”指针的完全占有性,即一个“裸”指针不能同时被两个以上的auto_ptr
所拥有。
虽然auto_ptr
提供了拷贝,但它的拷贝是一种管理权转移。被拷贝对象会失去资源的所有权,拷贝对象会接管这个资源。同样的赋值也会转移管理权。
//拷贝构造
auto_ptr(auto_ptr<T>& ap)
:_ptr(ap._ptr)
{
//管理权转移
ap._ptr = nullptr;
}
//赋值重载
auto_ptr<T>& operator=(auto_ptr<T>& ap)
{
// 检测是否为自己给自己赋值
if (this != &ap)
{
// 释放当前对象中资源
if (_ptr)
delete _ptr;
// 转移ap中资源到当前对象中
_ptr = ap._ptr;
ap._ptr = NULL;
}
return *this;
}
//...
由于auto_ptr
存在上述限制和潜在问题,C++11及以后的版本引入了更先进的智能指针,如std::unique_ptr
和std::shared_ptr
,它们提供了更强大和灵活的资源管理功能。因此,在现代C++编程中,建议使用这些新的智能指针来替代auto_ptr
。
四、unique_ptr
unique_ptr
不支持拷贝,其拷贝构造函数被delete
禁掉了。
unique_ptr(const unique_ptr<T>& sp) = delete;
unique_ptr<T>& operator=(const unique_ptr<T>& sp) = delete;
//不支持拷贝
unique_ptr<Date> up1(new Date);
unique_ptr<Date> up2(up1);
如果unique_ptr
管理的是多个连续的空间,则释放时会出错,因为它的底层是delete
,而释放连续的空间需要delete[]
。
解决这个问题可以通过定制删除器来解决。
定制删除器:
template<class T>
class DeleteArray
{
public:
void operator()(T* ptr)
{
delete[] ptr;
}
};
class Fclose
{
public:
void operator()(FILE* pf)
{
cout << "void operator()(FILE* pf)" << endl;
fclose(pf);
}
};
int main()
{
unique_ptr<Date, DeleteArray<Date>> up1(new Date[10]);
unique_ptr<FILE, Fclose> up2(fopen("text.txt", "w"));
return 0;
}
五、shared_ptr
我们重点学习shared_ptr
。
shared_ptr
的原理:通过引用计数的方式来实现多个shared_ptr
对象之间共享资源。
shared_ptr
在其内部,给每个资源都维护了着一份计数,用来记录该份资源被几个对象共享- 在对象被销毁时,就说明自己不使用该资源了,对象的引用计数减一
- 如果引用计数是0,就说明自己是最后一个使用该资源的对象,必须释放该资源
- 如果不是0,就说明除了自己还有其他对象在使用该份资源,不能释放该资源,否则其他对象就成野指针了
类似unique_ptr
,shared_ptr
也支持传定制删除器,但它们两个支持传定制删除器的位置有所不同。
另外,除了直接new
对象,也可以用make_shared
构造对象。它是一个函数模板:
make_shared
一次性完成了内存分配和对象构造,因此可以减少内存分配的次数,还可以使得控制块和对象可以分配在同一块连续的内存上,减少了内存碎片化的风险。
| 接下来模拟实现shared_ptr:
第一步:实现出RAII的框架
namespace yjz
{
template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
{}
~shared_ptr()
{
if (_ptr)
delete _ptr;
}
private:
T* _ptr;
};
}
第二步:如何实现引用计数
首先我们应该讨论引用计数保存在哪里,不能存在各自的对象中,应该满足一个资源配一个计数,也就是公共计数。静态成员变量计数也不行,因为静态成员变量不属于某个对象,而是属于类的所有对象,这显然是不行的,因为所有的对象不可能都指向同一资源,可能其中的几个对象分别指向不同的资源。
这里合理的处理是将引用计数开在堆上(也就是上面make_shared
部分提到的控制块),然后在对象中存一个指针指向这个计数。
一个资源配一个计数,所以计数也在构造的时候给出。
namespace yjz
{
template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
,_pcount(new int(1))
{}
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
, _pcount(sp._pcount)
{
(*_pcount)++;
}
~shared_ptr()
{
//当计数为0时释放资源
if (--(*_pcount) == 0)
{
delete _ptr;
delete _pcount;
_ptr = nullptr;
_pcount = nullptr;
}
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
int* _pcount;
};
}
第三步:赋值重载
赋值需要注意的是被赋值的指针原来管理的资源是否需要释放。那么这里就要显示地调用析构函数,不过析构函数最好和构造等一一对应,所以这里可以将析构的逻辑用一个函数重新包装,然后在赋值和析构函数中调用这个函数处理。
其中赋值还需要防止自己给自己赋值的情况,有可能会出现野指针的问题。
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
if (_ptr != sp._ptr)
{
release();
_ptr = sp._ptr;
_pcount = sp._pcount;
++(*_pcount);
}
return *this;
}
void release()
{
//当计数为0时释放资源
if (--(*_pcount) == 0)
{
delete _ptr;
delete _pcount;
_ptr = nullptr;
_pcount = nullptr;
}
}
第四步:加定制删除器
在构造shared_ptr
时我们要传一个可调用对象过去,这个对象可能是函数指针,可能是仿函数,也有可能是lambda
,这里不确定接收的类型,就可以用function
来接收。这里的删除器只供构造函数使用,因此不能传整个类模版。
//...
template<class D>
shared_ptr(T* ptr, D del)
:_ptr(ptr)
, _pcount(new int(1))
,_del(del)
{}
//...
void release()
{
//当计数为0时释放资源
if (--(*_pcount) == 0)
{
//delete _ptr;
_del(_ptr);
delete _pcount;
_ptr = nullptr;
_pcount = nullptr;
}
}
//...
private:
T* _ptr;
int* _pcount;
function<void(T* ptr)> _del = [](T* ptr) {delete ptr; }
}
function
成员变量需要一个缺省值,如果没有传定制的删除器需要用默认的删除操作进行释放。
第五步:解决循环引用的问题
在特殊场景,比如双向循环链表中,如果两个节点互相指向,就会出现循环引用的问题,最后导致内存泄漏。
struct ListNode
{
int _data;
shared_ptr<ListNode> _next;
shared_ptr<ListNode> _prev;
};
int main()
{
std ::shared_ptr<ListNode> n1(new ListNode);
std::shared_ptr<ListNode> n2(new ListNode);
n1->_next = n2;
n2->_prev = n1;
return 0;
}
这里内存泄漏的关键是:n2后定义的先析构,而n2指向的资源还有n1->_next
管理,所以n2指向的资源这里还不会释放;接下来析构n1指向的资源,而n1指向的资源还有n2->_prev
管理,所以n1指向的资源这里也还不会释放。最后n1和n2两个shared_ptr
都释放了它们原本指向的资源还得不到释放,因为还有这两个资源内部的shared_ptr
互相管理者。
为了处理这种情况的发生,出现了weak_ptr
来配合shared_ptr
解决这个问题。
六、weak_ptr
不同于上面的智能指针,weak_ptr
不支持直接管理资源(RAII),而是配合解决shared_ptr
循环引用导致的内存泄漏的缺陷。
在引用计数的场景下,把节点中的_prev
和_next
改成weak_ptr`就可以了
weak_ptr
用shared_ptr
构造不增加引用计数。
当然这里只实现了一个简单的weak_ptr
。
template<class T>
class weak_ptr
{
public:
weak_ptr()
{}
weak_ptr(const shared_ptr<T>& sp)
:_ptr(sp.get())
{}
weak_ptr<T>& operator=(const shared_ptr<T>& sp)
{
_ptr = sp.get();
}
private:
T* _ptr = nullptr;
};
struct ListNode
{
int _data;
weak_ptr<ListNode> _next;
weak_ptr<ListNode> _prev;
};
本篇文章的分享就到这里了,如果您觉得在本文有所收获,还请留下您的三连支持哦~