欢迎光临
我们一直在努力

【C++修炼】智能指针使用及原理

1. 智能指针的使用场景分析

        虽然有了析构,但C++程序员仍然无法避免手动管理内存,麻烦且容易导致内存泄漏。尤其是有了异常之后,过于跳跃,很不可控。C++是一门对性能要求比较高的语言,相比其他语言选择的垃圾回收器方案,C++选择了智能指针。

        下⾯程序中我们可以看到,new 了以后,我们也 delete 了,但是因为抛异常,正常 Func 函数逻辑中的 delete 没有得到执⾏,内存泄漏了。所以 new 以后需要立马捕捉 catch(…),如果捕获到异常就 delete 释放,再把异常重新抛出。但是 new 本⾝也可能抛异常,连续的两个 new 和下⾯的 Divide 都可能会抛异常,处理起来很⿇烦,每一个可能失败抛异常的都需要 catch 捕捉走一遍冗余的代码。

代码示例 1.1

#include <iostream>
#include <exception>

using namespace std;

// 基础除法函数
double Divide(int a, int b)
{
// 当除数为0时,主动抛出异常
if (b == 0)
{
throw "Divide by zero condition!";
}
else
{
return (double)a / (double)b;
}
}

// 核心业务函数
void Func()
{
// 使用裸指针(Raw Pointers)管理动态内存
int* array1 = new int[10];

// 危险点1:如果这里 new 失败(抛出 std::bad_alloc),
// array1 已经分配但还没来得及被任何机制接管,直接导致内存泄漏!
int* array2 = new int[10];

try
{
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
}
catch (…)
{
// 危险点2:如果 Divide 抛出异常,程序会直接跳转到 catch 块。
// 为了保证不泄漏,我们必须在 catch 中手动释放内存。
cout << "捕获异常,正在清理资源…" << endl;
delete[] array1;
delete[] array2;

// 重新抛出异常,交给外层处理
throw;
}

// 危险点3:代码冗余与维护
// 如果 try 块正常执行完毕,我们需要在函数末尾再次手动释放内存。
// 如果函数中有多个 return 语句,或者嵌套了更多的 try-catch,
// 每一个出口都必须记得 delete,极易遗漏!
delete[] array1;
delete[] array2;
}

int main()
{
try
{
Func();
}
catch (const char* errmsg)
{
// 捕获 Divide 中抛出的字符串异常
cout << "字符串异常: " << errmsg << endl;
}
catch (const exception& e)
{
// 捕获标准库异常(如 new 失败抛出的 std::bad_alloc)
cout << "标准异常: " << e.what() << endl;
}
catch (…)
{
// 兜底捕获所有未知异常
cout << "未知异常" << endl;
}

return 0;
}

2. RAII和智能指针的设计思路

        RAII是 Resource Acquisition Is Initialization 的缩写,即资源获取即初始化。意思是,申请到资源后,不应该由程序员自己管理,而是交给一个类对象进行管理,相当于交给这个类的构造函数来初始化该对象,这个对象就与这个资源绑定了,对象的生命周期结束,资源随之释放。

  • RAII是⼀种管理资源的类的设计思想,本质是利⽤对象的⽣命周期来管理获取到的动态资源,避免资源泄漏。这⾥的资源可以是内存、⽂件指针、⽹络连接、互斥锁等等。
  • RAII在获取资源时把资源委托给⼀个对象,借对象控制对资源的访问,资源在对象的⽣命周期内始终保持有效,最后在对象析构的时候释放资源。这种方式保障了资源的正常释放,避免了资源泄漏问题。
  • 智能指针这个类除了要满⾜RAII的设计思路,还要⽅便资源的访问。所以,智能指针类类型还会像迭代器类型⼀样,模拟指针的行为,重载 operator*/operator->/operator[] 等运算符。

代码示例 2.1

#include <iostream>
#include <exception>
using namespace std;

// 利用RAII思想设计的初级智能指针
// 模板类,支持任意类型的智能指针
template<class T>
class SmartPtr
{
public:
// 资源,或者说空间就是资源,通常都是用一个指针,存储该空间的地址
// 即资源总是由指针指向
// 构造函数:接收一个原始指针,由智能指针接管其生命周期(RAII 核心)
SmartPtr(T* ptr)
:_ptr(ptr)
{}

// 析构函数:当智能指针SmartPtr对象离开作用域被销毁时,自动释放其管理的内存
~SmartPtr()
{
cout << "delete[] " << _ptr << endl;
delete[] _ptr; // 自动释放,避免内存泄漏
}

// 重载解引用运算符,像普通指针一样获取所指对象的引用
T& operator*()
{
return *_ptr;
}

// 重载箭头运算符,方便访问所指对象的成员
T* operator->()
{
return _ptr;
}

// 重载下标运算符,支持像数组一样通过索引访问元素
T& operator[](size_t i)
{
return _ptr[i];
}

private:
T* _ptr; // 被对象内部管理持有的原始指针
};

