智能指针从入门到入土

写在前面

无论是C还是C++的开发者,几乎所有人都会使用指针。指针很强大,但它也有它的不足,下面简单说下裸指针有哪些缺点:

  • 忘记释放内存,导致内存泄漏。
  • 即使没有忘记释放内存,写了释放资源的代码,但是由于程序逻辑满足条件,从中间return掉了,导致释放资源的代码未被执行到,导致内存泄漏。
  • 代码运行过程中发生异常,随着异常栈展开,导致释放内存的代码未被执行到,导致内存泄露。

智能指针的智能二字,主要体现在用户可以不关注内存的释放,因为智能指针会帮我们完全管理内存的释放,无论程序逻辑怎么跑,正常执行或者产生异常。

在C++11中,提供了带引用计数的智能指针和不带引用计数的智能指针,这篇文章主要介绍它们的原理和应用场景,包括auto_ptr,scoped_ptr,unique_ptr,shared_ptr,weak_ptr。

首先介绍一下RAII

RAII是指C++中一种惯用法(idiom),它是Resource Acquisition Initialization的首字母缩写。中文可将其翻译为“资源获取就是初始化”。

使用局部对象管理资源的技术通常称为“资源获取就是初始化”。这种通用技术依赖于构造函数和析构函数的性质以及它们与异常处理的交互作用

首先我们来明确资源的概念,在计算机系统中,资源是数量有限且对系统正常运行具有一定作用的元素。比如说内存、文件句柄、网络套接字(network sockets)、互斥锁(mutex locks)等。它们都属于系统资源,由于资源的数据不是无限的,有的资源甚至在整个系统中仅有一份,因此我们在使用资源时务必严格遵循的步骤是:

  1. 获取资源
  2. 使用资源
  3. 释放资源

例如在下面的UseFile函数中:

void UseFile(char const* fn)
{
    FILE* f = fopen(fn, "r");        // 获取资源
    // 在此处使用文件句柄f...          // 使用资源
    fclose(f);                       // 释放资源
}

调用fopen()打开文件就是获取文件句柄资源,操作完成之后,调用fclose()关闭文件就是释放该资源。资源的释放工作至关重要,如果只获取而不释放,那么资源最终会被耗尽。**上面的代码是否能够保证在任何情况下都调用fclose()函数呢?**请考虑以下情况:

void UseFile(char const* fn)
{
    FILE* f = fopen(fn, "r");        // 获取资源
    // 使用资源
    if (!g()) return;                // 如果操作g失败!
    // ...
    if (!h()) return;                // 如果操作h失败!
    // ...
    fclose(f);                       // 释放资源
}

在使用文件的过程中,因某些操作失败而造成函数提前返回的现象经常出现。这时函数UseFile的执行流程将变为:

很显然,这里忘记了一个重要的步骤:在操作g或h失之后,UseFile函数必须首先调用fclose()关闭文件,然后才能返回其调用者,否则将会造成资源泄露。因此,需要将UseFile函数修改为:

void UseFile(char const* fn)
{
    FILE* f = fopen(fn, "r");        // 获取资源
    // 使用资源
    if (!g()) { fclose(f); return; }
    // ...
    if (!h()) { fclose(f); return; }
    // ...
    fclose(f);                       // 释放资源
}

现在的问题是:用于释放资源的代码fclose(f)需要在不同的位置重复书写多次。如果再加入异常处理,情况会变得更加复杂。例如,在文件的使用过程中,程序可能会抛出异常:

void UseFile(char const* fn)
{
    FILE* f = fopen(fn, "r");        // 获取资源
    // 使用资源
    try {
        if (!g()) { fclose(f); return; }
        // ...
        if (!h()) { fclose(f); return; }
        // ...
    }
    catch (...) {
        fclose(f);                   // 释放资源
        throw;
    }
    fclose(f);                       // 释放资源
}

显然我们发现,我们必须依赖catch()来捕获所有的异常,来关闭文件f并重新抛出该异常。随着控制流程复杂度的增加,需要添加资源释放代码的位置会越来越多。如果资源的数量还不止一个,那么程序员就更加难于招架了。可以想象这种做法的后果是:**代码臃肿,效率低下,更重要的是,程序的可理想性和可维护性明显降低。**此时我们应当思考,**是否存在一种方法可以实现资源管理的自动化呢?**答案是肯定的。假设UseResources函数要用到这n个资源,则进行资源管理的一般模式为:

void UseResources()
{
    // 获取资源1
    // ...
    // 获取资源n
    
    // 使用这些资源
    
    // 释放资源n
    // ...
    // 释放资源1
}

