C++修炼:智能指针
Hello大家好!很高兴我们又见面啦!给生活添点passion,开始今天的编程之路!
我的博客:<但凡.
我的专栏:《编程之路》、《数据结构与算法之美》、《题海拾贝》、《C++修炼之路》
欢迎点赞,关注!
目录
1、智能指针
1.1、智能指针的使用场景
1.2、智能指针的使用
2、C++库中的智能指针
2.1、auto_ptr
2.2、unique_ptr
2.3、shared_ptr
2.4、weak_ptr
3、内存泄漏
1、智能指针
1.1、智能指针的使用场景
我们看一下下面这个场景:
void test()
{int* arr1 = new int[10];int* arr2 = new int[10];int* arr3 = new int[10];int* arr4 = new int[10];//...delete[] arr1;delete[] arr2;delete[] arr3;delete[] arr4;
}
上期我们也说了,new本身也会抛出异常。现在假设我们第一个new因为内存空间不足无法开辟空间而抛异常了,没关系,我们编译器会直接终止这个函数,跳回到主函数,而主函数中一般有处理异常的catch。可如果第二个,第三个new抛异常了呢?这个函数中如果没办法捕获这个异常,会导致调用不到delete,造成内存泄漏。
这时候我们第一种解决思路就是每个new后面我都跟着一个捕获,捕获后处理或者重新抛出,到主函数再处理。可是这样的程序太冗余了,如果我们new一百个资源,岂不是要写一百个catch?
那么有什么好的办法解决呢? C++提供了一种很好的解决思路——RAII(Resource Acquisition Is Initialization)。其核心思想是将资源的获取与对象的初始化绑定,资源的释放与对象的析构绑定。通过这种方式,可以避免资源泄漏,确保异常安全。我们可以理解为申请资源自动释放。
而我们的智能指针,就是基于这种思路设计的。
这里提一嘴,其实C++也尝试过其他的解决方式,比如垃圾收集与基于可达性的泄漏检测,在java中,就是通过这种方式来判断泄漏的。但是这种方式对于C++来说并不适用,因为会降低效率。
1.2、智能指针的使用
我们先自己实现一个简单的智能指针,一会我们再来看库里的智能指针。
其实智能指针说到底也是一个类。我们要想让他在上面的场景中替代int*,就需要这个智能指针类支持[ ],*,->等运算符:
#include<iostream>
using namespace std;template<class T>
class SmartPtr
{
public:SmartPtr(T* ptr):_ptr(ptr){}~SmartPtr(){delete[] _ptr;}T& operator*(){return *_ptr;}T* operator->(){return _ptr;}T& operator[](size_t i){return _ptr[i];}
private:T* _ptr;
};
void test()
{SmartPtr<int> arr1 = new int[10];SmartPtr<int> arr2 = new int[10];SmartPtr<int> arr3 = new int[10];for (size_t i = 0; i < 10; i++){arr1[i] = arr2[i] = i;cout << arr1[i] << " " << arr2[i] << endl;}
}
int main()
{test();return 0;
}
我们在使用智能指针时就不需要自己释放资源了,因为析构函数是一定会被调用的。
在new异常抛出后,在栈展开的过程中,沿路上所有创建的资源都会销毁,也就是说都会去调用析构函数,所以我们也不用担心调用链中的资源不会被释放。
2、C++库中的智能指针
C++中的智能指针包在头文件<memory>下。C++库中的智能指针经过了一系列的发展,我们逐个来看
2.1、auto_ptr
auto_ptr是C++98时期提出来的。但是强烈不建议使用。因为他在拷贝时会把被拷贝对象管理权转移,也就是说被拷贝对象会被置空。如果这个时候再去访问被拷贝的对象就会出问题。
#include<iostream>
#include<memory>
using namespace std;
class A
{
public:A(int a):_a(a){}int _a;
};
int main()
{auto_ptr<A> A1(new A(10));auto_ptr<A> A2(A1);A1->_a;//报错return 0;
}
在C++11之后有了更好的智能指针代替这个东西,在C++17auto_ptr直接被移除了。要知道C++不会轻易移除某些功能。因为可能有的大型项目已经用了某些功能,不能轻易移除。但是C++17直接把auto_ptr移除了,可想而知这玩意是有多鸡肋了。
auto_ptr的模拟实现:
template<class T>
class auto_ptr
{
public:auto_ptr(T* ptr):_ptr(ptr){}auto_ptr(auto_ptr<T>& sp):_ptr(sp._ptr){// 管理权转移 sp._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(){if (_ptr){delete _ptr;}}// 像指针⼀样使⽤ T& operator*(){return *_ptr;}T* operator->(){return _ptr;}
private:T* _ptr;
};
2.2、unique_ptr
接下来的三个智能指针都是C++11中更新的。
先说unique_ptr。他不支持拷贝构造和拷贝赋值,但支持移动语义。使用起来和auto_ptr没啥区别:
#include<iostream>
#include<memory>
using namespace std;
class A
{
public:A(int a):_a(a){}int _a;
};
int main()
{unique_ptr<A> A1(new A(10));//临时对象//unique_ptr<A> A2(A1);//编译报错unique_ptr<A> A2(move(A1));//cout<<A1->_a<<endl;由于A1被move,所以输出结果为随机数或程序崩溃return 0;
}
需要注意的是虽然unique_ptr在直接构造时可以接收左值,只有在拷贝和赋值时不支持左值。
对于unique_ptr,如果想要使用new[]也有多种方法,第一我们可以使用特化,第二我们可以使用定制删除器:
template<class T>
void DeleteArrayFunc(T* ptr)
{delete[] ptr;
}
template<class T>
class DeleteArray
{
public:void operator()(T* ptr){delete[] ptr;}
};class A
{
public:A(int a=0): _a(a){}
private:int _a = 0;
};
int main()
{std::unique_ptr<A[]> test1(new A[10]);//特化std::unique_ptr<A, void(*)(A*)> test2(new A[10], DeleteArrayFunc<A>);//函数指针做定制删除器std::unique_ptr<A, DeleteArray<A>> test3(new A[10], DeleteArray<A>());//仿函数做定制删除器auto Deletefunc = [](A* a){ delete[] a; };std::unique_ptr<A, decltype(Deletefunc)> test4(new A[10], Deletefunc);//使用decltype推导出lambda的类型return 0;
}
unique_ptr的模拟实现:
这里只实现了一下简单的unique_ptr,并没有实现支持定制删除器的版本。
template<class T>
class unique_ptr
{
public:explicit unique_ptr(T* ptr)//防止编译器隐式类型转换:_ptr(ptr){}~unique_ptr(){if (_ptr){cout << "delete:" << _ptr << endl;delete _ptr;}}// 像指针⼀样使⽤ T& operator*(){return *_ptr;}T* operator->(){return _ptr;}unique_ptr(const unique_ptr<T>& sp) = delete;unique_ptr<T>& operator=(const unique_ptr<T>& sp) = delete;unique_ptr(unique_ptr<T>&& sp):_ptr(sp._ptr){sp._ptr = nullptr;}unique_ptr<T>& operator=(unique_ptr<T>&& sp){delete _ptr;_ptr = sp._ptr;sp._ptr = nullptr;}
private:T* _ptr;
};
2.3、shared_ptr
shared_ptr通过引用计数的机制实现了多个指针共同享用一块内存。当引用计数被减为0时这块内存被自动释放。
可以通过构造函数或 make_shared(C++14新增) 函数创建一个 shared_ptr。使用 make_shared 更高效,因为它一次性分配内存并构造对象。
shared_ptr<int> p1(new int(42)); // 构造函数shared_ptr<int> p2 = make_shared<int>(42); // make_sharedauto p3 = make_shared<A>(42);
对于shared_ptr来说,如果想支持new[]也是有两种方法,第一种是特化,第二种是定制删除器。但是和unique_ptr的使用方法不太一样:
template<class T>
void DeleteArrayFunc(T* ptr)
{delete[] ptr;
}
template<class T>
class DeleteArray
{
public:void operator()(T* ptr){delete[] ptr;}
};class A
{
public:A(int a=0): _a(a){}
private:int _a = 0;
};
int main()
{std::shared_ptr<A[]> test1(new A[10]);//特化std::shared_ptr<A> test2(new A[10], DeleteArrayFunc<A>);//函数指针std::shared_ptr<A> test3(new A[10], DeleteArray<A>());//仿函数std::shared_ptr<A> test3(new A[10], [](A* a) {delete[] a;});//lambda表达式return 0;
}
接下来是最重要的shared_ptr的模拟实现:
template<class T>class shared_ptr{public:explicit shared_ptr(T* ptr = nullptr):_ptr(ptr), _pcount(new int(1)){}//构造函数不支持隐式类型转换,所以以下操作会报错//shared_ptr<A> test4 = new A(10);template<class D>shared_ptr(T* ptr, D del): _ptr(ptr), _pcount(new int(1)), _del(del){}shared_ptr(const shared_ptr<T>& sp):_ptr(sp._ptr), _pcount(sp._pcount), _del(sp._del){++(*_pcount);}void release(){if (--(*_pcount) == 0)//如果引用计数归0了再释放空间{_del(_ptr);delete _pcount;_ptr = nullptr;_pcount = nullptr;}}shared_ptr<T>& operator=(const shared_ptr<T>& sp){if (_ptr != sp._ptr)//防止自己给自己赋值{release();_ptr = sp._ptr;_pcount = sp._pcount;++(*_pcount);_del = sp._del;}return *this;}~shared_ptr(){release();}T* get() const{return _ptr;}int use_count() const{return *_pcount;}T& operator*(){return *_ptr;}T* operator->(){return _ptr;}private:T* _ptr;int* _pcount;function<void(T*)> _del = [](T* ptr) {delete ptr;};//默认是不支持delete[]的};
对于这种实现方式有一点不好就是对于引用计数,如果我们存在多个shared_ptr,就会开辟很多小块内存,造成内存碎片。库里面的shared_ptr提供了一个内存池的版本解决了这个问题,另外还有一个解决这个问题的办法就是用make_shared。make_shared在底层开辟引用计数的资源时是和T*开在一起的,这样就不会造成内存碎片了。
另外,shared_ptr可以隐式类型转换为bool类型。如果shared_ptr不为空(即它管理某个对象),则转换为 true
;如果为空(不管理任何对象),则转换为 false
。这种转化是通过成员函数operator bool()来实现的。
2.4、weak_ptr
首先介绍一下boost库。boost库是一个第三方库,我们可以理解为是C++委员会内部弄的体验服,C++下一个版本的新功能有一部分就是从boost库中拿出来,在改造一下,放到C++标准库中的。我们的shared_ptr和weak_ptr都是当时从boost库中拿出来的。其中weak_ptr的诞生是专门为了解决shared_ptr的循环引用问题的。
我们来看下面这个场景:
#include <memory>
#include <iostream>class B;class A {
public:std::shared_ptr<B> b_ptr;~A() { std::cout << "A destroyed" << std::endl; }
};class B {
public:std::shared_ptr<A> a_ptr;~B() { std::cout << "B destroyed" << std::endl; }
};int main() {std::shared_ptr<A> a = std::make_shared<A>();std::shared_ptr<B> b = std::make_shared<B>();a->b_ptr = b; // A 持有 B 的 shared_ptrb->a_ptr = a; // B 持有 A 的 shared_ptr// 此时引用计数:// a 的引用计数:2(main 中的 a 和 b->a_ptr)// b 的引用计数:2(main 中的 b 和 a->b_ptr)// 退出作用域时,a 和 b 的引用计数仅减一,无法归零return 0;
}
我们结合图片来理解一下。初始状态是这样的:
接下来我们执行 a->b_ptr = b; b->a_ptr = a; 这两句代码:
当程序结束后,a和b销毁,但是开辟的空间并没有释放:
现在这两块空间的引用计数都是1,如果想释放A的话,必须让A的引用计数减到0,那么也就是让B不再指向他,也就是说B要先销毁。可是B销毁同样要求引用计数减到0,也就是A先销毁。这样就构成了循环引用,到最后谁都销毁不了,导致内存泄漏。
解决办法就是把类里面的shared_ptr换成weak_ptr,因为weaf_ptr不会增加引用计数。
#include <memory>
#include <iostream>class B;class A {
public:std::weak_ptr<B> b_ptr;~A() { std::cout << "A destroyed" << std::endl; }
};class B {
public:std::weak_ptr<A> a_ptr;~B() { std::cout << "B destroyed" << std::endl; }
};int main() {std::shared_ptr<A> a = std::make_shared<A>();std::shared_ptr<B> b = std::make_shared<B>();a->b_ptr = b; // A 持有 B 的 shared_ptrb->a_ptr = a; // B 持有 A 的 shared_ptrreturn 0;
}
weak_ptr必须从shared_ptr或者其他的weak_ptr来初始化:
std::shared_ptr<int> shared = std::make_shared<int>(42);
std::weak_ptr<int> weak(shared); // 从 shared_ptr 初始化
另外,如果想要访问weak_ptr指向的东西,是不能直接通过*和->来访问的,因为他根本就没有重载这两个符号。我们可以通过lock来获取一个新的shared_ptr,并通过这个新的shared_ptr来访问。
std::shared_ptr<int> shared = weak.lock();
if (shared) {// 对象仍存在,可使用 shared
} else {// 对象已被释放
}
同时,我们也可以用另一个接口expired来查看weak_ptr指向的内容是否过期,也就是说是否被销毁:
if (weak.expired()) {// 对象已无效
}
3、内存泄漏
我们在工程中会大量使用智能指针,需要拷贝构造就使用shared_ptr,不需要就使用unique_ptr,如果涉及循环引用就使用weak_ptr。说到底,使用智能指针就是为了减少内存泄漏的场景。
内存泄漏是指,我们开辟了一块内存,当时并没有释放他,造成这块内存的浪费。
实际上,在普通的练习程序中,内存泄漏无伤大雅, 因为随着程序进程的结束这块内存最终是会被释放的。而且现在的电脑普遍内存都很大了,申请的那点内存也不算什么。但是对于一个长期运行的项目来说,比如一个app,或者操作系统,内存泄漏会积小成多,最终造成程序卡死。所以说,智能指针的使用是非常必要的。我们在日常练习中,也要养成良好习惯,尽量避免内存泄漏。
好了,今天的内容就分享到这,我们下期再见!