WHCSRL 技术网

【C/C++】智能指针

  智能指针是<memory.h>的一部分,这个头文件主要负责C++的动态内存管理。C++的动态内存管理是通过 new/delete 实现,这其实在使用的时候很麻烦。所谓智能指针其实是一些模板类,它们负责自动管理一个指针的内存,免去了手动 new/delete 的麻烦
  侯捷在他的教程中提到:C++中一个 class type 的对象可能有两种特殊的情况:像一个指针(pointer-like class,迭代器、智能指针),或者像一个类(仿函数)。为什么要做一个“像指针”的类?因为可能语言的设计者觉得,承接自C语言的普通指针,其功能已经无法满足C++在C语言之外扩展的新功能的需求了。因此现在需要一种新的指针,它首先是个指针,却能比指针做更多。
  其实智能指针就是指针之外的一层封装,这些智能指针类都重载了 * 和 -> 运算符,因此完全可以当成普通指针去用(这跟迭代器其实有一些相似,都是C++中的一些特殊的指针)。
一个 pointer-like class 最基本的特点也就很清晰了:

  1. 有一个数据成员是真正的指针;
  2. 重载了 * 和 -> 运算符;
  3. 它的构造函数需要接收一根真正的指针去为数据成员赋初值。

(一)shared_ptr

  shared_ptr 对象里面不但有一个真正的指针,还有一个用于维护计数的 count 。所谓 share ,就是指这个智能指针指向的内存同时可以被多个 shared_ptr 所指(当然就会产生类似多线程的问题,姑且按住不表)。count 就负责统计当前时刻指向这块内存的 shared_ptr 的个数。【注意,每一个 count 是用来描述一块内存而非一根智能指针的,那么,这个 count 一定是所有 shared_ptr 共同维护的一个值,因此是 static 的。】这句可能是错的

  通常来说,动态申请了一片内存之后,可能会在多个地方会用到。对于裸指针,你需要自己记住在什么地方释放内存,不能在有别的地方还在使用的时候,你就释放,也不能忘记释放。而在 shared_ptr 里,有人用到这块内存的时候,count 增加 1,而不用的时候(离开作用域或者生命周期外),count 减少 1,如果一块内存的引用计数为 0,则自动释放内存。

1.1 基本操作

1.1.1 初始化与make_shared

shared_ptr<string> p1;						//初始化没有赋初值,则p1里面真正的指针被初始化为nullptr
shared_ptr<string> pint1 = make_shared<string>("safe uage!");	//安全的初始化方式
  • 1
  • 2

为什么使用make_shared?
【】

1.1.2 改变计数的操作:赋值、拷贝、reset

① 赋值

auto sp1 = make_shared<string>("obj1");			//sp1.use_count() = 1,本质是obj1的引用计数为1
auto sp2 = make_shared<string>("obj2");			//sp2.use_count() = 1,本质是obj2的引用计数为1
auto sp1 = sp2;				//sp1指向obj2,obj2的引用计数为2,sp1和sp2的count都是2,obj1引用计数为0被释放

shared_ptr<int> sp3 (new int, [](int* p){delete p;}, std::allocator<int>());
shared_ptr<int> sp4 (p3);						//sp3 和 sp4 的 count 都是2
shared_ptr<int> sp5 (std::move(p4));			//sp5 偷走了 sp4 指向那块内存的指针,sp4 的 count 变为 0,其余两个为 2
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

注释:获取一根智能指针 count 值的函数:use_count()

② 拷贝

auto sp1 = make_shared<string>("obj");						
auto sp2(sp1);									//sp1和sp2指向同一个对象,二者的count都是2
func(sp2);										//※
  • 1
  • 2
  • 3

  对于func(sp2),需要分情况讨论:

  • 当 func 的参数是一个 shared_ptr<string> 对象时,由于传参过程中发生值拷贝,则 func 执行过程中,有 sp1 和 sp2 以及 pass by value 生成的 sp2’ 三根智能指针 指向 obj ,因此三者的 count 都是3。func 结束后,由于 sp2’ 是 auto 生命期的变量,会被自动释放,因此 sp1 和 sp2 两根指针指向obj,二者的 count 恢复为2。
  • 当 func 的参数是一个 shared_ptr<string> 对象的引用时,传参过程中发生 pass by reference,没有 sp2’ 生成,二者的 count 一直是 2。

③ reset

// #case 1
auto sp1 = make_shared<string>("obj");	
sp1.reset();							//调用一次reset(),sp1的count减1变为0,obj的内存自动释放
sp1.reset();							//尽管sp1的心已经空了,但是没析构,还能继续reset,只不过永远是0了

