C++中的智能指针

以下四个智能指针是我们常用的智能指针
1.auto_ptr  管理权转移,带有缺陷的设计。尽量不要使用它
2.unique_ptr 防拷贝,高效简洁。不需要拷贝/赋值,尽量使用它
3.shared_ptr 共享(引用计数)管理、支持赋值/拷贝。缺陷:循环使用(针对循环使用有weak_ptr).
4.weak_ptr辅助shared_ptr解决循环引用,不增加引用计数。

为什么要引进智能指针?

  • 在C和C++中,通过访问指针对象存储的地址,可以实现对内存的直接操作。
  • 我们都知道我们在动态开辟了一块空间以后,需要手动delete,否则会发生内存泄漏的问题
  • 但在实际工程中,可能存在一些特殊的情况,很可能我们的程序没有执行到delete
  • 比如我们在程序中访问了无效的内存地址,除以0,或者在delete之前程序就已经return了
  • 前两种情况,程序会抛出异常,而第三种情况,程序过早地return导致我们没有释放资源
  • 更甚者,开发人员很可能会忘记调用free或delete来释放对应的内存
  • 虽然内存泄漏问题会在程序结束时解决,但是这对于一个服务器程序确是致命的(服务器程序不会经常重启)
  • 因此引入了智能指针,自动调用delete释放资源,而不需要手动去调用,减轻了开发人员的

智能指针的原理(RAII,资源申请即初始化)

  • 智能指针只是RAII中相当重要的一个例子,当然还有别的例子
  • 智能指针确保在任何情况下,动态分配的内存都能得到正确的释放,从而将开发人员从这项任务中解放出来(我们不用手动地调用delete去释放智能指针)
  • 这包括程序因为异常而中断,原本用于释放内存的代码被跳过的场景(既然我们不用手动释放了,自然也不用担心这个问题了)
  • 用一个动态分配的对象的地址来初始化智能指针,在析构的时候释放内存,就确保了这一点
  • 因为析构函数总是会被执行,这样所包含的内存也将总是会被释放
  • 因此智能指针其实不是指针,而是一个类,只是这个类的构造函数的参数,是动态开辟出来空间的地址(用new的返回值的指针来初始化智能指针,达到资源申请即初始化的目的)
  • 可以这么理解,智能指针 = 指针+RAII,RAII将指针封装成一个类,通过构造函数和析构函数管理指针,防止内存泄漏(因为一个类在出了类的作用域之后会自动调用类的析构函数)

auto_ptr(自动指针,特点:管理权转移)

class Test{
public:
    Test(int a = 0)
        : _a(a)
    {}
    ~Test(){
        cout << "Calling destructor" << endl;
    }
public:
    int _a;
};

int main() {
    auto_ptr<Test> ptr(new Test(5));
    cout << ptr->_a << endl;
    return 0;
}

auto_ptr完成了智能指针的基本功能,在申请资源的时候,调用构造函数进行初始化,达到资源申请即初始化的目的,同时当main函数结束时,ptr出了这个作用域也会自动调用析构函数来释放资源。

但是同时,auto_ptr因为它本身的特点(这个指针通过在拷贝构造和赋值运算符重载时采用管理权转移实现),会出现很多问题。

void Fun(auto_ptr<Test> ptr1) {
    cout << ptr1->_a << endl;
}

int main() {
    auto_ptr<Test> ptr(new Test(5));
    cout << ptr->_a << endl;
    Fun(ptr);
    cout << ptr->_a << endl;
    return 0;
}

上面的程序会崩溃。本来ptr拥有一块自己的内存,当Fun函数调用后,ptr关联的内存的所有权转移给了Fun函数的参数ptr1,ptr1是ptr的一份拷贝(因为这里采用的是值传递),ptr1拥有的是原来ptr拥有的内存块。当Fun函数执行完以后,ptr1拥有的内存块也就因为智能指针的特性随之释放了。而ptr这个对象在函数传参时它的所有权就没有了,它变成了一个空对象,它内部的指针也变成了一个空指针,这个时候再尝试去调用ptr->_a,就是是解引用空指针,程序崩溃。
这里写图片描述
这里写图片描述

然而上述在拷贝之后使用原来指针的情况是经常发生的!因此auto_ptr非常不被推荐使用

下面是关于auto_ptr的模拟实现

template<class T>
class Auto_ptr{
public:
    //构造函数
    Auto_ptr(T* ptr)
        :_ptr(ptr)
    {}

