C++智能指针详解:告别内存泄漏,拥抱安全高效
✨✨小新课堂开课了,欢迎欢迎~✨✨
🎈🎈养成好习惯,先赞后看哦~🎈🎈
所属专栏:C++:由浅入深篇
小新的主页:编程版小新-CSDN博客
引言:为什么引入智能指针?
1.C++手动释放内存的痛点:
- 内存泄漏:忘记delete或异常导致未释放。
- 野指针:访问已经释放的资源。
- 重复释放:同一内存被释放多次。
- 资源泄漏:不仅限于内存。
- 代码复杂性与维护困难。
2.RAII(Resource Acquisition Is Initialization)原则:获取资源即初始化。
- 核心思想:将资源的生命周期绑定到对象的生命周期。
- 对象构造时获取资源,对象析构时自动释放资源。
3.智能指针作为RAII的实践者:
- 智能指针是类模板,封装了原始指针,顾名思义就是比原始指针更智能。
- 通过重载运算符(->,*)模拟原始指针的行为。
- 核心价值:在析构函数中自动释放管理的资源,确保资源安全释放。
- 引如现代C++标准(auto_ptr的教训与C++11的革新)。
一.智能指针的场景引入
在下面的程序中我们可以看到,new了以后,我们也delete了。但是new本身也有可能抛异常,如果是第一个那还好,array1未被成功分配,就无需释放资源,异常被捕获,无内存泄漏。但是如果第二个new失败,array1成功分配内存,array2抛异常,如果不做特殊处理,异常被main函数的catch捕获,array1的内存就泄漏了。在没有学智能指针之前,我们是按如下方式解决的,但是这让我们处理起来很麻烦。
double Divide(int a, int b)
{// 当b == 0时抛出异常if (b == 0){throw "Divide by zero condition!";} else{return (double)a / (double)b;}
}
void Func()
{int* array1 = new int[10];int* array2 = new int[10]; // 抛异常呢try{int len, time;cin >> len >> time;cout << Divide(len, time) << endl;} catch(...){cout << "delete []" << array1 << endl;cout << "delete []" << array2 << endl;delete[] array1;delete[] array2;throw; // 异常重新抛出,捕获到什么抛出什么}cout << "delete []" << array1 << endl;delete[] array1;cout << "delete []" << array2 << endl;delete[] array2;
}
int main()
{try{Func();}catch(const char* errmsg){cout << errmsg << endl;} catch(const exception & e){cout << e.what() << endl;} catch(...){cout << "未知异常" << endl;} return 0;
}
二.RAII和智能指针的设计思路
RAII是一种管理资源的类的设计思想,本质是一种利用对象生命周期来代管(做到共同管理)获取到的动态资源,避免资源泄漏,这里的资源可以是内存、文件指针、网络连接、互斥锁等等。
RAII在获取资源时把资源委托给一个对象,接着控制对资源的访问,资源在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源,这样保障了资源的正常释放,避免资源泄漏问题。
智能指针类除了满足了RAII的设计思路,还要方便了资源的访问,所以智能指针类还会像迭代器类一样,重载 operator*/operator->/operator[] 等运算符,方便访问资源。
下面我们就来看一下是怎么用智能智能解决上面new的问题的。
template<class T>
class SmartPtr
{public :// RAIISmartPtr(T* ptr): _ptr(ptr){}~SmartPtr(){cout << "delete[] " << _ptr << endl;delete[] _ptr;} // 重载运算符,模拟指针的行为,方便访问资源T & operator*(){return *_ptr;} T* operator->(){return _ptr;} T& operator[](size_t i){return _ptr[i];}
private:T* _ptr;
};
double Divide(int a, int b)
{// 当b == 0时抛出异常if (b == 0){throw "Divide by zero condition!";} else{return (double)a / (double)b;}
} void Func()
{// 这里使用RAII的智能指针类管理new出来的数组以后,程序简单多了//将资源的生命周期绑定到对象的生命周期//对象构造时获取资源,对象析构时自动释放资源SmartPtr<int> sp1 = new int[10];SmartPtr<int> sp2 = new int[10];for (size_t i = 0; i < 10; i++){sp1[i] = sp2[i] = i;} int len, time;cin >> len >> time;cout << Divide(len, time) << endl;
}
int main()
{try{Func();} catch(const char* errmsg){cout << errmsg << endl;} catch(const exception & e){cout << e.what() << endl;} catch(...){cout << "未知异常" << endl;} return 0;
}
通过前面对智能指针的简单了解,我们已经大概知道了智能指针就是帮助代管资源的,模拟指针的行为,访问修改资源。那么智能指针的行为应该就属于浅拷贝,浅拷贝有什么问题,导致多次析构资源,这个问题智能指针需要解决,接下来我们就开看看他是怎么解决这一问题的。
三.C++标准库智能指针的使用及原理
C++标准库中的智能指针都在<memory>这个头文件下面,我们包含<memory>就可以是使用了,智能指针有好几种,除了weak_ptr他们都符合RAII和像指针一样访问的行为。
原理上而言主要是解决智能指针拷贝时的思路不同。
auto_ptr
auto_ptr - C++ Reference是C++98时设计出来的智能指针,他的特点是拷贝时把被拷贝对象的资源的管理权转移给拷贝对象,这是一个非常糟糕的设计,因为他会导致被拷贝对象悬空,访问报错的问题。
struct Date
{int _year;int _month;int _day;Date(int year = 1, int month = 1, int day = 1):_year(year), _month(month), _day(day){}~Date(){cout << "~Date()" << endl;}
};int main()
{auto_ptr<Date> ap1(new Date);// 拷贝时,管理权限转移,被拷贝对象ap1悬空auto_ptr<Date> ap2(ap1);// 空指针访问,ap1对象已经悬空//ap1->_year++;return 0;
}
**视频演示**
auto_ptr屏幕录制
**原理**
拷贝时,资源管理权转移,ap2代管资源,被拷贝对象ap1悬空。
**模拟实现**
namespace xin
{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 = nullptr;}return *this;}~auto_ptr(){if (_ptr){cout << "delete:" << _ptr << endl;delete _ptr;}}// 像指针⼀样使⽤T & operator*(){return *_ptr;} T* operator->(){return _ptr;}private:T* _ptr;};
}
unique_ptr
unique_ptr - C++ Reference是C++11设计出来的智能指针,他的名字翻译出来是唯一的指针,他的特点的不支持拷贝,只支持移动。如果不需要拷贝的场景就非常建议使用他。
int main()
{unique_ptr<Date> up1(new Date);// 不支持拷贝//unique_ptr<Date> up2(up1);// 支持移动,但是移动后up1也悬空,所以使用移动要谨慎//因为移动构造有被掠夺资源的风险,这里默认是你知道//你自己move的,就说明你知道有风险,所有才说他们本质是设计思路的不同unique_ptr<Date> up3(move(up1));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;}}//不支持拷贝unique_ptr(const unique_ptr<T>& up) = delete;unique_ptr<T>& operator=(const unique_ptr<T>& up) = delete;//支持移动unique_ptr(unique_ptr<T>&& up):_ptr(up._ptr){up._ptr = nullptr;}unique_ptr<T>& operator=( unique_ptr<T>&& up){delete _ptr;_ptr = up._ptr;up._ptr = nullptr;}T& operator*(){return *_ptr;}T& operator->(){return _ptr;}private:T* _ptr;};
shared_ptr
shared_ptr - C++ Reference是C++11设计出来的智能指针,他的名字翻译出来是共享指针,他的特点是支持拷贝,也支持移动。如果需要拷贝的场景就需要使用他了。底层是用引用计数的方式实现的。
int main()
{shared_ptr<Date> sp1(new Date);// 支持拷贝shared_ptr<Date> sp2(sp1);shared_ptr<Date> sp3(sp2);cout << sp1.use_count() << endl;sp1->_year++;cout << sp1->_year << endl;cout << sp2->_year << endl;cout << sp3->_year << endl;// 支持移动,但是移动后sp1也悬空,所以使用移动要谨慎shared_ptr<Date> sp4(move(sp1));cout << sp4.use_count() << endl;return 0;
}
**视屏演示**
shared_ptr
**运行结果**
**原理**
他的特点是支持拷贝,也支持移动,底层是用引用计数的方式实现的。
引用计数就是统计有几个智能智能共同管理这块资源的,一个资源对应一个引用计数,不是sp1有一个自己的引用计数,sp2有一个自己的引用计数这种。看了图片大家就大概知道怎么理解引用计数了。这个跟操作系统里的文件系统里的硬链接,软链接计算引用计数那个挺像的。
智能指针析构时默认是用delete释放资源,这也就意味着如果不是new出来的资源,交给智能指针管理,析构时就会崩溃。但是因为new []经常使用,为了简洁一点,unique_ptr和shared_ptr都特化了一份[]的版本。
int main()
{//这样实现程序会崩溃/*unique_ptr<Date> up1(new Date[10]);shared_ptr<Date> sp1(new Date[10]);*/// 解决⽅案1// 因为new[]经常使⽤,所以unique_ptr和shared_ptr// 实现了⼀个特化版本,这个特化版本析构时用的delete[]unique_ptr<Date[]> up1(new Date[5]);shared_ptr<Date[]> sp1(new Date[5]);return 0;
}
智能指针支持在构造时给个删除器,所谓删除器本质就是一个可调用对象,这个可调用对象中实现你想要的释放资源的方式,当构造智能指针时,给了定制的删除器,在智能指针析构时就会调用删除器去释放资源。
template<class T>
void DeleteArrayFunc(T* ptr)
{delete[] ptr;
}template<class T>
class DeleteArray
{public :void operator()(T* ptr){delete[] ptr;}
};
class Fclose
{public :void operator()(FILE* ptr){cout << "fclose:" << ptr << endl;fclose(ptr);}
};int main()
{// 解决方案2// 仿函数对象做删除器// unique_ptr和shared_ptr支持删除器的方式有所不同// unique_ptr是在类模板参数支持的,shared_ptr是构造函数参数支持的// unique_ptr<Date, DeleteArray<Date>> up2(new Date[5], DeleteArray<Date>());// 这里没有使用相同的方式还是挺坑的// 使用仿函数unique_ptr可以不在构造函数传递,因为仿函数类型构造的对象直接就可以调用// 但是下面的函数指针和lambda的类型不可以unique_ptr<Date, DeleteArray<Date>> up2(new Date[5]);//可以不在构造函数传递shared_ptr<Date> sp2(new Date[5], DeleteArray<Date>());//在构造函数传递// 函数指针做删除器unique_ptr<Date, void(*)(Date*)> up3(new Date[5], DeleteArrayFunc<Date>);shared_ptr<Date> sp3(new Date[5], DeleteArrayFunc<Date>);// lambda表达式做删除器auto delArrOBJ = [](Date* ptr) {delete[] ptr; };//我们无法知道lambda的类型unique_ptr<Date, decltype(delArrOBJ)> up4(new Date[5], delArrOBJ);////但是这里要显示传类型,就用了decltype,其作用是查询表达式的类型shared_ptr<Date> sp4(new Date[5], delArrOBJ);// 实现其他资源管理的删除器shared_ptr<FILE> sp5(fopen("Test.cpp", "r"), Fclose());shared_ptr<FILE> sp6(fopen("Test.cpp", "r"), [](FILE* ptr) {cout << "fclose:" << ptr << endl;fclose(ptr);});return 0;
}
shared_ptr 除了支持用指向资源的指针构造,还支持 make_shared 用初始化资源对象的值直接构造。
shared_ptr 和 unique_ptr 都支持了operator bool的类型转换,如果智能指针对象是一个空对象没有管理资源,则返回false,否则返回true,意味着我们可以直接把智能指针对象给if判断是否为空。
int main()
{shared_ptr<Date> sp1(new Date(2024, 9, 11));shared_ptr<Date> sp2 = make_shared<Date>(2024, 9, 11);auto sp3 = make_shared<Date>(2024, 9, 11);shared_ptr<Date> sp4;//支持无参构造// if (sp1.operator bool())if (sp1)cout << "sp1 is not nullptr" << endl;if (!sp4)cout << "sp4 is nullptr" << endl;// 报错 因为它们的构造函数都不支持隐士类型转化//shared_ptr<Date> sp5 = new Date(2024, 9, 11);//unique_ptr<Date> sp6 = new Date(2024, 9, 11);return 0;
}
**模拟实现**
下面的代码中使用了atomic<int>而不是普通的int是为了实现线程安全的引用计数,后面会更详细介绍。注意这里是不能用static的,static成员是所有同一类型实例共享的,而不是每个资源独立的。
template<class T>
class shared_ptr
{
public:explicit shared_ptr(T* ptr = nullptr )//标准库里支持无参构造:_ptr(ptr),_pcount(new atomic<int>(1))//_pcount(new int(1)){}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){//最后一个管理的对象,释放资源_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;atomic<int>* _pcount; //原子操作//int* _pcount;function<void(T*)> _del = [](T* ptr) {delete ptr; };//包装器来包装删除器,默认使用lambda
};
四.循环引用和weak_ptr
shared_ptr导致的循环引用问题
shared_ptr大多数情况下管理资源非常合适,支持RAII,也支持拷贝。但是在循环引用的场景下会导致资源没得到释放内存泄漏,所以我们要认识循环引用的场景和资源没释放的原因,并且学会使用weak_ptr解决这种问题。
struct ListNode
{int _data;std::shared_ptr<ListNode> _next;std::shared_ptr<ListNode> _prev;~ListNode(){cout << "~ListNode()" << endl;}
};
int main()
{// 循环引⽤ -- 内存泄露shared_ptr<ListNode> n1(new ListNode);shared_ptr<ListNode> n2(new ListNode);cout << n1.use_count() << endl;cout << n2.use_count() << endl;n1->_next = n2;n2->_prev = n1;cout << n1.use_count() << endl;cout << n2.use_count() << endl;return 0;
}
没有析构,内存泄漏。
如上图所述场景,n1和n2析构后,管理两个节点的引用计数减到1
1. 右边的节点什么时候释放呢,左边节点中的_next管着呢,_next析构后,右边的节点就释放了。
2. _next什么时候析构呢,_next是左边节点的的成员,左边节点释放,_next就析构了。
3. 左边节点什么时候释放呢,左边节点由右边节点中的_prev管着呢,_prev析构后,左边的节点就释放了。
4. _prev什么时候析构呢,_prev是右边节点的成员,右边节点释放,_prev就析构了。
• 至此逻辑上成功形成回旋镖似的循环引用,谁都不会释放就形成了循环引用,导致内存泄漏。
weak_ptr版本:
struct ListNode
{int _data;// 这⾥改成weak_ptr,当n1->_next = n2;绑定shared_ptr时// 不增加n2的引用计数,不参与资源释放的管理,就不会形成循环引用了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.use_count() << endl;cout << n2.use_count() << endl;n1->_next = n2;n2->_prev = n1;cout << n1.use_count() << endl;cout << n2.use_count() << endl;return 0;
}
weak_ptr
weak_ptr - C++ Reference是C++11设计出来的智能指针,他的名字翻译出来是弱指针,他完全不同于上面的智能指针,他不支持RAII,也就意味着不能用它直接管理资源。
weak_ptr构造时不支持绑定到资源,只支持绑定到shared_ptr,绑定到shared_ptr时,不增加shared_ptr的引用计数,那么就可以解决上述的循环引用问题。
int main()
{shared_ptr<string> sp1(new string("111111"));shared_ptr<string> sp2(sp1);weak_ptr<string> wp = sp1;cout << wp.expired() << endl;cout << wp.use_count() << endl;// sp1和sp2都指向了其他资源,则weak_ptr就过期了sp1 = make_shared<string>("222222");cout << wp.expired() << endl;cout << wp.use_count() << endl;sp2 = make_shared<string>("333333");cout << wp.expired() << endl;cout << wp.use_count() << endl;return 0;
}
**原理**
**模拟实现**
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;
};
我们这里实现的shared_ptr和weak_ptr都是以最简洁的方式实现的, 只能满足基本的功能,这里的weak_ptr lock等功能是无法实现的,想要实现就要/把shared_ptr和weak_ptr一起改了,把引用计数拿出来放到一个单独类型,shared_ptr 和weak_ptr都要存储指向这个类的对象才能实现,有兴趣可以去翻翻源代码。
五.shared_ptr的线程安全问题
还记得我们在上面shared_ptr的模拟实现部分使用的atomic。原子操作(atomic operation)指的是在多线程环境下不会被中断的操作。这里的atomic<int>是C++11引入的原子类型,用于保证对引用计数的增减操作是原子性的,从而使得shared_ptr的引用计数在多线程环境下是线程安全的,当然这个也可以用加锁来实现。这个和操作系统处理访问临界资源的原理高度相似。
简单来说,就是shared_ptr的引用计数本身是线程安全的,但是shared_ptr管理的对象本身并不是线程安全的。因为多个线程同时修改同一个shared_ptr管理的对象时,需要额外的同步措施。
创作不易,还请各位大佬支持~