C++智能指针
智能指针是一种用于管理动态分配内存的对象,可以在内存不再需要时自动释放。智能指针通过重载了指针操作符的类来实现,以模拟指针的行为,但具有自动资源管理的功能。
RAII思想
RAII
是资源获取即初始化(Resource Acquisition Is Initialization)的缩写,是一种 C++ 编程范式,它通过在对象的构造函数中获取资源,利用对象的生命周期来管理资源的释放,从而确保资源在不再需要时被正确释放。智能指针是RAII
思想的一种产物,在多线程中,守卫锁Guard Lock
也是一种常见的RAII
风格的加锁方式。
RAII
风格的Lock Guard(Linux下原生线程库)
arduino
#pragma once
#include <iostream>
#include <pthread.h>
//RAII枷鎖
class Mutex
{
public:
Mutex(pthread_mutex_t* mutex)
:_mutex(mutex)
{
pthread_mutex_init(_mutex,nullptr);
}
void lock()
{
pthread_mutex_lock(_mutex);
}
void unlock()
{
pthread_mutex_unlock(_mutex);
}
~Mutex()
{
pthread_mutex_destroy(_mutex);
}
private:
pthread_mutex_t* _mutex;
};
class LockGuard
{
public:
LockGuard(pthread_mutex_t* pmtx)
:_mtx(pmtx)
{
_mtx.lock();
}
~LockGuard()
{
_mtx.unlock();
}
private:
Mutex _mtx;
};
智能指针
C++标准库提供了两种主要的智能指针:std::unique_ptr
和 std::shared_ptr
。此外,C++17还引入了 std::weak_ptr
。
std::auto_ptr
C++在C++98中就引入了auto_ptr
,但是但在 C++11 标准中已经被废弃,并在 C++17 中被完全移除。这是因为 auto_ptr
存在一些严重的缺陷。
问题如下:
c
int main()
{
std::auto_ptr<int> p1(new int);
std::auto_ptr<int> p2(p1);
*p1 = 10;
*p2 = 10;
std::cout << *p1 << std::endl;
std::cout << *p2 << std::endl;
return 0;
}
上面的代码以指针的角度来看,就是让两个指针维护同一块地址空间,但是上面程序运行会奔溃。这是标准库中的auto_ptr
简单实现一份auto_ptr
然后了解一下auto_ptr
的问题。
arduino
#pragma once
#include <iostream>
namespace ding
{
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=(const auto_ptr<T>& sp)
{
if (&sp != this)
{
if (_ptr != nullptr)
{
delete _ptr;
}
_ptr = sp._ptr;
sp._ptr = nullptr;
}
return *this;
}
//模拟指针行为
T& operator*() const
{
return *_ptr;
}
T* operator->() const
{
return _ptr;
}
~auto_ptr()
{
if (_ptr != nullptr)
{
delete _ptr;
}
}
private:
T* _ptr;
};
}
使用auto_ptr
c
#include "auto_ptr.h"
int main()
{
ding::auto_ptr<int> p1(new int);
ding::auto_ptr<int> p2(p1);
*p1 = 10;
*p2 = 10;
std::cout << *p1 << std::endl;
std::cout << *p2 << std::endl;
return 0;
}
当执行完ding::auto_ptr<int> p2(p1);
后,p1对象的指针已经被置空了,在对其解引用,就会出现对空指针解用的问题。空指针是不能解引用的。这种情况编译器应该要出警告的,但是我的编译器还是能运行的,只是退出码不正常。这应该是vs2022的bug(我用的是2022测试版的)。
只能通过监视窗口来看!
C++98提供的auto_ptr
最大的问题就是这个了,称之为管理权转移,将p1的管理权转移给p2然后自己悬空。导致了auto_ptr
直接被禁用,甚至在17中被移除了。
std::unique_ptr
经过十几年后,在C++11中,出现了比auto_ptr
更靠谱的unique_ptr
。unique_ptr
主要解决auto_ptr
带来的问题,在unique_ptr
中直接禁用了拷贝和赋值。
std::unique_ptr
的使用
c
int main()
{
std::unique_ptr<int> p1(new int);
std::unique_ptr<int> p2(p1);//error
p2 = p1;//error
}
unique_ptr
不能再使用拷贝和赋值了,所以他的使用场景就被限制了,对于有些地址空间需要更多的指针来维护是不能实现的。
std::unique_ptr简易模拟实现
unique_ptr
就很简单了,对比auto_ptr
直接把拷贝构造和赋值用C++11的语法用delete禁用就行了。
arduino
namespace ding
{
template<class T>
class unique_ptr
{
public:
unique_ptr(T* ptr)
:_ptr(ptr)
{}
unique_ptr(unique_ptr<T>& sp) = delete;
//赋值运算符
unique_ptr<T>& operator=(const unique_ptr<T>& sp) = delete;
//模拟指针行为
T& operator*() const
{
return *_ptr;
}
T* operator->() const
{
return _ptr;
}
~unique_ptr()
{
if (_ptr != nullptr)
{
delete _ptr;
}
}
private:
T* _ptr;
};
}
std::shared_ptr
C++11还提供了更靠谱的智能指针shared_ptr
。解决了auto_ptr
的悬空问题和unique_ptr
的防拷贝问题。
std::shared_ptr
使用引用计数来跟踪有多少个智能指针指向相同的资源,当最后一个 std::shared_ptr
被销毁时,它所管理的资源也会被释放。
shared_ptr
在其内部,给每个资源都维护了着一份计数,用来记录该份资源被几个对象共享。- 在对象被销毁时(也就是析构函数调用),就说明自己不使用该资源了,对象的引用计数减 一。
- 如果引用计数是0,就说明自己是最后一个使用该资源的对象,必须释放该资源;
- 如果不是0,就说明除了自己还有其他对象在使用该份资源,不能释放该资源,否则其他对象就成野指针了。
shared_ptr 简易模拟实现
引用计数是shared_ptr
对象的公共资源,也就是说在实现shared_prt
时,不能简单的将引用计数_ref_count
设计为一个普通的成员变量,设计成为普通的成员变量,就意味着每个shared_ptr
对象都有一个引用计数。普通的成员变量导致无法正确记录内存块的使用情况。
图解:
使用static也不能解决,因为static是所有类对象共享的。这就会导致只要使用shared_ptr
不论管理的是哪一块地址空间,用的都是同一个引用计数。 图解:
将引用计数定义成为一个指针,当一块地址空间第一次被shared_ptr对象维护时,在堆区开辟一块空间用于存储其对应得引用计数,如果其他shared_ptr对象也要维护这块地址空间,除了将地址给他还要把引用计数的地址也给他。 图解:
arduino
namespace ding
{
template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
,_ref_count(new size_t(1))
{}
shared_ptr(shared_ptr<T>& sp)
:_ptr(sp._ptr)
,_ref_count(sp._ref_count)
{
++(*_ref_count);
}
shared_ptr<T>& operator=(shared_ptr<T>& sp)
{
if (this != &sp)
{
if (--(*_ref_count) == 0)
{
delete _ref_count;
delete _ptr;
}
_ptr = sp._ptr;
_ref_count = sp._ref_count;
++(*_ref_count);
}
return *this;
}
T& operator*() const
{
return *_ptr;
}
T* operator->()const
{
return _ptr;
}
~shared_ptr()
{
if (--(*_ref_count) == 0 && _ptr != nullptr)
{
delete _ptr;
delete _ref_count;
}
}
private:
T* _ptr;
size_t* _ref_count;//引用计数
};
}
通过监视窗口可以看出当前代码得sp1,sp2,sp3都指向得是同一块地址空间。引用计数都是3。没有问题。 执行完sp3 = sp4 后,sp1和sp2得引用计数应该变为2,sp3和sp4应该指向同一块地址空间,引用计数也是同一个。 监视窗口观看也没有问题。
如果是普通得成员变量得话,上面同样得代码,监视窗口结果如下:
引用计数得结果完全不符合要求。
shared_ptr线程安全问题
std::sharer_ptr
本身是线程安全得,当多个线程同时访问同一个 std::shared_ptr
对象时,std::shared_ptr
本身能够确保引用计数的操作是原子的,从而保证了线程安全性。 比如下面得场景:
创建两个线程,让他们疯狂的拷贝当前智能指针对象,引用计数就会一直增加。 线程执行完后,引用计数应该还是1。因为copy对象是一个局部对象,出了作用域就释放了。
cpp
void fun(std::shared_ptr<int> sp, size_t n)
{
for (size_t i = 0; i < n; ++i)
{
std::shared_ptr<int> copy(sp);
}
}
int main()
{
std::shared_ptr<int> sp1(new int);
const size_t n = 10000;
std::thread t1(fun, sp1, n);
std::thread t2(fun, sp1, n);
t1.join();
t2.join();
cout << sp1.use_count() << endl;
return 0;
}
运行结果:
没问题是线程安全的。
但是上面模拟实现的,存在线程安全问题。因为引用计数是共享得,多线程对同一个引用计数进行自增或自减操作,而自增和自减操作都不是原子操作,因此需要通过加锁来对引用计数进行保护,否则就会导致线程安全问题。
线程安全版本的shared_ptr
- 对引用计数操作的地方封装成为一个函数,方便加锁和解锁。
- 锁也是所有对象共享的,也需要创建在堆区。
- 在调用拷贝和赋值时,也需要把锁交给当前对象。
- 释放的时候也需要释放互斥锁,先解锁在释放,释放完直接return即可,不用执行后面的解锁逻辑。
cpp
namespace ding
{
template<class T>
class unique_ptr
{
public:
unique_ptr(T* ptr)
:_ptr(ptr)
{}
unique_ptr(unique_ptr<T>& sp) = delete;
//赋值运算符(注意先释放原空间在赋值 以及自己给自己赋值的情况)
unique_ptr<T>& operator=(const unique_ptr<T>& sp) = delete;
//模拟指针行为
T& operator*() const
{
return *_ptr;
}
T* operator->() const
{
return _ptr;
}
~unique_ptr()
{
if (_ptr != nullptr)
{
delete _ptr;
}
}
private:
T* _ptr;
};
template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
,_ref_count(new size_t(1))
,_sp_mtx(new std::mutex)
{}
void AddRef()
{
_sp_mtx->lock();
++(*_ref_count);
_sp_mtx->unlock();
}
void DeleteRef()
{
_sp_mtx->lock();
if (--(*_ref_count) == 0 && _ptr != nullptr)
{
delete _ptr;
delete _ref_count;
_sp_mtx->unlock();
delete _sp_mtx;
return;
}
_sp_mtx->unlock();
}
shared_ptr(shared_ptr<T>& sp)
:_ptr(sp._ptr)
,_ref_count(sp._ref_count)
,_sp_mtx(sp._sp_mtx)
{
AddRef();
}
shared_ptr<T>& operator=(shared_ptr<T>& sp)
{
//if (this != &sp) 可以 但是使用地址比较更好一点 比如sp1和sp2维护一块地址空间,sp1 = sp2 里面得逻辑就不用再走一遍了
if(_ptr != sp._ptr)
{
DeleteRef();
_ptr = sp._ptr;
_ref_count = sp._ref_count;
_sp_mtx = sp._sp_mtx;
AddRef();
}
return *this;
}
T& operator*() const
{
return *_ptr;
}
T* operator->()const
{
return _ptr;
}
~shared_ptr()
{
DeleteRef();
}
size_t RefCount()
{
return *_ref_count;
}
private:
T* _ptr;
size_t* _ref_count;//引用计数
std::mutex* _sp_mtx;
};
}
shared_ptr本身是线程安全的,但是shared_ptr对象维护的对象并不是线程安全的。也就是说多线程访问同一个shared_ptr对象管理的资源,这种行为不是线程安全的。需要自己进行处理。
sharer_ptr的问题
shared_ptr并不是完美的,第一个问题就是线程安全问题。 虽然 std::shared_ptr
本身是线程安全的,但对其引用计数的访问需要原子操作。在高并发的多线程环境中,引用计数的原子操作可能会成为性能瓶颈。此外,在使用 std::shared_ptr
进行多线程编程时,仍然需要注意并发访问共享资源的问题。 还有一个问题是比较严重的,就是循环引用问题。
循环引用问题 :如果两个或多个对象彼此持有对彼此的 std::shared_ptr
引用,就会导致循环引用。这会导致对象永远无法被释放,从而导致内存泄漏。 比如下面这种情况:
cpp
struct ListNode
{
std::shared_ptr<ListNode> _prev = nullptr;
std::shared_ptr<ListNode> _next = nullptr;
int _data;
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
void TestLoopRef()
{
std::shared_ptr<ListNode> node1(new ListNode);
std::shared_ptr<ListNode> node2(new ListNode);
cout << node1.use_count() << endl;
cout << node2.use_count() << endl;
node1->_next = node2;
node2->_prev = node1;
cout << node1.use_count() << endl;
cout << node2.use_count() << endl;
}
int main()
{
TestLoopRef();
return 0;
}
运行结果: 不会调用ListNode的析构释放资源。 图示如下:
- node1和node2两个智能指针对象分别维护两块地址空间,引用计数都为1。
- node1的_next 指向node2,node2的_prev指向node1。引用计数变为2.
- TestLoopRef函数执行完毕,局部对象node1和node2生命周期结束,调用析构释放内存。引用计数减到1,但是_next和_prev还分别维护着对方。
- 只有当_next和_prev析构了,node2和node1才会释放,否则就会造成内存泄漏问题。
- 但是_next属于node1的成员,node1释放了,_next才会析构,而node1又由_prev维护,同理,_prev属于node2的成员,node2释放了,_prev才会析构,但是node2又由_next维护。
- 这种情况就叫做循环引用,谁也不会释放。
解决这个问题,引入了weak_ptr
。
std::weak_ptr
std::weak_ptr
是 C++ 标准库提供的另一个智能指针类型,它用于解决 std::shared_ptr
循环引用问题和弱引用场景。相比于 std::shared_ptr
,std::weak_ptr
并不增加对象的引用计数,因此不会影响对象的生命周期。
使用weak_ptr解决循环引用:
cpp
struct ListNode
{
std::weak_ptr<ListNode> _prev;//使用weak_ptr
std::weak_ptr<ListNode> _next;
int _data;
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
void TestLoopRef()
{
std::shared_ptr<ListNode> node1(new ListNode);
std::shared_ptr<ListNode> node2(new ListNode);
cout << node1.use_count() << endl;
cout << node2.use_count() << endl;
node1->_next = node2;
node2->_prev = node1;
cout << node1.use_count() << endl;
cout << node2.use_count() << endl;
}
int main()
{
TestLoopRef();
return 0;
}
运行结果:
成功调用析构,并且不会增加引用计数
weak_ptr的原理
weak_ptr
并不符合RAII思想,只是辅助解决shared_ptr
的循环引用问题。他是一种弱智指针,支持像指针一样使用就行。weak_ptr
要支持用shared_ptr对象拷贝构造和拷贝赋值weak_ptr
对象,构造时获取shared_ptr对象管理的资源。
cpp
template<class T>
class weak_ptr
{
public:
weak_ptr()
:_ptr(nullptr)
{}
weak_ptr(const ding::shared_ptr<T>& sp)
:_ptr(sp.get())
{
}
weak_ptr<T>& operator=(const ding::shared_ptr<T>& sp)
{
_ptr = sp.get();
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
定制删除器
shared_ptr
对象在析构的时候都使用的是delete
进行释放资源,这不是很合理的,如果智能指针管理的是new[]
或者不是new
申请的。管理的是一个文件指针。使用delete就会有问题。 std提供了定制删除器。
- p 就是智能指管理的资源。
- del 就是定制的删除器,可以是发仿函数,函数指针,lambda表达式。
比如shared_ptr对象管理的是一个文件指针。
cpp
int main()
{
std::shared_ptr<FILE> fp1(fopen("main.cpp", "w"));
return 0;
}
这样是会出错的。因为默认析构是delete。这里采用仿函数进行解决:
cpp
template<class T>
struct DelArr
{
void operator()(T* ptr)
{
fclose(ptr);
}
};
int main()
{
std::shared_ptr<FILE> fp1(fopen("main.cpp", "w"), DelArr<FILE>());
return 0;
}
将定制的删除器传给智能指针对象即可,就不会执行delete进行析构了。 除了使用仿函数,使用lambda更方便,函数指针就不推荐了,使用太麻烦。
cpp
int main()
{
std::shared_ptr<FILE> fp1(fopen("main.cpp", "w"), [](FILE* ptr) {
fclose(ptr);
});
return 0;
}