c++中智能指针使用小结

简介


指针是c/c++中重要的概念,也因为指针,难倒了许多人。

举个例子,对于malloc或new出来的内存,是在堆上分配的。那么从该内存分配完成的时刻开始,这块内存就由应用程序来管理了,包括使用和释放。

c程序中很多与内存管理相关的bug,很大的一块就是忘记释放内存导致的内存泄漏,最终导致系统崩溃。可以说,c编程人员在内存管理上耗费了大量的时间和精力。

c中只有指针,没有智能指针的概念。c++中引入了智能指针,c++11作了进一步的优化和扩展。本文主要介绍c++11中的智能指针。

直接使用裸指针有以下劣势:

  • 裸指针在声明中未表明它指向的是单个对象还是一个数组(我遇到过一个指向基类数组的裸指针,这对于使用者可能导致崩溃)
  • 裸指针作为参数传递时,程序使用者在使用完毕后不清楚是否应该释放它,设计和编码上的缺陷和疏忽极可能导致内存泄漏
  • 裸指针的析构也存在不确定性,如使用delete/delete[],还是要有专门的析构函数,使用错误可能导致未定义的行为
  • 使用裸指针后,即使知道需要析构,也可能存在某条路径上忘记析构,或者重复析构的情况,这会导致内存泄漏/未定义的行为
  • 裸指针在使用时没有确切的方式来检测它的有效性(比如原来的指向已经发生了变化),或者存在野指针,导致崩溃

以上裸指针的缺点,可以使用面向对象的方法加以解决。智能指针其实就是对裸指针进行了包装,利用对象生命周期结束自动析构的特点完成自动管理。

c11中共有四种智能指针:std::auto_ptr, std::unique_ptr, std::shared_ptr, std::weak_ptr

auto_ptr是c++98遗留的已经被弃用的特性,它有一些缺陷,目前已经由unique_ptr取代。下面逐一介绍。

unique_ptr管理专属所有权的资源


忠告:当你想到要使用智能指针时,第一选择应该就是unique_ptr,当它不适用时,再考虑使用其他指针。

原因:

  • unique_ptr和裸指针大小相当,即使用的空间小
  • 执行的操作与裸指针指令基本相同,即速度相当快

特点:

  • unique_ptr实现的是专属所有权语义。专属所有权的对立面是共享所有权。
  • 一个非空的unique_ptr总是拥有所指向的资源,它只移类型,不能复制。移动后源指针会被置空。
  • 默认对其内部的裸指针使用delete完成析构,也可以自定义析构器,即指定析构时调用的函数,使用 unique_ptr的第二个实参指定,自定义析构器会增大其尺寸。
  • 不能直接将new出来的裸指针赋值给unique_ptr,而应该使用reset成员函数。
  • 可以直接通过赋值的方式将Unique_ptr转换为shared_ptr。
  • 提供了数组形式的unique_ptr<T[]>,但不如使用vector数据结构好,不推荐使用。

用途:

  • 作为工厂函数的返回类型(一般工厂函数返回堆对象,并由调用者负责释放内存)
  • 实现Pimpl用法,即在类中声明一个指向实现的成员变量,这样有利于减小依赖

shared_ptr管理共享所有权资源


与unique_ptr相对应,shared_ptr支持复制、赋值等操作,因为多个shared_ptr可以指向同一个对象,只有当最后一个指向该对象的shared_ptr失效时,对象才被析构。

特点:

  • shared_ptr通过引用计数来确定自己是否是最后一个指向某对象,引用计数表明了指向该对象的shared_ptr数量
  • shared_ptr构造时,增加引用计数,析构时减小。引用计数的操作是原子的,否则多个线程操作同一个对象的shared_ptr时会一致性问题,这会影响效率
  • 正是附加信息的存在,shared_ptr的尺寸是裸指针的两倍
  • 引用计数的内存也是动态分配的
  • 对shared_ptr执行移动操作会将源shared_ptr置空,移动操作不会增加引用计数,所以它比复制的效率高
  • 默认使用delete析构资源,可以指定析构器,与unique_ptr析构器是智能指针类型的一部分不同,它的实现形式为:std::shared_ptr<Foo> spf(new Foo, MyDel)
  • 每一个由shared_ptr管理的对象都有一个控制块,该控制块内包含引用计数、弱计数、自定义删除器等其他数据,示意图如下所示。

在这里插入图片描述

  • 创建首个指向对象的shared_ptr时,会依据以下规则创建控制块:

    • std::make_shared总是会创建控制块
    • 从具备专属所有权的指针出发构造shared_ptr时,会创建控制块
    • 使用裸指针作为实参调用shared_ptr的构造函数创建智能指针时,创建控制块
  • 永远不要使用同一个裸指针出发来构造多个shared_ptr,这会创建多个控制块,产生多重引用计数,导致多次析构!

// 错误做法
auto pf = new Foo;
shared_ptr<Foo> spf1(pf); // 创建控制块,引用计数减为0时,析构
shared_ptr<Foo> spf2(pf); // 又创建了控制块,引用计数减为0时,再次析构,结果未定义!

