C++之智能指针

一、为什么需要智能指针?

如果在 div() 输入的 b == 0,那么就会抛出一个异常,被 main() 捕获,但是在 Func() 中 new 申请的资源就会因没释放而发生泄露问题,这是一种异常安全问题。

#include <iostream>
using namespace std;

int div()
{
    int a, b;
    cin >> a >> b;
    if (b == 0)
        throw invalid_argument("除0错误");
return a / b; } void Func() { int* p = new int; cout << div() << endl; // 异常安全问题 cout << "delete:" << p << endl; delete p; } int main() { try { Func(); } catch (const exception& e) { cout << e.what() << endl; } return 0; }

 

那么为了处理这里的异常安全问题,可以在 Func() 中捕获异常然后释放资源,再重新抛出,但这种方式并没有从根源上解决问题。因为 new 可能存在多个并且也有可能抛异常,那么在这种情况下,就很难判断是谁抛的异常。所以,当多个可能会抛异常的地方交织在一起的时候,这种捕获再重新抛出的方式会让处理者处理得焦头烂额。

因此,C++ 引入了智能指针。

二、智能指针的使用及原理

2.1 RAII

RAII(Resource Acquisition Is Initialization,资源获取就是初始化)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。

对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构时释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:

  • 不需要显式地释放资源
  • 采用这种方式,对象所需的资源在其生命周期内始终保持有效

2.2 智能指针概念

  在c++中,动态内存的管理式通过一对运算符来完成的:

  • new--在动态内存中为对象分配空间并返回一个指向该对象的指针,我们可以选择对对象进行初始化
  • delete--接受一个动态对象的指针,销毁该对象,并释放与之关联的内存

动态内存的使用很容易出现问题,因为确保在正确的时间释放内存是极其困难的。有时使用完对象后,忘记释放内存,造成内存泄漏的问题。

概念:所谓的智能指针本质就是一个类模板,它可以创建任意的类型的指针对象,当智能指针对象使用完后,对象就会自动调用析构函数去释放该指针所指向的空间

2.3 智能指针框架结构

2.3.1 指针对象构造函数析构函数

下面是智能指针的基本框架,所有的智能指针类模板中都需要包含一个指针对象构造函数析构函数

template<class T>
class SmartPtr
{
public:
    //构造函数
    SmartPtr(T* ptr = nullptr)
        :_ptr(ptr)
    {}
    //析构函数
    ~SmartPtr()
    {
        if (_ptr)
        {
            cout << "samrtptr: delete" << endl;  
            delete _ptr;
           _ptr=nullptr;
        }
    }

private:
    //指针对象
    T* _ptr;
};    

2.3.2  " * " 和" -> " 运算符重载函数

智能指针的使用跟普通指针类似,可以使用运算符“ * " 和 ” -> "去获得指向的对象,因此,我们就需要在类中重载" * " 和" -> "函数

template<class T>
class SmartPtr
{
public:
    //构造函数
    SmartPtr(T* ptr = nullptr)
        :_ptr(ptr)
    {}
    //析构函数
    ~SmartPtr()
    {
        if (_ptr)
        {
            cout << "smartptr: delete" << endl;  
            delete _ptr;
           _ptr=nullptr;
        }
    }
  //像普通指针一样使用,需要重载运算符【 * / ->】
   T& operator*()
    {
        return *_ptr;
    }

   T* operator->()
    {
        return _ptr;
    }

private:
    //指针对象
    T* _ptr;
};    

2.3.3 调用构造函数、符号重载运算函数、析构函数

当程序结束时,此时ptr1和ptr2指针被销毁时,对象ptr1和ptr2会自动调用析构函数去释放所指向的资源,这是智能指针特点。

int main()
{
    SmartPtr<int> ptr1(new int(1));
    SmartPtr<string>ptr2(new string("string"));//调用重载运算符
    cout << *ptr1<< endl;
    cout <<  ptr2->c_str() << endl;

    return 0;
}         

2.3.4 问题:拷贝构造、赋值重载函数

由于类中没有定义拷贝构造函数和赋值重载函数,当使用拷贝构造与赋值函数时,那么只能调用类中原生的拷贝构造函数 (浅拷贝) 和赋值重载函数。那么就会程序就会出现崩溃的问题,如下:

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

    return 0;
} 

 

 

 

