C++基础之智能指针

C++基础之智能指针

“ 本文主要介绍C++11中智能指针的出现和基本使用以及注意事项,希望能对初学者带来比较全面且容易理解的智能指针相关知识。”

裸指针的危害

该部分介绍一下对于 C++11 为什么引入智能指针,以及说原始指针出现了哪些问题。
对于 C/C++ 这样的系统级别的编程语言,对于性能要求是非常高的,不同于具有垃圾回收机制的语言,如 Java、Python 等,C/C++ 需要手动的去管理内存资源来提高性能。C 语言中的 malloc/free 和 C++ 中new/delete 都是实现堆区资源创建和销毁的 API,那在我们平常的使用过程中可能会出现很多意想不到的问题。


int* p = new int();

int* pa = new int[10];

// call p or pa ...
// ...

delete p;

delete[] pa;

上述两种表现形式分别在堆区创建了两块空间并由两个指针分别指向这两块空间的首地址。然而在调用 p 或者 pa 的过程中我们是不知道当前指向的是数组空间还是单个对象空间的。这是挺重要的问题,因为我们应该在适当的地方去释放空间,如果不知道是数组还是单个对象 delete 运算符会默认执行删除单个对象的语义。同时,还可能遇到的问题如下。

  • 什么时机释放空间,如何判断当前空间不需要了
  • 如果中途程序提前终止,无法调用 malloc 或者 delete
  • 如何避免 double free
  • 遇到悬空指针如何处理

上述都是在使用这两组API会遇到的问题,我们可以说无论是malloc/free还是new/delete给我们提供了很大的自由度和灵活性,但同时管理起来是十分繁琐和难以处理的。正因为如此,C++11之后出现了智能指针,那后文主要介绍的就是几种智能指针和基本使用。

什么是智能指针

上一部分已经介绍了原始指针带来的危害以及风险,那这一部分主要介绍智能指针是如何解决原始指针的问题的。
智能指针其实本质上就是在原始指针的基础上“包装”一下,它们的行为与指针类似,却避免了很多在使用原始指针时遭遇的很多问题。因此说,相对于原始指针,优先选择使用智能指针,智能指针几乎能提供原始指针同样的操作,但犯错的成本却大大减少。
C++11中提供了四种不同功能的智能指针,分别是 std::auto_ptr、std::unique_ptr、std::shared_ptr、std::weak_ptr。基本原理是利用 RAII原理(在初始化时分配资源,在析构时释放资源)管理动态分配对象的生命周期。
我们基本可以按照独占式和共享式两种将智能指针划分开。std::auto_ptr是C++将智能指针标准化的尝试,但实际现在已经被废弃了。取而代之,std::unique_ptr 可以完成 std::auto_ptr 所能完成的任何事情,通过“移动”优于“拷贝”的思想完成对资源的管理。共享式指针的代表是std::shared_ptr,它提供了多个智能指针能够管理同一对象的操作,利用“引用计数”的思想实现,它和 std::unique_ptr 都是比较常用的智能指针。

std::unique_ptr

独占式智能指针(unique_ptr)实现了专属所有权语义。每个资源只能被一个std::unique_ptr管理,如果想要改变管理者(智能指针),就必须将资源的所有权从一个智能指针转移到另一个智能指针,这就是“转移所有权”的思想。
std::unique_ptr默认析构的语义是delete,实际上我们完全可以自定义析构方式(删除器)。


auto delInvmt = [](Investment* pInvestment) // 自定义删除器
                { //(lambda表达式)
                    makeLogEntry(pInvestment);
delete pInvestment; 
                };

std::unique_ptr<Investment, decltype(delInvmt)> //同之前一样
        pInv(investment, delInvmt);

delInvmt是自己实现的函数对象,我们可以指定std::unique_ptr的第二个泛型参数,然后在对象中指定具体函数对象名,这样就改变了std::unique_ptr默认的析构语义了。这里其实就是在调用析构操作前记录了日志信息,其他场景下还可以实现关闭文件操作等。不难看出,如果不指定std::unique_ptr的第二个泛型参数,默认是不能修改删除器的。
从存储空间考虑的话,默认的std::unique_ptr是和裸指针占用空间差不多的,都和存储的对象大小有关。那如果自定义了删除器的话,自然std::unique_ptr的尺寸就会增加,即存储一个函数指针(4字节或者8字节)。这里其实函数指针和函数对象都可以,但推荐使用函数对象。
函数指针和函数对象这两个概念对初学者而言,很容易就会混淆,因此,我在这里做一些说明。


#include <iostream>
#include <future>

using namespace std;