    //拷贝构造函数
    Auto_ptr(Auto_ptr<T>& a)
        :_ptr(a._ptr){
        //因为要转移指针权限,原来的指针就没有权限了
        //如果在这里不像函数体内部这样将a中的_ptr置为NULL,那么a中的_ptr就是指向了一块已经释放了的空间
        //再调用a的析构,此时就会出现重复delete的问题
        //可以尝试着把下面一行注释掉,这样程序就会崩溃了(如果进行了拷贝构造)
        a._ptr = NULL;
    }

    //赋值运算符重载
    Auto_ptr<T>& operator=(Auto_ptr<T>& a){
        _ptr = a._ptr;
        a._ptr = NULL;
        return *this;
    }

    //析构函数
    ~Auto_ptr(){
        std::cout << "In ~Auto_ptr()" << std::endl;
        if(_ptr){
            //这里一定要对_ptr判空,如果不为空才delete
            delete _ptr;
            _ptr = NULL;
        }
    }

    //把这个_ptr设为public方便测试
    T* _ptr;
};

因此,在这里总结一下auto_ptr的缺点,缺点太多啦,不推荐使用。

  • 容易在拷贝之后对原来的auto_ptr进行 -> 或 * 操作
  • auto_ptr不能指向一组对象,就是说它不能和操作符new[]一起使用。因为auto_ptr的析构函数是使用delete实现的而不是delete []。因此当我们创建一个数组对象时,就会发生错误。
  • auto_ptr不能和标准容器(vector,list,map….)一起使用(标准容器在分配内存的时候,必须要能够拷贝构造容器的元素。而且拷贝构造的时候,不能修改原来元素的值(可以记得拷贝构造函数中里面的元素都是const T&)。而auto_ptr在拷贝构造的时候,一定会修改元素的值)

关于第三个缺点,可以看下面的代码

//注意下面的代码是在VS2017环境下测试的,可能因为编译器不同,下面的代码就直接编译不过
int main() {
    vector< auto_ptr<int> > v(5);
    int i = 0;
    for (; i<5; i++)
    {
        v[i] = auto_ptr<int>(new int(i));
    }

    vector< auto_ptr<int> > v1(v);
    return 0;
}

这里写图片描述

原来的v中的auto_ptr都变为空了,但这并不是我们的本意,我们不想改变原有容器。

unique_ptr(唯一指针,特点:防拷贝)

  • 针对auto_ptr的缺陷(访问原指针可能导致程序崩溃),因此有了unique_ptr
  • unique_ptr通过限制拷贝构造和赋值运算的方式规避 auto_ptr 的缺陷(只声明不定义,并且定义为private)
  • unique_ptr遵循着独占语义。在任何时间点,资源只能唯一地被一个unique_ptr占有(通过禁止拷贝语义, 只有移动语义来实现)
  • 应该优先使用unique_ptr,可以避免不经意的权限传递(防拷贝,编译时就不会通过)
  • 记住unique_ptr不提供赋值语义(拷贝赋值和拷贝构造都不可以),只支持转移语义

创建

unique_ptr<int> uptr( new int );

unique_ptr相比于auto_ptr,它还可以创建数组对象的特殊方法,当指针离开作用域时,调用delete []来代替delete。当创建unique_ptr时,这一组对象被视作模板参数的部分。这样,程序员就不需要再提供一个指定的析构方法,如下:

unique_ptr<int[ ]> uptr( new int[5] );

可以通过下面的转移语义来转移所有权

unique_ptr<int> ptr(new int(5));
unique_ptr<int> ptr1 = move(ptr);//转移了以后ptr就是一个空对象,内部指针是空指针

接口

unique_ptr提供的接口和传统指针差不多,但是不支持指针运算。

unique_ptr提供一个release()的方法,释放所有权。release和reset的区别在于,release仅仅释放所有权但不释放资源,reset也释放资源。

shared_ptr(共享指针,特点:引用计数,支持赋值/拷贝)

  • 这是使用频率最高的一个智能指针,在C++11标准被引入标准库
  • shared_ptr基本上类似于unique_ptr,关键不同处在于shared_ptr可以进行拷贝和赋值操作。
  • 它可以和其他shared_ ptr类型的智能指针共享所有权,shared_ptr记录了有多少个shared_ptr在引用同一个对象,当引用对象的最后一个智能指针被摧毁后,对象才会被释放。
  • 这个计数被称为强引用,shared_ptr还有一个弱引用,在讲weak_ptr的时候会说到
  • 因为所有权可以在shared_ ptr之间共享(采用引用计数共享),任何一个共享指针都可以被复制,因此就可以在标准容器中存储智能指针了(shared_ptr在拷贝构造和赋值操作的时候,只会引起公用的引用计数的+1,不存在拷贝构造和赋值操作的参数不能是const的问题)
  • 与unique_ ptr类似的是,它的析构函数也是delete

