在C++编程中,内存管理一直是一个棘手的问题。传统的手动内存管理(使用new
和delete
)容易导致内存泄漏、悬垂指针等问题。为了解决这些问题,C++引入了智能指针的概念。
智能指针的使用场景
传统内存管理的问题
考虑以下代码示例:
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 (...) {
delete[] array1;
delete[] array2;
throw;
}
delete[] array1;
delete[] array2;
}
这段代码存在几个问题:
- 如果
Divide
函数抛出异常,必须记得在catch
块中释放内存 - 如果
array2
的new
操作抛出异常,array1
的内存会泄漏 - 代码冗长且容易出错
智能指针解决方案
使用智能指针可以大大简化代码:
void Func() {
SmartPtr<int> sp1 = new int[10];
SmartPtr<int> sp2 = new int[10];
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
}
智能指针会自动管理内存,无论是否发生异常,都能确保内存被正确释放。
RAII与智能指针设计思路
RAII原则
RAII(Resource Acquisition Is Initialization)是一种重要的资源管理思想:
- 在对象构造时获取资源
- 在对象生命周期内保持资源有效
- 在对象析构时释放资源
智能指针的基本实现
一个简单的智能指针实现如下:
template<class T>
class SmartPtr {
public:
SmartPtr(T* ptr) : _ptr(ptr) {}
~SmartPtr() {
delete[] _ptr;
}
T& operator*() { return *_ptr; }
T* operator->() { return _ptr; }
T& operator[](size_t i) { return _ptr[i]; }
private:
T* _ptr;
};
C++标准库智能指针的使用
C++标准库提供了几种智能指针,各有特点:
auto_ptr(已废弃)
- C++98引入
- 拷贝时把被拷贝对象的资源的管理权转移给拷贝对象
- 这是一个非常糟糕的设计,因为他会到被拷贝对象悬空,访问报错的问题,C++11设计出新的智能指针后,强烈建议不要使用auto_ptr。
unique_ptr
- 独占所有权
- 不支持拷贝,只支持移动
- 轻量高效,推荐在不需共享时使用
unique_ptr<Date> up1(new Date);
// unique_ptr<Date> up2(up1); // 错误,不支持拷贝
unique_ptr<Date> up3(move(up1)); // ⽀持移动,但是移动后up1也悬空,所以使⽤移动要谨慎
shared_ptr
- 共享所有权
- 使用引用计数管理资源
- 支持拷贝和移动
shared_ptr<Date> sp1(new Date);
shared_ptr<Date> sp2(sp1); // 引用计数增加
cout << sp1.use_count(); // 输出2
weak_ptr
- 不增加引用计数
- 用于解决shared_ptr的循环引用问题
- 不能直接访问资源,需通过lock()获取shared_ptr
weak_ptr不具备RAII特性,也无法直接访问资源。根据文档,weak_ptr在构造时只能绑定到shared_ptr,而不能直接绑定资源。这种绑定不会增加shared_ptr的引用计数,从而有效解决了循环引用问题。
weak_ptr没有重载operator*和operator->运算符,因为它不参与资源管理。如果绑定的shared_ptr已释放资源,weak_ptr访问资源将存在风险。weak_ptr提供了expired()
方法来检查资源是否失效,use_count()
可获取shared_ptr
的引用计数。当需要访问资源时,可以调用lock()
方法返回一个管理资源的shared_ptr:若资源已释放则返回空对象,否则可安全访问资源。
#include <iostream>
#include <memory>
#include <string>
using namespace std;
int main() {
// 创建原始shared_ptr并初始化资源
shared_ptr<string> sp1(new string("原始资源"));
// 创建第二个shared_ptr共享同一资源
shared_ptr<string> sp2(sp1);
// 创建weak_ptr观察sp1管理的资源
weak_ptr<string> wp = sp1;
// 检查weak_ptr是否失效(应返回false)
cout << "资源是否失效: " << wp.expired() << endl;
// 输出当前引用计数(应显示2)
cout << "当前引用计数: " << wp.use_count() << endl;
// 重置sp1指向新资源
sp1 = make_shared<string>("新资源1");
cout << "sp1重置后是否失效: " << wp.expired() << endl;
cout << "sp1重置后引用计数: " << wp.use_count() << endl;
// 重置sp2指向新资源
sp2 = make_shared<string>("新资源2");
cout << "sp2重置后是否失效: " << wp.expired() << endl;
cout << "sp2重置后引用计数: " << wp.use_count() << endl;
// 重新绑定weak_ptr到sp1
wp = sp1;
// 通过lock获取可用的shared_ptr
auto sp3 = wp.lock();
// 验证新绑定状态
cout << "重新绑定后是否失效: " << wp.expired() << endl;
cout << "重新绑定后引用计数: " << wp.use_count() << endl;
// 通过lock返回的shared_ptr修改资源
if(sp3) {
*sp3 += "###修改后缀";
cout << "修改后资源内容: " << *sp1 << endl;
}
return 0;
}
其他要点
- 智能指针析构时默认是进行
delete
释放资源,这也就意味着如果不是new
出来的资源,交给智能指针管理,析构时就会崩溃。智能指针支持在构造时给一个删除器,所谓删除器本质就是一个可调用对象,这个可调用对象中实现你想要的释放资源的方式,当构造智能指针时,给了定制的删除器,在智能指针析构时就会调用删除器去释放资源。因为new[]
经常使用,所以为了简洁一点,unique_ptr和shared_ptr都特化了一份[]的版本,使用时unique_ptr<Date[]> up1(new Date[5]);shared_ptr<Date[]> sp1(new Date[5]);
就可以管理new[]的资源。 shared_ptr
除了支持用指向资源的指针构造,还支持make_shared
用初始化资源对象的值直接构造,对比用new
构造make_shared
可以让引用计数和指针的地址联系更紧密,减少碎片地址。shared_ptr
和unique_ptr
都支持了operator bool
的类型转换,如果智能指针对象是一个空对象没有管理资源,则返回false
,否则返回true
,意味着我们可以直接把智能指针对象给if
判断是否为空。shared_ptr
和unique_ptr
都得构造函数都使用explicit
修饰,防止普通指针隐式类型转换成智能指针对象。
智能指针的原理
unique_ptr
原理简单,直接看代码
template<class T>
class unique_ptr
{
public:
explicit unique_ptr(T* ptr)//explicit防止隐式类型转换转换
:_ptr(ptr)
{}
~unique_ptr()
{
if (_ptr)
{
cout << "delete:" << _ptr << endl;
delete _ptr;
}
}
// 像指针⼀样使⽤
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;//禁用拷贝赋值
unique_ptr(unique_ptr<T>&& sp)
:_ptr(sp._ptr)
{
sp._ptr = nullptr;
}
unique_ptr<T>& operator=(unique_ptr<T>&& sp)
{
delete _ptr;
_ptr = sp._ptr;
sp._ptr = nullptr;
}
private:
T* _ptr;
};
shared_ptr
shared_ptr<Date> sp1(new Date);
// ⽀持拷⻉
shared_ptr<Date> sp2(sp1);
shared_ptr<Date> sp3(sp2);
重点关注shared_ptr的设计原理,特别是引用计数机制的设计。由于每个资源都需要独立的引用计数,使用静态成员方式无法实现这一需求,必须采用堆上动态分配的方式。每当构造一个智能指针对象时,不仅要管理资源,还要new一个对应的引用计数。当多个shared_ptr指向同一资源时,引用计数会递增;当shared_ptr对象析构时,引用计数递减。当引用计数减至0,说明当前析构的shared_ptr是最后一个管理该资源的对象,此时才会真正释放资源。
在面试笔试中,模拟实现shared_ptr经常出现,在没有明确要求的情况下,建议实现以下代码的内容,如果还要求实现删除器功能,则加上其中注释的代码即可。
template<class T>
class shared_ptr {
public:
// 构造函数,初始化智能指针并设置引用计数为1
// 参数ptr为指向动态分配对象的指针,默认nullptr
explicit shared_ptr(T* ptr = nullptr)
: _ptr(ptr)
, _pcount(new int(1)) // 动态分配引用计数,初始化为1
{}
// 拷贝构造函数,增加引用计数
// 参数sp为另一个shared_ptr对象
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
, _pcount(sp._pcount)
, _del(sp._del)
{
++(*_pcount); // 引用计数加1
}
// 释放资源函数,减少引用计数并在必要时销毁对象
void release()
{
if (--(*_pcount) == 0) // 引用计数减1
{
// 当引用计数为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; // 指向托管对象的指针
int* _pcount; // 指向引用计数器的指针
// function<void(T*)> _del = [](T* ptr) {delete ptr; }; // 删除器,默认使用delete
};
shared_ptr循环引用问题
如下图所示场景,n1和n2析构后,两个节点的引用计数都降为1
- 右侧节点何时释放?它由左侧节点的_next指针管理,当_next析构时,右侧节点即被释放
- _next何时析构?作为左侧节点的成员变量,当左侧节点释放时,_next随之析构
- 左侧节点何时释放?它由右侧节点的_prev指针管理,_prev析构后,左侧节点即被释放
- _prev何时析构?作为右侧节点的成员变量,当右侧节点释放时,_prev随之析构
这样就形成了相互制约的循环引用,导致内存无法释放。将ListNode结构体中的_next
和_prev
改为weak_ptr
,由于weak_ptr绑定到shared_ptr时不会增加引用计数,且不参与资源释放管理,成功打破了循环引用,解决了内存泄漏问题。
struct ListNode {
int data;
shared_ptr<ListNode> next;
shared_ptr<ListNode> prev;
~ListNode() { cout << "~ListNode()" << endl; }
};
int main() {
shared_ptr<ListNode> n1(new ListNode);
shared_ptr<ListNode> n2(new ListNode);
n1->next = n2;
n2->prev = n1; // 循环引用,内存泄漏
}
解决方案:使用weak_ptr打破循环引用
struct ListNode {
int data;
weak_ptr<ListNode> next;
weak_ptr<ListNode> prev;
};
weak_ptr
根据上文直接看代码,原理很简单
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;
};
智能指针的高级用法
自定义删除器
智能指针支持自定义删除器,用于管理非new分配的资源:
// 函数指针删除器
unique_ptr<Date, void(*)(Date*)> up3(new Date[5], DeleteArrayFunc<Date>);
// 仿函数删除器
shared_ptr<FILE> sp5(fopen("test.txt", "r"), Fclose());
// lambda删除器
shared_ptr<FILE> sp6(fopen("test.txt", "r"), [](FILE* ptr) {
fclose(ptr);
});
make_shared
更高效的shared_ptr创建方式:
auto sp = make_shared<Date>(2024, 9, 11);
线程安全问题
- shared_ptr的引用计数是线程安全的
- 但指向的对象本身不是线程安全的
- 需要额外同步机制保护共享数据
内存泄漏
什么是内存泄漏
程序未能释放不再使用的内存,导致可用内存逐渐减少。
如何避免内存泄漏
- 使用智能指针管理资源
- 遵循RAII原则
- 使用内存检测工具定期检查
总结
智能指针是C++中强大的资源管理工具,它们基于RAII原则,能够自动管理资源生命周期,显著减少内存泄漏和资源管理错误。理解各种智能指针的特点和适用场景,能够帮助我们编写更安全、更健壮的C++代码。
在实际开发中:
- 优先使用unique_ptr,除非需要共享所有权
- 需要共享所有权时使用shared_ptr
- 遇到循环引用时使用weak_ptr
- 对于非内存资源,使用自定义删除器