智能指针的完整实现代码链接:
code/智能指针2024_9_9 · future/my_road - 码云 - 开源中国 (gitee.com)
1.现象与底层
前面我们学习了异常的操作,在异常执行流跳转时容易产生内存泄漏的问题,我们可以使用RAII的思想使用智能指针来解决这个问题;(其实在我们前面的linux中的mutex锁的封装处,我们也使用到了RAII的思想进行了封装,与这里的智能指针的使用是相似的;)
那么接下来我们先用一个实例来辅助了解智能指针:(异常内存泄漏问题使用RAII方式解决)
RAII——resource acquisition is initialization(资源获得与初始化)
1.1智能指针解决抛异常内存泄漏
异常代码:
#include"smartPtr.hpp"
class A
{
public:
A()
{
cout << "A()" << endl;
}
~A()
{
cout << "~A()" << endl;
}
};
class B
{
public:
B()
{
cout << "B()" << endl;
}
~B()
{
cout << "~B()" << endl;
}
};
void fun()
{
//new A;
//new B;
smartPtr<A> pa = new A;
smartPtr<B> pb = new B;
throw string("抛出异常");
}
int main()
{
try
{
testOperator();
}
catch (const string& str)
{
cout << str << endl;
}
return 0;
}
现象:
换用注释的智能指针代码后:
我们可以看到智能指针确实解决内存泄漏问题,智能指针究竟是如何做到的呢?
1.2智能指针的底层
一个对象在栈帧上创建后,随着其生命周期的结束,它的内存空间也会一同销毁,但堆上数据并不会随着栈帧销毁,如果我们不进行手动的释放,就有可能导致内存泄漏;而人为的控制有可能会出现问题,更何况异常执行流跳转的问题更是让此雪上加霜,为了保证让堆上数据也可以正常释放,智能指针出现了;
智能指针封装成为了一个类,当在堆上申请数据时,智能指针类实例化出类对象,将堆上的数据的地址封装进了类中,当智能指针类的生命周期结束时调用析构函数,封装的堆上数据的析构函数也被一同调用,从而起到了自动释放空间的作用,我们叫这样的类似思想叫做RAII方式;
下面图片上的代码是我从完整实现上截取的,如果需要完整实现可以点击最上方的gitee链接查看
我们清楚了智能指针的原理后,接下来我们看看他的底层代码是如何操作的;
template<class T>
class smartPtr
{
public:
//下面两个构造是可以实现的
smartPtr(T* ptr = nullptr)
:_ptr(ptr)
{}
~smartPtr()
{
delete _ptr;
}
T* operator->()
{
return _ptr;
}
T& operator*()
{
return *_ptr;
}
private:
T* _ptr;
};
我们可以看到,好像智能指针实现非常简单!但其实智能指针的思想真的就是这么简单,只是将数据地址放在智能指针类中封装起来而已;但是它还有一些值得注意的麻烦的地方;
我们讲解一下其中值得注意的地方:
1.为了让智能指针这个类如真正的指针一般操作,我们重载了opreator*与->的操作;
2.当智能指针进行拷贝构造时,如果不人为的重新实现其拷贝,会导致拷贝的内容被两个指针同时时会出现二次析构的问题;
那究竟该如何实现拷贝构造与赋值重载呢?
我们通过智能指针发展的历史来讲解:
2.智能指针的发展过程
2.1 C++98auto_ptr
这可以说是第一版的智能指针,这一版的智能指针实现的是基本的数据随着智能指针对象的创建与销毁;但这一指针在拷贝上出现了一些问题,当想拷贝智能指针给另一个智能指针对象时,会发生权限转移,悬空被拷贝的智能指针对象;这显然是存在一些问题的,如果此时再使用被拷贝的指针会出现越界的情况,所以这样的拷贝是不合理的;
这样权限转移的方式是被许多程序员所诟病的,但后来C++组织创立了boost库;boost库是C++的准标准库,也是由C++标准委员会开发的库,但并不是C++标准库,这个库中的许多优秀的内容后来都被纳入到了C++标准库中;(我们可以将boost看作是测试服)即推动的C++标准库的研发速度,也减少了出现如auto_ptr这样不完善的代码出现在标准库中;
在boost库中研发出了scoped_ptr(c++标准库中改名为unique_ptr)与shared_ptr;这两个库成功的解决了拷贝所出现的问题;
2.2 unique_ptr(scoped_ptr)
这个智能指针对拷贝进行了限制,直接禁止了智能指针的拷贝,如果我们不需要拷贝构造时就直接使用这个智能指针即可:
template <class T>
class unique_ptr
{
unique_ptr(const unique_ptr<T>& p) = delete;
unique_ptr<T> & operator =(const unique_ptr<T>& p)=delete;
};
简单粗暴的进行了限制;
2.3 shared_ptr
这个智能指针通过引用计数的方式在智能指针进行拷贝时记录其被拷贝的次数从而让智能指针不会出现二次析构的问题;
其中我们还有一个值得注意的地方:
引用计数只能通过拷贝与赋值增加
智能指针如果想指向同一片空间要通过拷贝构造或者赋值运算符重载来拷贝智能指针,直接让两个智能指针分别指向同一片空间是不行的,这无法增加引用计数;
拓展:
将count计数设置为static成员变量是行不通的,因为会导致整个类中的成员只会拥有一个计数:
到这里智能指针的基本功能就差不多完善了,但是还存在一点点小的问题:
2.4循环引用问题
我们在堆上创建了一个节点对象时,将这个对象存入智能指针中,我们想让智能指针帮助我们对节点进行自动析构:
struct Node {
int _data;
//Node* _next;
//Node* _prev;
smartPtr<Node> _next;
smartPtr<Node> _prev;
Node(int data = 0)
:_data(data)
{
cout << "Node()" << endl;
}
~Node()
{
cout << "~Node()" << endl;
}
};
void test3()
{
smartPtr<Node>n1 = new Node;
smartPtr<Node>n2 = new Node;
//由于智能指针无法赋值给普通指针,所以要将节点_prev与_next设置为智能指针
n1->_next = n2;
n2->_prev = n1;
}
运行代码发现:
为什么会出现这个问题呢?
我们可以看到这个体系中的数据是循坏引用的;
析构时:
首先智能指针n2生命结束,由于n2指向的数据为node2
但node1的next节点也是指向node2的所以只--引用计数不析构
随后智能指针n1生命结束,由于n1指向的数据为node1
但node2的prev节点也是指向node1的所以只--引用计数不析构
所以node1与node2中的数据都不会进行析构;
从中我们看出,无法析构的原因是Node节点中的prev与next被我们设置为了智能指针,所以我们可以把prev和next的类型修改为不会增加引用计数的智能指针,这样就可以让节点正常析构了;
boost库的实现也是这么想的于是引出了一个新的类型:
2.5 weak_ptr
我们将node节点中的prev和next修改:
接下来我们实现一个不会增加引用计数但是可以获得智能指针中_ptr的weak_ptr类:
namespace myptr {//命名空间,防止和标准库中的weak_ptr冲突
template<class T>
class weak_ptr {
public:
weak_ptr(T* p=nullptr)
:_ptr(p)
{}
T* operator->()
{
return _ptr;
}
T& operator *()
{
return *_ptr;
}
weak_ptr<T>& operator=(const smartPtr<T>& sp)
{
_ptr = sp.get();
return *this;
}
weak_ptr(const smartPtr<T>& sp)
{
_ptr = sp.get();
}
private:
T* _ptr;
};
}
这样实现后获得现象:
此外还有一个new与new []时,析构应该使用delete还是delete[]存在的问题:
2.6定制删除器
当new对象为[]数组类型时析构时也需要以delete[]的方式进行析构;而我们的C++库中shared_ptr智能指针似乎是一个独立的类模板,它是如何实现适配两种不同的删除(析构)方式的呢?
历史是这么发展的,boost库在研发shared_ptr时与其一同存在的还有shared_arr,用shared_arr作为接收new[]数组对象的智能指针;后来C++11将boost库中精华纳入C++标准库时将unique_ptr与shared_ptr,weak_ptr吸收了;其中share_arr与shared_ptr合并为了shared_ptr;
如何合并的呢?
通过定制删除器的方式,传递一个删除的方法给shared_ptr类,类中自动调用这个方法即可,这个方法可以是lambda,仿函数,函数指针;只要让智能指针类接收了方法并可以调用即可:
运行下面的代码
void test2()
{
A* a = new A;
A* a1 = new A[5];
//smartPtr<A> pa = a;
smartPtr<A> pa1(a1, [](A* ptr) {delete[]ptr; });
throw string("抛出异常");
}
现象:
小提示:
这个定制删除器,定制的是一个删除方法,使用包装器接收了这个删除方法后,再在智能指针类中使用这个方法,所以这个方法就和函数实现一样可以有很多的功能不只可以用来删除,我们可以进行一系列自定义的行为;
就比如我们想通过智能指针打开一个文件,然后再自动关闭:
smartPtr<FILE> f(fopen("test.txt", "r"), [](FILE* fd) {fclose(fd); });
这样都是可以的,所以编程思想灵活多变,在严格的规则之下进行灵活高效的操作;
3.简要的说明内存泄漏
在C++中一切用户开辟的空间都需要程序员自己来维护,人的操作必定不是万无一失的,所以一定又出现错误的可能,所以要严格的遵守编程的规则,在创建对象空间时带上释放空间的代码;但是由于后面的C++11异常出现,执行流的跳转,就算我们进行了严格的规范依然会出现内存绣楼,死锁等问题,于是智能指针出现来辅助我们解决这些问题;而在java中有垃圾回收机制gc在后台不断轮询的检查内存,当某些内存不会再被用到时会自动进行释放;这样虽然免去了内存泄漏的风险,但是会降低程序的性能;而C++是一门极其注重性能的语言,注定了垃圾回收器这样存在负荷的功能无法存在与C++中;
所以对于内存泄漏,我们可以做的只有
1.严格的编码,在申请资源后一定记住释放
2.使用智能指针将申请内存绑定,使其可以自动释放
3.灵活运用工具,对于内存泄漏进行检查(这个我自己也不太清楚使用什么工具怎么检查,后续补充吧)