不难看出资源管理技术的关键在于:**要保证资源的释放顺序与获取顺序严格相反。**这自然使我们联想到局部对象的创建和销毁的过程。在C++中,定义在栈空间上的局部对象称为自动存储(automatic memory)对象。管理局部对象的任务非常简单,因为它们的创建和销毁工作是由系统自动完成的。我们只需要在某个作用域(scope)中定义局部对象(这时系统自动调用构造函数以创建对象),然后就可以放心大胆地使用之,而不必担心有关的善后(释放)工作;当控制流程超出这个作用域的范围时,系统会自动调用析构函数,销毁该对象。

大家可能会想:如果系统中的资源也具有如同局部对象一样的特性,自动获取,自动释放,那该多么美妙啊!事实上,你的想法已经与RAII不谋而合了。既然类是C++中的主要抽象工具,那么就将资源抽象为类,用局部对象来表示资源,把管理资源的任务转化为管理局部对象的任务。这就是RAII惯用法的真谛!可以毫不夸张地说,RAII有效地实现了C++资源管理的自动化。例如,我们可以将文件句柄FILE抽象为FileHandle类:

class FileHandle {
public:
    FileHandle(char const* n, char const* a) { p = fopen(n, a); }
    ~FileHandle() { fclose(p); }
private:
    // 禁止拷贝操作
    FileHandle(FileHandle const&);
    FileHandle& operator= (FileHandle const&);
    FILE *p;
};

FileHandle类的构造函数调用fopen()获取资源;FileHandle类的析构函数调用fclose()释放资源。请注意,考虑到FileHandle对象代表一种资源,它并不具有拷贝语义,因此我们将拷贝构造函数和赋值运算符重载声明进行隐藏。如果利用FileHandle类的局部对象表示句柄资源,那么前面的UseFile函数便可简化为:

void UseFile(char const* fn)
{
    FileHandle file(fn, "r"); 
    // 在此处使用文件句柄f...
    // 超出此作用域时,系统会自动调用file的析构函数,从而释放资源
}

现在我们就无需担心隐藏在代码之中的return语句了!不管函数是正常结束还是提前返回,系统都必须“乖乖地”调用file的析构函数,资源一定会被释放。Bjarne所谓“使用局部对象管理资源的技术……依赖于构造函数和析构函数的性质”,说的正是这种情形。

且慢!如若使用文件file的代码中有异常退出,难道析构函数还会被调用吗?此时RAII还能够如此奏效吗?问得好!事实上,当一个异常抛出之后,系统沿着函数调用栈,向上寻找catch出现的地方的过程,称为栈辗转开解(stack unwinding)。**C++标准规定,在辗转开解函数调用栈的过程中,系统必须确保调用所有已经创建起来的局部对象的析构函数。**例如:

void Foo()
{
    FileHandle file1("n1.txt", "r"); 
    FileHandle file2("n2.txt", "w");
    Bar();       // 可能抛出异常
    FileHandle file3("n3.txt", "rw");
}

当Foo()调用Bar()时,局部对象file1和file2已经在Foo的函数调用栈中创建完毕,而file3却尚未被创建。如果Bar()抛出异常,那么file2和file1的析构函数会被先后调用(注意:析构函数的调用顺序与构造函数相反);由于此时栈中尚不存在file3对象,因此它的析构函数不会被调用。只有当一个对象的构造函数执行完毕之后,我们才认为该对象的创建工作完成。栈辗转开解的过程仅调用已经创建的对象的析构函数。

**RAII惯用法同样适用于需要管理多个资源的复杂对象。**例如,Widget类的构造函数要获取两个资源:文件myFile和互斥锁myLock。每个资源的获取有可能失败并且抛出异常。为了正常使用Widget对象,这里我们必须维护一个不变式(invariant):当调用构造函数时,要么两个资源全部获得,对象创建成功;要么两个资源都没得到,对象创建失败。获取了文件而没有得到互斥锁的情况永远不会出现。也就是说,不允许建立Widget对象的“半成品”。如果将RAII惯用法应用于成员对象,那么我们就可以实现这个不变式:

class Widget {
public:
    Widget(char const* myFile, char const* myLock)
    : file_(myFile),     // 获取文件myFile
      lock_(myLock)      // 获取互斥锁myLock
    {}
    // ...
private:
    FileHandle file_;
    LockHandle lock_;
};