// 除法函数
double Divide(int a, int b)
{
if (b == 0)
{
throw "Divide by zero condition!"; // 除数为0时抛出异常
}
else
{
return (double)a / (double)b;
}
}

void Func()
{
// 使用智能指针管理 new 出来的数组,无需手动 delete
// new返回的就是开辟出来的空间的地址,直接拿来构造初始化智能指针对象
SmartPtr<int> sp1 = new int[10]; // new失败可能抛异常
SmartPtr<int> sp2 = new int[10]; // 可能抛异常

// 像普通数组一样使用智能指针
for (size_t i = 0; i < 10; i++)
{
sp1[i] = sp2[i] = i;
}

int len, time;
cin >> len >> time;

// 如果这里抛出异常,sp1 和 sp2 的析构函数依然会被调用,内存自动释放
cout << Divide(len, time) << endl;
}

int main()
{
try
{
Func();
}
catch (const char* errmsg)
{
cout << errmsg << endl;
}
catch (const exception& e)
{
cout << e.what() << endl;
}
catch (…)
{
cout << "未知异常" << endl;
}
return 0;
}

        在异常章节说过,即使抛出异常,也要经历栈展开的过程,沿着函数调用链,直到找到类型匹配的 catch 子句之前,依次释放函数栈帧,各函数内部的局部对象都会随之销毁释放,自然也会调用析构函数。因此即使抛出异常,也会沿着调用链,完成各个智能指针对象的析构。

  • 无论是正常结束,还是抛异常,智能指针管理的资源都是有保障的,都可以正常释放。

