C++ 智能指针详解

1. 智能指针的实现原理

智能指针的实现原理就是在一个类的内部封装了对象的指针,然后在析构函数里对我们的对象指针进行释放,因为类的析构是在类对象生命期结束时自动调用的,这样我们就省去了手动释放内存的操作,避免忘记手动释放导致的内存泄漏。

2. C++11四种智能指针总结

(1)auto_ptr:
  • auto_ptr以前是用在C98中,在C++11中被抛弃,一般用来作为独占指针
  • auto_ptr被赋值或者拷贝后,失去对原指针的管理
  • auto_ptr不能管理数组指针,因为auto_ptr的内部实现中,析构函数中删除对象使用delete而不是delete[],释放内存的时候仅释放了数组的第一个元素的空间,会造成内存泄漏。
  • auto_ptr不能作为容器对象,因为STL容器中的元素经常要支持拷贝,赋值等操作。

(2)unique_ptr:
  • C++11中用来替代auto_ptr
  • 拷贝构造和赋值运算符被禁用,不能进行拷贝构造和赋值运算.
  • 虽然禁用了拷贝构造和赋值运算符,但unique_ptr可以作为返回值,用于从某个函数中返回动态申请内存的所有权,本质上是移动拷贝,就是使用std:move()函数,将所有权转移

(3)share_ptr:
  • 多个指针可以指向相同的对象,调用release() 计数-1,计数0时资源释放
  • .use_count()查计数
  • .reset()放弃所指对象所有权
  • share_ptr多次引用同一数据会导致内存多次释放
  • 循环引用会导致死锁
  • 引用计数不是原子操作
shared_ptr 有两个数据成员,一个是指向对象的指针 ptr,另一个是 ref_count 指针(包含vptr、use_count、weak_count、ptr等);

shared_ptr<Foo> x(new Foo);
shared_ptr<Foo> y = x; 
y=x 涉及两个成员的复制,这两步拷贝不会同时(原子)发生,中间步骤1:复制 ptr 指针,中间步骤2:复制 ref_count 指针,导致引用计数加 1
步骤一:

步骤二:

注:因为是两步,如果没有 mutex 保护,那么在多线程里就有数据竞争。所以多线程读写同一个shared_ptr 必须加锁

(4)weak_ptr:
  • 解决两个share_ptr互相引用产生的死锁问题,计数永远降不到0,没办法进行资源释放,造成内存泄漏的问题。
  • 使用时配合share_ptr使用,把其中一个share_ptr更换为weak_ptr。


细说

我们知道除了静态内存和栈内存外,每个程序还有一个内存池,这部分内存被称为自由空间或者堆。程序用堆来存储动态分配的对象(在程序运行时分配的对象),当动态对象不再使用时,我们的代码必须显式的销毁它们。

在C++中,动态内存的管理是用一对运算符完成的:new和delete。
  • new: 在动态内存中为对象分配一块空间并返回一个指向该对象的指针。
  • delete:指向一个动态独享的指针,销毁对象,并释放与之关联的内存。
动态内存管理经常会出现两种问题:
  • 忘记释放内存,造成内存泄漏
  • 尚有指针引用内存的情况下就释放了它,从而导致引用非法内存的指针

为了更加容易(更加安全)的使用动态内存,引入了智能指针的概念。智能指针的行为类似常规指针,重要的区别是它负责自动释放所指向的对象
标准库提供的三种智能指针的区别在于管理底层指针的方法不同
  • shared_ptr允许多个指针指向同一个对象
  • unique_ptr则“独占”所指向的对象
  • 标准库还定义了一种名为weak_ptr的伴随类,它是一种弱引用,指向shared_ptr所管理的对象,
这三种智能指针都定义在memory头文件中。

(1)shared_ptr:

创建智能指针时必须提供额外的信息,指针可以指向的类型:
shared_ptr<string> p1;
shared_ptr<list<int>> p2;
默认初始化的智能指针中保存着一个空指针。
解引用一个智能指针返回它指向的对象,在一个条件判断中使用智能指针就是检测它是不是空。
if(p1  && p1->empty())
	*p1 = "hi";
如下表所示是shared_ptr和unique_ptr都支持的操作

如下表所示是shared_ptr特有的操作


