[C++]:智能指针
1. 智能指针的引入
首先我们在异常章节学过,如果对异常直接抛出,因为抛出异常可能会跳过某些代码不执行,所以就可能造成内存泄漏的问题。比如下面这段代码:
void func1()
{
//抛出异常
throw string("这是一个异常");
}
void func2()
{
int* arr = new int[10];
func1();
//...
cout << "delete[] arr" << endl;
delete[] arr;//释放内存
}
int main()
{
try
{
func2();
}
catch (const string& s)
{
cout << s << endl;
}
catch (...)
{
cout << "未知异常" << endl;
}
return 0;
}
我们可以利用对异常重新抛出的方式解决这个问题:
void func1()
{
//抛出异常
throw string("这是一个异常");
}
void func2()
{
int* arr = new int[10];
try
{
func1();
}
catch (...)
{
cout << "delete[] arr" << endl;
delete[] arr;//释放内存
throw;//重新抛出
}
//...
cout << "delete[] arr" << endl;
delete[] arr;//释放内存
}
int main()
{
try
{
func2();
}
catch (const string& s)
{
cout << s << endl;
}
catch (...)
{
cout << "未知异常" << endl;
}
return 0;
}
而本章节我们还可以利用一种方式解决这个问题,那就是智能指针,其本质是一种RAII
的思想。
2. 智能指针的使用与原理
2.1 RAII的概念
**RAII(Resource Acquisition Is Initialization)**是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:
- 不需要显式地释放资源。
- 采用这种方式,对象所需的资源在其生命期内始终保持有效。
2.2 智能指针的使用
然后我们就能利用RAII
的思想,实现一个智能指针,既然是指针,我们自然也要重载*
,->
。
#include<vector>
template<class T>
class SmartPtr
{
public:
SmartPtr(T* ptr = nullptr)
:_ptr(ptr)
{}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
~SmartPtr()
{
cout << "delete _ptr" << endl;
if (_ptr)
delete _ptr;
}
private:
T* _ptr;
};
然后我们就可以利用智能指针解决上面的内存泄漏问题:
void func1()
{
//抛出异常
throw string("这是一个异常");
}
void func2()
{
SmartPtr<int> ptr (new int);
func1();
//...
}
int main()
{
try
{
func2();
}
catch (const string& s)
{
cout << s << endl;
}
catch (...)
{
cout << "未知异常" << endl;
}
return 0;
}
在初始化时首先利用原生指针构造一个SmartPtr
的对象,然后无论是程序正常返回,还是因为抛异常返回,该对象出了作用域之后会自动调用其析构函数,释放内存。
2.3 智能指针的弊端
其实智能指针是存在一些弊端的,比如说如果对智能指针进行拷贝构造或者赋值重载,那么就发生内存崩溃与内存泄漏的问题.
比如说我们用智能指针定义了四个对象,分别为ptr1
,ptr2
,ptr3
,ptr4
。其中用ptr2
拷贝ptr1
,然后用ptr4
赋值ptr3
。
void Test1()
{
SmartPtr<int> ptr1 = new int;
SmartPtr<int> ptr2(ptr1);//拷贝构造
SmartPtr<int> ptr3 = new int;
SmartPtr<int> ptr4 = new int;
ptr3 = ptr4;//赋值重载
}
这是因为默认生成的拷贝构造是浅拷贝,ptr1
与ptr2
指向同一块空间,所以析构时对同一块空间析构两次,就会内存崩溃。而且默认生成的赋值重载也是浅拷贝,ptr3
与ptr4
也是浅拷贝,析构时也会发生内存崩溃,同时因为ptr4
指向ptr3
的空间,所以原本ptr4
的空间就会发生内存泄漏。
3. 标准库中的智能指针
因为智能指针的种种弊端,所以C++标准库提供三种不同的智能指针。
3.1 auto_ptr
3.1.1 auto_ptr的使用
auto_ptr
是C++98中引入的智能指针,其通过管理权转移的方式解决智能指针的拷贝问题,保证一个资源在任何时刻都只有一个对象在对其进行管理,这就能避免浅拷贝的多次析构问题。
void Test2()
{
auto_ptr<int> ap1(new int(3));
auto_ptr<int> ap2(ap1);
*ap2 = 1;
//*ap1 = 2; //error
auto_ptr<int> ap3(new int(1));
auto_ptr<int> ap4(new int(2));
ap3 = ap4;
}
但是唯一需要注意的是:管理权转移之后,就不能对原有空间就行访问,否则就会崩溃。这就导致使用auto_ptr
之前首先得了解其机制,否则就可能出错,这就为智能指针的使用增加了成本。因此有些公司会明令禁止使用auto_ptr
。
3.1.2 auto_ptr的实现
auto_ptr
本质和我们前面实现的SmartPtr
没什么区别,只是在拷贝构造与赋值重载时需要对原来指针置空。
namespace betty
{
template<class T>
class auto_ptr
{
public:
auto_ptr(T* ptr = nullptr)
:_ptr(ptr)
{}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
auto_ptr(auto_ptr<T>& ap)
:_ptr(ap._ptr)
{
//置空
ap._ptr = nullptr;
}
auto_ptr& operator=(auto_ptr<T>& ap)
{
if (this != &ap)
{
delete _ptr;
_ptr = ap._ptr;
ap._ptr = nullptr;//置空
}
return *this;
}
~auto_ptr()
{
if (_ptr)
delete _ptr;
}
private:
T* _ptr;
};
}
3.2 unique_ptr
3.2.1 unqiue_ptr 的使用
unique_ptr
是C++11中引入的智能指针,unique_ptr
通过直接防止拷贝与赋值的方式解决智能指针的拷贝问题,这样也能保证资源不会被多次释放。比如:
int main()
{
std::unique_ptr<int> up1(new int(0));
//std::unique_ptr<int> up2(up1); //error
return 0;
}
但防拷贝其实也不是一个很好的办法,因为总有一些场景需要进行拷贝。
3.2.2 unique_ptr 的实现
unique_ptr
本质和我们前面实现的SmartPtr
也 没什么区别,只是禁止对其进行拷贝构造与赋值重载。
#include <iostream>
namespace betty
{
template <class T>
class unique_ptr
{
public:
unique_ptr(T *ptr = nullptr)
: _ptr(ptr)
{
}
~unique_ptr()
{
if (_ptr != nullptr)
{
delete _ptr;
_ptr = nullptr;
}
}
T &operator*()
{
return *_ptr;
}
T *operator->()
{
return _ptr;
}
unique_ptr(const unique_ptr<T> &up) = delete;
unique_ptr<T> &operator=(const unique_ptr<T> &up) = delete;
private:
T *_ptr;
};
};
3.3 shared_ptr
3.3.1 shared_ptr 的使用
在C++11中又引入了一种极为实用的智能指针——shared_ptr
。它主要通过引用计数的巧妙机制来妥善解决智能指针在拷贝过程中可能出现的问题。
具体而言,对于每一个被 shared_ptr
所管理的资源,都会存在一个与之对应的引用计数。这个引用计数扮演着关键角色,它精准地记录着当下究竟有多少个对象正在对这块特定的资源实施管理。
当出现新增一个对象开始管理这块资源的情况时,相应地,就会对该资源所对应的引用计数执行自增操作(++)。反之,当某个对象不再管理这块资源,或者该对象自身经历析构过程时,那么就会针对该资源对应的引用计数开展自减操作(–)。而一旦某一资源的引用计数经过一系列的增减操作后最终减为0,这便意味着此刻已经不存在任何对象在对这块资源进行管理了。
int main()
{
shared_ptr<int> sp1(new int(1));
shared_ptr<int> sp2(sp1);
*sp1 = 10;
*sp2 = 20;
cout << sp1.use_count() << endl; //2
shared_ptr<int> sp3(new int(1));
shared_ptr<int> sp4(new int(2));
sp3 = sp4;
cout << sp3.use_count() << endl; //2
return 0;
}
3.3.2 shared_ptr 的实现
我们实现 shared_ptr
首先得知道指向同一块资源的变量也应该指向同一个引用计数,相当于将各个资源与其对应的引用计数进行了绑定。所以引用计数的资源也是在堆上开辟的。
由于引用计数的内存空间也是在堆上开辟的,因此当一个资源对应的引用计数减为0时,除了需要将该资源释放,还需要将该资源对应的引用计数的内存空间进行释放。
3.3.3 shared_ptr的线程安全问题
起始我们模拟实现的shared_ptr
还存在一个非常严重的问题,那就是线程安全问题,原因在于:
管理同一资源的多个
shared_ptr
对象的引用计数是共享的。多个线程可能同时对同一个引用计数进行自增(如通过拷贝shared_ptr
对象)或自减(如拷贝出的对象被销毁)操作。而自增和自减操作都不是原子操作,这就可能导致在多线程环境下出现数据不一致的情况。
比如我们以下面代码为例:
#include <iostream>
#include <thread>
#include "MySmartPtr.hpp"
// 函数接受一个shared_ptr和循环次数参数
void func(betty::shared_ptr<int>& sp, size_t n)
{
for (size_t i = 0; i < n; i++)
{
// 每次循环拷贝一份shared_ptr
betty::shared_ptr<int> copy(sp);
}
}
int main()
{
// 创建一个管理整型变量的shared_ptr
betty::shared_ptr<int> p(new int(0));
const size_t n = 1000;
// 创建两个线程并传入相同的shared_ptr和循环次数
std::thread t1(func, p, n);
std::thread t2(func, p, n);
// 等待两个线程执行完毕
t1.join();
t2.join();
// 输出最终的引用计数,理论上应为1
std::cout << p.use_count() << std::endl;
return 0;
}
在上述代码中:两个线程t1
和t2
分别对同一个shared_ptr
对象p
进行1000次拷贝操作,拷贝出的对象随后会立即被销毁。在此过程中,两个线程会不断对引用计数进行自增和自减操作。理论上,当两个线程执行完毕后,引用计数的值应该是1,因为拷贝出来的对象都已销毁,只剩最初的shared_ptr
对象在管理整型变量。但实际每次运行程序得到引用计数的值可能都不一样,这正是由于对引用计数的自增和自减操作不是原子操作所导致的线程安全问题。
要解决引用计数的线程安全问题,关键在于使引用计数的自增和自减操作变为原子操作,这里以加锁为例阐述解决办法。首先在<font style="color:rgb(28, 31, 35);">shared_ptr</font>
类中新增堆区创建的互斥锁成员变量,如此可确保管理同一资源的多个线程访问同一互斥锁,管理不同资源的线程访问不同互斥锁。调用拷贝构造函数与拷贝赋值函数时,除传递资源和引用计数外,还需移交对应的互斥锁。
当资源引用计数减为0时,除释放资源与引用计数,也要释放对应的互斥锁。为简化代码逻辑,可将拷贝构造函数和拷贝赋值函数中的引用计数自增操作封装成<font style="color:rgb(28, 31, 35);">AddRef</font>
函数,将拷贝赋值函数和析构函数中的引用计数自减操作封装成<font style="color:rgb(28, 31, 35);">ReleaseRef</font>
函数,后续只需对<font style="color:rgb(28, 31, 35);">AddRef</font>
和<font style="color:rgb(28, 31, 35);">ReleaseRef</font>
函数进行加锁保护即可。
template <class T>
class shared_ptr
{
private:
//++引用计数
void AddRef()
{
// 对互斥锁加锁,确保同一时间只有一个线程能操作引用计数
_pmutex->lock();
// 引用计数自增,表示多了一个对象共享该资源
(*_pcount)++;
// 操作完成后对互斥锁解锁
_pmutex->unlock();
}
//--引用计数
void ReleaseRef()
{
// 对互斥锁加锁
_pmutex->lock();
bool flag = false;
// 引用计数减1,如果减到0,表示没有对象再共享该资源了
if (--(*_pcount) == 0)
{
if (_ptr!= nullptr)
{
// 如果资源指针不为空,释放该资源所占用的内存
delete _ptr;
_ptr = nullptr;
}
// 释放引用计数所占用的内存
delete _pcount;
_pcount = nullptr;
flag = true;
}
// 对互斥锁解锁
_pmutex->unlock();
// 如果引用计数为0,释放互斥锁所占用的内存
if (flag == true)
{
delete _pmutex;
}
}
public:
// RAII(Resource Acquisition Is Initialization)机制:
// 在构造函数中初始化资源、引用计数和互斥锁
// 构造函数,默认参数为nullptr,可传入一个指向T类型对象的指针
shared_ptr(T *ptr = nullptr)
: _ptr(ptr), _pcount(new int(1)),
_pmutex(new std::mutex)
{
}
// 析构函数,在对象销毁时调用ReleaseRef函数释放资源等
~shared_ptr()
{
ReleaseRef();
}
// 拷贝构造函数,用于创建一个新的shared_ptr对象,共享原对象管理的资源
// 并增加引用计数
shared_ptr(shared_ptr<T> &sp)
: _ptr(sp._ptr),
_pcount(sp._pcount),
_pmutex(sp._pmutex)
{
AddRef();
}
// 赋值运算符重载,用于将一个shared_ptr对象赋值给另一个
// 如果两个对象管理的资源不同,先释放原对象资源,再共享新对象资源并更新引用计数
shared_ptr &operator=(shared_ptr<T> &sp)
{
if (_ptr!= sp._ptr)
{
ReleaseRef();
_ptr = sp._ptr;
_pcount = sp._pcount;
_pmutex = sp._pmutex;
AddRef();
}
return *this;
}
// 获取引用计数,返回当前管理的资源的引用计数
int use_count()
{
return *_pcount;
}
// 重载*运算符,使得可以像使用普通指针一样通过*来访问所管理资源的值
T &operator*()
{
return *_ptr;
}
// 重载->运算符,使得可以像使用普通指针一样通过->来访问所管理资源的成员
T *operator->()
{
return _ptr;
}
private:
T *_ptr; // 管理的资源,指向T类型的对象
int *_pcount; // 管理的资源对应的引用计数,记录共享该资源的对象个数
std::mutex *_pmutex; // 管理的资源对应的互斥锁,用于多线程环境下保护引用计数的操作安全
};
3.3.4 定制删除器
智能指针对象生命周期结束时,默认皆以 delete
方式释放资源,此做法欠妥。因智能指针所管理的资源并非仅限于通过 new
方式申请的内存空间,它还可能管理以 new[]
方式申请到的空间,亦或是一个文件指针等其他类型资源,仅采用 delete
方式来释放显然不能适配所有管理对象的释放需求。为此智能指针还提供一个模版参数 D
来定制删除资源的方式:
p
:需要让智能指针管理的资源。del
:删除器,这个删除器是一个可调用对象,比如函数指针、仿函数、lambda表达式以及被包装器包装后的可调用对象。
比如我们以下面代码为例:
int main()
{
shared_ptr<FILE> sp3(fopen("Test.cpp", "r"), [](FILE* ptr) {fclose(ptr); });//文件
shared_ptr<int> sp4((int*)malloc(4), [](int* ptr) {free(ptr); });
return 0;
}
为此,我们也可以通过 C++11 提供的包装器实现一个定制删除器:
template <class T>
class shared_ptr
{
private:
//++引用计数
void AddRef()
{
// 对互斥锁加锁,确保同一时间只有一个线程能操作引用计数
_pmutex->lock();
// 引用计数自增,表示多了一个对象共享该资源
(*_pcount)++;
// 操作完成后对互斥锁解锁
_pmutex->unlock();
}
//--引用计数
void ReleaseRef()
{
// 对互斥锁加锁
_pmutex->lock();
bool flag = false;
// 引用计数减1,如果减到0,表示没有对象再共享该资源了
if (--(*_pcount) == 0)
{
if (_ptr != nullptr)
{
// 如果资源指针不为空,释放该资源所占用的内存
_del(_ptr);
_ptr = nullptr;
}
// 释放引用计数所占用的内存
delete _pcount;
_pcount = nullptr;
flag = true;
}
// 对互斥锁解锁
_pmutex->unlock();
// 如果引用计数为0,释放互斥锁所占用的内存
if (flag == true)
{
delete _pmutex;
}
}
public:
// RAII(Resource Acquisition Is Initialization)机制:
// 在构造函数中初始化资源、引用计数和互斥锁
// 构造函数,默认参数为nullptr,可传入一个指向T类型对象的指针
template <class D>
shared_ptr(T *ptr = nullptr, D del = [](T *ptr)
{ delete ptr; })
: _ptr(ptr), _pcount(new int(1)),
_pmutex(new std::mutex),
_del(del)
{
}
shared_ptr(T *ptr)
: _ptr(ptr), _pcount(new int(1)),
_pmutex(new std::mutex)
{
}
// 析构函数,在对象销毁时调用ReleaseRef函数释放资源等
~shared_ptr()
{
ReleaseRef();
}
// 拷贝构造函数,用于创建一个新的shared_ptr对象,共享原对象管理的资源
// 并增加引用计数
shared_ptr(shared_ptr<T> &sp)
: _ptr(sp._ptr),
_pcount(sp._pcount),
_pmutex(sp._pmutex)
{
AddRef();
}
// 赋值运算符重载,用于将一个shared_ptr对象赋值给另一个
// 如果两个对象管理的资源不同,先释放原对象资源,再共享新对象资源并更新引用计数
shared_ptr<T> &operator=(shared_ptr<T> &sp)
{
if (_ptr != sp._ptr)
{
ReleaseRef();
_ptr = sp._ptr;
_pcount = sp._pcount;
_pmutex = sp._pmutex;
AddRef();
}
return *this;
}
// 获取引用计数,返回当前管理的资源的引用计数
int use_count()
{
return *_pcount;
}
T* get() const
{
return _ptr;
}
// 重载*运算符,使得可以像使用普通指针一样通过*来访问所管理资源的值
T &operator*()
{
return *_ptr;
}
// 重载->运算符,使得可以像使用普通指针一样通过->来访问所管理资源的成员
T *operator->()
{
return _ptr;
}
private:
T *_ptr; // 管理的资源,指向T类型的对象
int *_pcount; // 管理的资源对应的引用计数,记录共享该资源的对象个数
std::mutex *_pmutex; // 管理的资源对应的互斥锁,用于多线程环境下保护引用计数的操作安全
std::function<void(T *)> _del; // 定制删除器
};
3.4 weak_ptr
3.4.1 weak_ptr 的使用
shared_ptr
虽然看似很完美,但是在某些特殊情况就会引发出一种名为循环引用的问题,比如下面这段代码:
#include<iostream>
using namespace std;
#include<memory>
struct ListNode
{
shared_ptr<ListNode> _next;
shared_ptr<ListNode> _prev;
int _val;
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
int main()
{
shared_ptr<ListNode> node1(new ListNode);
shared_ptr<ListNode> node2(new ListNode);
node1->_next = node2;
node2->_prev = node1;
return 0;
}
这时程序运行结束后两个结点都没有被释放,但如果去掉连接结点时的两句代码中的任意一句,那么这两个结点就都能够正确释放,根本原因就是因为这两句连接结点的代码导致了循环引用。至于什么是循环引用,我们可以通过下面图示说明:
当出了 main
函数的作用域后,node1
和 node2
的生命周期也就结束了,因此这两个资源对应的引用计数最终都减到了1,但是此时就会出现一个致命的问题:
资源的释放条件是其对应的引用计数减为0。而在此情境下,资源1的释放取决于资源2的
_prev
成员,资源2的释放则取决于资源1的_next
成员。如此一来,便形成了一个相互制约的死循环,使得资源1和资源2最终都无法按照正常机制完成释放操作。
而为了解决这个问题,C++11 就又提供了一个智能指针 weak_ptr
。weak_ptr
不是用来管理资源的释放的,它主要是用来解决 shared_ptr
的循环引用问题的。
比如将 ListNode
中的 _next
和 _prev
成员的类型换成 weak_ptr
就不会导致循环引用问题:
#include<iostream>
using namespace std;
#include<memory>
struct ListNode
{
weak_ptr<ListNode> _next;
weak_ptr<ListNode> _prev;
int _val;
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
int main()
{
shared_ptr<ListNode> node1(new ListNode);
shared_ptr<ListNode> node2(new ListNode);
node1->_next = node2;
node2->_prev = node1;
return 0;
}
3.4.2 weak_ptr 的实现
而实现 weak_ptr
其实也很简单,只需要在前面实现智能指针的基础上专门提供 shared_ptr
的构造方法即可。
template <class T>
class weak_ptr
{
public:
weak_ptr()
: _ptr(nullptr)
{
}
weak_ptr(const shared_ptr<T> &sp)
: _ptr(sp.get())
{
}
weak_ptr<T> &operator=(const shared_ptr<T> &sp)
{
_ptr = sp.get();
return *this;
}
T &operator*()
{
return *_ptr;
}
T *operator->()
{
return _ptr;
}
private:
T *_ptr;
};