C++11 —— 智能指针
- 为什么需要智能指针
- 内存泄漏
- 什么是内存泄漏,内存泄漏的危害
- 内存泄漏分类
- 如何避免内存泄漏
- 智能指针的使用及原理
- RAII
- 智能指针的原理
- std::auto_ptr
- unique_ptr
- std::shared_ptr
- shared_ptr的线程安全问题
- 智能指针的历史
为什么需要智能指针
下面我们先分析一下下面这段程序有没有什么内存方面的问题?
int div()
{int a, b;cin >> a >> b;if (b == 0)throw invalid_argument("除0错误");return a / b;
}
void Func()
{// 1、如果p1这里new 抛异常会如何?// 2、如果p2这里new 抛异常会如何?// 3、如果div调用这里又会抛异常会如何?int* p1 = new int;int* p2 = new int;cout << div() << endl;delete p1;delete p2;
}
int main()
{try{Func();}catch (exception& e){cout << e.what() << endl;}return 0;
}
我们可以看到,上面的p1
和p2
都是int *
指针,如果new
操作抛出了异常,而且没有正确的处理异常,就可能会导致内存泄漏或者未定义行为,内存泄漏在C++11
是非常严重的问题,所以就得使用上节课介绍的try/catch
块来捕获异常。
int div()
{///
}void Func()
{int* p1 = nullptr;int* p2 = nullptr;try {p1 = new int;p2 = new int;}catch (exception& e) {cout << e.what() << endl;} if (p1) delete p1;if (p2) delete p2;
}int main()
{try {Func();}catch (exception& e) {cout << e.what() << endl;}return 0;
}
但是使用了try/catch
块来捕获异常就会导致我们的代码显得异常的繁杂,因为new
几次就得try
几次,所以,我们可以使用智能指针来解决这个问题。
内存泄漏
什么是内存泄漏,内存泄漏的危害
内存泄漏是指程序在动态分配内存后,在不需要使用该内存时未能及时释放,结果导致内存空间被一直占用,直到程序运行结束。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,造成了内存的浪费。
内存泄漏的危害:
- 性能下降:内存泄漏会导致程序占用的内存越来越多,从而降低程序的运行速度。
- 系统崩溃:严重的内存泄漏会导致程序占用的内存耗尽,从而引发系统崩溃或程序异常退出。
- 资源浪费:内存泄漏会导致系统中的可用内存越来越少,从而浪费系统资源。
代码示例
下面的代码示例展示了内存泄漏的一种情况:
void MemoryLeaks()
{// 1. 内存申请了忘记释放int* p1 = (int*)malloc(sizeof(int));int* p2 = new int;// 2. 异常安全问题int* p3 = new int[10];Func(); // 这里Func函数抛异常导致 delete[] p3未执行,p3没被释放delete[] p3;
}
在这个例子中,有两种情况会导致内存泄漏:
p1
和p2
指针指向的内存在函数退出时没有被释放,造成内存泄漏。- 如果
Func()
函数抛出异常,delete[] p3
语句将不会被执行,导致p3
指针指向的内存泄漏。
为了避免内存泄漏,我们需要在合适的时候释放不再使用的内存,并且要注意异常安全问题。
内存泄漏分类
C/C++程序中一般我们关心两种方面的内存泄漏:
堆内存泄漏(Heap Leak)
堆内存泄漏是指程序在堆上动态分配内存后,在不需要使用该内存时未能及时释放,导致该内存空间无法被重复利用。这种情况下,已分配但无法访问的内存块就会一直占用着,直到程序结束。
堆内存泄漏的主要原因包括:
- 忘记释放动态分配的内存: 使用
malloc
、calloc
或new
等函数在堆上分配内存后,忘记调用free
或delete
来释放内存。 - 丢失指向动态内存的指针: 如果程序丢失了指向动态分配内存的指针,就无法释放该内存。
- 异常处理不当: 如果在抛出异常的情况下没有释放内存,也会导致内存泄漏。
- 复杂数据结构的内存管理不善: 在链表、树等复杂数据结构中,如果内存管理不当也容易造成泄漏。
系统资源泄漏
系统资源泄漏指程序使用系统分配的资源(如套接字、文件描述符、管道等)后,没有及时关闭或释放,导致系统资源被占用而无法被其他程序使用。这种情况下,系统资源会被逐渐耗尽,严重影响系统性能和稳定性。
系统资源泄漏的主要原因包括:
- 忘记关闭打开的文件或释放其他系统资源。
- 在异常情况下没有正确处理和释放系统资源。
- 在复杂的控制流程中,某些分支没有释放资源。
如何避免内存泄漏
- 使用智能指针: C++11及以上版本提供了
unique_ptr
、shared_ptr
等智能指针,可以自动管理动态分配的内存,在指针销毁时自动释放内存。 - 尽量减少动态内存分配: 减少动态内存分配的数量可以降低内存泄漏的风险。
- 优先使用栈内存: 在可能的情况下,优先使用栈内存而不是堆内存。栈内存在函数返回时会自动释放。
- 养成良好的内存管理习惯: 在动态分配内存后,要及时检查是否成功分配。在不需要时立即释放内存。
- 使用内存分析工具: 可以使用
Valgrind
、AddressSanitizer
等工具来检测内存泄漏。 - 编写异常安全的代码: 在可能抛出异常的地方,要确保即使发生异常也能正确释放内存。
- 定期检查和优化内存使用: 在开发和测试过程中,要定期检查内存使用情况,发现并修复内存泄漏。
智能指针的使用及原理
RAII
RAII的基本概念
RAII (Resource Acquisition Is Initialization) 是一种 C++ 编程技术,旨在通过对象的生命周期管理资源(如内存、文件句柄、网络连接等),以确保资源在不再需要时能够被自动释放,从而避免资源泄漏。RAII的核心思想是将资源的获取和释放与对象的构造和析构绑定在一起。
在RAII中,当对象被构造时,它会获取所需的资源,并在对象析构时自动释放这些资源。
这种方法有两个主要好处:
- 自动管理资源:程序员不需要显式地释放资源,减少了出错的可能性。
- 资源有效性:在对象的生命周期内,所需的资源始终保持有效。
RAII的实现示例
以下是一个使用RAII思想设计的SmartPtr类示例:
template<class T>
class SmartPtr
{
public:SmartPtr(T* ptr = nullptr):_ptr(ptr){}~SmartPtr(){if (_ptr) delete _ptr;cout << "~SmartPtr" << endl;}
private:T* _ptr;
};
在上面实现的类中,SmartPtr
类负责管理动态分配内存的指针,当SmartPtr
对象创建时就动态分配内存,而当SmartPtr
对象被销毁的时候,它会自动释放其持有的内存。
异常安全性
RAII还提供了异常安全性。在 C++ 中,如果在函数执行过程中抛出异常,局部变量会被销毁,从而调用它们的析构函数。例如,在以下代码中,即使发生了除零错误,SmartPtr
仍然会确保内存被正确释放:
int div()
{int a, b;cin >> a >> b;if (b == 0){throw invalid_argument("除0错误");}return a / b;
}void Func()
{SmartPtr<int> sp1(new int);SmartPtr<int> sp2(new int);cout << div() << endl;
}int main()
{try {Func();}catch (const exception& e){cout << e.what() << endl;}return 0;
}
在这个示例中,即使div()
函数抛出异常,sp1
和sp2
的析构函数也会被调用,从而确保内存得到释放。
智能指针的原理
上述的SmartPtr
还不能将其称为智能指针,因为它还不具有指针的行为。指针可以解引用,也可以通过->
去访问所指空间中的内容,因此:AutoPtr
模板类中还得需要将*
、->
重载下,才可让其像指针一样去使用。
template<class T>
class SmartPtr
{
public:SmartPtr(T* ptr = nullptr):_ptr(ptr){}~SmartPtr(){if (_ptr) delete _ptr;cout << "~SmartPtr" << endl;}//禁止拷贝构造和拷贝赋值SmartPtr(const SmartPtr& t) = delete;SmartPtr& operator=(const SmartPtr& t) = delete;//移动构造和移动赋值SmartPtr(SmartPtr&& sp):_ptr(sp._ptr){sp._ptr = nullptr;}SmartPtr& operator=(SmartPtr&& sp){if (this != sp){delete _ptr;_ptr = sp._ptr;sp._ptr = nullptr;}return *this;}T* get() const{return _ptr;}T& operator*(){return *_ptr;}T* operator->(){return _ptr;}T& operator[](size_t i){return _ptr[i];}
private:T* _ptr;
};
int main()
{SmartPtr<int> p(new int[10]); //声明一个int的p指针p[0] = 10; //访问p的第一个元素,设为10cout << p.get() << endl; //get p的地址cout << p.operator*() << endl; //解引用,取第一个元素的值cout << p.operator->() << endl; //-> 取地址return 0;
}
移动构造:
SmartPtr<int> getp()
{SmartPtr<int> p(new int[10]);return p;
}int main()
{SmartPtr<int> p(new int[10]); //声明一个int的p指针cout << "---------------" << endl;SmartPtr<int> p2(getp());cout << "---------------" << endl;
}
注: 后构造的对象先被析构。
std::auto_ptr
C++98版本的库中就提供了auto_ptr
的智能指针。下面演示的auto_ptr
的使用及问题,std::auto_ptr文档。
auto_ptr
的实现原理:管理权转移的思想,下面简化模拟实现了一份auto_ptr
来了解它的原理。
#include <iostream>
using namespace std;template<class T>
class auto_ptr {
public:// 构造函数,接受一个指向动态分配内存的指针auto_ptr(T* ptr): _ptr(ptr) // 初始化成员变量 _ptr{}// 析构函数,释放所管理的资源~auto_ptr() {if (_ptr) {delete _ptr; // 释放动态分配的内存cout << "~auto_ptr()" << endl; // 输出析构信息}}// 拷贝构造函数,转移资源所有权auto_ptr(auto_ptr<T>& sp): _ptr(sp._ptr) // 将传入的 auto_ptr 的指针赋值给当前对象{sp._ptr = nullptr; // 将传入的 auto_ptr 的指针置为 nullptr,防止双重释放}// 移动赋值操作符,转移资源所有权auto_ptr<T>& operator()(auto_ptr<T>& sp) {if (this != &sp) { // 防止自我赋值if (_ptr) { // 如果当前对象有资源,先释放它delete _ptr;}// 转移资源所有权_ptr = sp._ptr; // 将传入的 auto_ptr 的指针赋值给当前对象sp._ptr = nullptr; // 将传入的 auto_ptr 的指针置为 nullptr,防止双重释放}return *this; // 返回当前对象的引用}// 箭头操作符,支持像指针一样访问成员T* operator->() {return _ptr; // 返回内部指针}// 解引用操作符,支持像指针一样解引用T& operator*() {return *_ptr; // 返回指向的对象引用}private:T* _ptr; // 管理的原始指针
};
但是auto_ptr
并不是一个好的设计,因为auto_ptr
的本质是管理权的转移,假设有sp1
和sp2
,然后吧sp1
转移给了sp2
,这时的sp1
指针处于悬空态,指向nullptr
,所以后续的sp1
就不能再继续被使用了!
int main()
{qq::auto_ptr<int> sp1(new int);qq::auto_ptr<int> sp2(sp1);*sp2 = 10;cout << "*sp1 = " << *sp1 << endl;cout << "*sp2 = " << *sp2 << endl;return 0;
}
unique_ptr
C++11中开始提供更靠谱的unique_ptr
- unique_ptr文档
unique_ptr
的实现原理:简单粗暴的防拷贝,下面简化模拟实现了一份unique_ptr
来了解它的原理:
template<class T>class unique_ptr{public:unique_ptr(T* ptr):_ptr(ptr){}~unique_ptr(){if (_ptr){delete _ptr;cout << "~unique_ptr" << endl;}}unique_ptr(unique_ptr<T>&& sp):_ptr(sp._ptr){sp._ptr = nullptr; //将源指针置为nullptr}unique_ptr& operator=(unique_ptr<T>&& sp){if (this != &sp) //防止自我赋值{delete _ptr; //释放当前的资源_ptr = sp._ptr; //转移sp._ptr = nullptr; //将源指针置为nullptr}return *this;}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;private:T* _ptr;};
实现一个class A
使用unique_ptr
来管理一个动态数组,这里使用库里面的unique_ptr
#include <iostream>
#include <memory>class A {
public:int _a1;int _a2;A() : _a1(0), _a2(0) {}
};int main() {// 使用 unique_ptr 管理一个 A 类型的动态数组std::unique_ptr<A[]> sp1(new A[10]);// 访问和修改数组元素sp1[0]._a1 = 1;sp1[0]._a2 = 2;std::cout << "sp1[0]._a1: " << sp1[0]._a1 << std::endl; // 输出 1std::cout << "sp1[0]._a2: " << sp1[0]._a2 << std::endl; // 输出 2return 0; // 当 main 函数结束时,sp1 会自动释放其管理的数组
}
std::shared_ptr
C++11中开始提供更靠谱的并且支持拷贝的 shared_ptr
通过一个例子来解释 shared_ptr
的工作原理
假设有一个图书馆,里面有很多书籍。每本书都有一个唯一的编号,比如 1
、2
、3
等等。每当有读者想要借阅一本书时,图书馆就会给这个读者一张借书证,上面写着这本书的编号。
现在,我们把这个图书馆比作内存,每本书就相当于内存中的一个资源。读者就相当于 shared_ptr 对象,借书证就相当于 shared_ptr
内部维护的引用计数。
- 当第一个读者借阅一本书时,图书馆会给他一张写有该书编号的借书证,并将该书的借阅次数设为
1
。这就相当于创建了第一个shared_ptr
对象,引用计数初始化为1
。 - 如果第二个读者也想借阅同一本书,图书馆就会再给他一张写有该书编号的借书证,并将该书的借阅次数加
1
。这就相当于创建了第二个shared_ptr
对象,引用计数变为2
。 - 当一个读者还书时,他会将借书证交回图书馆。图书馆会将该书的借阅次数减
1
。这就相当于一个shared_ptr
对象被销毁,引用计数减1
。 - 如果一本书的借阅次数变为
0
,说明没有人在使用这本书了。图书馆就可以将这本书放回书架。这就相当于引用计数变为0
,shared_ptr
会自动释放所管理的资源。 - 如果一本书的借阅次数不为
0
,说明还有读者在使用这本书。图书馆就不能将这本书放回书架,否则其他读者就无法继续借阅了。这就相当于引用计数不为0
,shared_ptr
不会释放资源。
所以,当新增一个对象管理这块资源时则将该资源对应的引用计数进行++
,当一个对象不再管理这块资源或该对象被析构时则将该资源对应的引用计数进行--
,当该资源-
到0
的时候就释放这个资源。
所以怎么来控制这里的计数呢?
这里采用使用
int* _pRefcount
来控制这里的计数问题!
因为,这里的计数是一个共享资源,当多个shared_ptr
实例指向同一个资源时,必须有一个机制来跟踪有多少个指针正在使用该资源!
所以使用int* _pRefcount
可以在堆上动态分配内存来存储这个引用计数。
手动模拟实现简单的shared_ptr
:
template<class T>
class shared_ptr {
public:// 构造函数shared_ptr(T* ptr): _ptr(ptr), _count(new int(1)) {} // 初始化指针和引用计数// 拷贝构造函数shared_ptr(const shared_ptr& sp): _ptr(sp._ptr), _count(sp._count) {(*_count)++; // 增加引用计数}// 赋值操作符shared_ptr& operator=(const shared_ptr& sp) {if (this != &sp) { // 防止自我赋值release(); // 释放当前资源_ptr = sp._ptr; // 转移指针_count = sp._count; // 转移引用计数(*_count)++; // 增加引用计数}return *this; // 返回当前对象的引用}// 析构函数~shared_ptr() {release(); // 释放资源}void release() {if (--(*_count) == 0) { // 减少引用计数并检查是否为0delete _ptr; // 释放资源delete _count; // 释放引用计数内存}}T& operator*() {return *_ptr; // 解引用操作符}T* operator->() {return _ptr; // 箭头操作符}int use_count() const { // 获取当前引用计数return *_count;}private:T* _ptr; // 指向资源的指针int* _count; // 引用计数指针
};
测试:
// 测试类 A
class A {
public:A() {cout << "A() 被构造." << endl;}~A() {cout << "~A() 被销毁." << endl;}
};// 测试函数
int main() {cout << "创建 shared_ptr sp1." << endl;shared_ptr<A> sp1(new A); // 创建一个 shared_ptr 管理 A 对象cout << "创建 shared_ptr sp2 从 sp1." << endl;shared_ptr<A> sp2(sp1); // 拷贝构造,引用计数增加cout << "sp1 的引用计数: " << sp1.use_count() << endl; // 输出引用计数cout << "sp2 的引用计数: " << sp2.use_count() << endl; // 输出引用计数return 0; // 当 main 函数结束时,所有共享的资源会被正确释放
}
shared_ptr的线程安全问题
是的,引用计数的加减是加锁保护或者使用原子类型来声明计数变量。但是指向资源不是线程安全的!
智能指针的历史
在 C++ 的发展历史中,智能指针的概念经历了多个阶段,从早期的 auto_ptr
到后来的 unique_ptr
、shared_ptr
和 weak_ptr
,其演变反映了 C++ 对内存管理和资源管理的不断改进。
早期的智能指针:auto_ptr
在 C++98 标准中,auto_ptr
是第一个引入的智能指针。它的设计目的是自动管理动态分配的内存,确保在对象超出作用域时能够自动释放资源。尽管 auto_ptr
提供了基本的内存管理功能,但它存在一些缺陷,例如:
- 拷贝语义问题:
auto_ptr
在拷贝时会转移所有权,而不是创建一个新的拷贝。这可能导致意外的双重释放和未定义行为。 - 不支持移动语义:在 C++11 之前,缺乏对移动语义的支持,使得资源管理不够灵活。
由于这些问题,auto_ptr 在 C++11 中被弃用,并被更先进的智能指针所取代。
Boost 库的贡献
在 C++11 发布之前,Boost 库提供了一系列更强大和灵活的智能指针,包括:
scoped_ptr
:用于管理局部对象生命周期,当对象超出作用域时自动释放。shared_ptr
:允许多个指针共享同一资源,通过引用计数来管理资源的生命周期。weak_ptr
:与 shared_ptr 配合使用,避免循环引用的问题。
Boost 的这些实现为 C++11 的标准库提供了重要的基础,使得智能指针的功能更加完善。
C++11 的智能指针
C++11 标准吸收了 Boost 库中的智能指针精华,推出了以下三种主要类型的智能指针:
std::unique_ptr
:
- 实现独占所有权,确保同一时间只有一个
unique_ptr
可以管理某个资源。 - 当
unique_ptr
超出作用域时,它会自动释放资源。 - 不支持拷贝,但支持移动语义。
-
std::shared_ptr
:
允许多个shared_ptr
实例共享同一资源,通过引用计数来跟踪资源的使用情况。
只有当最后一个指向该资源的shared_ptr
被销毁时,资源才会被释放。 -
std::weak_ptr:
作为对shared_ptr
的补充,不增加引用计数,用于打破循环引用。
可以安全地观察shared_ptr
管理的对象,但不拥有它。