FileHandle和LockHandle类的对象作为Widget类的数据成员,分别表示需要获取的文件和互斥锁。资源的获取过程就是两个成员对象的初始化过程。在此系统会自动地为我们进行资源管理,程序员不必显式地添加任何异常处理代码。例如,当已经创建完file_,但尚未创建完lock_时,有一个异常抛出,则系统会调用file_的析构函数,而不会调用lock_的析构函数。Bjarne所谓构造函数和析构函数"与异常处理的交互作用",说的就是这种情形。

综上所述,RAII的本质内容是用对象代表资源,把管理资源的任务转化为管理对象的任务,将资源的获取与对象的构造和析构先对应起来,从而确保在对象的生存期内资源是中有效,对象销毁时资源必被释放。换句话说,拥有对象就等于拥有资源,对象存在则资源必定存在。由此可见,RAII惯用法是进行资源管理的有力武器。C++程序员依靠RAII写出的代码不仅简洁优雅,而且做到了异常安全。

微软的MSDN杂志曾在一篇文章中承认:”若论资源管理,谁也比不过标准C++“。

先实现一个最简单的智能指针感受一下:

#include <iostream>
using namespace std;

template<typename T>
class SmartPtr
{
public:
	SmartPtr(T *ptr = nullptr) :mptr(ptr) {}
	~SmartPtr() { delete mptr; }

	T& operator*() { return *mptr; } // 1
	const T& operator*()const { return *mptr; } // 2

	T* operator->() { return mptr; }
	const T* operator->()const { return mptr; }
private:
	T *mptr;
};

先说一下智能指针的原理,也就是它为什么可以接管内存释放这项工作。
上面已经介绍了RAII的原理,而智能指针该原理的最佳实践:

  • 智能指针体现在把裸指针进行了一次面向对象的封装,在构造函数中初始化资源地址,在析构函数中负责释放资源;
  • 利用局部对象(栈上的对象)出作用域时自动执行析构函数这个特点,在智能指针的析构函数中保证释放资源。

这里提出提个问题,智能指针可以定义在堆上吗?
答:可以是可以的,但是当你要析构智能指针时又要手动去执行delete,这不是又回到裸指针的缺点了吗?套娃了!!!

现在来简单对比下裸指针和智能指针的使用:

使用裸指针

int main()
{
	int *p = new int;
	/*其它的代码...*/
	/*
	如果这里忘记写delete,或者上面的代码段中程序return掉了,
	没有执行到这里,都会导致这里没有释放内存,内存泄漏
	*/
	delete p;

	return 0;
}

使用智能指针:

template<typename T>
class SmartPtr
{
public:
	SmartPtr(T *ptr = nullptr) :mptr(ptr) {}
	~SmartPtr() { delete mptr; }
private:
	T *mptr;
};

int main()
{
	SmartPtr<int> ptr(new int);
	/*其它的代码...*/

	/*由于ptr是栈上的智能指针对象,不论函数是否正常执行完,还是运行过程中出现
	异常,栈上的对象都会自动调用析构函数,在析构函数中进行了delete
	操作,进而执行智能指针的析构函数,保证释放资源*/
	return 0;
}

再解释一下为什么会有以下两个成员函数:

T& operator*() { return *mptr; } // 1
const T& operator*()const { return *mptr; } // 2
  • 既然智能指针是个指针,那么指针该有的操作他肯定也是要有的,所以在智能指针中重载了指针运算符和->运算符;
  • 那第二个版本是什么意思呢?第二个const指的是调用这个指针运算符的对象是个常量对象,那么其内部的成员肯定都是不能修改的,但我们又返回其内部的成员变量mptr,那么这个mptr肯定也是不能修改的。所以我们把返回的引用类型定义成常量引用,让用户无法通过指针运算符去修改mptr。

再接着往下讲之前先引出两个问题,凡事都没有完美的,智能指针也不是完美的,它是有很多陷阱的,但如果我们合理的去规避这些陷阱,那智能指针还是一个好指针。
比如下面这这种情况:

int main()
{
	SmartPtr<int> ptr1(new int);
	SmartPtr<int> ptr2(ptr1);
	return 0;
}

这个main函数运行,代码直接崩溃,问题出在默认的拷贝构造函数做的是浅拷贝,两个智能指针都持有一个new int资源,ptr2先析构释放了资源,到ptr1析构的时候,就成了delete悬空指针了,造成程序崩溃。所以这里引出来智能指针需要解决的两件事情:

  • 怎么解决智能指针的浅拷贝问题?
  • 多个智能指针指向同一个资源的时候,怎么保证资源只释放一次,而不是每个智能指针都释放一次,造成代码崩溃。

