C++智能指针详解:告别内存泄漏,拥抱安全高效
本文系统介绍了C++智能指针的设计原理与使用场景。文章首先分析了手动管理内存的痛点(内存泄漏、野指针等),引出RAII原则和智能指针的价值。重点讲解了四种智能指针:auto_ptr(管理权转移,已弃用)、unique_ptr(独占所有权)、shared_ptr(引用计数)和weak_ptr(解决循环引用)。通过代码示例演示了各智能指针的特性和模拟实现,特别强调了shared_ptr的线程安全问题(
✨✨小新课堂开课了,欢迎欢迎~✨✨
🎈🎈养成好习惯,先赞后看哦~🎈🎈
所属专栏: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 :
// RAII
SmartPtr(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管理的对象时,需要额外的同步措施。
创作不易,还请各位大佬支持~
更多推荐



所有评论(0)