创建

shared_ptr<int> ptr( new int );

使用make_shared宏来加速创建的过程。

shared_ptr<int> ptr = make_shared<int>(100);

两种创建方式的区别:默认的构造函数会申请两次内存,而make_shared只会申请一次内存。因为shared_ptr内部有一个引用计数以及存放数据的内存,等于说有两部分内存,默认构造函数为数据内存和引用计数每个分别申请一次内存,而make_shared将数据和引用计数的内存申请放到一起。

关于make_shared的缺点
make_shared 只分配一次内存, 这看起来很好. 减少了内存分配的开销. 问题来了, weak_ptr 会保持控制块(强引用, 以及弱引用的信息)的生命周期, 而因此连带着保持了对象分配的内存, 只有最后一个 weak_ptr 离开作用域时, 内存才会被释放. 原本强引用减为 0 时就可以释放的内存, 现在变为了强引用, 弱引用都减为 0 时才能释放, 意外的延迟了内存释放的时间. 这对于内存要求高的场景来说, 是一个需要注意的问题

析构

如果用户采用一个不一样的析构策略时,可以自由指定构造这个shared_ptr的策略

shared_ptr<Test> sptr1( new Test[5], 
       [ ](Test* p) { delete[ ] p; } );

上面的代码表示创建了一个数组对象,并采用了指定的delete []来析构

接口

  • use_count() :可以得到shared_ptr的引用计数(强引用计数,strong_ref)
  • get() :获取到对象的资源,注意这是获取到的是一个指向申请到的内存的指针
  • reset() :释放关联内存块的所有权,如果是最后一个指向该资源的shared_ptr,就释放这块内存
  • unique() :判断是否是唯一指向当前内存的shared_ptr
  • operator bool : 判断当前的shared_ptr是否指向一个内存块,可以直接用于if表达式(if(shared_ptr))

下面是关于shared_ptr的模拟实现

template<class T>
class Shared_ptr{
public:
    Shared_ptr(T* ptr)
        :_ptr(ptr),
         _ref(new int(1))
    {}

    Shared_ptr(const Shared_ptr<T>& a)
        :_ptr(a._ptr),
         _ref(a._ref)
    {
        std::cout << "拷贝前计数:" << *_ref << std::endl;
        //让引用计数++
        (*_ref)++;
        std::cout << "拷贝后计数:" << *_ref << std::endl;
    }

    Shared_ptr<T>& operator=(const Shared_ptr<T>& a){
        if(_ptr != a._ptr){
            int *tmp = _ref;
            std::cout << "赋值前原有对象计数:" << *_ref << std::endl;
            if(--(*_ref) == 0){
                //因为当前引用对象要去引用a对象的引用计数了
                //如果当前引用对象只剩一个了,即这个即将要去引用a对象的对象
                //那么当前引用对象的计数就为0,此时需要释放该对象
                delete _ptr;
                delete _ref;
            }
            std::cout << "赋值后原有对象计数:" << *tmp << std::endl;
            std::cout << "赋值前被赋值对象计数:" << *a._ref << std::endl;
            _ptr = a._ptr;
            _ref = a._ref;
            (*_ref)++;
            std::cout << "赋值前被赋值对象计数:" << *_ref << std::endl;
        }
    }

    ~Shared_ptr(){
        if(--(*_ref) == 0){
            delete _ptr;
            delete _ref;
            _ptr = NULL;
            _ref = NULL;
        }
    }

    int* _ref;
    T* _ptr;
};

但是shared_ptr也是会产生一些问题

int main() {
    shared_ptr<int> sptr1(new int);  //引用计数为1
    shared_ptr<int> sptr2 = sptr1;   //引用计数为2
    shared_ptr<int> sptr3;            
    sptr3 = sptr1;                   //引用计数为3

    //析构的时候依次析构sptr3,sptr2,sptr1,引用计数依次减到0,并释放资源
    return 0;    
}

上面的代码没有问题,下面的代码就有问题了

int main() {
    int* p = new int;
    shared_ptr<int> sptr1(p);
    shared_ptr<int> sptr2(p);
    return 0;
}

因为sptr2并不是拷贝sptr1或者通过sptr1赋值过来的,因此两个shared_ptr的引用计数其实都是1,在析构的时候,sptr2先析构,并释放了资源,但是sptr1也是由这个资源初始化的,并且它的引用计数并不是0,所以析构sptr1的时候,就会出现重复释放的问题,释放已经释放了的内存

避免上述问题的方法就是尽量不要从一个裸指针中创建shared_ptr。

关于shared_ptr的循环引用问题

当下面的代码运行时,会出现无限循环的问题

