Fork me on GitHub

动态内存与智能指针

前言

到目前为止,我们编写的程序中所使用的对象都有着严格定义的生命期。全局对象在程序启动时分配,在程序结束时销毁。对于局部自动对象,当我们进入其定义所在的程序块时被创建,在离开块时销毁。局部static对象在第一次使用前分配,在程序结束时销毁。

除了自动和static对象外,C++还支持动态分配对象。动态分配的对象的生存期与它们在哪里创建是无关的,只有当显式地被释放时,这些对象才会销毁。

动态对象的正确释放被证明是编程中极其容器出错的地方。为了更安全地使用动态对象,标准库定义了两个智能指针类型来管理动态分配的对象,当一个对象应该被释放时,指向它的智能指针可以确保自动地释放它。

静态内存、栈内存、堆

静态内存:用来保存局部static对象、类static数据成员以及定义在任何函数之外的变量。

栈内存:用来保存定义在函数内的非static对象。

分配在静态或栈内存中的对象由编译器自动创建和销毁。对于栈对象,仅在其定义的程序块运行时才存在;static对象在使用之前分配,在程序结束时销毁。

除了静态内存和栈内存,每个程序还拥有一个内存池。这部分内存被称作自由空间(free store)堆(heap)。程序用堆来存储动态分配(dynamically allocate)的对象,即那些在程序运行时分配的对象。动态对象的生存期由程序来控制,也就是说,当动态对象不再使用时,我们的代码必须显式地销毁它们。

warning:虽然使用动态内存有时是必要的,但众所周知,正确管理动态内存是非常棘手的。

动态内存与智能指针

在C++中,动态内存的管理是通过一对运算符来完成的:new,在动态内存中为对象分配空间并返回一个指向该对象的指针。我们可以选择对对象进行初始化;delete,接受一个动态对象的指针,销毁该对象,并释放与之关联的内存。

动态内存的使用很容易出问题,因为确保在正确的时间释放内存是及其困难的。有时我们会忘记释放内存,在这种情况下就会产生内存泄漏;有时在尚有指针引用内存的情况下我们就释放它了,在这种情况下就会产生引用非法内存的指针

为了更容易(同时也安全)地使用动态内存,新的标准库提供了两种智能指针类型来管理动态对象。只能指针的行为类似常规指针,重要的区别是它负责自动释放所指向的对象。新标准库提供的这两种只能指针的区别在于管理底层指针的方式:shared_ptr允许多个指针指向同一对象;unique_ptr则“独占”所指向的对象。标准库还定义了一个名为weak_ptr的伴随类,它是一种弱引用,指向shared_ptr所管理的对象。

shared_ptr类

类似vector,只能指针也是模板。因此,当我们创建一个智能指针时,必须提供额外的信息——指针可以指向的类型。与vector一样,我们在尖括号内给出类型,之后是所定义的这种指针的名字:

1
2
shared_ptr<string> p1 ; //shared_ptr,可以指向string
shared_ptr<list<int> > p2; //shared_ptr,可以指向int的list

默认初始化的智能指针中保存着一个空指针。

只能指针的使用方式与普通指针类似。解引用一个智能指针返回它所指的对象。如果在一个条件判断中使用智能指针,效果就是检测它是否为空:

1
2
3
//如果p1不为空,检查它是否指向一个空string
if(p1&&p1->empty())
  *p1="hi";

下表列出了shared_ptr和unique_ptr都支持的操作。只适用shared_ptr的操作列入下面。

shared_ptr和unique_ptr都支持的操作

1
2
3
4
5
6
7
8
shared_ptr<T> sp 空智能指针,可以指向类型为T的对象
unique_ptr<T> up
p      将p用作一个条件判断,若p指向一个对象,则为true
*p      解引用p,获得它指向的对象
p->mem     等价于(*p).mem
p.get()      返回p中保存的指针,要小心使用,若智能指针释放了其对象,返回的指针所指向的对象也就消失了
swap(p,q)     交换p和q中的指针
p.swap(q)

shared_ptr独有的操作