ptr2和ptr1指向的同一块空间当ptr2被销毁时,它会调用它的析构函数去delete该资源对象当ptr1被销毁时,也会去调用它的析构函数去释放ptr1所指向的资源.所以,当程序结束时,ptr2被先被销毁,同时释放ptr2所指向的资源,然后ptr1被销毁,也去释放该资源对象,那么如下的资源对象同时被释放两次,所以程序就会被崩溃掉。(资源对象被释放后,如果再去释放该资源,程序就会崩溃)

 

 

 综上所述,我们不能使用原生的拷贝构造函数和赋值重载函数,并且定义的拷贝构造函数和赋值重载函数需要考虑只能释放一次资源对象

 

 那么该如何解决拷贝问题呢?c++库中提供了以下几种智能指针。

三、c++库中的智能指针

3.1 auto_ptr [ 管理权转移 ,实际上是失败的智能指针 ]

为了处理这个问题,C++98 中智能指针 auto_ptr 的解决方案是管理权转移
既然两个对象指向同一块空间最后会析构两次,如果永远只有一个对象指向一块空间,那么就不会出现上述问题了。
所以当一个对象拷贝构造另一个对象时,先进行值拷贝,然后把原对象置空,此时就只有新对象指向一块资源空间,这就实现了管理权的转移

// C++98 管理权转移 auto_ptr
template<class T>
class auto_ptr    //我们简化模拟的auto_ptr
{
public:
    auto_ptr(T* ptr = nullptr)
        :_ptr(ptr)
    {}

    //拷贝构造函数
    auto_ptr(auto_ptr<T>& ap)
        :_ptr(ap._ptr)
    {
        ap._ptr = nullptr;  //管理权转移
    }

    //赋值重载函数
    auto_ptr<T>& operator=(auto_ptr<T>& ap)
    {
        if (this != &ap)
        {
            if (_ptr)  // 释放当前对象的资源
            {
                delete _ptr;
            }

            _ptr = ap._ptr;  // 转移资源到当前对象中
            ap._ptr = nullptr;
        }

        return *this;
    }

    ~auto_ptr()
    {
        if (_ptr)
        {
            cout << "delete:" << _ptr << endl;  //测试用
            delete _ptr;
        }
    }

    T& operator*()
    {
        return *_ptr;
    }

    T* operator->()
    {
        return _ptr;
    }

private:
    T* _ptr;
};

int main()
{
    auto_ptr<int> ap1(new int);
    auto_ptr<int> ap2(ap1);  //管理权转移,ap1置为nullptr
    
    return 0;
}

 

虽然 auto_ptr 这样设计解决了拷贝问题,但同时也出现了一个很大的问题是原对象悬空了。如果不小心访问,会出现访问空指针问题,导致程序崩溃

int main()
{
    auto_ptr<int> ap1(new int);
    auto_ptr<int> ap2(ap1);  //管理权转移
    
    //ap1悬空
    *ap2 = 10;
    cout << *ap2 << endl;
    cout << *ap1 << endl;  //不小心访问了ap1
    
    return 0;
}

结论auto_ptr 是一个失败设计,很多公司明确要求不能使用 auto_ptr

3.2 unique_ptr [ 禁用拷贝、禁用赋值 ]

unique_ptr 是c++11版本库中提供的智能指针,它直接将拷贝构造函数和赋值重载函数给禁用,因此,不让其进行拷贝和赋值,实际上是防拷贝防赋值的智能指针,。// C++11库才更新智能指针实现

 

// C++11出来之前,boost -> scoped_ptr/shared_ptr/weak_ptr
// C++11将boost库中智能指针精华部分吸收了过来
// C++11 -> unique_ptr/shared_ptr/weak_ptr

// unique_ptr/scoped_ptr
// 原理:简单粗暴 -- 禁用拷贝和赋值
template<class T>
class unique_ptr    //我们简化模拟的unique_ptr
{
public:
    unique_ptr(T* ptr)
        :_ptr(ptr)
    {}

    ~unique_ptr()
    {
        if (_ptr)
        {
            cout << "delete:" << _ptr << endl;  //测试用
            delete _ptr;
        }
    }

    T& operator*()
    {
        return *_ptr;
    }

    T* operator->()
    {
        return _ptr;
    }

