一文详解C++11的三种智能指针
一、三种智能指针的关系概述
在C++11中出现三种智能指针,分别是unique_ptr、shared_ptr、weak_ptr。这三种智能指针分别有各自的应用场景,同时这些应用场景是有相互联系的。
总的来说:
1. unique_ptr的出现,在一定程度上解决了裸指针使用完没有及时释放导致的内存泄漏问题,但多个unique_ptr不能指向同一资源;
2. shared_ptr的出现,解决了unique_ptr不能指向同一资源的问题。它的特点是引用计数不为0,就坚决不释放资源。这又导致了另一个问题:交叉引用导致资源无法释放,又会内存泄漏;
3. weak_ptr的特点是引用对象时引用计数不增加,但自能成为资源的观察者,无法使用资源。如果要使用资源,必须升级为shared_ptr。weak_ptr解决了shared_ptr交叉引用的问题。
二、unique_ptr
和名字一样,unique_ptr的特点就是“独占”的意思,多个unique_ptr无法指向同一对象。相比于裸指针,unique_ptr的最大优势当然是超出作用域时会自动释放资源。其实unique_ptr解决的并不只是资源自动释放的问题。在C++11之前还有auto_ptr、scope_ptr
这两种类型的指针,unique_ptr在资源自动释放的同时也解决了两个指针的拷贝、赋值问题(指针的浅拷贝问题)。
假设有下面的代码:
#include <iostream>
#include <memory>
int main() {
std::auto_ptr<int> p1(new int(100));
std::auto_ptr<int> p2(p1); // 拷贝构造
std::cout << *p1 << std::endl; // 拷贝构造之后还在使用原来的资源
}
在上面的代码中,p2是通过p1的拷贝构造而来的,但这个拷贝构造就很坑了,是指针的浅拷贝。我们可以看一下auto_ptr的源码:
auto_ptr(auto_ptr& _Right) noexcept
: _Myptr(_Right.release())
{ // construct by assuming pointer from _Right auto_ptr
}
_Ty * release() noexcept
{ // return wrapped pointer and give up ownership
_Ty * _Tmp = _Myptr;
_Myptr = nullptr;
return (_Tmp);
}
在上面的代码中,_Myptr就是智能指针管理的对象。
在拷贝构造的过程中,对象p1先调用了release函数,而release函数是先将p1的管理对象暂时保存起来,然后把它管理的对象指向nullptr,再用前面保存的临时对象去初始化p2的对象。现在好了,p1偷偷摸摸的把自己管理的对象置成了nullptr,可我们并不知道!当我们继续想使用p1的时候程序就会崩溃了!
这个过程,用户完全不知情啊,因此,为了解决这个问题,unique_ptr登场了。很明显,由于unique_ptr是“独占一份资源”,如果要把unique_ptr拷贝给另一个unique_ptr,上面的步骤肯定是要发生的,那怎么办呢?unique_ptr的设计者说,我不给拷贝了,我要把我的拷贝构造和拷贝赋值函数都声明成delete的。哈哈,这下没有指针浅拷贝的问题了吧!如下:
如果想用unique_ptr的拷贝构造或者拷贝赋值函数,编译器直接报错了。那现在我想把一个unique_ptr的资源交给另一个unique_ptr管理,怎么办呢?在auto_ptr中,出先的问题是auto_ptr偷偷摸摸的把我们的资源转移了,而我们不知道。所以unique_ptr的设计者说,这个资源转移的任务,就交给你们自己完成。没错,std::move这个时候发挥作用了:
如果我们自己来完成资源转移的任务后,还想使用原来的资源,那不就是你的锅?现在把原来用p1的左值去初始化p2变成了p1的右值去初始化p2了。由于我们知道std:move函数之后原对象的资源就不可用了,因此就不会发生拷贝构造之后还想使用原来对象的问题了。
同时,可以看见上面我们拷贝时传入一个右值就不报错了,所以肯定是unique_ptr虽然delete了拷贝构造、拷贝赋值,但人家定义了移动构造、移动赋值来进行对象资源的转移。
三、shared_ptr
如果我想多个指针共享一个资源怎么办?所以相比于unique_ptr,shared_ptr登场了。其实 shared_ptr的核心就是引入了一个引用计数,表示现在有多少个对象(shared_ptr)在共享着一份资源(l里面的裸指针),只要还有一个对象在用着这个指针,那就坚决不把这个资源给释放掉。
这里我们实现了一个简易版本的shared_ptr,实现时考虑的结构如下:
#include <iostream>
#include <memory>
template<typename T>
class CSmartPtr;
template<typename T>
class RefCnt {
public:
friend CSmartPtr<T>;
RefCnt(T* ptr = nullptr) : _ptr(ptr), _count(0) {
if (nullptr != _ptr) {
++_count;
}
}
// 增加引用计数
void addRef() {
++_count;
}
// 减少引用计数
int deleteRef() {
return --_count;
}
private:
T* _ptr;
int _count;
};
template<typename T>
class CSmartPtr {
public:
CSmartPtr(T* ptr = nullptr) : _ptr(ptr) {
_pRefCnt = new RefCnt<T>(_ptr);
}
~CSmartPtr() {
if (_pRefCnt->deleteRef() == 0) {
delete _pRefCnt;
delete _ptr;
}
}
CSmartPtr(const CSmartPtr<T>& src) : _ptr(src._ptr),
_pRefCnt(src._pRefCnt)
{
_pRefCnt->addRef();
}
CSmartPtr& operator=(const CSmartPtr& rhs) {
if (this == &rhs) {
return *this;
}
if (_pRefCnt->deleteRef() == 0) {
delete _ptr;
}
_ptr = rhs._ptr;
_pRefCnt = rhs._pRefCnt;
_pRefCnt->addRef();
return *this;
}
T& operator*() {
return *_ptr;
}
T* operator->() {
return _ptr;
}
// 返回引用计数
int use_count() {
return _pRefCnt->_count;
}
private:
T* _ptr;
RefCnt<T>* _pRefCnt;
};
int main() {
CSmartPtr<int> p1(new int(100));
std::cout << *p1 << std::endl;
std::cout << p1.use_count() << std::endl;
// 测试拷贝构造
{
std::cout << " ========== copy constructor ====== " << std::endl;
CSmartPtr<int> p2(p1);
std::cout << *p2 << std::endl;
std::cout << p1.use_count() << std::endl;
std::cout << p2.use_count() << std::endl;
}
// p2出了作用域 引用计数减1
std::cout << " ========== out of scope ============ " << std::endl;
std::cout << p1.use_count() << std::endl;
return 0;
}
tips:注意这里我们没有考虑线程的安全性,其实在标准库中的shared_ptr,里面的计数量count是原子性的,也就是线程安全的。
四、weak_ptr
如果自己按上面的代码实现了一下CSmartPtr,应该对shared_ptr什么时候释放资源很清楚了。只有当shared_ptr的引用计数为0了,才进行资源的释放。但是这又导致了交叉引用的问题。比如有如下代码:
class B;
class A {
public:
A() { std::cout << "A()" << std::endl; }
~A() { std::cout << "~A()" << std::endl; }
std::shared_ptr<B> _sptrB;
};
class B {
public:
B() { std::cout << "B()" << std::endl; }
~B() { std::cout << "~B()" << std::endl; }
std::shared_ptr<A> _sptrA;
};
int main() {
std::shared_ptr<A> pA(new A);
std::shared_ptr<B> pB(new B);
std::cout << pA.use_count() << std::endl;
std::cout << pB.use_count() << std::endl;
}
在上面的代码中,智能指针A的里面有个对象是智能指针B类型,而智能指针B的里面有个对象是智能指针A类型。
此时没有交叉引用的运行结果,一切正常:
而出现交叉引用之后:
int main() {
std::shared_ptr<A> pA(new A);
std::shared_ptr<B> pB(new B);
pA->_sptrB = pB;
pB->_sptrA = pA;
std::cout << pA.use_count() << std::endl;
std::cout << pB.use_count() << std::endl;
}
我们发现,pA和pB的析构函数都没有被调用起来,原因是在main函数结束的时候,pA和pB的引用计数都减了1,但不是0,所以无法调用起它们的析构函数。如果类A、B里面有从堆内存中申请的资源,那就发生内存泄漏了。
那怎么办呢?当然是weak_ptr登场了,在上面的例子中,把类A和B里面的对象类型从shared_ptr改成weak_ptr即可:
class B;
class A {
public:
A() { std::cout << "A()" << std::endl; }
~A() { std::cout << "~A()" << std::endl; }
std::weak_ptr<B> _sptrB;
};
class B {
public:
B() { std::cout << "B()" << std::endl; }
~B() { std::cout << "~B()" << std::endl; }
std::weak_ptr<A> _sptrA;
};
int main() {
std::shared_ptr<A> pA(new A);
std::shared_ptr<B> pB(new B);
pA->_sptrB = pB;
pB->_sptrA = pA;
std::cout << pA.use_count() << std::endl;
std::cout << pB.use_count() << std::endl;
}
运行结果:
但是,新的问题出现了:weak_ptr只是对象的观察者,不拥有对象本身。从实现上看,weak_ptr没有重载 "operator *" 和 "operator ->",自然无法使用资源了。
那怎么解决这个问题呢?答案就是我们要对weak_ptr做一个提升,也就是调用lock函数,把资源“锁住,抓在手里”,再来使用资源:
int main() {
std::shared_ptr<A> pA(new A);
std::shared_ptr<B> pB(new B);
pA->_sptrB = pB;
pB->_sptrA = pA;
pB->testB();
// 资源的提升
std::shared_ptr<B> pBTemp = pA->_sptrB.lock();
if (nullptr != pBTemp) {
pBTemp->testB();
}
// 使用完记得reset释放
pBTemp.reset();
std::cout << pA.use_count() << std::endl;
std::cout << pB.use_count() << std::endl;
}
这样就可以欢快的使用资源了。这种抓住资源的动作,其实在多线程的环境中非常有用,在使用共享资源的时候,其实共享资源可能已经被释放了,但本线程并不知道。而有了lock函数之后,就可以尝试“抓住资源”,如果返回不是nullptr,那就可以正常的使用资源了。
至此,C++的智能指针,基本讨论结束了。