目录
1. 为何需要智能指针?
1.1 抛异常场景
1.2 什么是内存泄漏
2. 智能指针的原理
2.1 RAII技术
2.2 补充实现
3. auto_ptr
4. unique_ptr
4.1 使用及原理
4.2 定制删除器
5.2.1 基本框架
5.2.2 拷贝构造 赋值重载
5.2.3 删除器
5.2.4 其他接口函数及测试
6. weak_ptr
6.1 循环引用
6.2 weak_ptr使用
6.3 原理及简单实现
总结
1. 为何需要智能指针?
1.1 抛异常场景
在下面的场景中,Fun函数在堆上开辟了两个对象。如果Add函数中的两个参数相加为0,就会抛异常。抛出的异常被catch关键字捕获之后,会执行catch内的代码,不会再执行Func函数后面的内容。而catch内部代码可能会忘记释放之前的对象,就会造成内存泄漏。
//假设有特殊要求相加结果不能为0,为0抛异常
int Add(int x, int y)
{
if (x + y == 0)
throw "相加为0";
return x + y;
}
void Func()
{
int* p1 = new int;
int* p2 = new int;
try
{
int x, y;
cin >> x >> y;
Add(x, y);
}
catch(...)
{
delete p1;
throw;
}
delete p1;
delete p2;
}
int main()
{
try
{
Func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
1.2 什么是内存泄漏
内存泄漏是指在程序运行过程中,由于疏忽或错误而未能释放不再使用的内存,导致这部分内存得不到回收。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,从而造成了内存的浪费。
内存泄漏的影响可能包括:
- 内存使用量增加:随着内存泄漏的累积,程序占用的内存会越来越多。
- 程序性能下降:频繁的内存分配和释放可能导致内存碎片化,进而影响程序性能。
- 系统资源耗尽:长时间运行的程序可能会耗尽系统内存,特别是对于操作系统、游戏服务器、数据库系统和客户端应用程序等关键任务程序,会导致响应越来越满,最终卡死。
2. 智能指针的原理
2.1 RAII技术
RAII(Resource Acquisition Is Initialization)是一种编程技术,主要用于C++等语言中,它将资源的获取与对象的生命周期绑定在一起,以确保资源在使用完毕后能够被正确释放,从而防止资源泄漏。
RAII的基本思想是:将资源的获取封装在一个对象的构造函数中。将资源的释放封装在同一个对象的析构函数中。利用对象的自动生命周期管理(即对象创建时自动调用构造函数,对象销毁时自动调用析构函数)来管理资源。这种做法有两大好处:
- 不需要显式地释放资源。
- 采用这种方式,对象所需的资源在其生命期内始终保持有效。
如下面代码所示,将资源创建跟SmartPtr类对象的生命周期绑定在一起,不管什么时候抛异常,执行跳到哪一步,都会随着对象的销毁并释放资源。
struct A
{
~A()
{
cout << "~A()" << endl;
}
};
template<class T>
class SmartPtr
{
public:
SmartPtr(T* ptr = nullptr)
: _ptr(ptr)
{}
~SmartPtr()
{
if (_ptr)
delete _ptr;
}
private:
T* _ptr;
};
//假设有特殊要求相加结果不能为0,为0抛异常
int Add(int x, int y)
{
if (x + y == 0)
throw "相加为0";
return x + y;
}
void Func()
{
SmartPtr<A> sp1(new A);
SmartPtr<A> sp2(new A);
try
{
int x, y;
cin >> x >> y;
Add(x, y);
}
catch(...)
{
throw;
}
}
int main()
{
try
{
Func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
运行结果如下:
2.2 补充实现
上面实现的SmartPtr类还不算智能指针。因为他只能进行资源的获取和释放操作,却不能类似于指针,进行解引用操作和使用->访问所指向的空间内容。我们需要重载一下*和->符号,达到类似指针的操作。
如下面的代码所示,先创建一个日期类,成员变量有表示年月日的整型。使用SmartPtr类创建两个对象,对象类型分别是日期类和整型。可以使用该类进行类似于指针的操作,对日期类的成员变量进行修改,并调用Print函数打印年月日。对于内置类型也可以进行操作。
struct Date
{
int _year;
int _month;
int _day;
Date(int year = 1900, int month = 1, int day = 1)
:_year(year)
, _month(month)
, _day(day)
{}
void Print()
{
cout << "日期:" << _year << "/" << _month << "/" << _day << endl;
}
~Date()
{
cout << "~Date()" << endl;
}
};
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;
};
int main()
{
SmartPtr<Date> sp1(new Date);
SmartPtr<int> sp2(new int);
sp1->_year = 2024;
sp1->_month = 9;
sp1->_day++;
sp1->Print();
*sp2 = 3;
cout << "sp2指向的内容->" << *sp2 << endl;
return 0;
}
运行结果如下:
3. auto_ptr
auto_ptr 是 C++98 标准库中的一个智能指针,用于管理动态分配的对象,以避免内存泄漏。在 C++11 标准发布后,auto_ptr 被废弃,并在 C++17 标准中被彻底移除。它的功能由 unique_ptr 取代,因为 unique_ptr 提供了更安全、更灵活的内存管理功能。
auto_pt的定义位于头文件<memory>中。它是一个模版类,可以管理任何类型的动态分配的对象。使用方式和上面实现的SmartPtr类类似。
#include <memory>
void test_auto1()
{
auto_ptr<Date> ap1(new Date);
ap1->_year = 2024;
ap1->Print();
}
运行结果如下:
看着上面的代码,发现auto_ptr好像也没什么问题,那为什么会被弃用呢?
void test_auto2()
{
auto_ptr<Date> ap1(new Date);
auto_ptr<Date> ap2(ap1);
ap1->_day++;
}
运行这段代码,你会发现程序直接崩溃了,这是为什么?
调试中,我们可以在监视窗口中可以变量。初始化ap1时,如下图所示,没有问题。
但是ap2调用拷贝ap2进行初始化时,会发现ap1变量内部的指针被置为空指针。也就是说,被其他auto_ptr对象拷贝之后,内部会被悬空。所以再使用ap1访问元素,会导致使用空指针访问。
这种将管理权转移的方法,会导致被拷贝对象悬空,进而造成访问空指针的现象。所以auto_ptr不建议使用(甚至可以说是禁止使用)。auto_ptr实现的原理就是进行拷贝构造函数时,将被拷贝对象的指针置为空指针。
4. unique_ptr
4.1 使用及原理
如果你使用的指针不需要拷贝构造,就可以使用C++11推出的unique_ptr。unique_ptr也是一个模板类,用法跟auto_ptr类似。虽然不可以进行拷贝构造,但是可以使用移动构造,因为移动构造会窃取原来对象的资源。
void test_unique()
{
unique_ptr<Date> up1(new Date);
up1->_day = 10;
//禁止拷贝构造
unique_ptr<Date> up2(up1);
//可以使用移动构造,此时up1不能使用了。
unique_ptr<Date> up3(move(up1));
up3->_day = 5;
}
unique_ptr的原理也比较简单,使用delete关键字禁用了拷贝构造函数和赋值重载函数。
4.2 定制删除器
unique_ptr其实有两个模版参数,第一个模版参数是资源的类型,第二个模版参数是删除器,里面重载()符号的函数,内部是释放资源的代码。如果不传第二个模版参数,默认是使用default_delete类删除器。default_delete删除器释放资源的方式就是正常的delete操作。
unique_ptr默认删除器是使用delete释放资源,对于创建一块资源的对象,可以正常处理。如果对象使用多个资源初始化,如下面的第二行代码所示,运行的程序会报错。因为使用new[]创建的对象,要使用delete[]连续释放。
void test_unique2()
{
unique_ptr<Date> up1(new Date);
//会报错
unique_ptr<Date> up2(new Date[5]);
}
我们可以写一个类,重载()符号。初始化unique_ptr对象时,第二个模版参数写上该类,就可以使用delete[ ]释放连续的资源。运行程序就不会报错。
template<class T>
class DeleteArray
{
public:
void operator()(T* ptr)
{
delete[] ptr;
}
};
void test_unique2()
{
unique_ptr<Date> up1(new Date);
unique_ptr<Date, DeleteArray<Date>> up2(new Date[5]);
}
如果需要使用delete[]释放资源,其实可以不用实现一个删除器类。unique_ptr给出了一个array specialization特化版本的删除器,是需要在第一个模版参数后面加个“[ ]”即可。
void test_unique2()
{
unique_ptr<Date> up1(new Date);
unique_ptr<Date[]> up2(new Date[7]);
}
由于可以自定义释放资源方式的类,我们可以使用unique_ptr来管理文件指针。只需要创建一个类,重载()符号函数中使用fclose即可。
class DeleteFclose
{
public:
void operator()(FILE* ptr)
{
cout << "fclose" << ptr << endl;
fclose(ptr);
}
};
void test_unique2()
{
unique_ptr<Date> up1(new Date);
unique_ptr<Date, DeleteArray<Date>> up2(new Date[5]);
unique_ptr<FILE, DeleteFclose> up3(fopen("test.cpp", "r"));
}
运行结果如下:
5. shared_ptr
5.1 shared_ptr简介及使用
如果你想使用拷贝构造和赋值重载函数,那么就可以使用C++11推出的shared_ptr。shared_ptr使用了引用计数的方式支持多个对象共享一块资源。
- shared_ptr内部,对于共享同一块资源的对象,都维护着同一份计数。计数用来记录该资源被几个对象共享。
- 在对象被销毁时,不在使用该资源,该对象的引用计数减一,但是不释放该资源。
- 只有最后一个管理该资源的对象被删除,那么该资源就要被释放。
shared_ptr的用法跟unique_ptr类似,多了一些接口函数。get函数是获得该对象管理资源的指针,use_count函数可以查看共享资源的对象有几个,下面有两个对象共享该资源。
void test_shared1()
{
shared_ptr<Date> sp1(new Date);
sp1->_day = 5;
shared_ptr<Date> sp2(sp1);
sp2->_year = 2024;
Date* ptr = sp1.get();
ptr->_month = 9;
sp1->Print();
cout <<"引用计数:" << sp1.use_count() << endl;
}
运行结果如下:
如上图,可知shared_ptr只有一个模版参数,不能像unique_ptr在模版中传入删除器类,以此决定如何释放资源。不过可以在构造函数传个删除器类。对于使用new[]创建多个资源的对象,可以在传模版参数后面加一个“[ ]”,会使用delete[]释放资源。
如下面的代码,第一行代码使用的是模版特化,可以释放多个连续的资源。第二行代码,初始化sp2对象时,传入DeleteFclose的匿名对象,就可以使用shared_ptr管理文件指针。
class DeleteFclose
{
public:
void operator()(FILE* ptr)
{
cout << "fclose" << ptr << endl;
fclose(ptr);
}
};
void test_shared2()
{
shared_ptr<Date[]> sp1(new Date[5]);
shared_ptr<FILE> sp2(fopen("test.cpp", "w"), DeleteFclose());
}
5.2 shared_ptr简单实现
5.2.1 基本框架
实现一个shared_ptr类,需要使用模版参数。成员变量一开始有模版类型对应的指针。那么计数该用什么变量存储?
- 如果使用普通整型变量存储计数,会发现共享同一块资源的对象,每个计数变量都是独立的。当删除其中一个对象,其他对象的计数变量不会受影响,就无法确定有多少个对象管理该资源。
- 如果使用静态整型变量,那么该计数变量属于该类的所有对象,管理两块不同的资源对象初始化时,都要对该变量加一。这样也无法判断某个资源有多少个对象管理。
- 所以需要使用整型指针,对于同一块资源,可以清楚记录有多少个对象在进行共享。而不会干扰其他资源记录对象。
构造函数_pcount初始化为1,说明有一个对象正在管理该资源。析构函数只有解引用_pcount指针为0的情况下,即最后一个管理该资源的对象被销毁,才能释放_ptr指向的资源和_pcount指针,并且都置为空指针。并把这部分封装成release函数,因为赋值重载函数也需要使用到该函数。
namespace Rustle
{
template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr = nullptr)
: _ptr(ptr)
,_pcount(new int(1))
{}
void release()
{
if (--(*_pcount) == 0)
{
delete _ptr;
delete _pcount;
_ptr = nullptr;
_pcount = nullptr;
}
}
~shared_ptr()
{
release();
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
int* _pcount;
};
5.2.2 拷贝构造 赋值重载
- 拷贝构造函数只需要将被拷贝对象的资源和计数拷贝过来即可。再把计数加一。
- 赋值重载函数需要先调用release函数,释放先前的资源和计数。将拷贝传参对象的资源和计数,再把计数加一。
//s2(s1)
shared_ptr(const shared_ptr<T>& sp)
: _ptr(sp._ptr)
, _pcount(sp._pcount)
{
++(*_pcount);
}
//s2 = s1
shared_ptr<T> operator=(const shared_ptr<T>& sp)
{
if (_ptr != sp._ptr)
{
release();
_ptr = sp._ptr;
_pcount = sp._pcount;
++(*_pcount);
}
return *this;
}
5.2.3 删除器
shared_ptr的使用中,在构造函数中可以传入自定义的删除器,删除器中可以定义各种释放资源的方法。因此,我们也需要实现一个删除器,需要使用包装器。
包装器可以封装仿函数类,做到类似使用函数的形式。定义一个包装器类,模版传函数返回值和函数参数类型。我们给一个缺省值,Default_delete类是使用delete释放资源。
template<class T>
struct Default_delete
{
void operator()(T* ptr)
{
delete ptr;
}
};
class shared_ptr
{
public:
//使用包装器
void release()
{
if (--(*_pcount) == 0)
{
_del(_ptr);//类似使用函数的形式
delete _pcount;
_ptr = nullptr;
_pcount = nullptr;
}
}
template<class D>
shared_ptr(T* ptr, D del)
: _ptr(ptr)
, _pcount(new int(1))
,_del(del)
{}
//...
private:
//...
//包装器
function<void(T* ptr)> _del = Default_delete<T>();
};
5.2.4 其他接口函数及测试
- get函数是获得管理资源的指针变量。
- use_count函数可以获取管理该资源的对象个数。
T* get() const
{
return _ptr;
}
int use_count() const
{
return *_pcount;
}
#include "shared_ptr.h"
void test_MySharedPtr()
{
Rustle::shared_ptr<Date> sp1(new Date);
sp1->_day = 5;
sp1->_month = 9;
sp1->_year = 2024;
sp1->Print();
Rustle::shared_ptr<Date> sp2(new Date[5], DeleteArray<Date>());
Rustle::shared_ptr<FILE> sp3(fopen("test.cpp", "w"), DeleteFclose());
}
运行结果如下:
6. weak_ptr
weak_ptr是跟shared_ptr配套使用的,为了应对循环引用这个场景。
6.1 循环引用
下面的代码就是循环引用的示例。创建一个双向链表,链表指针的前后指针使用shared_ptr进行管理。这样才可以进行相互赋值。
struct ListNode
{
int _data;
std::shared_ptr<ListNode> _next;
std::shared_ptr<ListNode> _prev;
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
int main()
{
//下面是循环也能用-->导致内存泄漏
std::shared_ptr<ListNode> n1(new ListNode);
std::shared_ptr<ListNode> n2(new ListNode);
cout << "n1结点计数:" << n1.use_count() << endl;
cout << "n2结点计数:" << n2.use_count() << endl;
n1->_next = n2;
n2->_prev = n1;
cout << endl;
cout << "n1结点计数:" << n1.use_count() << endl;
cout << "n2结点计数:" << n2.use_count() << endl;
return 0;
}
再创建两个shared_ptr对象管理ListNode类资源,当其中任意一个对象指向另外一个对象,并且不是互相指向时,是没有问题的。运行结果如下,资源被正常释放。
但是两个对象内部指针互相指向,如上面的代码所示。运行结果如下,两块资源都没有释放。
观察下图,跟着下面的推导过程一步一步走,你会发现下面的推导过程形成了一个循环。也就是说,无法释放这两个结点所指向的资源。这就是循环引用。
6.2 weak_ptr使用
- 上面是weak_ptr构造函数的原型。我们可以看到weak_ptr不支持给一块资源初始化。支持正常拷贝构造。最值得注意的是支持使用shared_ptr对象进行构造。
- weak_ptr的赋值重载函数不仅提供了对同类型的赋值操作,还有对shared_ptr对象的赋值操作。
下面的代码是使用示例。
void test_weak()
{
//不支持管理资源,不支持RAII计数
std::weak_ptr<Date> wp1(new int);//error
//默认构造,拷贝构造
std::weak_ptr<Date> wp2;
std::weak_ptr<Date> wp3(wp2);
//赋值重载
std::weak_ptr<Date> wp4;
wp4 = wp2;
//对shared_ptr的构造
std::shared_ptr<ListNode> sp1(new ListNode);
std::weak_ptr<ListNode> wp5(sp1);
//对shared_ptr赋值重载
std::weak_ptr<ListNode> wp6;
wp6 = sp1;
}
6.3 原理及简单实现
struct ListNode
{
int _data;
std::weak_ptr<ListNode> _next;
std::weak_ptr<ListNode> _prev;
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
int main()
{
std::shared_ptr<ListNode> n1(new ListNode);
std::shared_ptr<ListNode> n2(new ListNode);
cout << "n1结点计数:" << n1.use_count() << endl;
cout << "n2结点计数:" << n2.use_count() << endl;
n1->_next = n2;
n2->_prev = n1;
cout << endl;
cout << "n1结点计数:" << n1.use_count() << endl;
cout << "n2结点计数:" << n2.use_count() << endl;
return 0;
}
前面有提到weak_ptr是为了解决循环引用这个场景而产生的。我们将ListNode类中的_next和_prev类型换成weak_ptr。并分别打印两次n1结点和n2结点的计数。
运行程序发现这两块资源得到释放,并且在n1结点和n2结点互相指向时。因为weak_ptr会获得管理这块资源的权利,但是计数不会发生改变。这样在释放n1和n2时,两个资源计数都会减到0。
实现简单的weak_ptr,最主要的是支持shared_ptr对象对weak_ptr对象的构造和赋值。
- 默认构造函数只要将内部指针变量置为空指针即可。
- shared_ptr对象对weak_ptr对象的构造,可使用shared_ptr的get函数获取内部管理资源的指针。赋值重载函数也是类似的做法。
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();
return *this;
}
private:
T* _ptr = nullptr;
};
将上述循环引用例子中的智能指针类替换成自己实现的智能指针类,进行测试。运行结果如下,没有问题。
当然,weak_ptr还有一些其他接口函数,也可以尝试去实现。
总结
正确使用智能指针是避免内存泄漏的关键策略。在C++中,智能指针提供了一种自动管理内存资源的方式,从而大幅降低了因疏忽而导致的内存泄漏风险。auto_ptr和unique_ptr作为早期的智能指针实现,其原理较为简单,主要是通过独占所有权模型来防止内存泄漏。然而,在需要多个对象共同管理同一块资源的情况下,shared_ptr和weak_ptr的配套使用则显得尤为重要。
创作不易,希望这篇文章能给你带来启发和帮助,如果喜欢这篇文章,请留下你的三连,你的支持的我最大的动力!!!