    //禁用拷贝构造函数和赋值重载函数
    unique_ptr(const unique_ptr<T>& up) = delete;
    unique_ptr<T>& operator=(const unique_ptr<T>& up) = delete;

private:
    T* _ptr;
};

int main()
{
    unique_ptr<int> up1(new int);
    /*unique_ptr<int> up2(up1);*/  // 会编译报错,因为不能拷贝
    unique_ptr<int> up3(new int);
    /*up3=up1;*/  // 会编译报错,因为不能赋值

    return 0;
}

3.3 shared_ptr [ 支持拷贝构造、赋值重载 ]

在某些场景下,需要用到支持拷贝的智能指针。share_ptr是c++11版本库中的智能指针,shared_ptr允许多个智能指针可以指向同一块资源,并且能够保证共享的资源只会被释放一次,因此是程序不会崩溃掉。

但引用计数有线程安全问题,是智能指针本身需要处理的,所以需要使用互斥锁来进行维护。

3.3.1  shared_ptr的原理 [引用计数]

shared_ptr采用的是引用计数原理来实现多个shared_ptr对象之间共享资源, 引用计数支持多个拷贝管理同一个资源,最后一个析构对象释放资源

  •   shared_ptr在内部会维护着一份引用计数,用来记录该份资源被几个对象共享。
  •   当一个shared_ptr对象被销毁时(调用析构函数),析构函数内就会将该计数减1
  •   如果引用计数减为0后,则表示自己是最后一个使用该资源的shared_ptr对象,必须释放资源
  •   如果引用计数不是0,就说明自己还有其他对象在使用,则不能释放该资源,否则其他对象就成为野指针

引用计数是用来记录资源对象中有多少个指针指向该资源对象。

 

 

 销毁过程:

3.3.2  shared_ptr赋值重载

赋值重载的三种情况:

  • ptr1=ptr1;智能指针自己给自己赋值,不做处理
  • ptr2=ptr1;如果ptr1和ptr2指向同一块空间,不做处理
  • ptr2=ptr1;只有ptr2和ptr1指向的空间不一样,才会进行处理,过程如下:

 

 

 

3.3.3  shared_ptr线程问题

因为_ptrcount指向的对象是在堆上,因此所有的线程都能够访问到该资源,多线程在修改_ptrcount时,则会出现线程安全问题,因此需要在修改_prtcount时需要用来保证其数据的正确性。

引用计数有线程安全问题,是智能指针本身需要处理的,所以需要使用互斥锁来进行维护。“ * "会返回ptr指向的对象,为什么不需要锁对其进行保护?因为ptr返回的对象有可能被读或者被写,这个不是指针内部所考虑的,而是由调用者进行考虑的。

struct Date
{
    int _year = 0;
    int _month = 0;
    int _day = 0;
};


// shared_ptr智能指针内部引用计数的加减是加锁保护的,所以是线程安全的
// 但是指向的资源不是线程安全的
// 指向堆上资源的线程安全问题是访问的人处理的,智能指针不管,也管不了
void SharePtrFunc(shared_ptr<Date>& sp, size_t n, mutex& mtx)
{
    cout << sp.get() << endl;
    for (size_t i = 0; i < n; ++i)
    {
        //内部引用计数的加减是线程安全的
        shared_ptr<Date> copy(sp);  

        //指向的资源不是线程安全的,需要自行加锁保护
        //只有这部分需要锁,后面的部分不需要锁
        //因此,我们可以把这部分括起来,特地弄成一个局部域
        //这样的话,对象出了作用域就会销毁,自动解锁
        {
            unique_lock<mutex> lk(mtx);
            copy->_year++;
            copy->_month++;
            copy->_day++;
        }

        // ...
    }
}

int main()
{
    shared_ptr<Date> sp(new Date);
    cout << sp.get() << endl;
    const size_t n = 100000;
    mutex mtx;
    thread t1(SharePtrFunc, std::ref(sp), n, std::ref(mtx));
    thread t2(SharePtrFunc, std::ref(sp), n, std::ref(mtx));

    t1.join();
    t2.join();

    cout << sp->_year << endl;
    cout << sp->_month << endl;
    cout << sp->_day << endl;

    cout << sp.use_count() << endl;
    
    return 0;
}

3.3.4  shared_ptr代码实现

