【c++11】智能指针 -- 摆脱内存困扰,现代编程的智能选择
🌟🌟作者主页:ephemerals__
🌟🌟所属专栏:C++
前言
在软件开发的世界里,资源的有效管理至关重要,尤其是在处理动态分配的内存时。稍不留神,内存泄漏就会像潜伏的幽灵,悄无声息地消耗系统资源,最终导致程序崩溃或性能下降。
现代编程语言和技术为我们提供了更智能的工具来应对这一挑战。其中一种优雅的解决方案就是“智能指针”。它们通过自动化资源回收过程,将开发者从繁琐的手动内存管理中解放出来。
本文将深入探讨智能指针的概念及其在实践中的应用。你将了解到它们是如何工作的,以及如何在你的项目中利用它们来编写更健壮、更可靠的代码,从而告别那些令人头疼的内存管理问题。让我们一起探索智能指针的奥秘,提升我们的编程效率和代码质量。
一、RAII设计思想
先看一段代码:
#include <iostream>
using namespace std;void func()
{int* p1 = new int[10]{0};float* p2 = new float[10]{0};int x = -1;cin >> x;if(x == -1) throw x;delete p1;delete p2;
}int main()
{try{func();}catch(const exception& e){std::cerr << e.what() << endl;}return 0;
}
试想,在上述代码中,如果p1动态申请失败,抛出异常会怎么样?
程序会自动处理异常,不会造成内存泄漏问题。
如果p2动态申请失败会怎么样?
此时p1肯定动态申请了内存,而程序会直接跳到异常处理的位置,并没有释放内存,就会导致内存泄漏。
如果输入x错误抛出异常会怎么样?
此时p1、p2都动态申请了内存,程序跳到异常处理处,两者都没有释放内存,导致内存泄漏。
针对这些问题,如果在异常处理逻辑中释放内存,虽然能解决问题,但会增加代码冗余,并且如果有更多内存需要申请,代码就会更加杂乱。因此,就有了RAII设计思想。
什么是RAII
RAII是“Resource Acquisition Is Initialization”的缩写,其核心思想是将资源的生命周期与对象的生命周期绑定在一起。在我们获取资源时,将资源委托给一个对象,让该对象访问并控制资源。随着对象的生命周期结束,通过析构函数自动释放资源,就有效避免了内存泄漏问题。
注:这里的“资源”可以是动态申请的内存、文件指针、互斥锁等等。
因此,刚才的问题当中,如果抛出异常,程序就会直接退出func函数,跳到主函数的异常处理部分。如果将资源委托给RAII对象,那么它就会在跳转之前调用析构,从而释放资源。
二、智能指针
在C++当中,“智能指针”就是RAII设计思想的具体体现。
接下来,我们写一个简单的智能指针,感受一下RAII思想的妙处:
template<class T>
class SmartPtr
{
public://构造函数接收资源地址SmartPtr(T* ptr):_ptr(ptr){}//析构函数,释放资源~SmartPtr(){delete _ptr;}//一些重载函数,支持类似于指针的操作T& operator*(){return *_ptr;}T* operator->(){return _ptr;}T& operator[](int i){return _ptr[i];}
private:T* _ptr;
};
使用示例:
SmartPtr<int> p1(new int[10]{0});
SmartPtr<float> p2(new float[10]{0});
这样,随着p1、p2离开作用域,生命周期结束,就会自动调用析构函数,释放申请的内存资源。
标准库的智能指针
刚才我们实现的智能指针有一个巨大的问题:如果要进行拷贝,默认生成的拷贝构造会让两个智能指针指向同一份资源,这样如果一个智能指针的生命周期结束后,会释放该资源,而等到另一个指针释放资源时,就会导致同一块资源多次释放的问题。
那么,如果我们手动实现深拷贝呢?也不可行,因为我们不知道指向的内存空间有多大,是连续的还是非连续的。因此还得支持浅拷贝,通过某些机制解决问题。
为此,C++标准库也设计了几种智能指针,针对拷贝问题的应对方式各有不同,接下来让博主一一讲解。
注:使用标准库的智能指针时,需要引头文件
<memory>
;标准库智能指针初始化时不能用赋值符号,因为不支持隐式类型转换。
auto_ptr
auto_ptr
是C++98设计的智能指针,也是第一代智能指针。当auto_ptr
间发生拷贝时,它的应对措施是:将原指针指向的资源移动给新指针。这就会导致原指针失效,后续使用时,稍不注意就会出现错误。因此,auto_ptr
是一个妥妥的败笔,强烈建议不使用。
unique_ptr
unique_ptr
是C++11提出的智能指针,它的特点是要求一份资源仅被一个unique_ptr
维护,而不能是多个unique_ptr
指向同一份资源。因此,它不支持拷贝,仅支持移动。这样就有效地避免了多次释放等问题。在不需要地址拷贝的场景下,非常建议使用unique_ptr
。
#include <iostream>
#include <memory>
using namespace std;int main()
{unique_ptr<int> p1(new int);unique_ptr<int> p2(p1); // 报错,不可拷贝unique_ptr<int> p3(move(p1)); // 仅支持移动return 0;
}
为了更安全、更高效地创建unique_ptr
所管理的对象,C++14引入了一个函数make_unique
,用于创建一个unique_ptr
对象。使用示例:
#include <iostream>
#include <memory>
using namespace std;class MyClass
{
public:MyClass(int x, int y) :_x(x), _y(y) {}
private:int _x;int _y;
};int main()
{unique_ptr<MyClass> p1 = make_unique<MyClass>(3, 5); // 用make_unique构造对象并赋值return 0;
}
shared_ptr
shared_ptr
也是C++11提出的智能指针,它可以支持多个shared_ptr
指向同一份资源,但析构时不会造成多次释放问题(底层使用引用计数实现,引用计数表示指向这份资源的shared_ptr
的个数,当引用计数为0时才会释放资源)。
当然,shared_ptr
也具有对应的make_shared函数。
使用示例:
#include <iostream>
#include <memory>
using namespace std;class MyClass
{
public:MyClass(){cout << "调用构造函数" << endl;}~MyClass(){cout << "调用析构函数" << endl;}
};int main()
{shared_ptr<MyClass> p1 = make_shared<MyClass>();{shared_ptr<MyClass> p2 = p1;cout << "拷贝给p2" << endl;{shared_ptr<MyClass> p3 = p1;cout << "拷贝给p3" << endl;cout << "p3析构" << endl;}cout << "p2析构" << endl;}cout << "p1析构" << endl;return 0;
}
运行结果:
可以看到,当所有指向该资源的shared_ptr
全部都析构时,才会将资源进行释放。
接下来,我们手动实现一个简单的shared_ptr
,体会其引用计数机制的妙处。
手动实现shared_ptr
之前提到shared_ptr
是用引用计数的方式实现的,不同的shared_ptr
,针对同一份资源,使用同一个引用计数。
要让不同的shared_ptr
使用同一份引用计数,你首先想到的可能是使用static成员。但这种做法是错误的,为什么呢?因为引用计数是针对资源而言的,表示的是指向该资源的shared_ptr
的个数。因此,正确做法是:在构造shared_ptr
的同时new一个整形变量,表示引用计数。发生拷贝时,将这个引用计数作为公共资源,新的shared_ptr
也指向它;析构时,将引用计数-1,此时如果减为0,说明没有shared_ptr
指向公共资源了,就将资源和引用计数一起释放。 这样引用计数就可以和资源绑在一起,多个shared_ptr
对象也可以操作同一份引用计数了。
代码如下:
template<class T>
class SharedPtr
{
public://构造函数explicit SharedPtr(T* ptr = nullptr):_ptr(ptr), _pcount(new int(1)) // 动态申请引用计数{}//拷贝构造SharedPtr(const SharedPtr<T>& sp):_ptr(sp._ptr), _pcount(sp._pcount) // 多个指针指向同一份引用计数{(*_pcount)++; // 引用个数+1}//析构函数~SharedPtr(){release();}//负责引用计数的减少和释放void release(){(*_pcount)--;if(*_pcount == 0) // 如果减到0,说明没有SharedPtr指向该资源了,释放引用计数和资源{if(_ptr) delete _ptr; // 防止空指针释放delete _pcount;}}//赋值重载SharedPtr<T>& operator=(const SharedPtr<T>& sp){//如果不是自己给自己赋值,就调用拷贝构造的逻辑if(*this != sp){release(); // 当前指针不在指向原来的资源,所以要release一次//重新赋值_ptr = sp._ptr;_pcount = sp._pcount;(*_pcount)++;}return *this;}T& operator*(){return *_ptr;}T* operator->(){return _ptr;}
private:T* _ptr; // 指向资源int* _pcount; // 指向引用计数
};
测试:
#include <iostream>
using namespace std;class MyClass
{
public:MyClass(){cout << "调用构造函数" << endl;}~MyClass(){cout << "调用析构函数" << endl;}
};int main()
{{SharedPtr<MyClass> p1(new MyClass());{SharedPtr<MyClass> p2 = p1;cout << "拷贝给p2" << endl;{SharedPtr<MyClass> p3 = p1;cout << "拷贝给p3" << endl;cout << "p3析构" << endl;}cout << "p2析构" << endl;}cout << "p1析构" << endl;}return 0;
}
运行结果:
weak_ptr
weak_ptr
是C++11提出的,但它不支持RAII,专门用于解决shared_ptr
的循环引用问题。
什么是循环引用
先来看一段代码:
#include <iostream>
#include <memory>
using namespace std;//双向链表节点
struct ListNode
{int data;shared_ptr<ListNode> prev;shared_ptr<ListNode> next;
};int main()
{shared_ptr<ListNode> p1(new ListNode);shared_ptr<ListNode> p2(new ListNode);p1->next = p2;p2->prev = p1;return 0;
}
这里我们用shared_ptr
创建了一个双向链表的节点,并分别用两个shared_ptr
维护两个节点,并且将两个节点连接起来。这段代码会出现内存泄漏问题。为什么呢?让我们分析一下:
首先,p1和p2分别维护一个链表节点,此时它们的引用计数各为1。
接下来,连接两个节点,此时节点1的next指向节点2,节点2的prev指向节点1,再加上p1和p2,它们的引用计数都为2。
现在,p1的生命周期结束,节点1的引用计数变为1;p2的生命周期结束,节点2的引用计数变为1。
此时两个指针都已经销毁了,但是两个节点并没有释放。想要释放节点1,就要先释放节点2,节点2的prev才会释放;想要释放节点2,就要先释放节点1,节点1的next才会释放…这样无限循环,谁也不放过谁,就出现了循环引用,导致内存泄漏。
weak_ptr
解决循环引用问题
weak_ptr
不支持RAII,也不支持访问资源(没有operator*和operator->),但是通过weak_ptr
就可以解决循环引用的问题。它的特点是:无法直接指向资源,只能接收shared_ptr
。此时weak_ptr
和shared_ptr
虽然指向同一份资源,但 weak_ptr
并不会参与引用计数。 在刚才的代码中,如果我们将链表节点的成员指针改成weak_ptr
,由于weak_ptr
不参与引用计数,所以p1和p2释放后,引用计数变为0,直接释放节点,就不会出现循环引用了。
有两个问题:
weak_ptr
既然不支持访问资源,那么作为链表的指针域还有什么意义呢? 其实weak_ptr
可以间接性的访问资源。weak_ptr
的成员函数lock可以返回一个shared_ptr
,它指向的是weak_ptr
指向的资源,使用这个shared_ptr
就可以进行资源访问。shared_ptr
不参与引用计数,也不支持RAII,那么如果其维护的资源已经释放了,如何判断呢?weak_ptr
的成员函数成员函数expired可以帮助我们判断其指向的资源是否已经释放(如果已经被释放,返回true)。当然,如果已经被释放,lock也会返回一个空对象。
定制删除器
刚才我们学的unique_ptr
和shared_ptr
,它们在释放资源时默认使用delete进行释放,这也就意味着如果我们申请了多个资源或者将文件/网络套接字作为资源,就会在析构时出现错误。
因此,在定义智能指针时,如果我们需要申请多个资源或打开文件,那么就需要传入对应的删除/关闭规则,定制删除器。
unique_ptr
定制删除器示例:
#include <iostream>
#include <memory>
using namespace std;template<class T>
class delete_array
{void operator()(T* p){delete[] p;}
};class close_file
{void operator()(FILE* pf){fclose(pf);}
};template<class T>
void delete_arr(T* ptr)
{delete[] ptr;
}int main()
{//unique_ptr,在模板传入可调用对象的类型,定制删除器unique_ptr<int, delete_array<int>> p(new int[10]);unique_ptr<FILE, close_file> pf(fopen("xxx.txt", "r"));//如果传入函数指针,需要在模板中传入函数类型,并在构造函数的第二个参数中传入函数地址unique_ptr<int, void(int*)> p2(new int[10], delete_arr<int>);//如果传入lambda表达式,需要在模板中传入lambda的类型,并且在构造函数的第二个参数中传入lambdaauto del = [](int* ptr){ delete[] ptr; };unique_ptr<int, decltype(del)> p3(new int[10], del);//多个资源的申请也可以使用针对数组的特化版本unique_ptr<int[]> p4(new int[10]);return 0;
}
shared_ptr
定制删除器示例:
#include <iostream>
#include <memory>
using namespace std;template<class T>
void delete_arr(T* ptr)
{delete[] ptr;
}int main()
{//shared_ptr,在构造函数的第二个参数中传入可调用对象,定制删除器shared_ptr<int> p(new int[10], [](int* ptr){ delete[] ptr; });shared_ptr<FILE> pf(fopen("xxx.txt", "r"), [](FILE* ptr){ fclose(ptr);});//也可以直接传入删除函数的地址shared_ptr<int> p2(new int[10], delete_arr<int>);//多个资源的申请也可以使用针对数组的特化版本shared_ptr<int[]> p3(new int[10]);return 0;
}
吐槽一下,这两种智能指针定制删除器的方法居然完全不一样,一个是在模板中,一个是传给构造函数,用的时候很容易混淆。并且各种可调用对象的传入方式也不尽相同,实际开发中还是使用仿函数比较方便。
补充知识
unique_ptr
和shared_ptr
的成员函数get可以帮我们获取到底层的原生指针,需要时可以使用它。shared_ptr
和weak_ptr
的成员函数use_count可以帮我们获取到当前资源的引用计数数量。
接下来,基于我们刚才学习的删除器定制,以及一些成员函数,再略微完善一下我们手动实现的shared_ptr
:
#include <iostream>
#include <functional>
#include <cassert>
using namespace std;template<class T>
class SharedPtr
{
public://构造函数explicit SharedPtr(T* ptr = nullptr):_ptr(ptr), _pcount(new int(1)) // 动态申请引用计数{}//显式传入删除器的重载构造template<class D>explicit SharedPtr(T* ptr = nullptr, D del):_ptr(ptr), _pcount(new int(1)) // 动态申请引用计数, _del(del){}//拷贝构造SharedPtr(const SharedPtr<T>& sp):_ptr(sp._ptr), _pcount(sp._pcount) // 多个指针指向同一份引用计数, _del(sp._del) // 删除器拷贝{(*_pcount)++; // 引用个数+1}//析构函数~SharedPtr(){release();}//负责引用计数的减少和释放void release(){(*_pcount)--;if(*_pcount == 0) // 如果减到0,说明没有SharedPtr指向该资源了,释放引用计数和资源{if(_ptr) _del(_ptr); // 用删除器删除delete _pcount;}}//赋值重载SharedPtr<T>& operator=(const SharedPtr<T>& sp){//如果不是自己给自己赋值,就调用拷贝构造的逻辑if(*this != sp){release(); // 当前指针不在指向原来的资源,所以要release一次//重新赋值_ptr = sp._ptr;_pcount = sp._pcount;(*_pcount)++;}return *this;}//获取底层原生指针T* get(){return _ptr;}//获取引用计数int use_count(){return *_pcount;}T& operator*(){return *_ptr;}T* operator->(){return _ptr;}//[]重载T& operator[](int i){assert(i >= 0);return _ptr[i];}//支持将对象转换为bool类型,直接判断是否是空指针,例如if(p)explicit operator bool() const{return _ptr != nullptr;}
private:T* _ptr; // 指向资源int* _pcount; // 指向引用计数function<void(T*)> _del = [](T* ptr){ delete ptr; }; // 定制删除器,默认是delete
};
总结
通过本篇文章,我们从原理上理解了 C++ 资源管理的精髓。从 RAII 这一核心设计思想出发,我们看到了 auto_ptr
的历史局限,进而掌握了 unique_ptr
的独占所有权,以及 shared_ptr
如何通过引用计数实现共享所有权。特别是对循环引用的解析和 weak_ptr
的引入,为我们处理复杂对象关系提供了优雅的方案。
定制删除器则进一步展现了智能指针的强大和灵活性,使我们能以统一且安全的方式管理各种自定义资源。最终,无论是避免内存泄漏,还是提高代码的健壮性和可维护性,智能指针都无疑是现代 C++ 编程中不可或缺的利器。希望这些知识能够帮助大家在未来的 C++ 之旅中,写出更安全、更高效的代码。如果你觉得博主讲的还不错,就请留下一个小小的赞在走哦,感谢大家的支持❤❤❤