make_shared函数:
最安全的分配和使用动态内存的方法就是调用一个名为make_shared的标准库函数,此函数在动态内存中分配一个对象并初始化它,返回指向此对象的shared_ptr。
shared_ptr<int> p3 = make_shared<int>(42);
shared_ptr<string> p4 = make_shared<string>(10,'9');
shared_ptr<int> p5 = make_shared<int>();
make_shared用其参数来构造给定类型的对象,如果我们不传递任何参数,对象就会进行值初始化。

shared_ptr的拷贝和赋值:
当进行拷贝和赋值时,每个shared_ptr都会记录有多少个其他shared_ptr指向相同的对象。
auto p = make_shared<int>(42);
auto q(p);
我们可以认为每个shared_ptr都有一个关联的计数器,通常称其为引用计数,无论何时我们拷贝一个shared_ptr,计数器都会递增。当我们给shared_ptr赋予一个新值或是shared_ptr被销毁时,计数器就会递减,一旦一个shared_ptr的计数器变为0,它就会自动释放自己所管理的对象
auto r = make_shared<int>(42);    //r指向的int只有一个引用者
r=q;   //给r赋值,令它指向另一个地址
      //递增q指向的对象的引用计数
      //递减r原来指向的对象的引用计数
      //r原来指向的对象已没有引用者,会自动释放
shared_ptr自动销毁所管理的对象
当指向一个对象的最后一个shared_ptr被销毁时,shared_ptr类会自动销毁此对象,它是通过另一个特殊的成员函数 - 析构函数完成销毁工作的,类似于构造函数,每个类都有一个析构函数。析构函数控制对象销毁时做什么操作。析构函数一般用来释放对象所分配的资源。shared_ptr的析构函数会递减它所指向的对象的引用计数如果引用计数变为0,shared_ptr的析构函数就会销毁对象,并释放它所占用的内存

shared_ptr还会自动释放相关联的内存:
当动态对象不再被使用时,shared_ptr类还会自动释放动态对象,这一特性使得动态内存的使用变得非常容易。
注:如果你将shared_ptr存放于一个容器中,而后不再需要全部元素,而只使用其中一部分,要记得用erase删除不再需要的那些元素。(否则会浪费动态内存)


程序使用动态内存的原因:
  • 程序不知道自己需要使用多少对象
  • 程序不知道所需对象的准确类型
  • 程序需要在多个对象间共享数据

直接管理内存:
C++定义了两个运算符来分配和释放动态内存,new和delete,使用这两个运算符非常容易出错。

使用new动态分配和初始化对象:
自由空间分配的内存是无名的,因此new无法为其分配的对象命名,而是返回一个指向该对象的指针。
int *pi = new int;   //pi指向一个动态分配的、未初始化的无名对象
此new表达式在自由空间构造一个int型对象,并返回指向该对象的指针

默认情况下,动态分配的对象是默认初始化的,这意味着内置类型或组合类型的对象的值将是未定义的,而类类型对象将用默认构造函数进行初始化。
string *ps = new string;    //初始化为空string
int *pi = new int;     //pi指向一个未初始化的int
我们可以直接使用直接初始化方式来初始化一个动态分配一个动态分配的对象。我们可以使用传统的构造方式,在新标准下,也可以使用列表初始化
int *pi = new int(1024);
string *ps = new string(10,'9');
vector<int> *pv = new vector<int>{0,1,2,3,4,5,6,7,8,9};
也可以对动态分配的对象进行初始化,只需在类型名之后跟一对空括号即可。

动态分配的const对象:
const int *pci = new const int(1024);     //分配并初始化一个const int
const string *pcs = new const string;      //分配并默认初始化一个const的空string
类似其他任何const对象,一个动态分配的const对象必须进行初始化。对于一个定义了默认构造函数的类类型,其const动态对象可以隐式初始化,而其他类型的对象就必须显式初始化。
由于分配的对象是const的,new返回的指针就是一个指向const的指针。

内存耗尽:
虽然现代计算机通常都配备大容量内村,但是自由空间被耗尽的情况还是有可能发生。一旦一个程序用光了它所有可用的空间,new表达式就会失败
默认情况下,如果new不能分配所需的内存空间,他会抛出一个bad_alloc的异常,我们可以改变使用new的方式来阻止它抛出异常。
//如果分配失败,new返回一个空指针
int *p1 = new int;                //如果分配失败,new抛出std::bad_alloc
int *p2 = new (nothrow)int;      //如果分配失败,new返回一个空指针
我们称这种形式的new为定位new,定位new表达式允许我们向new传递额外的参数。
本例中我们传给它一个由标准库定义的nothrow的对象,如果将nothrow传递给new,我们的意图是告诉它不要抛出异常如果这种形式的new不能分配所需内存,它会返回一个空指针
bad_alloc和nothrow都在头文件new中。