template<class T>
class shared_ptr    //我们简化模拟的shared_ptr
{
public:
    shared_ptr(T* ptr = nullptr)
        :_ptr(ptr)
        , _pRefCount(new int(1))
        , _pmtx(new mutex)
    {}

       //拷贝构造
    shared_ptr(const shared_ptr<T>& sp)
        :_ptr(sp._ptr)
        , _pRefCount(sp._pRefCount)
        ,_pmtx(sp._pmtx)
    {
        AddRef();  //引用计数+1
    }

        //赋值重载
    shared_ptr<T>& operator=(const shared_ptr<T>& sp)
    {
        if (_ptr != sp._ptr)  //比较内部的指针才能真正避免自己给自己赋值
        {
            Release();  //释放资源

            _ptr = sp._ptr;
            _pRefCount = sp._pRefCount;
            _pmtx = sp._pmtx;

            AddRef();  //引用计数+1
        }

        return *this;
    }

    int use_count()
    {
        return *_pRefCount;
    }

    ~shared_ptr()
    {
        Release();
    }

    T& operator*()
    {
        return *_ptr;
    }

    T* operator->()
    {
        return _ptr;
    }

    T* get() const
    {
        return _ptr;
    }

private:
    void Release()  //释放资源
    {
        _pmtx->lock();  //保证引用计数的安全性
        
        bool flag = false;  //flag是局部变量,用于判断是否释放锁
        if (--(*_pRefCount) == 0 && _ptr)  //引用计数-1,并判断是否是0
        {
            cout << "delete:" << _ptr << endl;  //测试用
            delete _ptr;
            delete _pRefCount;

            flag = true;
        }

        _pmtx->unlock();

        if (flag == true)
        {
            delete _pmtx;  //mutex是new来的,最后需要delete,只能在解锁后delete
        }
    }

    void AddRef()  //引用计数+1
    {
        _pmtx->lock();  //保证引用计数的安全性
        ++(*_pRefCount);
        _pmtx->unlock();
    }

private:
    T* _ptr;         //资源指针
    int* _pRefCount; //引用计数指针
    mutex* _pmtx;    //互斥锁指针,用于维护引用计数的安全性,拷贝与赋值增加计数与析构释放计数
};
  

3.3.5  shared_ptr循环引用问题 [ 针对双向链表问题 ]

shared_ptr固然好用,但是它也会有问题存在。假设我们要使用定义一个双向链表,如果我们想要让创建出来的链表的节点都定义成shared_ptr智能指针,节点内的_pre和_next定义:

  • 定义成普通指针,那么就不能赋值给shared_ptr的智能指针
  • 定义成shared_ptr的智能指针,才能正确进行赋值操作

 

 

如果_pre和_next定义成shared_ptr的智能指针,那么当其中两个节点互相引用的时候,就会出现循环引用的现象。

 

 

 

 

 

 use_count() 返回智能指针对象的引用计数

 

 

 

  • 创建出node1和node2智能指针对象时,引用计数都是1.
  • 当node1的next指向node2所指向的资源时,node2的引用计数就+1,变成2,node2的pre指向noede1所指向的资源时,node1的引用计数+1变成2.
  • 当这两个智能指针使用完后,调用析构函数,引用计数都-1,都变成1,由于引用计数不为0,所以node1和node2所指向的对象不会被释放
  • node1所指向的资源释放需要当node2中的_prev被销毁,就需要node2资源的释放;node2所指向的资源释放就需要当node1中的_next被销毁,就需要node1资源的释放

因此node1和node2都有对方的“把柄”,这两个就造成循环引用现象,最终这node1和node2资源就不会进行释放

 

 

 

 

那么如何解决这个shared_ptr的循环引用呢?

  • c++库中存在weak_ptr类型的智能指针。weak_ptr类的对象它可以指向shared_ptr,并且不会改变shared_ptr的引用计数一旦最后一个shared_ptr被销毁时,对象就会被释放

3.4 weak_ptr [ 解决循环引用问题 ]

weak_ptr 不是常规意义的智能指针,它没有一个接收原生指针的构造函数,也不符合 RAII 。