1
2
3
4
5
make_shared<T>(args)     返回一个shared_ptr,指向一个动态分配的类型为T的对象,使用args初始化此对象
shared_ptr<T> p(q)       p是shared_ptr q的一个拷贝,此操作会递增q中的计数器。q中的指针必须都能转换为T*
p=q              p和q都是shared_ptr,所保存的指针必须能相互转换。此操作会递减p的引用计数,递增q的引用计数,若p的引用计数变为0,则将其管理                的原内存释放
p.unique()            若p.use_count()为1,返回true,否则返回false
p.use_count()          返回与p共享对象的智能指针的数量;可能很慢,主要用于调试

make_shared函数

最安全的分配和使用动态内存的方法是调用一个名为make_shared的标准库函数。此函数在动态内存中分配一个对象并初始化它,返回指向对象的shared_ptr。与智能指针一样,make_shared也定义在头文件memory中。

当要使用make_shared时,必须指定想要创建的对象的类型。定义方式与模板类相同,在函数名之后跟一个尖括号,在其中给出类型:

1
2
3
4
5
6
//指向一个值为42的int的shared_ptr
shared_ptr<int> p3=make_shared<int> (42);
//p4指向一个值为"99999"的string
shared_ptr<string> p4=make_shared<string> (5,'9');
//p5指向一个值初始化的int,即,值为0
shared_ptr<int> p5=make_shared<int> ();

类似顺序容器的emplace成员,make_shared用其参数来构造给定类型的对象。例如,调用make_shared时传递的参数必须与string的某个构造函数相匹配,调用make_shared 时传递的参数必须能用来初始化一个int,依次类推。如果我们不传递任何参数,对象就会进行值初始化。

当然,我们通常用auto定义一个对象来保存make_shared的结果,这种方式较简单:

1
2
//p6指向一个动态分配的空vector<string>
auto p6=make_shared<vector<string>> ();

shared_ptr的拷贝和赋值

当进行拷贝或赋值时,每个shared_ptr都会记录有多少个其他shared_ptr指向相同的对象。

1
2
auto p=make_shard<int>(42); //p指向的对象只有p一个引用者
auto q(p); //p和q指向相同的对象,此对象有两个引用者

我们可以认为每个shared_ptr都有一个关联的计数器,通常称其为引用计数。无论何时我们拷贝一个shared_ptr,计数器都会递增。例如,当用一个shared_ptr初始化另一个shared_ptr,或将它作为参数传递给一个函数以及作为函数的返回值时,它所关联的计数器都会递增,当我们给shared_ptr赋予一个新值或是shared_ptr被销毁(例如一个局部的shared_ptr离开其作用域时,计数器就会递减)

一旦一个shared_ptr的计算器变为0,它就会自动释放自己所管理的对象:

1
2
auto r=make_shared<int>(42); //r指向的int只有一个引用者
r=q; //给r赋值,令它指向另一个地址,递增q指向的对象的引用计数,递减r原来指向的对象的引用计数,r原来指向的对象已没有引用者,会自动释放

此例中我们分配了一个int,将其指针保存在r中。接下来,我们将一个新值赋予r。在此情况下,r是唯一执行此int的shared_ptr,在把q赋给r的过程中,此int被自动释放。

shared_ptr自动销毁所管理的对象

当指向一个对象的最后一个shared_ptr被销毁时,shared_ptr类会自动销毁此对象。它是通过另一个特殊的成员函数——析构函数完成销毁工作的。类似与构造函数,每个类都有一个析构函数。就像构造函数控制初始化一样,析构函数控制此类型的对象销毁时做什么操作。

析构函数一般用来释放对象分配的资源。例如,string的构造函数(以及其他string成员)会分配内存来保存构成string的字符。string的析构函数就负责释放这些内存。类似的,vector的若干操作都会分配内存来保存其元素。vector的析构函数就负责销毁这些元素,并释放它们所占用的内存。

shared_ptr的析构函数会递减它所指对象的引用计数。如果引用计数变为0,shared_ptr的析构函数就会销毁对象,并释放它所占用的内存

shared_ptr还会自动释放相关联的内存

当动态对象不再被使用时,shared_ptr类会自动释放动态对象,这一特性使得动态内存的使用变得非常容易。例如,我们可能有一个函数,它返回一个shared_ptr,指向一个Foo类型的动态分配的对象,对象是通过一个类型为T的参数进行初始化的:

1
2
3
4
5
6
7
//factory返回一个shared_ptr,指向一个动态分配的对象
shared_ptr<Foo> factory(T arg)
{
//恰当地处理arg
//shared_ptr负责释放内存
return make_shared<Foo>(arg);
}

由于factory返回一个shared_ptr,所以我们可以确保它分配的对象会在恰当的时刻被释放。例如,下面的函数将factory返回的shared_ptr保存在局部变量中:

1
2
3
4
5
6
void use_factory(T arg)
{
shared_ptr<Foo> p=factory(arg);
//使用p
//p离开了作用域,它指向的内存会被自动释放掉
}

由于p是use_factory的局部变量,在use_factory结束时它将被销毁。当p被销毁时,将递减其引用计数并检查它是否为0。在此例中,p是唯一引用factory返回的内存的对象。由于p将要销毁,p指向的这个对象也会被销毁,所占用的内存会被释放。

但如果有其他的shared_ptr也指向这块内存,它就不会被释放掉:

1
2
3
4
5
6
void use_factory(T arg)
{
shared_ptr<Foo> p=factory(arg);
//使用p
return p; //当我们返回p时,引用计数进行了递增操作
} //p离开了作用域,但它指向的内存不会被释放

在此版本中,use_factory中的return语句向此函数的调用者返回一个p的拷贝。拷贝一个shared_ptr会增加所管理对象的引用计数值。现在当p被销毁时,它所指向的内存还有其他使用者。对于一块内存,shared_ptr类保证只要有任何shared_ptr对象引用它,它就不会被释放掉。

由于在最后一个shared_ptr销毁前内存都不会释放,保证shared_ptr在无用之后不再保留就非常重要了。如果你忘记了销毁程序不再需要的shared_ptr,程序仍会正确执行,但会浪费内存。shared_ptr在无用之后仍然保留的一种可能情况是,你将shared_ptr存放在一个容器中,随后重排了容器,从而不再需要某种元素。在这种情况西下,你应该确保erase删除哪些不再需要的shared_ptr元素。

注意:如果你将shared_ptr存放于一个容器中,而后不再需要全部元素,而只使用其中一部分,要记得用erase删除不再需要的那些元素。

使用了动态生存期的资源的类

程序使用动态内存处于以下三种原因之一:

1 程序不知道自己需要使用多少个对象;

2 程序不知到所需的准确类型

3 程序需要在多个对象间共享数据

容器类是处于第一种原因而使用动态内存的典型例子。

使用动态内存的一个常见原因是运行多个对象共享相同的状态。

直接管理内存

C++语言定义了两个运算符来分配和释放动态内存。运算符new分配内存,delete释放new分配的内存。

相对于智能指针,使用这两个运算符管理内存非常容易出错,随着我们逐步详细介绍这两个运算符,这一点会更为清楚。而且,自己直接管理内存的类与使用智能指针的类不同,它们不能依赖类对象拷贝、赋值和销毁操作的任何默认定义。因此,使用智能指针的程序更容易编写和调试。

使用new动态分配和初始化对象

在自由空间分配的内存是无名的。因此,new无法为其分配的对象命名,而是返回一个执行对象的指针:

1
int *pi=new int ; // pi指向一个动态分配的、未初始化的无名对象

此new表达式在自由空间构造一个int型对象,并返回指向该对象的指针。

默认情况下,动态分配的对象是默认初始化的,这意味着内置类型或组合类型的对象的值将是未定义的,而类类型对象将用默认函数进行初始化

1
2
string *ps=new string; //初始化为空string
int *pi=new int ; // pi指向一个未初始化的int

我们可以使用直接初始化方式来初始化一个动态分配的对象。我们可以使用传统的构造方式(使用圆括号),在新标准下,也可以使用列表初始化(使用花括号):

1
2
3
int *pi=new int(1024) ;//pi的对象的值为1024
string *ps=new string(10,'9') ; //vector有10个元素,值依次从0到9
vector<int> *pv=new vector<int>{0,1,2,3,4,5,6,7,8,9};

也可以对动态的对象进行值初始化,只需在类型名之后跟一对空括号即可

