智能指针类模板:auto_ptr、unique_ptr、shared_ptr的原理与使用

1. 什么是智能指针

智能指针是行为类似于指针的类对象,通常用于管理动态内存分配。C++程序通常手动动态分配堆内存,但如果动态分配的内存没有释放,则会发生内存泄漏。
例如代码段1.1。

// 代码段1.1
void demo()
{
   double *pd = new double;
   *pd = 25.5;
}

因为使用了new关键字申请动态内存,因此每次调用这个函数,都会从堆中分配一段内存,但直到函数运行结束,都没有释放分配的内存,因此产生了内存泄漏。
图示内存泄漏
解决该问题的方法为在函数return前添加一段代码delete pd;

另一种常见的产生内存泄漏的情况是在函数出现异常的时候。例如代码段1.2。

// 代码段1.2
void demo()
{
   double *pd = new double;
   if (weird_thing()) {
   	throw_exception();
   }
   *pd = 25.5;
   delete pd;
}

当出现异常时,不会执行delete语句,因此发生内存泄漏。
上面的代码中,pd只是普通的指针。如果使用对象来完成pd的功能,那么我们就可以利用对象的构造函数和析构函数,来完成动态申请和释放内存的操作。在创建对象时,自动调用构造函数申请内存;在函数返回时,对象过期,也会自动调用析构函数释放内存。实现上述功能的模板类就是智能指针。

C++98提供的解决方案是模板auto_ptr,但它也有自己的缺陷,因此C++11将其摒弃,并提供了unique_ptr和shared_ptr作为另外两种解决方案。

2. 智能指针的常规使用

先从机制最简单的auto_ptr讲起。我们提取auto_ptr的实现代码,为了方便可读性,此处略作删减。具体源码可以自行到memory头文件中查阅。

// 代码段2.1
template <class _Ty> class auto_ptr {
private:
    _Ty* _Myptr; // the wrapped object pointer

public:
    // 构造函数:根据已有的指针构造auto_ptr,并保存到
    explicit auto_ptr(_Ty* _Ptr = nullptr) noexcept : _Myptr(_Ptr) {
    }

    // 析构函数:销毁申请的内存空间
    ~auto_ptr() noexcept {
        delete _Myptr;
    }

    // return wrapped pointer
    _NODISCARD _Ty* get() const noexcept {
        return _Myptr;
    }

	// 重载*运算符:取指针的值
    _NODISCARD _Ty& operator*() const noexcept {
#if _ITERATOR_DEBUG_LEVEL == 2
        _STL_VERIFY(_Myptr, "auto_ptr not dereferencable");
#endif
        return *get();
    }

    // 重载->运算符:访问结构体成员
    _NODISCARD _Ty* operator->() const noexcept { // return pointer to class object
#if _ITERATOR_DEBUG_LEVEL == 2
        _STL_VERIFY(_Myptr, "auto_ptr not dereferencable");
#endif
        return get();
    }

    ...
};

注:

  1. noexcept是C++11中引入的关键字,其含义是程序员向编译器保证该函数不会发射异常。
  2. _NODISCARD定义为就是C++17中的新属性[[nodiscard]],定义在函数前表示该函数的返回值非void,调用该函数时最好使用一个变量或对象来保存返回值,否则会报warning。
  3. _STL_VERIFY(_Myptr, "xxxx")的作用是判断指针_Myptr如果为空,则抛出错误"Expression: xxxx",否则不做任何操作。

因此,可以对代码段1.1进行改写,将pd更换成auto_ptr。

// 代码段2.2
void demo()
{
	auto_ptr<double> apd(new double);
	*apd = 25.5;
}

new double会返回new申请的动态内存的指针,作为参数传递给auto_ptr的构造函数,构造函数会用该指针初始化私有成员_Myptr。
在这里插入图片描述
但是,auto_ptr有一个重大缺陷,严重影响了它的使用的安全性,下面我们做详细介绍。

3. auto_ptr的缺陷

如果auto_ptr只完成上述功能,那么会有一个严重的问题。例如代码段3.1,创建两个auto_ptr对象p1、p2,并将p2指向p1的同一块内存空间。

// 代码段3.1
void demo()
{
	auto_ptr<double> p1(new double);
	auto_ptr<double> p2;
	p2 = p1;
}

但这种做法实际上是不能被接受的,因为在函数运行结束时,程序将试图释放这块内存空间两次——一次是在p2过期调用析构函数时,一次是在p1过期调用析构函数时。也就是说,如果将代码段3.1改写成new-delete方式,与代码段3.2等价。代码段3.2运行时会直接报错。

// 代码段3.2
void demo()
{
	double* p1 = new double;
	double* p2;
	p2 = p1;
	delete p2;
	delete p1;
}

为了解决这个问题,auto_ptr类模板制定了一个”所有权(ownership)“的概念。对于一个特定的对象,只允许有一个auto_ptr拥有它。使用=赋值号的的时候,发生所有权的转移,将对象的所有权从旧的auto_ptr转移给新的auto_ptr,并将就得auto_ptr置为nullptr,这样在释放内存空间时,不会出现好几个auto_ptr试图释放同一块内存空间得情况。