// #case 2
//有一个自定义类型 Zoo,里面有一个int a
auto sp1 = make_shared<string>(new Zoo);
auto sp2(sp1);							//二者的count都是2
sp1.reset();
//sp1->a = 10;
sp2.reset();
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

  重点关注 case 2,【case 2 的问题将在】:

  • sp1.reset() 后:sp1 的 count 变成0,sp2 的 count 变成1
  • sp2.reset() 后:sp1 和 sp2 的 count 全部变成 0。
  • sp1.reset() 之后通过 sp1 访问地址里面的值,这是未定义的行为,不同编译器处理结果不同,反正都不是好结果。MinGW 没有报错,程序到这里直接终止,不再执行这一句及以下的语句。

1.1.3 注意事项

① 存放于容器中的shared_ptr

  如果容器的class T是一个shared_ptr<type>,那么一旦后面的程序不再需要某个元素时,需要用 erase 主动删除。否则由于引用计数一直存在,这个智能指针类型的元素将直至容器被析构的时候才会被销毁。

② shared_ptr作为unordered容器的key导致hash退化为链表

  在一些老版本的编译环境中,如果把 boost::shared_ptr 放到 unordered_set 中,或者用于 unordered_map 的 key,hash table 可能会退化为链表(这是一个bug)。Boost 1.46.0 之前,unordered_set<std::shared_ptr<T> > 虽然可以编译通过,但是其 hash_value 是 shared_ptr 隐式转换为 bool 的结果。也就是说,如果不自定义hash函数,那么 unordered_set/map 会退化为链表。

③ 避免用一个裸指针初始化多个shared_ptr

  如果两个 shared_ptr 管理同一个对象,当其中一个被销毁时,其管理的对象会被销毁,而另外一个销毁时,对象会二次销毁。然而实际上,对象已经不在了,最终造成严重后果。
  使用 get()方法 从 sp1 中取出一个裸指针赋值给 sp2,和上面的过程本质上没什么不同,不要这样做。

删除器

  和其它大多数标准库模板类一样,shared_ptr 也有两个隐藏的参数:删除器和分配器。直接看构造函数。

// with deleter
template <class U, class D> shared_ptr (U* p, D del);
template <class D> shared_ptr (nullptr_t p, D del);

// with allocator
template <class U, class D, class Alloc> shared_ptr (U* p, D del, Alloc alloc);
template <class D, class Alloc> shared_ptr (nullptr_t p, D del, Alloc alloc);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  1. 如果不显式传递删除器,其默认值为 delete;
  2. 如果处理数组,则必须显式传递删除器 delete[],当然,动态数组不好用,不如直接用vector。

e.g. 显式传递删除器