class Test
{
public:
    char c;
    int a;
    double b;
};

void delpFunc(Test* t)
{
    cout << "2. Test log..." << endl;  
    delete t;
}

int main()
{
    auto delTest = [](Test* t) {
        cout << "1. Test log..." << endl;  
        delete t;
    };
    // 函数对象
    std::unique_ptr<Test, decltype(delTest)> p1(new Test(), delTest);
    cout << sizeof(p1) << endl;
    // 函数指针
    std::unique_ptr<Test, void(*)(Test*)> p2(new Test(), &delpFunc);
    cout << sizeof(p2) << endl;

    return 0;
}

/*
8
16
2. Test log...
1. Test log...
*/

上述代码分别展示了使用函数对象和函数指针作为删除器的例子,我获取了智能指针p1和p2的存储空间大小最终得到的结果是不同,这究竟是为什么呢?
这里其实就是函数指针和函数对象不同的一点,函数指针是我们事先声明出来,然后使用时根据具体函数传参,因此说编译时期是知道这个函数指针会占用多少存储空间的;然而,函数对象在编译时期却不知道具体大小,必须等到运行时执行才能获取到。所以说,我们是更推荐使用函数对象作为删除器使用的,而不是函数指针。
最后,我想告诉读者的是std::unique_ptr是可以隐式转换成std::shared_ptr,但这一点我在介绍 std::shared_ptr 时还会特意说明。

std::shared_ptr 和 std::weak_ptr

该部分主要介绍std::shared_ptr的基本原理、为什么出现make_shared、std::weak_ptr究竟是干什么的,还会讲一下什么是std::enable_shared_from_this。这一小结介绍的东西会比较多,所以不能说的过于细致,如果有需要后面还会发一些比较全面讲解的文章。
std::shared_ptr是是一个通过“引用计数”的思想实现的智能指针,它和std::unique_ptr比较重要的区别是,多个std::shared_ptr可以管理同一个对象,它将释放资源的操作,交给最后一个管理者,所以说std::shared_ptr的使用场景会更广,当然,std::shared_ptr实现起来也会更复杂。
在这里插入图片描述

上面这个模型就是std::shared_ptr的内存模型,可见,std::shared_ptr并不是那么简单的只存在“引用计数”而已。std::shared_ptr主要就是两个部分,一是有一个指针指向管理的堆区对象,另一个就是控制块的指针,包含有引用计数、弱计数和自定义删除器等。(注:这里不对弱计数和分配器做讲解)
首先是引用计数,它的基本原理就是,每次有指针指向某一个对象,这些智能指针的引用计数都会加一,那如果当前只剩下最后一个智能指针,它的引用计数就是1,那这个智能指针就有权利释放其所管理的对象资源。


~SmartPtr()
{
    if (refCnt_->delRef() == 0)
    {
        delete mptr; 
        mptr = nullptr;
    }
}

/*
    T* mptr; // 指向对象资源的指针
    RefCount<T>* refCnt_; // 引用计数类的指针
*/

引用计数去管理资源,最大的问题在于会出现循环引用问题,如果类A中含有指向类B的智能指针,同时类B含有指向类A的智能指针,那么这时就会产生引用计数不能为0的问题,无法释放对象资源,从而造成内存泄漏。
C++11中的std::weak_ptr就是这种弱引用计数的智能指针,对于std::weak_ptr来说,它并不会影响引用计数的增减,而只起到查看的作用,这样我们完全可以把类A和类B中的智能指针类型声明为 std::weak_ptr,这样就能完美解决循环引用问题了。std::weak_ptr的另一个使用场景就是如何线程安全的使用智能指针。


// 多线程访问共享对象的线程安全问题

class A
{
public:
  A() { cout << "A()" << endl; }
  ~A() { cout << "~A()" << endl; }
  void testA() { cout << "test" << endl; }
};

// 问题场景:主线程创建新对象,子线程执行函数,主线程释放对象;
// 其中子线程需要执行一部分流程(sleep),
// 主线程过早地释放对象会造成子线程访问已经释放空间
void handler01(weak_ptr<A> pw)
{
  std::this_thread::sleep_for(std::chrono::seconds(4));

  // 检测对象是否存活
  auto q = pw.lock();
  if (q != nullptr)
  {
    q->testA();
  }
  else
  {
    cout << "A对象已经析构" << endl;
  }
}

int main()
{
  //A* p = new A();
  {
    shared_ptr<A> p(new A());
    thread t1(handler01, p);
    t1.detach();

  }
  std::this_thread::sleep_for(std::chrono::seconds(20));

  //t1.join();
  return 0;
}