释放动态内存:
为了防止内存耗尽,在动态内存使用完之后,必须将其归还给系统,使用delete归还。

指针值和delete:
我们传递给delete的指针必须指向动态内存,或者是一个空指针。释放一块并非new分配的内存或者将相同的指针释放多次,其行为是未定义的。
即使delete后面跟的是指向静态分配的对象或者已经释放的空间,编译还是能够通过,实际上是错误的。

动态对象的生存周期直到被释放时为止:
shared_ptr管理的内存 在最后一个shared_ptr销毁时会被自动释,但是通过内置指针类型来管理的内存就不是这样了,内置类型指针管理的动态对象,直到被显式释放之前都是存在的,所以调用这必须记得释放内存。

使用new和delete管理动态内存常出现的问题:
  • 忘记delete内存
  • 使用已经释放的对象
  • 同一块内存释放两次

delete之后重置指针值:
delete之后,指针就变成了空悬指针,即指向一块曾经保存数据对象但现在已经无效的内存的地址。
有一种方法可以避免悬空指针的问题:在指针即将离开其作用域之前 释放掉它所关联的内存
如果我们需要保留指针可以在delete之后将nullptr赋予指针,这样就清楚的指出指针不指向任何对象。
注:动态内存的一个基本问题是可能多个指针指向相同的内存

shared_ptr和new结合使用:
如果我们不初始化一个智能指针,它就会被初始化成一个空指针。
接受指针参数的智能指针是explicit的,因此我们不能将一个内置指针 隐式转换为一个智能指针,必须以直接初始化形式 来初始化一个智能指针。
shared_ptr<int> p1 = new int(1024);    //错误:必须使用直接初始化形式
shared_ptr<int> p2(new int(1024));     //正确:使用了直接初始化形式

下表为定义和改变shared_ptr的其他方法


不要混合使用普通指针和智能指针:
如果混合使用的话,智能指针自动释放之后,普通指针有时就会变成悬空指针,当将一个shared_ptr绑定到一个普通指针时,我们就将内存的管理责任交给了这个shared_ptr。一旦这样做了,我们就不应该再使用内置指针来访问shared_ptr所指向的内存了。
也不要使用get初始化另一个智能指针或为智能指针赋值
shared_ptr<int> p(new int(42));//引用计数为1
int *q = p.get();    //正确: 但使用q时要注意,不要让它管理的指针被释放
{
     //新程序块
     //未定义: 两个独立的share_ptr指向相同的内存
     shared_ptr(q);
} //程序块结束,q被销毁,它指向的内存被释放
int foo = *p;    //未定义,p指向的内存已经被释放了
p和q指向相同的一块内存,由于是相互独立创建,因此各自的引用计数都是1,当q所在的程序块结束时,q被销毁,这会导致q指向的内存被释放,p这时候就变成一个空悬指针,再次使用时,将发生未定义的行为,当p被销毁时,这块空间会被二次delete。

其他shared_ptr操作:
可以使用reset来将一个新的指针赋予一个shared_ptr:
p = new int(1024);         //错误: 不能将一个指针赋予shared_ptr
p.reset(new int(1024));    //正确: p指向一个新对象
与赋值类似,reset会更新引用计数,如果需要的话,会释放p的对象。
reset成员经常和unique一起使用,来控制多个shared_ptr共享的对象。
在改变底层对象之前,我们检查自己是否是当前对象仅有的用户。如果不是,在改变之前要制作一份新的拷贝:
if(!p.unique())
    p.reset(new string(*p));     //我们不是唯一用户,分配新的拷贝
*p+=newVal;     //现在我们知道自己是唯一的用户,可以改变对象的值
 
智能指针和异常:
如果使用智能指针,即使程序块过早结束,智能指针也能确保在内存不再需要时将其释放,sp是一个shared_ptr,因此sp销毁时会检测引用计数,当发生异常时,我们直接管理的内存是不会自动释放的。如果使用内置指针管理内存,且在new之后在对应的delete之前发生了异常,则内存不会被释放