void myClose(int *fd){
    close(*fd);
}
int main()
{
    int socketFd = 10;
    std::shared_ptr<int> up(&socketFd, myClose);						//传递函数指针作为删除器
	
	std::shared_ptr<int> sp1(new int[10],[](int *p){delete[] p;});		//传递delete[]处理数组,用到了lambda表达式
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

1.2 数据结构

   该部分转载自 陈硕的blog:为什么多线程读写 shared_ptr 要加锁?

1.2.1 基本数据结构

  shared_ptr 是引用计数型智能指针,几乎所有的实现都采用在堆上放个计数值的办法(除此之外理论上还有用循环链表的办法,不过没有实例)。具体来说,shared_ptr 包含两个指针,一个是指向 Foo 的指针 ptr,另一个是 ref_count 指针(其类型不一定是原始指针,有可能是 class 类型,但不影响这里的讨论),指向堆上的 ref_count 对象。ref_count 对象有多个成员,其中 deleter 和 allocator 是可选的。

在这里插入图片描述

(1)为什么 ref_count 中也有指向 Foo 的指针?

  shared_ptr<Foo> sp(new Foo) 在构造 sp 的时候捕获了 Foo 的析构行为。实际上 shared_ptr.ptr 和 ref_count.ptr 可以是不同的类型(只要它们之间存在隐式转换),这是 shared_ptr 的一大功能。分三点来说:

  1. 无需虚析构:假设 Bar 是 Foo 的基类,但是 Bar 和 Foo 都没有虚析构。
shared_ptr<Foo> sp1(new Foo); 					// ref_count.ptr 的类型是 Foo*
shared_ptr<Bar> sp2 = sp1; 						// 可以赋值,自动向上转型(up-cast),Foo 的 count 变为 2
sp1.reset(); 									// 这时 Foo 对象的引用计数降为 1
  • 1
  • 2
  • 3

  此后 sp2 仍然能安全地管理 Foo 对象的生命期,并安全完整地释放 Foo,因为其 ref_count 记住了 Foo 的实际类型
  其实这个例子和上文 case 2 是一样的,sp1.reset(); 调用前,二者的 count 都是2;调用后,sp1 的 count 变为 0,而 sp2 的是 1。

  1. shared_ptr<void> 可以指向并安全地管理(析构或防止析构)任何对象muduo::net::Channel class 的 tie() 函数就使用了这一特性,防止对象过早析构。
shared_ptr<Foo> sp1(new Foo); 					// ref_count.ptr 的类型是 Foo*
shared_ptr<void> sp2 = sp1; 					// 可以赋值,Foo* 向 void* 自动转型
sp1.reset(); 									// 这时 Foo 对象的引用计数降为 1
  • 1
  • 2
  • 3

  此后 sp2 仍然能安全地管理 Foo 对象的生命期,并安全完整地释放 Foo,不会出现 delete void* 的情况,因为 delete 的是 ref_count.ptr,不是 sp2.ptr。

  1. 多继承:假设 Bar 是 Foo 的多个基类之一。
shared_ptr<Foo> sp1(new Foo);
shared_ptr<Bar> sp2 = sp1; 								// 这时 sp1.ptr 和 sp2.ptr 可能指向不同的地址
														// 因为 Bar subobject 在 Foo object 中的 offset 可能不为0
sp1.reset(); 											// 此时 Foo 对象的引用计数降为 1
  • 1
  • 2
  • 3
  • 4

  但是 sp2 仍然能安全地管理 Foo 对象的生命期,并安全完整地释放 Foo。因为 delete 的不是 Bar*,而是原来的 Foo*。换句话说,sp2.ptr 和 ref_count.ptr 可能具有不同的值(当然它们的类型也不同)。

(2)为什么使用make_shared?

  最直观的一个理由:shared_ptr 里面有两根指针,如果采用shared_ptr<Foo> x(new Foo); 赋值,就是上面的那张图,需要分配两次内存,一块由 ptr 指向,另一块由 ref 指向。而 make_shared 可以节省一次内存分配,即一次性分配一块足够大的内存同时容纳两块地址。并且数据结构就变成了这样:
在这里插入图片描述
  不过 Foo 的构造函数参数要传给 make_shared(),后者再传给 Foo::Foo(),其中需要 perfect forwarding

1.2.2 shared_ptr赋值过程中的race condition

  shared_ptr x(new Foo); 对应的内存数据结构如下(后文只画出 use_count 的值):在这里插入图片描述
  再执行 shared_ptr y = x; 那么对应的数据结构如下:
在这里插入图片描述
  但是 y=x 涉及两个成员的复制,这两步拷贝不会同时(原子)发生,需要两个中间步骤——复制 ptr 指针复制 ref_count 指针,实现顺序不一定,通常先 ptr 后 ref,复制ref后,use_count增加1
在这里插入图片描述
在这里插入图片描述
  既然 y=x 有两个步骤,如果没有 mutex 保护,那么在多线程里就有 race condition(竞争)

  考虑一种最简单的场景(有更复杂的),三个shared_ptr 对象 x、g、n:

shared_ptr<Foo> g(new Foo); 							// 线程 A、B 共享的 shared_ptr
shared_ptr<Foo> x; 										// 线程 A 的局部变量(未被赋值)
shared_ptr<Foo> n(new Foo); 							// 线程 B 的局部变量(已被赋值)
  • 1
  • 2
  • 3

(1)一开始,各安其事
在这里插入图片描述
(2)线程 A 执行 x = g; (即 read g),但完成了 ptr 的拷贝,还没来得及拷贝 ref,就切换到了 B 线程
在这里插入图片描述
(3)同时让 B 执行 g = n; (即 write g),ptr 和 ref 的拷贝一起完成了,此时 Foo1 对象已经销毁,x.ptr 成了悬空指针
在这里插入图片描述
在这里插入图片描述
(4)最后回到线程 A,完成 ref 的拷贝,线程 A 结束,程序出错
在这里插入图片描述
  多线程无保护地读写 g(A 没读完,B 就写),造成了 x 是悬空指针的后果。因此多线程读写同一个 shared_ptr 必须加锁!

(二)weak_ptr

【还有一种情况,对于某些对象,如它可能作为缓存。它有的时候,我就用一下,没有的时候就不用,也不负责去管理资源的释放资源,岂不美哉?】
把weak_ptr放在第二部分,是因为某种程度上,它像是shared_ptr的补充。首先明确两个概念:

  • 强引用:
  • 若引用:观察shared_ptr管理的内存对象 ,只观察但不拥有。

成员函数lock返回shared_ptr对象,若对应内存已经删除,则shared_ptr对象==nullptr,weak_ptr对象可以拷贝构造,拷贝构造出来的对象和原对象观察的是同一段内存。成员函数reset可以解除对内存的观察,注意,是解除观察,并不会删除对应内存对象。可以避免因shared_ptr的循环引用而引起的内存泄露。

2.1 对象所有权

(三)unique_ptr

  shared_ptr允许多个智能指针共同share同一块地址,而unique_ptr则恰好与之相反。也就是说,一个对象有一个专属的unique_ptr,这个unique_ptr不能被复制,那么当这个专属的智能指针不再使用它的时候,就可以自动释放内存了。

推荐阅读