1
2
3
4
string *ps1=new string; //默认初始化为空string
string *ps=new string(); //值初始化为空string
int *pi1=new int; //默认初始化:*pi1的值未定义
int *pi2=new int(); // 值初始化为0 *pi2为0

对于定义与自己的构造函数的类类型(例如string)来说,要求值初始化是没有意义的;不管采用什么形式,对象都会通过默认构造函数来初始化。但对于内置类型,两种形式的差别就很大了;值初始化的内置类型对象有着良好定义的值,而默认初始化的对象的值则是未定义的。类似的,对于类中那些依赖于编译器合成的默认构造函数的内置类型成员,如果它们未在类内被初始化,那么它们的值也是未定义的。

如果我们提供了一个括号包围的初始化器,就可以使用auto从此初始化器来推断我们想要分配的对象的类型。但是,由于编译器要用初始化器的类型来推断要分配的类型,只有当括号中仅有单一初始化器时才可以使用auto:

1
2
auto p1=new auto(obj) ; //p指向一个与obj类型相同的对象
auto p2=new auto(a,b,c) ; //错误:括号中只能有单个初始化器

p1的类型是一个指针,执行从obj自动推断的类型。若obj是一个int,那么p1就算int;若obj是一个string,那么p1是一个string;依次类推。新分配的对象用obj的值进行初始化。

动态分配的const对象

用new分配const对象是合法的:

1
2
3
4
//分配并初始化一个const int
const int *pci=new const int(1024);
//分配并默认初始化一个const的空string
const string *pcs=new const string;

类似其他任何const对象,一个动态分配的const对象必须进行初始化。对于一个定义了默认构造函数的类类型,其const动态对象可以隐式初始化,而其他类型的对象就必须显式初始化。由于分配的对象是const的,new 返回的指针是一个指向const的指针

内存耗尽

虽然现代计算机通常都配备大容量内存,但是自由空间被耗尽的情况还是有可能发生。一旦一个程序用光了它所有可用的内存,new表达式就会失败,默认情况下,如果new不能分配所要求的内存空间,它就会抛出一个类型为bad_alloc的异常。我们可以改变使用new的方式来阻止它抛出异常:

1
2
3
//如果分配失败,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表达式接受一个指针,指向我们想要释放的对象

1
delete p; // p必须指向一个动态分配的对象或是一个空指针

与new 类型类似,delete表达式也执行两个动作:销毁给定的指针指向的对象;释放对应的内存。

指针值和delete

我们传递给delete的指针必须指向动态分配的内存,或是一个空指针。释放一块并非new分配的内存,或者将相同的指针值释放多次,其行为都是未定义的:

1
2
3
4
5
6
7
int i,*pi1=&i,*pi2=nullptr;
double *pd=new double(33),*pd2=pd;
delete i; //错误,i不是一个指针
delete pi1; //未定义:pi1执行一个局部变量
delete pd; //正确
delete pd2; //未定义,pd2指向的内存已经被释放了
delete pi2; //正确:释放一个空指针总是没有错误的

对于delete i的请求,编译器会生成一个错误信息,因为它知道i不是一个指针。执行delete pi1和pd2所产生的错误则更具潜在危害:通常情况下,编译器不能分辨一个指针指向的是静态还是动态分配的对象。类似的,编译器也不能分辨一个指针所指向的内存是否已经被释放了。对于这些delete表达式,大多数编译器会编译通过,尽管它们是错误的。

虽然一个const对象的值不能被改变,但它本身是可以被销毁的,如同任何其他动态对象一样,想要释放一个const动态对象,只要delete指向它的指针即可。

1
2
const int *pci=new const int(1024);
delete pci; //正确:释放一个从const对象

动态对象的生存期直到被释放时为止

由shared_ptr管理的内存在最后一个shared_ptr销毁时会被自动释放。但对于通过内置指针类型来管理的内存,就不是这样了。对于一个由内置指针管理的动态对象,直到被显示释放之前它都是存在的。

返回指向动态内存的指针(而不是智能指针)的函数给其调用者增加了一个额外负担——调用者必须记得释放内存:

1
2
3
4
5
6
//factory返回一个指针,指向一个动态分配的对象
Foo* factory(T arg)
{
  //视情况处理arg
  return new Foo(arg); //调用者负责释放此内存
}