3. C++标准库智能指针的使用

        智能指针真正的问题是拷贝。智能指针是代替管理资源,模拟原生指针的行为。因此,两个智能指针指向同一份资源,应该使用浅拷贝,而不是深拷贝。但两个智能指针对象销毁时,又会调用两次析构释放同一份资源。这就是智能指针必须解决的问题。智能指针的设计思想早在C++98之前已经出现,但C++98推出的智能指针并没有很好的解决上述问题,直到C++11才有了不错的解决方案。

  • C++标准库中的智能指针相关都在 <memory> 头⽂件:前两个是C++98支持的智能指针,后面都是C++11新增的。最关键的是C++11的两个智能指针 unique_ptr shared_ptr,它们同样符合RAII设计思想,并且模拟指针⾏为。auto_ptr/unique_ptr/shared_ptr 是解决智能指针拷⻉问题的3种不同方案,其他几个不算智能指针,更多算是智能指针在实现或使用上的辅助工具。
  • auto_ptr 是C++98推出的智能指针,C++17已被移除,。特点是拷⻉时会转移资源管理权给新智能指针对象,始终只有一个智能指针管理资源,自然只析构一次。但这是⼀个糟糕的设计,它会让原本的被拷⻉对象悬空,即“空指针”问题,导致越界访问报错。强烈建议不要使⽤ auto_ptr。即使C++11出来之前很多公司也是明令禁⽌使⽤ auto_ptr 的,例如谷歌之前的C++规范有一条就是明确不要使用C++98的 auto_ptr
  • unique_ptr 是C++11推出的智能指针,名字翻译过来是唯⼀指针。特点是独占所有权,同一资源同时只能由一个 unique_ptr 对象持有管理。不⽀持拷⻉构造/拷贝赋值 (被显式删除=delete),只⽀持移动。如果在不需要拷⻉的场景下,⾮常建议使⽤ unique_ptr
  • shared_ptr 是C++11推出的智能指针,名字翻译过来是共享指针。特点是共享所有权,允许同时有多个 shared_ptr 对象持有管理同一资源。⽀持拷⻉,也⽀持移动。如果在需要拷⻉的场景下,就需要使⽤ shared_ptr 了。底层是⽤引⽤计数的⽅式实现的。
  • weak_ptr 是C++11推出的智能指针,名字翻译过来是弱指针。完全不同于上⾯的智能指针,它不⽀持RAII,不直接管理资源。weak_ptr 主要解决的是 shared_ptr 循环引⽤导致内存泄漏的问题,本质是一个辅助工具,提供一种“弱引用”机制。
  • 智能指针对象管理资源,通过析构再 delete 释放资源,这也意味着如果资源不是 new 出来的但交给智能指针管理,析构时就会崩溃。智能指针⽀持在构造时给⼀个删除器,本质是⼀个可调⽤函数对象 (函数指针/仿函数/lambda/function包装器/bind表达式),可以执行我们想要的释放资源的⽅式。智能指针对象构造时,给予定制的删除器,析构时调⽤对应删除器来释放资源。
  • 由于经常 new[] 申请资源,标准库中的智能指针 unique_ptr/shared_ptr 都特化了 [] 版本 unique_ptr<T[]>/shared_ptr<T[]>,算是删除器之外的另一种解决方案,析构中可以专门调用 delete[],但不够通用。如果管理的是 fopen 打开的文件资源,释放要用 fclose;如果资源是 malloc 出来的,释放要用 free,不可能每个都实现一份特化版本。删除器则摆脱了这种冗余和局限性,提供了一种通用的解决方案。 // 调用特化版本的unique_ptr<Date[]>和shared_ptr<Date[]>
    //unique_ptr<Date> up1(new Date[5]);
    unique_ptr<Date[]> up1(new Date[5]);
    //shared_ptr<Date> sp1(new Date[5]);
    shared_ptr<Date[]> sp1(new Date[5]);

    // 使用删除器
    // lambda函数对象
    shared_ptr<int> up2(new int[5], [](int* ptr) {
    delete[] ptr;
    });
    // 仿函数对象
    class Fclose
    {
    public:
    void operator()(FILE* ptr)
    {
    fclose(ptr);
    }
    };
    shared_ptr<FILE> sp2(fopen("Test.cpp", "r"), Fclose());
    // 函数指针
    template<class T>
    void DeleteArrayFunc(T* ptr)
    {
    delete[] ptr;
    }
    shared_ptr<Date> sp3(new Date[5], DeleteArrayFunc<Date>);

    // 注意,unique_ptr的删除器在unique_ptr类模板处声明,即类型处声明
    // 实例化时需要声明删除器的类型,而不是将删除器对象传参给构造函数
    // 函数指针/仿函数等好用,lambda函数对象就不好用,拿不到它的类型
    unique_ptr<FILE, Fclose> up3(fopen("Test.cpp", "r"));
    // lambda必须用function包装器或者bind一下,或者用auto + decltype
    auto delArr = [](Date* ptr) { delete[] ptr; };
    std::unique_ptr<Date[], decltype(delArr)> up4(new Date[5], delArr);
    // unique_ptr建议用仿函数作为删除器,shared_ptr都可以,相对建议lambda

  • shared_ptr 除了⽀持⽤指向资源的指针构造,还⽀持 make_shared ⽤初始化资源对象的值直接构造。相比 shared_ptrunique_ptr 更适用于不支持拷贝的场景。shared_ptr 支持拷贝则带来了额外的消耗,比如引用计数。引用计数有维护成本,而且在堆上开辟,如果资源过多,同时存在多个引用计数,有内存碎片的风险。不过一般不需要考量,除非有更高的性能需求。 // 以下两种写法是等价的
    //shared_ptr<Date> sp(new Date(2024, 9, 11));
    // 可直接用初始化Date对象的值2024、9、11初始化sp
    shared_ptr<Date> sp = make_shared<Date>(2024, 9, 11);

    // make_shared的好处就是减少内存碎片问题
    // 它不是直接new一个Date对象再交给sp的构造,再单独开辟一个引用计数
    // make_shared拿到的是构造Date对象的值,Date对象还未创建
    // make_shared同时为Date对象和引用计数申请空间,将空间一起开辟,避免碎片化
    // 而且两个空间一起开辟,只需要开辟一次,效率也高一点点

    // 内存碎片化问题另一种解决方案就是内存池
    // 我们可以单独为引用计数开辟一个内存池,传给shared_ptr的构造
    // 标准库有支持该构造函数重载,引用计数全部从这个内存池中申请

  • unique_ptr/shared_ptr 都实现了一个叫 operator bool 的成员函数,允许对应自定义类型转换成内置类型 bool。如果一个智能指针对象是空对象没有管理资源,则返回false,否则返回true,这意味着我们可以直接if判断智能指针对象是否为空。 shared_ptr<Date> sp(new Date);
    // if (sp) 等价于 if (sp.operator bool())
    if (sp) cout << "非空对象,sp有管理资源" << endl;
    else cout << "空对象,sp没有管理资源" << endl;

  • unique_ptr/shared_ptr 的构造函数都得使⽤ explicit 修饰禁止隐式类型转换,防⽌普通指针隐式类型转换成智能指针对象。 // shared_ptr和unique_ptr的构造函数都不支持隐式类型转换
    // new Date(2024, 1, 1)构造Date匿名对象,返回Date对象的原始指针
    // 再用Date对象的原始指针隐式类型转换成shared_ptr<Date>或unique_ptr<Date>
    // 这种隐式类型转换是不可以的,包括其他类型的普通指针,都不可以转换成智能指针
    shared_ptr<Date> sp = new Date(2024, 1, 1);
    unique_ptr<Date> up = new Date(2022, 1, 2);

    // 只可以直接调用智能指针的构造函数,构造智能指针对象
    shared_ptr<Date> sp(new Date(2024, 1, 1));
    unique_ptr<Date> up(new Date(2022, 1, 2));

  • 代码示例 3.1

    #include <iostream>
    #include <memory>
    using namespace std;

    struct Date
    {
    int _year;
    int _month;
    int _day;
    Date(int year = 1, int month = 1, int day = 1)
    :_year(year)
    , _month(month)
    , _day(day)
    { }
    ~Date()
    {
    cout << "~Date()" << endl;
    }
    };
    int main()
    {

    auto_ptr<Date> ap1(new Date);
    // 拷⻉时,管理权限转移,被拷⻉对象ap1悬空
    auto_ptr<Date> ap2(ap1);
    // 空指针访问,ap1对象已经悬空
    //ap1->_year++;

    unique_ptr<Date> up1(new Date);
    // 不⽀持拷⻉
    //unique_ptr<Date> up2(up1);
    // ⽀持移动,但是移动后up1也悬空,所以使⽤移动要谨慎
    unique_ptr<Date> up3(move(up1));

    shared_ptr<Date> sp1(new Date);
    // ⽀持拷⻉
    shared_ptr<Date> sp2(sp1);
    shared_ptr<Date> sp3(sp2);
    cout << sp1.use_count() << endl;
    sp1->_year++;
    cout << sp1->_year << endl;
    cout << sp2->_year << endl;
    cout << sp3->_year << endl;
    // ⽀持移动,但是移动后sp1也悬空,所以使⽤移动要谨慎
    shared_ptr<Date> sp4(move(sp1));

    return 0;
    }

    运行结果:

    4. 智能指针的原理

            下⾯模拟实现了 auto_ptr unique_ptr 的核⼼功能,这两个智能指针的实现⽐较简单,了解原理即可。auto_ptr 的思路是拷⻉时转移资源管理权给被拷⻉对象,这种思路是不被认可的,也不建议使⽤ auto_ptrunique_ptr 不⽀持拷⻉,只支持移动。

    代码示例 4.1

    // 抢占式:管理权转移
    template<class T>
    class auto_ptr
    {
    public:
    // 构造函数
    auto_ptr(T* ptr)
    :_ptr(ptr)
    { }
    // 拷贝构造:转移ap的资源给当前对象*this
    auto_ptr(auto_ptr<T>& ap)
    : _ptr(ap._ptr)
    {
    // 抢占式,原对象不再持有资源,避免共同管理导致的两次析构
    ap._ptr = nullptr;
    }
    // 拷贝赋值:将ap的资源全部掠夺过来
    auto_ptr<T>& operator=(auto_ptr<T>& ap)
    {
    // 避免自己给自己赋值
    if (this != &ap)
    {
    // 释放当前智能指针对象*this自己的资源
    if (_ptr) delete _ptr;
    // 转移ap中的资源到当前对象*this
    _ptr = ap._ptr;
    ap._ptr = NULL;
    }
    return *this;
    }
    // 析构:delete释放当前对象*this的资源
    ~auto_ptr()
    {
    if (_ptr)
    {
    cout << "delete:" << _ptr << endl;
    delete _ptr;
    }
    }

    // 模拟指针行为,重载运算符
    T& operator*()
    {
    return *_ptr;
    }
    T* operator->()
    {
    return _ptr;
    }

    private:
    T* _ptr; // 指向被管理的资源
    };

    // 独占式:管理权独有
    template<class T>
    class unique_ptr
    {
    public:
    // 构造函数:explicit禁止隐式类型转换,避免普通指针转换成智能指针
    explicit unique_ptr(T* ptr)
    :_ptr(ptr)
    { }
    // 析构:delete释放当前对象*this的资源
    ~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;

    // 支持移动构造:掠夺up的资源
    unique_ptr(unique_ptr<T>&& up)
    : _ptr(up._ptr)
    {
    // 掠夺后up置空,避免共同管理析构两次
    up._ptr = nullptr;
    }
    // 支持移动赋值:掠夺up的资源
    unique_ptr<T>& operator=(unique_ptr<T>&& up)
    {
    // 先释放当前对象*this自己的资源
    delete _ptr;
    // 掠夺
    _ptr = up._ptr;
    up._ptr = nullptr;
    }

    private:
    T* _ptr; // 指向被管理的资源
    };

            重点是 shared_ptr 的设计,尤其是引⽤计数的设计。

    • 多个 shared_ptr 对象共同管理同一份资源,⼀份资源代表⼀个引⽤计数,每多一个管理者,引用计数加1。智能指针对象生命周期到了,调用析构,让引用计数减1,除非引用计数减到0才释放资源。
    • 强调:不可使用静态成员变量作为引用计数。静态成员变量属于这个类的所有对象,只要智能指针类型相同,就会共有静态成员变量。同样类型的资源可以有多份,管理这些资源的智能指针类型都相同,只有一个静态成员变量,却需要多份引用计数。因此,引⽤计数要使⽤堆上动态开辟的⽅式。构造智能指针对象时,根据资源的数量 new 引⽤计数,而不是资源的类型。

    代码示例 4.2

    // 共享式:管理权共有
    template<class T>
    class shared_ptr
    {
    public:
    // 构造函数:多一份资源就多一个引用计数
    // explicit禁止普通指针通过该构造隐式类型转换成智能指针
    explicit shared_ptr(T* ptr == nullptr)
    : _ptr(ptr)
    , _pcount(new int(1))
    {
    }
    // 拷贝构造:多一个管理者,引用计数++
    shared_ptr(const shared_ptr<T>& sp)
    : _ptr(sp._ptr)
    , _pcount(sp._pcount)
    {
    (*_pcount)++;
    }
    // 拷贝赋值:当前管理者资源的引用计数–,转而管理sp的资源,对应引用计数++
    shared_ptr<T>& operator=(const shared_ptr<T>& sp)
    {
    // 避免同一份资源的管理者之间互相赋值,包括自己给自己赋值
    if (_ptr != sp._ptr)
    {
    if (–(*_pcount) == 0)
    {
    delete _ptr;
    delete _pcount;
    }
    _ptr = sp._ptr;
    _pcount = sp._pcount;
    ++(*_pcount);
    }
    return *this;
    }
    // 析构:引用计数大于0则–,等于0则释放资源
    ~shared_ptr()
    {
    if (–(*_pcount) == 0)
    {
    delete _ptr;
    delete _pcount;
    }
    }

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

    private:
    T* _ptr;
    int* _pcount; // 引用计数通过动态内存申请
    };

    定制删除器

    #include <iostream>
    #include <functional>
    using namespace std;

    struct Date
    {
    int _year;
    int _month;
    int _day;
    Date(int year = 1, int month = 1, int day = 1)
    :_year(year)
    , _month(month)
    , _day(day)
    { }
    ~Date()
    {
    cout << "~Date()" << endl;
    }
    };

    namespace david
    {
    template<class T>
    class shared_ptr
    {
    public:
    explicit shared_ptr(T* ptr = nullptr)
    : _ptr(ptr)
    , _pcount(new int(1))
    {
    }
    // 带定制删除器的构造
    // 难点1:需要保持删除器,D是删除器的类型,但只属于当前函数模板,
    // 类里面用不了,定义不了函数对象
    template<class D>
    shared_ptr(T* ptr, D del)
    : _ptr(ptr)
    , _pcount(new int(1))
    , _del(del)
    {
    }
    shared_ptr(const shared_ptr<T>& sp)
    : _ptr(sp._ptr)
    , _pcount(sp._pcount)
    {
    (*_pcount)++;
    }

    void release()
    {
    if (–(*_pcount) == 0)
    {
    _del(_ptr);
    delete _pcount;
    _ptr = nullptr;
    _pcount = nullptr;
    }
    }

    shared_ptr<T>& operator=(const shared_ptr<T>& sp)
    {
    if (_ptr != sp._ptr)
    {
    // 释放当前对象*this的资源
    release();
    // 拷贝sp共同管理资源
    _ptr = sp._ptr;
    _pcount = sp._pcount;
    ++(*_pcount);
    _del = sp._del;
    }
    return *this;
    }
    ~shared_ptr()
    {
    // 析构释放资源
    release();
    }

    T& operator*()
    {
    return *_ptr;
    }
    T* operator->()
    {
    return _ptr;
    }
    T* get() const { return _ptr; }
    int use_count() const { return *_pcount; }

    private:
    T* _ptr;
    int* _pcount; // 引用计数通过动态内存申请
    // 用function包装器解决删除器的类型问题
    // void是可调用对象的返回类型,(T*)是可调用对象的参数列表
    // function的默认构造是禁用的,不可调用
    // 必须给缺省值,默认的lambda执行delete删除
    function<void(T*)> _del = [](T* ptr) { delete ptr; };
    };
    }

    int main()
    {
    david::shared_ptr<Date> sp1(new Date);
    david::shared_ptr<Date> sp2(new Date[10], [](Date* ptr) { delete[] ptr; });
    cout << sp1.use_count() << endl;
    cout << sp2.use_count() << endl;

    return 0;
    }

    5. shared_ptr 和 weak_ptr

    5.1 shared_ptr 的循环引用问题

            大部分情况下,shared_ptr 非常合适用于管理资源,⽀持RAII,也⽀持拷⻉。但是在极端场景下,即循环引⽤的场景下,会导致内存泄漏。

            下面我们来看看:

  • 什么是循环引⽤场景?
  • 为什么会导致资源得不到释放?
  • 如何用 weak_ptr 解决问题?
  • 代码示例 5.1.1

    #include <iostream>
    #include <memory>
    using namespace std;

    struct ListNode
    {
    // 双向循环链表的结点
    // 有前驱结点指针_next,同时也有后继结点指针_prev
    // 都通过智能指针管理前驱结点和后继结点
    int _data;
    shared_ptr<ListNode> _next = nullptr;
    shared_ptr<ListNode> _prev = nullptr;

    // 不可以用原生指针ListNode*管理,这样在外面赋值的话
    // 例如n1->_next = n2就编译报错了
    // 因为n1->_next 是ListNode*,但n2是shared_ptr<ListNode>
    //ListNode* _next = nullptr;
    //ListNode* _prev = nullptr;

    ~ListNode()
    {
    cout << "~ListNode()" << endl;
    }
    };

    int main()
    {
    // 智能指针n1和n2分别管理两个链表结点
    shared_ptr<ListNode> n1(new ListNode);
    shared_ptr<ListNode> n2(new ListNode);
    cout << n1.use_count() << endl;
    cout << n2.use_count() << endl;

    // n1的后继结点是n2
    n1->_next = n2;
    // n2的前驱结点是n1
    n2->_prev = n1;
    // 构成了互相指向的情况
    cout << n1.use_count() << endl;
    cout << n2.use_count() << endl;

    return 0;
    }

            我们只定义了两个智能指针对象,分别管理结点。通过 n1 管理结点1,即资源1;通过 n2 管理结点2,即资源2。智能指针对象 n1 析构销毁则释放结点1,智能指针对象 n2 析构销毁则释放结点2。

            但实际上:

  • 结点作为资源的同时,内部还用智能指针管理着别的资源。因此这两份结点/资源,除了被当前结点的智能指针 n1/n2 管理,还被后继结点的 _prev 或前驱结点的 _next 管理。
  • 像上面这种互相指向的场景,n1 智能指针析构销毁,资源1的引用计数-1,结点1还被 n2->_prev 管理着,结点1未释放。n2 智能指针析构销毁,资源2的引用计数-1,结点2还被 n1->_next 管理着,结点2未释放。
  • 我们定义的智能指针对象 n1/n2 都析构销毁了,但它们管理的两个结点却没有释放,这就造成了内存泄漏。
  •         结点1想释放,还得销毁结点2的智能指针对象 _prev,想销毁 _prev 就得释放结点2。反过来,想释放结点2,就得释放结点1,陷入了无法破解的循环。

            究其原因,本质是结点本身就是资源,但资源内部还管理着其他资源,并且参与影响了引用计数。_next _prev 可以指向结点/资源,但不应该参与管理,更不应该参与引用计数。

    • 因此,可以将 _next _prev 改成 std::weak_ptrweak_ptr 是弱引用,不是RAII设计,不参与管理资源,用 shared_ptr 智能指针构造或赋值 weak_ptr 智能指针不会增加它的引⽤计数。_next _prev 不参与资源释放管理逻辑,就成功打破了循环引⽤,解决了这⾥的问题。

    weak_ptr 简单模拟实现

    // shared_ptr和weak_ptr都是模拟的最简实现,只满足基本功能
    // weak_ptr更多的功能如lock等是无法实现的
    // 除非将shared_ptr和weak_ptr的引用计数提前出来
    // 单独封装引用计数为一个类类型,shared_ptr和weak_ptr存储该类对象
    template<class T>
    class weak_ptr
    {
    public:
    // 无参构造
    weak_ptr()
    {
    }
    // shared_ptr构造
    weak_ptr(const shared_ptr<T>& sp)
    :_ptr(sp.get())
    {
    }
    // shared_ptr拷贝赋值
    weak_ptr<T>& operator=(const shared_ptr<T>& sp)
    {
    _ptr = sp.get();
    return *this;
    }
    private:
    // 弱引用机制:只进行安全观测与访问,只是帮shared_ptr保存资源的工具
    T* _ptr = nullptr;
    };

    5.2 weak_ptr

    • weak_ptr 不⽀持RAII,也不⽀持访问资源,所以我们看⽂档发现 weak_ptr 构造不⽀持绑定到资源,只⽀持绑定到 shared_ptr
    • weak_ptr 也没有重载 operator*operator-> 等,不模拟指针的访问行为。因为不参与资源管理就不能随意访问。如果绑定的 shared_ptr 已经释放了资源,再去访问资源就是很危险的。
    • 标准库中通常会让 weak_ptr 保存一份 shared_ptr 的引用计数,避免悬空风险,但这也让它必须参与到引用计数的管理。引用计数本身也是一种资源,堆上动态开辟自然也要释放。如果 shared_ptr 释放资源的同时,也无脑释放引用计数。weak_ptr 既无法访问资源,也无法通过引用计数判断资源能不能访问。因此,引用计数必须被共同管理。通常会将引用计数提取出来单独封装成一个类,通过对象的生命周期进行管理。weak_ptr 和 shared_ptr 各自保存一份引用计数对象,所有 weak_ptr 对象和 shared_ptr 对象都销毁了,没有引用计数对象被保存了,才会调用引用计数的析构释放引用计数。
    • 因此,weak_ptr 也有 use_count,可以获取绑定的 shared_ptr 当前的引用计数,因为 shared_ptr 的引用计数不会跟着资源一起释放,所以还在,但会减到0,表示 weak_ptr 为空或者绑定的 shared_ptr 的资源释放了。weak_ptr 也支持 expired 接口检查指向的资源是否过期。确实过期了,就返回true;没有过期,就返回false,底层其实也是通过判断引用计数实现的。
    • weak_ptr 虽然不参与资源的管理,但可能有访问资源的需求。我们可以调⽤ weak_ptr::lock 接口返回⼀个管理资源的 shared_ptr。如果资源已经被释放,返回的 shared_ptr 就是⼀个空对象;如果资源没有释放,就可以直接访问资源。因此,通过返回的 shared_ptr 访问资源是绝对安全的。我们可以根据返回对象判断是否能安全访问。

    6. shared_ptr 的线程安全问题

            shared_ptr 的引⽤计数对象在堆上。如果多个 shared_ptr 对象在多个线程中,拷⻉或析构时访问修改引⽤计数就会出现线程安全问题,因为引用计数是共享资源。例如在两个线程中,可能同时有两个对象析构,同时获取到当前的引用计数为5,各自执行引用计数减1,结果为4,但实际少了两个对象,应该减到3。这就是 shared_ptr 的线程安全问题。

    • 解决引用计数的线程安全问题,必须保证 shared_ptr 的引用计数操作是原子性的。可通过加锁或者其他原⼦操作来保证线程安全。例如在 shared_ptr 中加一把锁,将引用计数变为临界资源,每次访问或修改操作前先尝试获取锁,持有锁才可以进行操作,操作结束必须释放锁,确保各个线程执行这部分代码时都是互斥的 (锁,或称互斥量,也相当于一种引用计数,但只有0或1,同样通过对象的生命周期管理,操作完出了作用域对象就销毁析构进行解锁)。
    • 因此,标准库中的 std::shared_ptr 本身是线程安全的,但管理的资源就不一定了。不过这个对象的线程安全问题也不归 shared_ptr 管,应该由外层的使⽤者进⾏线程安全的控制。

            可以使用互斥锁,也可以直接将 shared_ptr 中的引⽤计数从 int* 改成 atomic<int>*,保证对该类型的变量的操作都是原子的,也可以保证线程安全。

    代码示例 6.1

    #include <iostream>
    #include <memory>
    #include <functional>
    #include <atomic>
    #include <thread>
    #include <mutex>
    using namespace std;

    namespace david
    {
    template<class T>
    class shared_ptr
    {
    public:
    // 构造函数:_pcount改为atomic<int>*类型
    explicit shared_ptr(T* ptr = nullptr)
    : _ptr(ptr)
    , _pcount(new atomic<int>(1))
    { }
    template<class D>
    shared_ptr(T* ptr, D del)
    : _ptr(ptr)
    , _pcount(new atomic<int>(1))
    , _del(del)
    { }
    shared_ptr(const shared_ptr<T>& sp)
    : _ptr(sp._ptr)
    , _pcount(sp._pcount)
    {
    (*_pcount)++;
    }
    void release()
    {
    if (–(*_pcount) == 0)
    {
    _del(_ptr);
    delete _pcount;
    _ptr = nullptr;
    _pcount = nullptr;
    }
    }
    shared_ptr<T>& operator=(const shared_ptr<T>& sp)
    {
    if (_ptr != sp._ptr)
    {
    release();
    _ptr = sp._ptr;
    _pcount = sp._pcount;
    ++(*_pcount);
    _del = sp._del;
    }
    return *this;
    }
    ~shared_ptr()
    {
    release();
    }
    T& operator*()
    {
    return *_ptr;
    }
    T* operator->()
    {
    return _ptr;
    }
    T* get() const { return _ptr; }
    int use_count() const { return *_pcount; }

    private:
    T* _ptr;
    //int* _pcount;
    // 改为atomic<int>*类型,该变量的操作就是原子的,保证线程安全
    atomic<int>* _pcount;
    function<void(T*)> _del = [](T* ptr) { delete ptr; };
    };
    }

    // 一个自定义类型AA,有两个变量_a1和_a2
    struct AA
    {
    int _a1 = 0;
    int _a2 = 0;
    ~AA()
    {
    cout << "~AA()" << endl;
    }
    };

    int main()
    {
    // 通过shared_ptr智能指针管理AA对象
    david::shared_ptr<AA> p(new AA);
    // n为10万
    const size_t n = 100000;
    // 对资源的操作不是线程安全的,外部自己管理
    // 这里用互斥锁管理,定义一个锁对象mtx
    mutex mtx;

    // 定义一个lambda函数对象
    // 进行10万次循环,每次都拷贝一份shared_ptr智能指针
    // 该操作多一个copy管理资源,增加引用计数,但循环很快结束,引用计数又减回去
    // 关键在于多线程,可能同时有多个线程中在执行这段代码
    // 但引用计数是atomic类型,保证线程互斥,同时只有一个线程修改引用计数
    auto func = [&]()
    {
    for (size_t i = 0; i < n; ++i)
    {
    // 拷贝智能指针,shared_ptr内对引用计数的操作都是线程安全的
    david::shared_ptr<AA> copy(p);
    // 对资源进行操作,修改智能指针管理的对象中的_a1和_a2
    {
    // 线程不安全,必须自己确保线程安全,可以通过加锁
    // 尝试获取mtx锁,并用unique_lock唯一锁进行加锁与管理
    // 同一时间只有一个线程可以拿到锁
    unique_lock<mutex> lk(mtx);
    // 拿到锁以后,执行对资源的操作
    copy->_a1++;
    copy->_a2++;
    // {}花括号围起来就算一个作用域,lk出去就析构释放锁/解锁
    }
    // 拷贝完毕,先加锁,再进行资源操作,操作完锁也释放了,进行下一个循环
    }
    };

    // 创建两个线程一起执行func
    thread t1(func);
    thread t2(func);
    t1.join();
    t2.join();
    cout << p->_a1 << endl;
    cout << p->_a2 << endl;
    cout << p.use_count() << endl;

    return 0;
    }

    运行结果:

            结果为20万。因为两个线程分别执行 func,各自创建函数栈帧,各自创建自己的 i 变量,都是10万,各自进行了10万次循环,两个线程共执行20万次循环,但被智能指针管理的 AA 对象是共享的,AA 对象中的 _a1 _a2 相当于加了20万次。

    7. C++11和boost中智能指针的关系

            Boost库,也被称为C++的准标准库,是为C++标准库提供扩展的⼀些C++程序库的总称。Boost社区建⽴的初衷之⼀就是为C++的标准化⼯作提供可供参考的实现,社区发起⼈Dawes本⼈就是C++标准委员会的成员之⼀。在Boost库的开发中,Boost社区确实在这个⽅向上取得了丰硕的成果,C++11及之后的新语法和库有很多都是从Boost中来的,例如右值引用、移动语义、智能指针等。

    • C++98产⽣了第⼀个智能指针 auto_ptr
    • C++boost库给出了更实⽤的 scoped_ptr/scoped_arrayshared_ptr/shared_array weak_ptr 等。
    • C++TR1引⼊了 shared_ptr 等,但C++TR1并不是标准版,而是作为标准库的扩展。
    • C++11,引⼊了 unique_ptrshared_ptr weak_ptrunique_ptr 即对应boost的 scoped_ptr。这些智能指针的实现原理都是参考boost库实现的。

    8. 内存泄漏

    • 内存泄漏:程序运行过程中,申请了资源,但资源不再使用了却没有释放,即因为疏忽或错误造成程序未能释放已经不再使⽤的内存。

            ⼀般是忘记释放或者发⽣异常没能执行释放代码。内存泄漏并不是内存在物理上的消失,⽽是应⽤程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因⽽造成了内存的浪费。

    • 内存泄漏的危害:运行一会就结束的普通程序,出现内存泄漏基本没什么影响,进程正常结束,⻚表的映射关系解除,物理内存一样释放。但更多的是⻓期运⾏的程序,如果出现内存泄漏影响很⼤,如操作系统、后台服务、⻓时间运⾏的客户端等等,不断出现内存泄漏会导致可⽤内存不断变少,各种功能响应越来越慢,最终卡死。

    代码示例 8.1

    int main()
    {
    // 申请1G内存未释放
    // 程序⻢上就结束,进程结束各种资源就回收
    // 毫无影响
    char* ptr = new char[1024 * 1024 * 1024];
    cout << (void*)ptr << endl;

    return 0;
    }

            避免内存泄漏要做到:

    • ⼯程前期有良好的设计规范,养成良好的编码规范,申请了内存空间就匹配释放。但大多是出现异常等,就算注意释放了,还是可能出问题。通过智能指针来管理才是最有保证的。
    • 尽量使⽤智能指针管理资源,且保证正确使用智能指针,如果场景⽐较特殊,也可以采⽤RAII思想⾃⼰造个轮⼦管理。
    • 可以定期使⽤内存泄漏⼯具帮助检测程序,尤其是每次项⽬快上线前,但需要找到靠谱的⼯具,还要考虑收费情况和成本。

            总结:内存泄漏⾮常常⻅,解决⽅案分两种:1、事前预防型。如智能指针等;2、事后查错型。如泄漏检测⼯具。一般还是倾向方案1,提前设计hao

    赞(0)
    未经允许不得转载:171主机测评 » 【C++修炼】智能指针使用及原理
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址