C++:智能指针
C++:智能指针
- 内存泄漏
- RAII
- 智能指针
- auto_ptr
- unique_ptr
- shared_ptr
- 循环引用
- weak_ptr
- deleter
- shared_ptr
- unique_ptr
内存泄漏
内存泄漏是指程序在动态分配内存后,忘记或无法释放已经不再使用的内存,从而导致系统内存资源被逐渐耗尽的问题。这种情况下,即使程序本身并没有出现逻辑错误,也会因为内存泄漏而导致程序运行时间越来越长,甚至最终崩溃。
案例分析:
下面是一个典型的 C++ 内存泄漏案例:
while (true) { int* p = new int(10); // 这里忘记释放内存,导致内存泄漏 }
在这个程序中,我们在一个无限循环中不断分配新的内存块。然而,我们忘记在循环体内释放这些内存块,导致程序运行时内存占用会越来越大,直到最终耗尽系统内存,程序崩溃。
这种情况下,即使程序本身没有逻辑错误,也会因为内存泄漏而导致严重的问题。
内存泄漏的危害:
- 系统内存资源被逐渐耗尽:内存泄漏会导致程序运行时内存占用越来越大,直到最终耗尽系统内存。这会严重影响程序的性能和稳定性。
- 程序可能会崩溃:内存耗尽后,程序会尝试访问非法内存地址,从而导致程序崩溃。
- 资源浪费:即使程序没有崩溃,内存泄漏也会导致大量内存资源被无谓地占用,造成资源浪费。
内存泄漏是 C++ 程序中一个非常常见的问题,如果不能及时发现并修复,会对程序的性能、稳定性和安全性造成严重影响。
RAII机制就是一种自动管理资源的机制,其可以帮助程序员自动释放资源,来避免内存泄漏,C++中,智能指针就是基于RAII产生的。
RAII
RAII (Resource Acquisition Is Initialization) 是 C++ 中一种非常重要的内存管理机制,它可以帮助我们有效地管理资源,避免内存泄漏等问题。
RAII 的核心思想是:
- 将资源的分配和释放绑定在对象的生命周期上。
- 在对象构造时获取资源,在对象析构时释放资源。
以下示例就是一个基本的RAII:
template class smartPtr { public: smartPtr(T* ptr) :_ptr(ptr) {} ~smartPtr() { delete _ptr; } private: T* _ptr; }; int main() { smartPtr ptr1 = new int(5); smartPtr ptr2 = new double(3.14); smartPtr ptr3 = new vector(10); return 0; }
在上述示例中,smartPtr 类负责管理资源的获取和释放。在 main 函数中,我们创建了三个 smartPtr 对象 ptr1、 ptr2、 ptr3。
当这些对象进入作用域时,构造函数会被调用,将smatrPtr的_ptr成员初始化为对应动态内存的指针。
当三个对象离开作用域时,析构函数会被自动调用,自动delete _ptr释放资源。
通过 RAII 机制,我们可以确保资源一定会被正确释放,避免了手动释放资源时忘记的问题。这种做法也使得代码更加简洁易读,并且可以实现异常安全性。
这样做的好处是:
- 确保资源一定会被正确释放,避免了手动释放资源时忘记的问题。
- 资源的获取和释放过程被封装在对象的构造和析构函数中,使得代码更加简洁易读。
- 可以利用 RAII 机制实现异常安全性,即使程序抛出异常,资源也能被正确释放。
而以上案例,就是C++智能指针最根本的原理,C++一共提供了四种智能指针:auto_ptr、unique_ptr、shared_ptr、weak_ptr。
智能指针
讲解这四种智能指针之前,我们先看看我刚刚案例中的smartPtr存在的问题:
template class smartPtr { public: smartPtr(T* ptr) :_ptr(ptr) {} ~smartPtr() { delete _ptr; } private: T* _ptr; }; int main() { smartPtr ptr1 = new int(5); smartPtr ptr2 = ptr1; return 0; }
在main函数中,先构造了ptr1,指向一个int类型的动态内存。随后拿ptr1拷贝构造出了ptr2,此时ptr1和ptr2都指向这个int的动态内存。那么此时就要面临一个问题:当ptr1和ptr2出了作用域,那么两个对象都会调用析构函数,导致同一块内存被delete两次!这会直接导致进程崩溃,C++的智能指针需要解决这个问题。
另外的,智能指针本质上是一个类,不自带*,->等操作,所以要对操作符进行重载:
T operator*() { return *_ptr; } T* operator->() { return _ptr; }
*,->等操作本质都是在对_ptr做操作,至于为什么要这样操作,可见博客[C++:迭代器的封装思想]。
接下来我们正式讲解C++自带的各种智能指针:
C++的智能指针,包含在头文件中
讲解以下四个智能指针时,默认包含了头文件。
auto_ptr
auto_ptr是C++标准中,最早出现的智能指针,属于C++98。
构造函数:
auto_ptr自带一个通过指针来进行初始化的构造函数,但是其被explicit修饰,这说明不允许进行类型转换:
auto_ptr ptr1(new int(5));//正确 auto_ptr ptr2 = new int(10);//错误
auto_ptr只允许通过第一种方式,直接构造,第二种方式的本质是类型转换,从int*转为auto_ptr,只要有对应类型的构造函数那么C++就可以支持这个类型转换,但是如果构造函数被explicit修饰,这个类型转换功能就会被禁止,而auto_ptr就被禁止了。
其实,C++的四种智能指针,都不允许通过原生指针的类型转化来构造,也就是四种智能指针都只能通过小括号来初始化。
不过拷贝构造是允许的:
auto_ptr ptr1(new int(5)); auto_ptr ptr3(ptr1); auto_ptr ptr2 = ptr1;
后两种拷贝方式,都是正确的,因为拷贝构造没有被explicit修饰。
提到拷贝,我们刚讲到RAII中,如果多个类指向同一块空间,会导致资源重复释放的问题,那么auto_ptr是如何解决的呢?
当auto_ptr发生拷贝,原先的auto_ptr会变成空指针
为了方便观察,我们需要得知指针指向的地址:auto_ptr可以get接口看到内部存储的地址。
示例:
auto_ptr ptr1(new int(5)); auto_ptr ptr2 = ptr1; cout 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}); cout 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}); cout 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}); cout 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}); cout public: ListNode(int val) : _prev(nullptr) , _next(nullptr) , _val(val) {} ListNode* _prev; ListNode* _next; int _val; }; int main() { ListNode* l1 = new ListNode(5); ListNode* l2 = new ListNode(10); l1-_next = l2; l2-_prev = l1; delete l1; delete l2; return 0; } public: ListNode(int val) : _prev(nullptr) , _next(nullptr) , _val(val) {} shared_ptr shared_ptr public: ListNode(int val) : _prev(nullptr) , _next(nullptr) , _val(val) {} ListNode* _prev; ListNode* _next; int _val; };
p但是现在存在一个问题:/p pre class="brush:python;toolbar:false"l1-_next = l2; l2->_prev = l1;这两天语句是错误的,因为l2的类型是shared_ptr,而l1->_next的类型是ListNode*,shared_ptr的类型转换是被禁止的,所以只能通过get接口:
l1->_next = l2.get(); l2->_prev = l1.get();
这样未免太不简洁,此时就需要我们的最后一个智能指针weak_ptr出场了。
weak_ptr
weak_ptr是一种不参与资源管理的智能指针,其只存在三种构造函数:
- 无参默认构造,此时weak_ptr初始化为空指针
- 拷贝构造,拷贝其它weak_ptr
- 通过shared_ptr初始化,此时shared_ptr和weak_ptr指向同一块内存
当shared_ptr和weak_ptr指向同一块内存的时候,weak_ptr不会增加引用计数!
weak_ptr离开作用域的时候,不会释放自己指向的资源,其只负责访问资源。特点在于可以通过shared_ptr初始化。
因此刚刚的循环引用可以被优化为:
class ListNode { public: ListNode(int val) : _val(val) {} weak_ptr _prev; weak_ptr _next; int _val; };
要注意的是,weak_ptr不支持原生指针初始化,哪怕是nullptr也不可以,因此在ListNode的初始化列表中,删掉了_next和_prev的初始化。
现在以下代码就完全合法了:
shared_ptr l1(new ListNode(5)); shared_ptr l2(new ListNode(10)); l1->_next = l2; l2->_prev = l1;
weak_ptr可以通过shared_ptr初始化,因此可以直接将shared_ptr赋值给weak_ptr,又由于weak_ptr不参与计数,最后只要l1,l2离开作用域,空间就会被正常释放。
deleter
对于智能指针,有时候需要用特殊的方式来对资源进行释放,比如文件指针:
shared_ptr fp(fopen("test.txt", "w"));
对于指针fp,不能简单地delete pf,而是通过fclose(pf),此时我们就要用到自定义删除器deleter了。
对于shared_ptr和unique_ptr,两者的deleter语法不太相同,此处分开讲解:
shared_ptr
shared_ptr的语法为:
shared_ptr p(new T, deleter_function);
其中, deleter_function是一个满足删除器要求的可调用对象,包括函数指针,仿函数,lambda三种。
比如通过lambda来完成文件的fclose:
shared_ptr fp(fopen("test.txt", "w"), [](FILE* ptr) { fclose(ptr); });
通过仿函数:
struct deleteFile { void operator()(FILE* ptr) { fclose(ptr); } }; int main() { shared_ptr fp(fopen("test.txt", "w"), deleteFile()); return 0; }
也就是说,对于shared_ptr,只需要把删除器的可调用对象,直接作为第二个参数传入即可。
unique_ptr
unique_ptr的删除器语法比较别扭,要求在模板参数中传入可调用对象的类型。
unique_ptr p(new T, 可调用对象);
同样的,可调用对象支持函数指针,仿函数,lambda三种。
以刚刚的关闭文件为例:
- 使用函数指针:
void deleteFunc(FILE* ptr) { fclose(ptr); } int main() { unique_ptr fp2(fopen("test.txt", "w"), deleteFunc); return 0; }
该函数指针的类型为void(*)(FILE*),作为unique_ptr的第二个模板参数。
- 使用仿函数:
struct deleteFile { void operator()(FILE* ptr) { fclose(ptr); } }; int main() { unique_ptr fp(fopen("test.txt", "w"), deleteFile()); return 0; }
仿函数的类型是deleteFile,即类名,作为unique_ptr的第二个模板参数。
- 使用lambda表达式:
auto expression = [](FILE* ptr) { fclose(ptr); }; unique_ptr fp(fopen("test.txt", "w"), expression);
这里, expression是一个lambda表达式,由于lambda的类型是随机的,只能通过decltype(expression)来检测类型,作为unique_ptr的第二个模板参数。