类似我们之前定义的factory函数,这个版本的factory分配一个对象,但并不delete它。factory的调用者负责在不需要此对象时释放它。不幸的是,调用者经常忘记释放对象:

1
2
3
4
5
void use_factory(T arg)
{
  Foo *p=factory(arg);
  //使用p但不delete它
} //p离开了它的作用域,但它所指向的内存没有被释放

此外,use_factory函数调用factory,后者分配一个类型为Foo的新对象。当use_factory返回时,局部变量p被销毁,此变量是一个内置指针,而不是一个智能指针。

与类类型不同,内置类型的对象被销毁时什么也不会发生。特别是,当一个指针离开其作用域时,它所指的对象什么也不会发生。如果这个指针指向的是动态内存,那么内存将不会被自动释放。

注意由内置指针(而不是智能指针)管理的动态内存在被显式释放前一直都会存在

在本例中,p是指向factory分配的内存的唯一指针。一旦use_factory返回,程序就没有办法释放这块内存了。根据整个程序的逻辑,修正这个错误的正确方法是在use_factory中记得要释放内存:

1
2
3
4
5
6
void use_factory(T arg)
{
  Foo *p=factory(arg);
  //使用p
  delete p ; //现在记得释放内存,我们已经不需要它了
}

还有一种可能,我们的系统中的其他代码要使用use_factory所分配的对象,我们就应该修改此函数,让他返回一个指针,指向它分配的内存:

1
2
3
4
5
6
Foo* use_factory(T arg)
{
  Foo *p=factory(arg);
  //使用p
  return p; //调用者必须释放内存
}

小心:动态内存的管理非常容易出错

使用new和delete管理动态内存存在三个常见问题:

  • 忘记delete内存。忘记释放动态内存会导致人们常说的“内存泄漏”问题,因为这种内存不可能被归还给自由空间了。查找内存泄漏错误是非常困难的,因为通常应用程序运行很长时间后,真正耗尽内存时,才能检测到这种错误。

  • 使用以及释放掉的对象,通过在释放内存后将指针置为空,有时可以检测出这种错误。

  • 同一块内存被释放两次,当有来年刚给指针指向相同的动态分配对象时,可能发生这种错误。如果对其中一个指针进行了delete操作,对象的内存就被归还给自由空间了,如果我们随后又delete第一个指针,自由空间就可能会被破坏。

相对于查找和修正这种错误来源,制造出这些错误要简单很多。

坚持只使用智能指针,就可以避免所有这些问题。对于一块内存,只有在没有任何只能智能指针指向它的情况下,智能指针才会自动释放它

delete之后重置指针值。。。。

当我们delete一个指针后,指针值就变为无效了。虽然指针已经无效,但在很多机器上指针仍然保存着(已经释放了的)动态内存的地址。在delete之后,指针就变成了人们所说的空悬指针,即,指向一块层级保存数据对象但现在以及无效的内存的指针。

为初始化指针的所有确定空悬指针也都有,有一种方法可以避免空悬指针的问题;在指针即将要离开其作用域之前释放掉它所关联的内存。这样在指针关联的内存被释放掉之后,就没有机会继续使用指针了。如果我们需要保留指针,可以在delete之后将nullptr赋予指针,这样就清楚地指出指针不指向任何对象。

这只是提供了有限的保护

动态内存的一个基本问题是可能有多个指针指向相同的内存,在delete内存之后重置指针的方法只对这个指针有效,对其他任何指向(已释放的)内存的指针是没有作用的。例如:

1
2
3
4
int *p(new int (42)); //p指向动态内存
auto q=p; //p和q指向相同的内存
delete p; //p和q均变为无效
p=nullptr; //指出p不再绑定到任何对象

本例中p和q指向相同的动态分配的对象。我们delete此内存,然后将p置为nullptr指出它不再指向任何对象,但是,重置p对q没有任何作用,在我们释放p所指向的(同时也是q所指向的)内存时,q也变为无效了。

-------------本文结束感谢您的阅读-------------

本文地址:http://www.wangxinri.cn/2017/11/10/动态内存与智能指针/
转载请注明出处,谢谢!

梦想夹带眼泪,咸咸的汗水!