下面介绍以下C++标准库中提供的智能指针是怎么解决上面提到的问题的。

不带引用计数的智能指针

C++标准库中提供的不带引用计数的智能指针主要包括:auto_ptr,scoped_ptr,unique_ptr。

auto_ptr

auto_ptr主要源码如下:

	class auto_ptr
	{	// wrap an object pointer to ensure destruction
public:
	typedef _Ty element_type; // 指针类型

	explicit auto_ptr(_Ty * _Ptr = nullptr) noexcept
		: _Myptr(_Ptr){	// construct from object pointer}

	~auto_ptr() { delete _Myptr; }
	/*这里是auto_ptr的拷贝构造函数,
	_Right.release()函数中,把_Right的_Myptr
	赋为nullptr,也就是换成当前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);
		}
	
private:
	_Ty * _Myptr;	// the wrapped object pointer
};

从auto_ptr的源码可以看到,只有最后一个auto_ptr智能指针持有资源,原来的auto_ptr都被赋nullptr了,delete一个指向nullptr的指针是安全的。

int main()
{
	auto_ptr<int> p1(new int);
	/*
	经过拷贝构造,p2指向了new int资源,
	但是p1现在为nullptr了,如果再次使用p1,相当于
	访问空指针了,很危险
	*/
	auto_ptr<int> p2 = p1;
	*p1 = 10;
	return 0;
}

所以不要在容器中使用auto_ptr,C++建议最好不要使用auto_ptr,除非应用场景非常简单。

【总结】:auto_ptr智能指针不带引用计数,它处理浅拷贝的问题,是直接把前面的auto_ptr中存储的裸指针成员置为nullptr,只让最后一个auto_ptr持有资源。

scoped_ptr

scoped_ptr是Boost库中的,并不在C++标准库中,就不介绍了,其原理很简单,就是把拷贝构造函数和operator=赋值运算符直接私有化,从根本上杜绝了拷贝的发生,也就解决了智能指针浅拷贝的问题。
auto_ptr与scoped_ptr的区别在于,auto_ptr永远把资源的所有权向下传递,scoped_ptr是永远把资源的所有权握在自己手中,永远不会传递。

unique_ptr

这里实现一个我自己的unique_ptr。
有个小技巧说一下,在移动构造函数中转发时尽量都使用完美转发,因为如果一个对象转化为右值了,那么它的成员理应也是右值,所以在移动构造函数内理应对对象的成员使用完美转发,防止对象的成员使用拷贝构造而没有使用移动构造。

/// @brief 默认删除器
class Delete {   
public:
    template<typename T>
    void operator()(T *p) const {
        delete  p;
    }
};
template<typename T,typename D = Delete>
class unique_ptr {
public:
    explicit unique_ptr(T *p = nullptr, const D& del = D())
        :_ptr(p), _del(del)
        {  
        }
    ~unique_ptr() { 
        del(_ptr); 
    } 
    /* 不支持拷贝与赋值   */
    unique_ptr(const unique_ptr&) = delete ;
    unique_ptr& operator=(const unique_ptr&) = delete ;
 
    /* 支持移动构造 */
    unique_ptr(unique_ptr&& up):
        _ptr(up._ptr), del(std::forward<D>(up._del)) { // D为原始类型,那么forward一定返回将亡值
        up._ptr = nullptr ;
    }
    unique_ptr& operator = (unique_ptr&& up){
        if(this != &up){
            del(_ptr);
            _ptr = up._ptr;
            del = std::forward<D>(up._del);
            up._ptr = nullptr;
        }
        return *this;
    }
    //u.release()   u 放弃对指针的控制权,返回指针,并将 u 置为空
    T* release(){ 
        T* tmp = _ptr;
        _ptr = nullptr;
        return  tmp;
    }
    /*
    u.reset()   释放u指向的对象
    u.reset(q)  如果提供了内置指针q,就令u指向这个对象 
    u.reset(nullptr) 将 u 置为空
    */
    void reset(){ del(_ptr); }
    void reset(T* p){ 
        if(_ptr){
            del(_ptr);
            _ptr = p;
        }
        else 
            _ptr = p; 
    }
    void swap(unique_ptr &up){
        using std::swap; 
        swap(_ptr, up._ptr);
        swap(_del, up._del);
    } 
    T* get() { return _ptr; }
    D& get_deleter(){ return  _del; }
    T& operator*()  { return *_ptr ;}
    T* operator->() { return  _ptr; }
private:
    T* _ptr = nullptr ;
    D _del;
};
#endif

带引用计数的智能指针shared_ptr、weak_ptr

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值