struct Node //链表节点的定义
{
    int _data;
    shared_ptr<Node> _prev;
    shared_ptr<Node> _next;

    Node(int data)
        :_data(data),
        _prev(NULL),
        _next(NULL)
    {}
};

void Test() {
    shared_ptr<Node> p1(new Node(1));
    cout << p1.use_count() << endl;
    shared_ptr<Node> p2(new Node(2));
    cout << p2.use_count() << endl;

    //让p2是p1的next
    p1->_next = p2;
    cout << p2.use_count() << endl;
    //让p1是p2的prev
    p2->_prev = p1;
    cout << p1.use_count() << endl;
}

int main() {
    Test();
    return 0;
}

在Test中,引用计数的变化情况如下
这里写图片描述

在p1,p2离开作用域时它们的引用计数还是1,也就是说,因为它们的引用计数是1,所以它们的资源其实都没有释放!!!

因此针对上述可能出现的循环引用的问题,引入了weak_ptr

weak_ptr(弱指针,特点:辅助shared_ptr使用)

  • weak_ptr总是通过shared_ptr来初始化的
  • weak_ptr 拥有共享语义和不包含语义。这意味着,weak_ptr可以共享shared_ptr持有的资源。所以可以从一个包含资源的shared_ptr创建weak_ptr。同时,weak_ptr不支持普通指针包含的*,->操作。它并不包含资源所以也不允许程序员操作资源
  • 因此shared_ptr并不实际拥有对象的内存,实际拥有的是通过lock接口返回的shared_ptr对象
  • weak_ptr不控制对象的生命期,但是它可以知道对象是否还活着。如果对象还活着(没有被释放),那么它可以返回一个有效的shared_ptr来使用,否则返回一个空的shared_ptr

创建

可以以shared_ptr作为参数构造weak_ptr。从shared_ptr创建一个weak_ptr增加了共享指针的弱引用计数(weak_ref,这个引用计数之前介绍过,是专门为了weak_ptr设计的)。但是当shared_ptr离开作用域时,这个计数(弱引用计数)不作为是否释放资源的依据。换句话说,就是除非强引用计数变为0,才会释放掉指针指向的资源

int main() {
    shared_ptr<int> sptr(new int(5));    //强引用计数+1
    weak_ptr<int> wptr(sptr);           //强引用计数不变,弱引用计数+1
    weak_ptr<int> wptr1 = wptr;         //强引用计数不变,弱引用计数+1
    return 0;
}

这里写图片描述

将一个weak_ptr赋给另一个weak_ptr会增加弱引用计数

所以,当shared_ptr离开作用域时,其内的资源释放了,这时候指向该shared_ptr的weak_ptr发生了什么?weak_ptr过期了(expired)

int main() {
    shared_ptr<int> sptr(new int(5));    //强引用计数+1
    weak_ptr<int> wptr(sptr);           //强引用计数不变,弱引用计数+1
    weak_ptr<int> wptr1 = wptr;         //强引用计数不变,弱引用计数+1
    sptr.reset();       //强引用计数变为0,弱引用计数不变
    return 0;
}

调用sptr.reset()之后
这里写图片描述

如何判断weak_ptr是否指向有效资源,有两种方法:

  • 调用use_count()去获取引用计数,该方法只返回强引用计数,并不返回弱引用计数。
  • 调用expired()方法。比调用use_count()方法速度更直观,如果资源有效,返回true,无效则返回false

从weak_ptr调用lock()可以得到shared_ptr

int main() {
    shared_ptr<int> sptr(new int(5));
    weak_ptr<int> wptr(sptr);
    weak_ptr<int> wptr1 = wptr;
    shared_ptr<int> sptr2 = wptr.lock();
    return 0;
}

这里写图片描述

从weak_ptr中获取shared_ptr增加强引用计数(上面的图中因为调用了lock强引用从1变成了2)。

所以对于上述的测试代码,只要将结构体中的shared_ptr改成weak_ptr就可以了

struct Node //链表节点的定义
{
    int _data;
    weak_ptr<Node> _prev;
    weak_ptr<Node> _next;

    Node(int data)
        :_data(data)
    {}
};

void Test() {
    shared_ptr<Node> p1(new Node(1));
    shared_ptr<Node> p2(new Node(2));

    //让p2是p1的next
    p1->_next = weak_ptr<Node>(p2);
    //让p1是p2的prev
    p2->_prev = weak_ptr<Node>(p1);
}

int main() {
    Test();
    return 0;
}

在Test中,引用计数的变化情况如下
这里写图片描述

因为p1和p2的强引用计数都是0了,所以它们的资源就被释放,很好地解决了循环引用问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值