// 正确的做法
shared_ptr<Foo> spf1(new Foo);// 创建控制块,引用计数减为0时,析构
shared_ptr<Foo> spf2(spf1);// 不会创建控制块

// 更好的做法
shared_ptr<Foo> spf1 = std::make_shared<Foo>();// 创建控制块,引用计数减为0时,析构
shared_ptr<Foo> spf2(spf1);// 不会创建控制块
  • 同上一条,不要使用this来构建多个shared_ptr,使用shared_from_this()。

对于有可能空悬的shared_ptr使用weak_ptr


weak_ptr与shared_ptr类似,但它无需管理所指向的对象的共享所有权。即它影响指向对象的引用计数。那么它所指向的对象有可能已经被析构,即空悬的指针。

从某种意义上说,weak_ptr不是独立的智能指针,而是shared_ptr的扩充。weak_ptr由shared_ptr创建,但它不增加引用计数,如下:

auto spf = std::make_shared<Foo>(); // 创建控制块,引用计数为1

std::weak_ptr<Foo> wpf(spf); // wpf/spf指向同一个Foo,引用计数仍然是1

spf = nullptr; // 引用计数变为0,Foo被析构,此时wpf悬空

if (wpf.expired())
{
	// true
}

特点:

  • weak_ptr提供了一种检测指针有效性的方法
  • 可以通过检测,在原子操作中再访问该资源,方法是通过weak_ptr创建出shared_ptr,如下:
// 使用lock函数,若创建失败,返回nullptr
std::shared_ptr<Foo> spf1 = wpf.lock();
auto spf2 = wpf.lock;

// 使用构造函数,如果wpf失效,则抛出异常
std::shared_ptr<Foo> spf3(wpf);
  • 关于shared_ptr环路,可以使用weak_ptr,如下:

在这里插入图片描述

关于B指向A的指针:

  1. 如果使用裸指针,当A被析构,C仍然指向B,则B保存的A的空悬指针,B无法检测出来
  2. 如果使用shared_ptr,A和B相互保存着指向对方的shared_ptr,这种环路会阻止AB的析构,两者互相保持引用计数为1,导致内存泄漏
  3. 使用weak_ptr,若A被析构,B的weak_ptr会空悬,且B能够检测出来。尽管AB互相引用,但B的weak_ptr不会增加A的引用计数,A可以正常析构

当然,这种环路指针一般可以从设计上避免。在继承式的数据结构中,子结点通常被父节点拥有,当父节点析构时,子节点也应该被析构。

因此,从父节点到子节点链接可以用unique_ptr,而由子到父的反向链接可以用裸指针。因为子节点的生存期不会比父节点长,不会存在子节点去使用指向父节点空悬指针的风险。

优先使用make_shared


好处:

  • 减少代码重复,make_shared返回的总是shared_ptr
  • 异常安全性的考虑,如下两行代码:
// 可能导致内存泄漏
processFoo(std::shared_ptr<Foo>(new Foo), someOthreWork); // 与语句执行顺序有关,someOthreWork抛出异常可能导致已经new的内存泄漏
// 不会有泄漏
processFoo(std::make_shared<Foo>(), someOthreWork);
  • 性能提升。它能使编译器有机会利用更简洁的数据结构产生更小的更快的代码。
// 直接使用new
std::shared_ptr<Foo> spf(new Foo);// 引发2次内存分配,new一次,产生控制块一次
auto spf = std::make_shared_ptr<Foo>();// 仅一次内存分配,既保存Foo,又保存控制块

限制:

  • 所有make系列函数都不支持自定义析构器,所以在需要使用自定义析构器的情况下,得使用new
  • make系列函数会向对象的构造函数完美转发其形参,且使用圆括号而非大括号,所以在需要使用大括号初始化物来创建对象指针时,得使用new。如下:
// 产生10个元素,每个元素值为20的vector
auto spv = std::make_shared<std::vector<int>>(10, 20);// 使用圆括号,优先匹配形参为非std::initializer_list的构造函数

// 产生2个元素,值分别为10和20
auto initList = {10, 20};
auto spv = std::make_shared_ptr<std::vector<int>>(initList);
  • 具有自定义版本的operator new/operator delete的类而言,全局版本的分配/释放函数不再适用,不能使用make函数。
  • make高效的内存管理方式带来的一个副作用是,只有当与对象关联的控制块释放时,对象才会被释放。而控制块的释放同时依赖于shared_ptr和weak_ptr。当对象尺寸较大,且weak_ptr最后析构的时间较长时,建议使用new

总结


关于c11智能指针的基础内容应该有了一个清晰的了解,这里作一总结:

  • 现代c++程序中应该不再需要出现裸指针
  • 优先考虑使用unique_ptr,它小而快,且可以在任何需要的时候转换为shared_ptr,反之则不然
  • 使用shared_ptr时要注意那些可能导致问题的用法,如不要用同一个裸指针给多个shared_ptr赋值,需要用this时,使用shared_from_this()函数
  • 能使用make系列函数时就使用,不能使用时要想办法使用(前提是要正确)
  • 不要再考虑使用auto_ptr了
参考资料

《Effective Modern C++》


版权声明:本文为guotianqing原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。