上述代码主线程创建智能指针,而工作线程调用智能指针,工作线程中的参数是std::weak_ptr是无法直接调用的,我们需要手动的将std::weak_ptr提升为std::shared_ptr进行使用,然后可以根据pw.lock()的返回值是否为nullptr来判断智能指针提升成功,这样就可以避免由于线程争抢资源导致工作线程中的智能指针调用失败。
我们再次回到std::shared_ptr,如果经常使用智能指针的读者会知道还有两种创建智能指针的方式,分别是std::make_shared和std::make_unique。std::make_shared是C++11为我们提供的,而std::make_unique是C++14之后出现的。要想比较好的使用这两种make系列函数就需要了解为什么会出现他们?


auto upw1(std::make_unique<Widget>());      //使用make函数
std::unique_ptr<Widget> upw2(new Widget);   //不使用make函数

auto spw1(std::make_shared<Widget>());      //使用make函数
std::shared_ptr<Widget> spw2(new Widget);   //不使用make函数

观察上述代码我们发现:

  • make系列函数只需要使用auto自动推导类型,不需要手动写入
  • 不需要new关键字,make系列函数可以自动调用构造函数
  • 这两个都是make系列函数的优势,而它还具有保证异常安全的功能。

void processWidget(std::shared_ptr<Widget> spw, int priority);

processWidget传入的参数分别是智能指针spw和优先级参数priority,我们呢可以根据不同的优先级来实现不同功能。我们可以将智能指针的创建分为下面三个步骤:

表达式“new Widget”必须计算,例如,一个Widget对象必须在堆上被创建
负责管理new出来指针的std::shared_ptr构造函数必须被执行
computePriority必须运行
由于指令重排的问题,这三个指令的执行顺序可能并不和预想的一样,那如果步骤3执行在步骤2之前,就会将步骤一中创建的堆区对象资源泄露,这并不是我们想要的。解决这一问题很简单,使用std::make_shared。


processWidget(std::make_shared<Widget>(),   //没有潜在的资源泄漏
              computePriority());

解释如下:在运行时,std::make_shared和computePriority其中一个会先被调用。如果是std::make_shared先被调用,在computePriority调用前,动态分配Widget的原始指针会安全的保存在作为返回值的std::shared_ptr中。如果computePriority产生一个异常,那么std::shared_ptr析构函数将确保管理的Widget被销毁。如果首先调用computePriority并产生一个异常,那么std::make_shared将不会被调用,因此也就不需要担心动态分配Widget(会泄漏)。
std::make_shared另一个特性(与直接使用new相比)是效率提升。


std::shared_ptr<Widget> spw(new Widget);

auto spw = std::make_shared<Widget>();

下一个话题是std::enable_shared_from_this这种比较奇怪的东西。如果我们想将类中的this指针传递出去,就需要使用到这个模板类型。std::enable_shared_from_this被称为奇妙递归模板,我们如果想使用到它,首先需要继承这个模板类,然后模板参数传递的是该类的类型,再传递this指针处调用shared_from_this()即可。


class Widget: public std::enable_shared_from_this<Widget> {
public:
    //完美转发参数给private构造函数的工厂函数
    template<typename... Ts>
    static std::shared_ptr<Widget> create(Ts&&... params);
    …
    void process();     //和前面一样
    …
private:
    …                   //构造函数
};

void Widget::process()
{
    //和之前一样,处理Widget
    …
    //把指向当前对象的std::shared_ptr加入processedWidgets
    // processedWidgets.emplace_back(this); 是错误的
    processedWidgets.emplace_back(shared_from_this());
}

总结

智能指针是C++解决裸指针的种种问题所出现的一种解决方案,它的出现其实很大程度上让C++变得更”安全“。我主要从裸指针的危害说起,介绍了智能指针“以对象管理资源”的基本思想。对于常见的两种智能指针std::unique_ptr和std::shared_ptr分别从原理和使用上做了分享。我们在平时的使用过程中需要去关注“独占”和“共享”的选择,这能很好的区别出这两种智能指针的使用场景,不至于误用。std::unique_ptr是基于“以移动优于拷贝”的思想设计的,Rust语言中也有类似的思想;而std::shared_ptr是基于“引用计数”设计的,而引用计数最大的问题在于循环引用问题,这时std::weak_ptr和std::shared_ptr的结合使用就是关键。make系列函数具有异常安全、效率提升、简化代码的优势,我们尽可能使用它来代替new,如果想要将this指针传递出去,请使用std::enable_shared_from_this。最后,希望能带给读者收获。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值