【语法】C++的智能指针
目录
lock_guard/unique_lock(RAII机制托管锁)
由于C++没有GC(垃圾回收机制),所有动态开辟的空间都需要我们手动释放,这就会导致
- 忘记释放
- 异常安全问题
不管哪种情况,都会导致内存泄漏
下面先来简单复习一下异常安全问题
int Div() throw(invalid_argument)
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void fun()
{
int* p = new int;
cout << Div() << endl;
delete p;
}
int main()
{
try
{
fun();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
此时,如果在Div中抛出异常,就会跳过fun()函数最后的 delete p; 语句,从而导致内存泄漏,这就是异常安全问题
那么异常是怎么解决这个问题的呢?
int Div() throw(invalid_argument)
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void fun()
{
int* p = new int;
try
{
cout << Div() << endl;
}
catch (...)//若抛出异常,这里会先捕获异常,释放空间后再重新抛出
{
delete p;
throw;
}
delete p;
}
int main()
{
try
{
fun();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
使用了异常的重新抛出,先在内层的catch语句中完成空间的释放,再将该异常重新抛出交给外面的catch处理
但这样的解决方法会涌现很多问题
例如,当我们开辟了多个空间,如果DIv抛异常,就需要先将这3个空间都释放掉
void fun()
{
int* p1 = new int;
int* p2 = new int;
int* p3 = new int;
try
{
cout << Div() << endl;
}
catch (...)
{
delete p1;
delete p2;
delete p3;
throw;
}
delete p1;
delete p2;
delete p3;
}
但是开辟空间如果失败了也是会抛异常的,如果这里的new抛异常怎么办?并且还需要清楚是哪个变量的new抛出的异常
有读者可能会想,给每个new都放在一个单独的try块中,再给每个try写一个单独的catch,不就可以解决了吗?
首先,如果new语句在try块内,那么在try块外的delete语句就会找不到p1,p2,p3
再者,就算可以找到,那怎么判断哪个new抛的异常,从而不去delete对应对象,又是个问题
这时候,就要请出本文的主角——智能指针
智能指针的原理
智能指针其实是一个类
template <class T>
class SmartPtr
{
public:
SmartPtr(T* ptr)
:_ptr(ptr)
{}
~SmartPtr()
{
if(_ptr)
{
delete _ptr;
_ptr = nullptr;
}
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
在该类中,定义了构造、析构、*重载、->重载,而类中存的是一个指针
现在我如果把一个原生指针存进去,就不需要我手动释放了
int* p = new int;
SmartPtr<int> sp(p);
因为类在生命周期结束后会自动调用析构函数,而现在原生指针交给智能指针管理后,当原生指针的生命周期结束时,智能指针的生命周期也会结束,此时调用该类的析构函数,就可以释放该原生指针的空间
*重载 和->重载是为了让智能指针可以更好的模拟原生指针的行为
既然智能指针可以完全模拟原生指针的行为了,那其实我们可以这么定义
SmartPtr<int> sp(new int);//或SmartPtr<int> sp = new int;//这里调用的也是构造
有了智能指针,再发生异常安全问题时,就无需重新抛出异常了
int Div() throw(invalid_argument)
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void fun()
{
SmartPtr<int> sp = new int;
cout << Div() << endl;
}
int main()
{
try
{
fun();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
此时如果Div函数内部抛出异常,那么在异常抛出后会先执行SmartPtr的析构函数,再执行catch块中的代码
即使没有抛异常,在fun函数执行完后,也会调用SmartPtr的析构函数,相当于将指针托管给了智能指针
RAII
智能指针的这种做法,就被叫做RAII(Resource Acquisition Is Initialization),它是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。
详细点解释,就是在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:
- 不需要显式地释放资源。
- 采用这种方式,对象所需的资源在其生命期内始终保持有效。
需要注意的是,RAII是一种托管资源的思想,智能指针是依靠RAII实现的,除了智能指针之外,还有例如unique_lock/lock_gurad等等也是依托RAII实现的
智能指针的问题
当我们想将一个智能指针的值赋给另一个智能指针时,问题就出现了
SmartPtr<int> sp1 = new int;
SmartPtr<int> sp2 = sp1;//调用默认拷贝构造
此时会报错

这是因为sp1和sp2中指针的地址是同一个,那么就会导致两次释放,和浅拷贝问题很像

针对这个问题,C++一共给出了三种解决方案
- C++98:auto_ptr 管理权转移 (了解)
- C++11:unique_ptr 防拷贝
- C++11:shared_ptr 引用计数的共享拷贝 循环拷贝问题需要用weak_ptr解决
auto_ptr
auto_ptr是C++98中自带的类,因此如果要实现自己的auto_ptr,需要将它放在一个命名空间中
namespace valkyrie
{
template <class T>
class auto_ptr
{
public:
auto_ptr(T* ptr)
:_ptr(ptr)
{}
auto_ptr(auto_ptr<T>& ap)//管理权转移,将ap存的地址转移给*this
:_ptr(ap._ptr)
{
ap._ptr = nullptr;
}
auto_ptr& operator=(auto_ptr<T>& ap)//依旧管理权转移,不过需要先将原先this中的空间释放
{
if (this != &ap)
{
if (_ptr)
delete _ptr;
_ptr = ap._ptr;
ap._ptr = nullptr;
}
return *this;
}
~auto_ptr()
{
if (_ptr)
{
delete _ptr;
_ptr = nullptr;
}
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
}
大部分代码都和之前我们实现的SmartPtr一样,唯一区别在于拷贝构造和赋值重载显式定义了
该拷贝构造的核心在于原先ap1管理的空间,交给ap2管理,而ap1再置空

赋值重载也一样,但需要先将原先存的空间释放掉

那此时如果再想 *ap2 = 1; 就会引发空指针解引用问题,而崩溃
这也是C++早期的设计缺陷,一般都是公司明令禁止使用的
unique_ptr
unique_ptr 是C++11新引入的智能指针,它对于拷贝构造和赋值重载的做法是直接禁止,已达到防拷贝的目的
namespace valkyrie
{
template <class T>
class unique_ptr
{
public:
unique_ptr(unique_ptr<T>&) = delete;//删除该类的拷贝构造
unique_ptr<T>& operator=(unique_ptr<T>&) = delete;//删除该类的赋值重载
unique_ptr(T* ptr)
:_ptr(ptr)
{
}
~unique_ptr()
{
if (_ptr)
{
delete _ptr;
_ptr = nullptr;
}
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
}
这样在我们尝试调用拷贝构造或赋值重载时,就会报错

unique_ptr的思路简单粗暴,是比较推荐实用的,但缺陷也很明显:如果有需要拷贝的场景,它就没法使用
shared_ptr
shared_ptr 也是C++11引入的智能指针,它和 unique_ptr 的区别在于它可以拷贝构造和赋值
shared_ptr 的思路是通过引用计数的方式来实现多个 shared_ptr 对象之间共享资源。
每个资源都对应一个计数指针,用来记录该份资源被几个对象共享。
拷贝构造:

为什么计数的变量是指针?因为只有指针才可以实现在多个对象中同步变化
赋值重载时,需要先判断原先存储的空间需不需要释放,再执行如同拷贝构造一样的操作

此时就是直接释放的情况,下面再演示一下不需要释放的情况

namespace valkyrie
{
template <class T>
class shared_ptr
{
public:
shared_ptr(T* ptr)
:_ptr(ptr)
,_pcount(new int(1))
{}
shared_ptr(const shared_ptr<T>& sp)//拷贝构造,将指针和计数指针都拷贝给它,并将引用计数+1,表示又多一个托管这块内存的智能指针
:_ptr(sp._ptr)
,_pcount(sp._pcount)
{
(*_pcount)++;
}
shared_ptr<T>& operator=(shared_ptr<T>& sp)//赋值重载,先判断原先该智能指针中的空间需不需要释放(等于先调用了一次析构函数),再执行和拷贝构造一样的步骤
{
if (this != &sp)
{
if (--(*_pcount) == 0 && _ptr)
{
delete _ptr;
_ptr = nullptr;
delete _pcount;
_pcount = nullptr;
}
_ptr = sp._ptr;
_pcount = sp._pcount;
(*_pcount)++;
}
return *this;
}
~shared_ptr()
{
if (--(*_pcount) == 0 && _ptr)//先将引用计数-1,看看是否为0,如果为0,就代表没有智能指针在托管这块空间了,直接释放;但如果不为0,就代表还有*pcount个智能指针在托管这块空间,不能释放
{
delete _ptr;
_ptr = nullptr;
delete _pcount;
_pcount = nullptr;
}
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
int* _pcount;//引用计数,用来统计目前有几个智能指针在同时托管这块空间
};
}
shared_ptr的线程安全问题
当有多个线程用 shared_ptr 管理着同一份共享资源时,由于引用计数需要++(析构时也需要--),因此就会有多个线程同时对一个引用计数++(--)而导致的数据不一致问题。
C++11的shared_ptr是线程安全的,下面简单实现一下通过将引用计数包装成 atomic 原子类型而实现的线程安全版本:
namespace val
{
template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr)
:_ptr(ptr)
,_count(new atomic<int>(1))
{}
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
,_count(sp._count)
{
if(_count)
(*_count)++;
}
shared_ptr<T>& operator=(shared_ptr sp)
{
//现代写法,并且保证了线程安全
if(this != &sp)
{
swap(_ptr,sp._ptr);
swap(_count,sp._count);
}
return *this;
}
~shared_ptr()
{
if(_count && --(*_count) == 0)
{
delete _ptr;
delete _count;
}
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
atomic<int>* _count;//将引用计数包装成原子类型
};
}
shared_ptr的循环引用问题
当shared_ptr托管的对象需要双向依赖关系,例如双向链表(next节点和prev节点),或树的父子节点,就会因为引用计数无法归零而导致内存泄漏
例如下面这种情况:
template <class T>
struct ListNode
{
T val;
val::shared_ptr<ListNode> prev;
val::shared_ptr<ListNode> next;
};
int main()
{
val::shared_ptr<ListNode<int>> ln1(new ListNode<int>);
val::shared_ptr<ListNode<int>> ln2(new ListNode<int>);
cout << ln1.getcnt()/*获取当前智能指针的引用计数*/ << endl << ln2.getcnt() << endl;
ln1->next = ln2;
ln2->prev = ln1;
cout << ln1.getcnt() << endl << ln2.getcnt() << endl;
return 0;
}
在这段代码里,ln1内托管的对象由ln1和ln2->prev同时托管,ln2内托管的对象由ln2和ln1->next同时托管,那么ln1和ln2的引用计数都为2

当程序结束,ln1和ln2调用析构函数后,各自都把引用计数减到1,各自都以为还有别的shared_ptr在托管该资源(next和prev),所以都没释放,这就造成了内存泄漏
要想解决,就需要把双向依赖关系里的指针不再引用计数++,因此C++11还有一个 weak_ptr ,它不是智能指针,而是专门用于解决shared_ptr循环引用问题的指针,将shread_ptr赋给weak_ptr时,引用计数不会++,而只是像个普通指针一样
template <class T>
class weak_ptr
{
public:
weak_ptr() = default; // 默认构造函数
weak_ptr(const shared_ptr<T> &sp)
: _ptr(sp.operator->())
{
}
weak_ptr<T> &operator=(shared_ptr<T> sp)//参数是shared_ptr
{
_ptr = sp.operator->(); // 获取sp._ptr
return *this;
}
~weak_ptr()
{
_ptr = nullptr;//不释放,只置空
}
T &operator*()
{
return *_ptr;
}
T *operator->()
{
return _ptr;
}
private:
T *_ptr;
};
只要将双向依赖关系中的资源交给weak_ptr管理,就解决了循环引用问题
template <class T>
struct ListNode
{
T val;
val::weak_ptr<ListNode> prev;
val::weak_ptr<ListNode> next;
};
int main()
{
val::shared_ptr<ListNode<int>> ln1(new ListNode<int>);
val::shared_ptr<ListNode<int>> ln2(new ListNode<int>);
cout << ln1.getcnt()/*获取当前智能指针的引用计数*/ << endl << ln2.getcnt() << endl;
ln1->next = ln2;
ln2->prev = ln1;
cout << ln1.getcnt() << endl << ln2.getcnt() << endl;
return 0;
}
定制删除器
在C++11之前,也就是C++98后,智能指针只有auto_ptr,这之间的十几年由于官方一直没有作为,一个名为 boost 的第三方库就出现了,它给出了 scoped_ptr 、 shared_ptr 、 weak_ptr ,并且除了智能指针也有非常多的实现,后续C++11的 unique_ptr 、 shared_ptr 、 weak_ptr 就是参考 boost 库实现的
boost库中除了有scoped_ptr和shared_ptr,还有 scoped_array 和 shared_array 。由于智能指针默认的释放资源的方式都是delete,如果是以new []创建的数组,就不能用delete了,因此scoped_array/shared_array是boost中专门给new []准备的智能指针,它们的释放资源的方式是delete[]
但C++11没有引入xxx_array的智能指针,它靠定制删除器来实现(也是借鉴的boost库)
如果直接将new []的数据交给智能指针管理,在析构时会报错
class A
{
public:
~A()
{
cout << "~A()" << endl;
}
private:
int a;
int b;
};
int main()
{
shared_ptr<A>(new A[10]);
return 0;
}

此时可以定义一个仿函数。让智能指针在释放资源时自动调用仿函数内的 operator()
template<class T>
struct deleteArr
{
void operator()(T* v)
{
delete[] v;
}
};
int main()
{
shared_ptr<A>(new A[10],deleteArr<A>());
return 0;
}
这就叫做定制删除器
除了new,new[]之外,malloc/fopen等等需要回收的类型都可以使用定制删除器交给智能指针管理
class A
{
public:
~A()
{
cout << "~A()" << endl;
}
private:
int a;
int b;
};
template<class T>
struct deleteArr
{
void operator()(T* v)
{
delete[] v;
}
};
template<class T>
struct deleteFree
{
void operator()(T* v)
{
free(v);
}
};
struct deleteFclose
{
void operator()(FILE* v)
{
fclose(v);
}
};
int main()
{
shared_ptr<A>(new A[10],deleteArr<A>());
shared_ptr<A>((A*)malloc(sizeof(A)),deleteFree<A>());
shared_ptr<FILE>(fopen("tmp.txt","w"),deleteFclose());
return 0;
}
需要注意的是,上面只是为了演示定制删除器的用法,若真需要用智能指针管理数组,应该用unique_ptr,它有operator[]重载
lock_guard/unique_lock(RAII机制托管锁)
平时有加锁解锁的操作时,如果中间的某个函数抛出异常,就会出现死锁问题,需要通过捕获重新抛出的方式对其解锁
mtx.lock();
try
{
f();
}
catch(...)
{
mtx.unlock();//捕获重新抛出
throw;
}
mtx.unlock();
C++11中,专门为锁的RAII写了两个对象, lock_guard 和 unique_lock ,它们可以在出生命周期时自动释放锁
lcok_guard就是纯粹的RAII的管理锁,构造时加锁,析构时解锁,该对象只有构造和析构两个接口

template<class lock>
class Lock_Guard
{
public:
Lock_Guard(lock& lk)//引用传参,这样传过去的锁也是同一个
:_lk(lk)
{
//_lk = lk;
_lk.lock();
}
~Lock_Guard()
{
_lk.unlock();
}
Lock_Guard(const Lock_Guard<lock>&) = delete;//lock_guard不允许拷贝
Lock_Guard<lock>& operator=(Lock_Guard<lock>) = delete;
private:
lock& _lk;//引用类型
};
mutex mtx;
void fun()
{
Lock_Guard<mutex> lg(mtx);
throw(string("抛出异常"));
}
需要注意的是lock_guard是不支持拷贝的
unique_lock 除了可以像lock_guard一样RAII,还可以自己加锁或解锁

void fun()
{
unique_lock<mutex> lg(mtx);
throw(string("抛出异常"));
lg.unlock();
//...其他工作
lg.lock();
//...
}
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)