weak_ptr 是为了配合 shared_ptr 而引入的一种智能指针,它可以从一个 shared_ptr 或另一个 weak_ptr 对象来构造,它的构造和析构不会引起 shared_ptr 引用记数的增加或减少(不参与资源的释放管理),所以它可以解决 shared_ptr 循环引用的问题。

 

 

 weak_ptr对象指向shared_ptr对象时,不会增加shared_ptr中的引用计数,因此当node1销毁掉时,则node1指向的空间就会被销毁掉,node2类似,所以weak_ptr指针可以很好解决循环引用的问题。所以在定义双向链表或者在二叉树等有多个指针的时候,如果想要将该类型定义成智能指针,那么结构体内的指针需要定义成weak_ptr类型的指针,防止循环引用的出现

它的use_count()返回的是 shared_ptr 的引用计数。

template<class T>
class weak_ptr    //我们简化模拟的weak_ptr
{
public:
    weak_ptr()
        :_ptr(nullptr)
    {}

    //通过"shared_ptr"对象构造
    weak_ptr(const shared_ptr<T>& sp)
        :_ptr(sp.get())
    {}

    //通过"weak_ptr"对象构造
    weak_ptr(const weak_ptr<T>& wp)
        :_ptr(wp._ptr)
    {}

    weak_ptr<T>& operator=(const shared_ptr<T>& sp)
    {
        _ptr = sp.get();

        return *this;
    }

    weak_ptr<T>& operator=(const weak_ptr<T>& wp)
    {
        _ptr = wp._ptr;

        return *this;
    }

private:
    T* _ptr;
};
 

3.5 删除器  [ 智能指针中自定义析构时的删除方式]

new 和 delete 需要匹配使用:new 和 delete 、new[ ] 和 delete[ ] 。否则可能会报错。如果我们使用了 new[ ] ,但最后使用 delete 而非 delete[ ] ,未带[]它指示编译器此指针指向的是一个对象数组的第一个元素,并且要释放资源对象的内部实现了析构函数,那么一定会运行出错。

 

 

  •  我们如果在动态内存中创建出一个数组,用一个shared_ptr对象去指向该数组,当shared_ptr使用完后,就会去调用析构函数,由于shared_ptr默认的删除方式是 delete ptr,后面没有带方括号,那么程序就会崩掉

  • 如果我们打开一个了文件,返回一个文件指针,让一个shared_ptr对象去指向该文件,那么在调用析构函数的时候就不能采用delete方法,而是使用flose()函数去关闭该文件

我们申请的资源有可能不是 new 出来的,比如:new[ ]、malloc、fopen ,因此,shared_ptr 类中提供了一个构造函数可以自定义一个删除器去指定析构函数的删除方式

 

 

 这个自定义删除器可以是函数指针,仿函数,lamber, 包装器

仿函数删除器:shared_ptr中的析构函数会去调用DelArry仿函数去释放动态数组。

 

 

 

 

测试用例:

//定制删除器
template<class T>
struct DeleteArray
{
    void operator()(const T* ptr)
    {
        cout << "delete[]:" << ptr << endl;  // 测试用
        delete[] ptr;
    }
};

//定制删除器
struct DeleteFile
{
    void operator()(FILE* ptr)
    {
        cout << "fclose:" << ptr << endl;  // 测试用
        fclose(ptr);
    }
};

int main()
{
    //删除器在类模板参数给 -- 类型
    std::unique_ptr<A> up1(new A);
    std::unique_ptr<A, DeleteArray<A>> up2(new A[10]);
    std::unique_ptr<FILE, DeleteFile> up3(fopen("test.txt", "w"));

    //删除器在构造函数的参数给 -- 对象
    std::shared_ptr<A> sp1(new A);
    std::shared_ptr<A> sp2(new A[10], DeleteArray<A>());
    std::shared_ptr<FILE> sp3(fopen("test.txt", "w"), DeleteFile());
    
    std::shared_ptr<A> sp4(new A[10], [](A* p) {delete[] p; });
    std::shared_ptr<FILE> sp5(fopen("test.txt", "w"), [](FILE* p) {fclose(p); });

    return 0;
}

 

四、参考文章

————————————————

 

版权声明:本文为CSDN博主「努力学习的少年」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/sjp11/article/details/123899141


版权声明:本文为CSDN博主「Butayarou」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/m0_59938453/article/details/128510433

posted on 2023-02-02 18:04  斗战胜佛美猴王  阅读(227)  评论(0编辑  收藏  举报