使用我们自己的释放操作:
默认情况下,shared_ptr假定他们指向的是动态内存,因此当一个shared_ptr被销毁时,会自动执行delete操作,为了用shared_ptr来管理一个connection,我们必须首先必须定义一个函数来代替delete。这个删除器函数必须能够完成对shared_ptr中保存的指针进行释放的操作。

智能指针陷阱:
  • 不使用相同的内置指针值初始化(或reset)多个智能指针。
  • 不delete get()返回的指针。
  • 不使用get()初始化或reset另一个智能指针。
  • 如果你使用get()返回的指针,记住当最后一个对应的智能指针销毁后,你的指针就变为无效了。
  • 如果你使用智能指针管理的资源不是new分配的内存,记住传递给它一个删除器。

(2)unique_ptr:

某个时刻只能有一个unique_ptr指向一个给定对象,由于一个unique_ptr拥有它指向的对象,因此unique_ptr不支持普通的拷贝或赋值操作。

下表是unique的操作

虽然我们不能拷贝或者赋值unique_ptr,但是可以通过调用release或reset将指针所有权从一个(非const)unique_ptr转移给另一个unique_ptr
//将所有权从p1转移给p2
unique_ptr<string> p2(p1.release());     //release将p1置为空,返回指针
unique_ptr<string>p3(new string("Trex"));

//将所有权从p3转移到p2
p2.reset(p3.release());     //release将p3置为空,返回指针;reset释放了p2原来指向的内存,重新指向p3.release()返回的指针
release成员返回unique_ptr当前保存的指针并将其置为空。因此,p2被初始化为p1原来保存的指针,而p1被置为空。
reset成员接受一个可选的指针参数,令unique_ptr重新指向给定的指针。
调用release会切断unique_ptr和它原来管理的的对象间的联系。release返回的指针通常被用来初始化另一个智能指针或给另一个智能指针赋值。

记得使用u.release()的返回值:
调用u.release()时是不会释放u所指的内存的,这时返回值就是对这块内存的唯一索引,如果没有使用这个返回值释放内存或是保存起来,这块内存就泄漏了
不能拷贝unique_ptr有一个例外:
我们可以拷贝或赋值一个将要被销毁的unique_ptr (移动拷贝)。 (最常见的例子是从函数返回一个unique_ptr)
unique_ptr<int> clone(int p)
{
    return unique_ptr<int>(new int(p));    //正确:从int*创建一个unique_ptr<int>
}
还可以返回一个局部对象的拷贝:
unique_ptr<int> clone(int p)
{
    unique_ptr<int> ret(new int(p));
    return ret;
}

向后兼容auto_ptr:
标准库的较早版本包含了一个名为auto_ptr的类,它具有uniqued_ptr的部分特性,但不是全部。

用unique_ptr传递删除器:
unique_ptr默认使用delete释放它指向的对象,我们以重载一个unique_ptr中默认的删除器
我们必须在尖括号中unique_ptr指向类型之后提供删除器类型。在创建或reset一个这种unique_ptr类型的对象时,必须提供一个指定类型的可调用对象删除器。

(3)weak_ptr:

weak_ptr是一种不控制所指对象的生存期的智能指针。
指向一个由shared_ptr管理的对象,将一个weak_ptr绑定到一个shared_ptr不会改变shared_ptr的引用计数。一旦最后一个指向对象的shared_ptr被销毁,对象就会被释放,即使有weak_ptr指向对象,对象还是会被释放。

weak_ptr的操作

由于对象可能不存在,我们不能使用weak_ptr直接访问对象,而必须调用lock(),此函数检查weak_ptr指向的对象是否存在。如果存在,lock返回一个指向共享对象的shared_ptr,如果不存在,lock将返回一个空指针.

(4)scoped_ptr:

scoped和weak_ptr的区别:给出了拷贝和赋值操作的声明并没有给出具体实现,并且将这两个操作定义成私有的,这样就保证scoped_ptr不能使用拷贝来构造新的对象 也不能执行赋值操作,因此更加安全并且增加了"++"、"–"、"*"、"->"这些操作,比weak_ptr能实现更多功能


面试八股 文章被收录于专栏

offer速来!

全部评论

相关推荐

点赞 1 评论
分享
牛客网
牛客企业服务