通过重载运算符=,实现上述功能,具体代码为代码段3.3。

	// return wrapped pointer and give up ownership
    _Ty* release() noexcept {
        _Ty* _Tmp = _Myptr;
        _Myptr    = nullptr;
        return _Tmp;
    }

	// 重置_Myptr的值:如果传入的地址与_Myptr指向的地址不同,
	// 则先释放掉_Myptr指向的空间,再让其指向传入的地址空间
    void reset(_Ty* _Ptr = nullptr) { 
        if (_Ptr != _Myptr) {
            delete _Myptr;
        }

        _Myptr = _Ptr;
    }

	// 重载=运算符:让右值指向nullptr,并将右值的赋给左值
    auto_ptr& operator=(auto_ptr& _Right) noexcept {
        reset(_Right.release());
        return *this;
    }

重载=运算符后,代码段3.1就可以编译通过并成功运行了。但是如果我们想在p1的所有权转移之后再访问p1的地址,就会报错。如代码段3.4。

// 代码段3.4
void demo()
{
	auto_ptr<double> p1(new double);
	*p1 = 25.5;
	cout << *p1 << endl;	// 正常打印25.5
	auto_ptr<double> p2;
	p2 = p1;
	cout << *p1 << endl;	// 报错:auto_ptr not dereferencable
}

执行单步调试可以看出,在执行完语句p2 = p1之后,p1就会被置为nullptr,无法再被访问。

auto_ptr的拷贝构造函数与赋值有着同样的问题,因此也采用相同的方法解决,即调用拷贝构造函数时会转移所有权。此处不再赘述。

4. C++11新策:shared_ptr和unique_ptr

由于上文所述的种种缺陷,在C++11中弃置了auto_ptr,并实现了shared_ptr和unique_ptr,分别使用两种方法来解决该问题。

unique_ptr

unique_ptr延续了auto_ptr的所有权机制, unique_ptr所指向的对象只能有一个unique_ptr指针,因此unique_ptr不支持普通的拷贝和赋值操作。

将代码段3.1中的auto_ptr直接改成unique_ptr,则赋值语句会直接在编译时报语法错误。编译阶段的错误比潜在的程序崩溃更安全。

// 代码段4.1
void demo()
{
	unique_ptr<double> p1(new double);
	unique_ptr<double> p2;
	p2 = p1; // 编译错误
}

但是,unique_ptr并不是禁止赋值操作。如果赋值号的右值是临时的右值,赋值后不会留下悬空指针,那么这种赋值操作是被允许的。如代码段4.2。

// 代码段4.2
void demo()
{
	unique_ptr<double> p3;
	p3 = unique_ptr<double>(new double);	// allowed
}

代码段4.2是可以成功编译运行的,因为赋值号的右值是一个临时右值,它调用了unique_ptr的构造函数创建了一个临时对象,并在所有权转让给p3后被销毁。因此不会留下悬空指针。

unique_ptr通过C++11新增的移动构造函数和引用区分安全和不安全的用法。

此外,相比于auto_ptr,unique_ptr可用于数组的变体。C++中,new和delete配对,new []和delete [],但auto_ptr中只实现了delete而没有实现delete [],因此只能使用new分配内存。unique_ptr实现了delete和delete [],因此可以使用new []初始化数组。

// 代码段4.3
	unique_ptr<double[]> pd(new double(5));

shared_ptr

shared_ptr通过引用计数(reference counting)的方式来解决多个智能指针指向同一块内存空间的问题。引用计数记录了指向同一块内存的智能指针的个数,发生赋值操作或复制时,计数加1;每当一个指针过期时,计数减1。当最后一个指针过期时,才调用delete释放内存空间。

因此,当程序需要多个指针指向同一个对象时,使用shared_ptr。

但shared_ptr存在的一个问题是,在多线程情况下,它不是线程安全的。这是因为shared_ptr的内存模型中包含两个指针——指向对象的指针和指向引用计数的指针。
shared_ptr的内存模型
无论是对象本身还是对象的引用计数都是可以被多个shared_ptr共享的,因此是临界资源。而赋值操作本身两个步骤才能完成:①智能指针指向对象②计数加1。这并不是一个原子操作(即一步就能完成的操作)。因此在多线程情况下可能引发安全问题,甚至带来悬空指针。如代码段4.4。

// 代码段4.4
shared_ptr<double> gx(new double(1));

线程A:
void demoA()
{
	shared_ptr<double> pa;
	pa = gx;
}

线程B:
void demoB()
{
	shared_ptr<double> pb(new double(2));
	gx = pb;
}

上述代码中包括两个线程A、B。三个shared_ptr,其中gx为全局变量(线程A、B均可访问),pa为线程A局部变量,pb为线程B局部变量。假设有如下情况

  1. 线程A执行pa = gx,即读gx。但该赋值操作只来得及完成步骤①指针指向对象,尚未完成步骤②引用计数加1,这时切换成了线程B;
  2. 线程B执行gx = gb,即写gx。该赋值操作完成了步骤①和②。线程B运行结束并退出,释放了pb的空间并减少
  3. 继续执行线程A,由于此时object 1的引用计数已经变成0,则会释放该对象,导致pa成为悬空指针。

shared_ptr的线程不安全场景

boost官方文档中有如下结论:
3. 同一个shared_ptr被多个线程“读”是安全的;
4. 同一个shared_ptr被多个线程“写”是不安全的;
5. 共享引用计数的不同的shared_ptr被多个线程”写“ 是安全的

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值