C++标准库 --动态内存 (Primer C++ 第五版 · 阅读笔记)
- 第12章 动态内存------(持续更新)
- 12.1、动态内存与智能指针
- 12.1.1、shared_ptr类
- 12.1.2、直接管理内存
- 12.1.3、shared_ptr和new结合使用
- 12.1.4、智能指针和异常
- 12.1.5、unique_ptr
- 12.1.6、weak_ptr
- 12.2、动态数组
- 12.2.1、new和数组
- 12.2.2、allocator类
- 12.3、使用标准库:文本查询程序
- 12.3.1、文本查询程序设计
- 12.3.2、文本查询程序类的设计
第12章 动态内存------(持续更新)
我们的程序到目前为止只使用过静态内存或栈内存。
- 静态内存用来保存局部
static
对象(参见6.6.1节)、类static
数据成员(参见7.6节)以及定义在任何函数之外的变量。 - 栈内存用来保存定义在函数内的
非static
对象。 - 分配在静态或栈内存中的对象由编译器自动创建和销毁。
- 对于栈对象,仅在其定义的程序块运行时才存在:
static
对象在使用之前分配,在程序结束时销毁。
除了静态内存和栈内存,每个程序还拥有一个内存池。这部分内存被称作自由空间( free store
)或堆(heap
)。程序用堆来存储动态分配(dynamically allocate
)的对象——即,那些在程序运行时分配的对象。动态对象的生存期由程序来控制,也就是说,当动态对象不再使用时,我们的代码必须显式地销毁它们。
虽然使用动态内存有时是必要的,但众所周知,正确地管理动态内存是非常棘手的。
12.1、动态内存与智能指针
在C++中,动态内存的管理是通过一对运算符来完成的:
new
,在动态内存中为对象分配空间并返回一个指向该对象的指针,我们可以选择对对象进行初始化;delete
,接受一个动态对象的指针,销毁该对象,并释放与之关联的内存。
动态内存的使用很容易出问题,因为确保在正确的时间释放内存是极其困难的。有时我们会忘记释放内存,在这种情况下就会产生内存泄漏;有时在尚有指针引用内存的情况下我们就释放了它,在这种情况下就会产生引用非法内存的指针。
为了更容易(同时也更安全)地使用动态内存,新的标准库提供了两种智能指针(smart pointer
)类型来管理动态对象。智能指针的行为类似常规指针,重要的区别是它负责自动释放所指向的对象。新标准库提供的这两种智能指针的区别在于管理底层指针的方式:
shared_ptr
允许多个指针指向同一个对象;unique_ptr
则“独占”所指向的对象。- 标准库还定义了一个名为
weak_ptr
的伴随类,它是一种弱引用,指向shared _ptr
所管理的对象。 - 这三种类型都定义在
memory
头文件中。
12.1.1、shared_ptr类
类似vector
,智能指针也是模板(参见3.3节)。因此,当我们创建一个智能指针时,必须提供额外的信息——指针可以指向的类型。与vector
一样,我们在尖括号内给出类型,之后是所定义的这种智能指针的名字:
//默认初始化的智能指针中保存着一个空指针
shared ptr<string> pl; // shared_ptr,可以指向string
shared_ptr<list<int>> p2; // shared ptr,可以指向int的list
//如果p1不为空,检查它是否指向一个空string
if (pl && p1->empty())
*p1 = "hi"; // 如果p1指向一个空string,解引用pl,将一个新值赋予string
- 智能指针的使用方式与普通指针类似。解引用一个智能指针返回它指向的对象。
表12.1列出了shared_ptr
和 unique_ptr
都支持的操作。只适用于shared_ptr
的
⭐️make_shared函数
最安全的分配和使用动态内存的方法是调用一个名为make_shared
的标准库函数。
- 此函数在动态内存中分配一个对象 并初始化 它,返回指向此对象的
shared_ptr
。 - 与智能指针一样,
make_shared
也定义在头文件memory
中。
当要用make_shared
时,必须指定想要创建的对象的类型。定义方式与模板类相同,在函数名之后跟一个尖括号,在其中给出类型:
//指向一个值为42的int的shared_ptr
shared_ptr<int> p3 = make_shared<int>(42);
// p4指向一个值为"9999999999"的string
shared_ptr<string> p4 = make_shared<string>(10, '9');
// p5指向一个值初始化的(参见3.3.1节)int,即,默认值为0
shared_ptr<int> p5 = make_shared<int>();
//当然,我们通常用auto(参见2.5.2节)定义一个对象来保存make_shared的结果,这种方式较为简单:
// p6指向一个动态分配的空vector<string>
auto p6 = make_shared<vector<string>>();
类似顺序容器的 emplace
成员(参见9.3.1节),make_shared
用其参数来构造给定类型的对象。例如,
- 调用
make_shared<string>
时传递的参数必须能用来初始化一个的某个构造函数相匹配, - 调用
make _sharea<int>
时传递的参数必须能用来初始化一个int
,依此类推。
如果我们不传递任何参数,对象就会进行值初始化(默认值初始化)(参见3.3.1 节)。
⭐️shared_ptr 的拷贝和赋值
当进行拷贝或赋值操作时,每个shared_ptr
都会记录有多少个其他shared_ptr
指向相同的对象。
我们可以认为每个shared_ptr
指向的对象都有一个关联的计数器,通常称其为引用计数(reference count
)。无论何时我们拷贝一个shared_ptr
,计数器都会递增。例如:
- 当用一个
shared_ptr
初始化另一个shared_ptr
, - 或将它作为参数传递给一个函数(参见6.2.1节)
- 以及作为函数的返回值(参见6.3.2节)时,它所关联的计数器就会递增。
当我们给shared_ptr
赋予一个新值或是shared_ptr
被销毁(例如一个局部的shared_ptr
离开其作用域时,计数器就会递减。
auto p = make_shared<int>(42); // p 指向的对象只有p 一个引用者
auto q(p); // p和q指向相同对象,此对象有两个引用者
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
被销毁时,shared_ptr
类会自动销毁此对象。它是通过另一个特殊的成员函数——析构函数( destructor)完成销毁工作的。类似于构造函数,每个类都有一个析构函数。就像构造函数控制初始化一样,析构函数控制此类型的对象销毁时做什么操作。
析构函数一般用来释放对象所分配的资源。例如,
string
的构造函数 (以及其他string
成员)会分配内存来保存构成string
的字符。string
的析构函数就负责释放这些内存。- 类似的,
vector
的若干操作都会分配内存来保存其元素。vector
的析构函数就负责销毁这些元素,并释放它们所占用的内存。
shared_ptr
的析构函数会递减它所指向的对象的引用计数。如果引用计数变为0
,shared_ptr
的析构函数就会销毁对象,并释放它占用的内存。
⭐️……shared_ptr还会自动释放相关联的内存
当动态对象不再被使用时,shared_ptr
类会自动释放动态对象,这一特性使得动态内存的使用变得非常容易。例如,我们可能有一个函数,它返回一个shared_ptr
,指向一个Foo
类型的动态分配的对象,对象是通过一个类型为T
的参数进行初始化的:
// factory返回一个shared_ptr,指向一个动态分配的对象
shared_ptr<Foo> factory(T arg)
{
//恰当地处理arg
// shared ptr负责释放内存
return make_shared<Foo>(arg);
}
由于factory
返回一个shared_ptr
,所以我们可以确保它分配的对象会在恰当的时刻被释放。例如,下面的函数将factory
返回的shared_ptr
保存在局部变量中:
void use_factory(T arg)
{
shared_ptr<Foo> p = factory(arg);
//使用p
}// p离开了作用域,它指向的内存会被自动释放掉
但如果有其他shared_ptr
也指向这块内存,它就不会被释放掉:
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
删除不再需要的那些元素。
⭐️使用了动态生存期的资源的类
程序使用动态内存出于以下三种原因之一:
- 程序不知道自己需要使用多少对象
- 程序不知道所需对象的准确类型
- 程序需要在多个对象间共享数据
容器类是出于第一种原因而使用动态内存的典型例子,我们将在第15章看到出于第二种原因而使用动态内存的例子。在本节中,我们将定义一个类,它使用动态内存是为了让多个对象能共享相同的底层数据。
到目前为止,我们使用过的类中,分配的资源都与对应对象生存期一致。例如,每个vector
“拥有”其自己的元素。当我们拷贝一个vector
时,原vector
和副本vector
中的元素是相互分离的:
vector<string> vl; //空vector
{//新作用域
vector<string> v2 = {"a", "an", "the"};
vl = v2; //从v2拷贝元素到v1中
}// v2被销毁,其中的元素也被销毁
// v1有三个元素,是原来v2中元素的拷贝
由一个vector
分配的元素只有当这个vector
存在时才存在。当一个vector
被销毁时,这个vector
中的元素也都被销毁。
但某些类分配的资源具有与原对象相独立的生存期。例如,假定我们希望定义一个名为Blob
的类,保存一组元素。与容器不同,我们希望Blob
对象的不同拷贝之间共享相同的元素。即,当我们拷贝一个Blob
时,原Blob
对象及其拷贝应该引用相同的底层元素。
一般而言,如果两个对象共享底层的数据,当某个对象被销毁时,我们不能单方面地销毁底层数据:
Blob<string> b1; //空Blob
{//新作用域
Blob<string> b2 = {"a", "an", "the"};
b1 = b2;// b1和b2共享相同的元素
}// b2被销毁了,但b2中的元素不能销毁
// b1指向最初由b2创建的元素
在此例中,b1
和 b2
共享相同的元素。当b2
离开作用域时,这些元素必须保留,因为b1
仍然在使用它们。
使用动态内存的一个常见原因是允许多个对象共享相同的状态。
例:使用动态内存创建类
12.1.2、直接管理内存
C++语言定义了两个运算符来分配和释放动态内存。运算符 new
分配内存delete
释放new
分配的内存。
- 相对于智能指针,使用这两个运算符管理内存非常容易出错,随着我们逐步详细介绍这两个运算符,这一点会更为清楚。
- 而且,自己直接管理内存的类与使用智能指针的类不同,它们不能依赖类对象拷贝、赋值和销毁操作的任何默认定义(参见7.1.4 节)。因此,使用智能指针的程序更容易编写和调试。
在学习第13章之前,除非使用智能指针来管理内存,否则不要分配动态内存。
⭐️使用new 动态分配和初始化对象
在自由空间分配的内存是无名的,因此 new
无法为其分配的对象命名,而是返回一个指向该对象的指针:
int
是内置类型;而string
是容器,是类类型。
int *pi = new int; // pi指向一个动态分配的、未初始化的无名对象
默认情况下,动态分配的对象是默认初始化的,这意味着内置类型或组合类型的对象的值将是未定义的,而类类型对象将用默认构造函数进行初始化:
string *ps = new string; //初始化为空string
int *pi = new int; //pi指向一个未初始化的int
我们可以使用直接初始化方式来初始化一个动态分配的对象。我们可以使用传统的构造方式(使用圆括号),在新标准下,也可以使用列表初始化(使用花括号):
int *pi = new int(1024); // pi指向的对象的值为1024
string *ps = new string(10, '9'); // *ps为"9999999999"
// vector有10个元素,值依次从0到9
vector<int> *pv = new vector<int>{0,1,2,3,4,5,6,7,8,9};
也可以对动态分配的对象进行值初始化,只需在类型名之后跟一对空括号即可:
string *psl = new string; //默认初始化为空string
string *ps = new string(); //值初始化为空string
int *pil = new int; //默认初始化; *pi1的值未定义
int *pi2 = new int(); //值初始化为0; *pi2为0
- 对于定义了自己的构造函数的类类型(例如
string
)来说,要求值初始化是没有意义的;不管采用什么形式,对象都会通过默认构造函数来初始化。 - 但对于内置类型,两种形式的差别就很大了;
- 值初始化的内置类型对象有着良好定义的值;
- 而默认初始化的对象的值则是未定义的。
- 类似的,对于类中那些依赖于编译器合成的默认构造函数的内置类型成员,如果它们未在类内被初始化,那么它们的值也是未定义的(参见7.1.4节)。
出于与变量初始化相同的原因,对动态分配的对象进行初始化通常是个好主意。
如果我们提供了一个括号包围的初始化器,就可以使用auto
(参见2.5.2节)从此初始化器来推断我们想要分配的对象的类型。但是,由于编译器要用初始化器的类型来推断要分配的类型,只有当括号中仅有单一初始化器时才可以使用auto
:
auto p1 = new auto(obj); //p1指向一个与obj类型相同的对象
//该对象用obj进行初始化
auto p2 = new auto{a,b,c}; //错误:括号中只能有单个初始化器
p1
的类型是一个指针,指向从obj
自动推断出的类型。- 若
obj
是一个int
,那么p1
就是int*
; - 若
obj
是一个string
,那么p1
是一个string*
; - 依此类推。新分配的对象用
obj
的值进行初始化。
- 若
⭐️动态分配的const对象
用new
分配 const
对象是合法的:
//分配并初始化一个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
的方式来阻止它抛出异常:
//如果分配失败,new返回一个空指针
int *p1 = new int; // 如果分配失败,new抛出std::bad_alloc
int *p2 = new (nothrow) int; // 如果分配失败,new返回一个空指针
- 我们称这种形式的
new
为 定位new (placement new
), 定位new表达式允许我们向new
传递额外的参数。 - 在此例中,我们传递给它一个由标准库定义的名为
nothrow
的对象。如果将nothrow
传递给new
,我们的意图是告诉它不能抛出异常。 - 如果这种形式的
new
不能分配所需内存,它会返回一个空指针。 bad_alloc
和nothrow
都定义在头文件new
中。
⭐️释放动态内存
为了防止内存耗尽,在动态内存使用完毕后,必须将其归还给系统。我们通过 delete 表达式(delete expression
)来将动态内存归还给系统。delete
表达式接受一个指针,指向我们想要释放的对象:
delete p; // p必须指向一个动态分配的对象或是一个空指针
与new
类型类似,delete
表达式也执行两个动作:
- 销毁给定的指针指向的对象;
- 释放对应的内存。
⭐️指针值和delete
我们传递给delete
的指针必须指向动态分配的内存,或者是一个空指针。释放一块并非new
分配的内存,或者将相同的指针值释放多次,其行为是未定义的:
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
指向它的指针即可:
const int *pci = new const int(1024);
delete pci; //正确:释放一个const对象
⭐️动态对象的生存期直到被释放时为止
如由shared_ptr
管理的内存在最后一个shared_ptr
销毁时会被自动释放。但对于通过内置指针类型来管理的内存,就不是这样了。对于一个由内置指针管理的动态对象,直到被显式释放之前它都是存在的。
返回指向动态内存的指针(而不是智能指针)的函数给其调用者增加了一个额外负担——调用者必须记得释放内存:
// factory返回一个指针,指向一个动态分配的对象
Foo* factory(T arg)
{
//视情况处理arg
return new Foo(arg) ; //调用者负责释放此内存
}
类似我们之前定义的 factory
函数(参见12.1.1节),这个版本的factory
分配一个对象,但并不delete
它。factory
的调用者负责在不需要此对象时释放它。不幸的是,调用者经常忘记释放对象:
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
中记得释放内存:
void use_factory(T arg)
{
Foo *p = factory(arg);
//使用p
delete p;//现在记得释放内存,我们已经不需要它了
}
还有一种可能,我们的系统中的其他代码要使用use_factory
所分配的对象,我们就应该修改此函数,让它返回一个指针,指向它分配的内存:
Foo* use_factory (T arg)
{
Foo *p = factory(arg); //使用p
return p; //调用者必须释放内存
}
小心:动态内存的管理非常容易出错
使用new
和delete
管理动态内存存在三个常见问题:
1、忘记delete
内存。忘记释放动态内存会导致人们常说的“内存泄漏”问题,因为这种内存永远不可能被归还给自由空间了。查找内存泄露错误是非常困难的,因为通常应用程序运行很长时间后,真正耗尽内存时,才能检测到这种错误。
2、使用已经释放掉的对象。通过在释放内存后将指针置为空,有时可以检测出这种错误。
3、同一块内存释放两次。当有两个指针指向相同的动态分配对象时,可能发生这种错误。如果对其中一个指针进行了delete
操作,对象的内存就被归还给自由空间了。如果我们随后又delete
第二个指针,自由空间就可能被破坏。
相对于查找和修正这些错误来说,制造出这些错误要简单得多。
坚持只使用智能指针,就可以避免所有这些问题。对于一块内存,只有在没有任何智能指针指向它的情况下,智能指针才会自动释放它。
⭐️delete之后重置指针值……
当我们delete
一个指针后,指针值就变为无效了。虽然指针已经无效,但在很多机器上指针仍然保存着(已经释放了的)动态内存的地址。在delete
之后,指针就变成了人们所说的空悬指针( dangling pointer
),即,指向一块曾经保存数据对象但现在已经无效的内存的指针。
未初始化指针的所有缺点空悬指针也都有。有一种方法可以避免空悬指针的问题:
- 在指针即将要离开其作用域之前释放掉它所关联的内存。这样,在指针关联的内存被释放掉之后,就没有机会继续使用指针了。
- 如果我们需要保留指针,可以在
delete
之后将nullptr
赋予指针,这样就清楚地指出指针不指向任何对象。
⭐️ …这只是提供了有限的保护
动态内存的一个基本问题是可能有多个指针指向相同的内存。在 delete
内存之后重置指针的方法只对这个指针有效,对其他任何仍指向(已释放的)内存的指针是没有作用的。例如:
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
也变为无效了。在实际系统中,查找指向相同内存的所有指针是异常困难的。
12.1.3、shared_ptr和new结合使用
如前所述,如果我们不初始化一个智能指针,它就会被初始化为一个空指针。如表12.3所示,我们还可以用 new
返回的指针来初始化智能指针:
shared ptr<double> pl; // shared_ptr可以指向一个double
shared ptr<int> p2(new int(42)); // p2指向一个值为42的int
接受指针参数的智能指针构造函数是 explicit
的。
- 因此,我们不能将一个内置指针隐式转换为一个智能指针;
- 必须使用直接初始化的形式来初始化一个智能指针:
shared_ptr<int> p1 = new int(1024); //错误:必须使用直接初始化形式
shared_ptr<int> p2(new int(1024)); //正确:使用了直接初始化形式
p1
的初始化隐式地要求编译器用一个new
返回的 int*
来创建一个 shared_ptr
。由于我们不能进行内置指针到智能指针间的隐式转换,因此这条初始化语句是错误的。
出于相同的原因,一个返回shared_ptr
的函数不能在其返回语句中隐式转换一个普通指针:
shared_ptr<int> clone(int p){
return new int(p); //错误:隐式转换为shared ptr<int>
}
//我们必须将shared_ptr显式绑定到一个想要返回的指针上:
shared_ptr<int> clone(int p){
//正确:显式地用int*创建shared_ptr<int>
return shared_ptr<int> (new int(p));
}
默认情况下,一个用来初始化智能指针的普通指针必须指向动态内存,因为智能指针默认使用
delete
释放它所关联的对象。
我们可以将智能指针绑定到一个指向其他类型的资源的指针上,但是为了这样做,必须提供自己的操作来替代delete
。我们将在 12.1.4节介绍如何定义自己的释放操作。
⭐️ 不要混合使用普通指针和智能指针……
shared_ptr
可以协调对象的析构,但这仅限于其自身的拷贝(也是shared_ptr
)之间。
- 这也是为什么我们推荐使用
make_shared
而不是new
的原因。 - 这样,我们就能在分配对象的同时就将
shared_ptr
与之绑定,从而避免了无意中将同一块内存绑定到多个独立创建的shared_ptr
上。
考虑下面对shared_ptr
进行操作的函数:
//在函数被调用时ptr被创建并初始化
void process(shared_ptr<int> ptr){
//使用ptr
}// ptr离开作用域,被销毁
process
的参数是传值方式传递的,因此实参会被拷贝到 ptr
中。拷贝一个 shared_ptr
会递增其引用计数,因此,在 process
运行过程中,引用计数值至少为2
。当process
结束时,ptr
的引用计数会递减,但不会变为 0
。因此,当局部变量 ptr
被销毁时,ptr
指向的内存不会被释放。
使用此函数的正确方法是传递给它一个shared_ptr
:
shared ptr<int> p (new int(42));//引用计数为1
process(p);//拷贝p会递增它的引用计数;在process中引用计数值为2
int i = *p;//正确:引用计数值为1
虽然不能传递给 process
一个内置指针,但可以传递给它一个(临时的) shared_ptr
,这个 shared_ptr
是用一个内置指针显式构造的。但是,这样做很可能会导致错误:
int *x (new int(1024)); //危险:x是一个普通指针,不是一个智能指针
process(x); //错误:不能将int*转换为一个shared_ptr<int>
process(shared_ptr<int>(x)); //合法的,但内存会被释放!
int j = *x; //未定义的: x是一个空悬指针!
在上面的调用中,我们将一个临时 shared_ptr
传递给 process
。当这个调用所在的表达式结束时,这个临时对象就被销毁了。销毁这个临时变量会递减引用计数,此时引用计数就变为 0
了。因此,当临时对象被销毁时,它所指向的内存会被释放。
但 x
继续指向(已经释放的)内存,从而变成一个空悬指针。如果试图使用 x
的值,其行为是未定义的。
当将一个s
shared_ptr
绑定到一个普通指针时,我们就将内存的管理责任交给了这个shared_ptr
。一旦这样做了,我们就不应该再使用内置指针来访问shared_ptr
所指向的内存了。
使用一个内置指针来访问一个智能指针所负责的对象是很危险的,因为我们无法知道对象何时会被销毁。
⭐️ ……也不要使用get初始化另一个智能指针或为智能指针赋值
智能指针类型定义了一个名为 get
的函数(参见表12.1),它返回一个内置指针,指向智能指针管理的对象。此函数是为了这样一种情况而设计的:
- 我们需要向不能使用智能指针的代码传递一个内置指针。
- 使用 get 返回的指针的代码不能
delete
此指针。
虽然编译器不会给出错误信息,但将另一个智能指针也绑定到 get
返回的指针上是错误的:
shared_ptr<int> p (new int(42)); //引用计数为1
int *q = p.get(); //正确:但使用 q 时要注意,不要让它管理的指针被释放
{ //新程序块
//未定义:两个独立的shared_ptr指向相同的内存
shared_ptr<int> (q);
}//程序块结束,q被销毁,它指向的内存被释放
int foo = *p; //未定义:p指向的内存已经被释放了
在本例中,p
和 q
指向相同的内存。由于它们是相互独立创建的,因此各自的引用计数都是 1
。当 q
所在的程序块结束时,q
被销毁,这会导致 q
指向的内存被释放。从而 p
变成一个空悬指针,意味着当我们试图使用 p
时,将发生未定义的行为。而且,当 p
被销毁时,这块内存会被第二次 delete
。
get
用来将指针的访问权限传递给代码,你只有在确定代码不会delete
指针的情况下,才能使用get
。
特别是,永远不要用get
初始化另一个智能指针或者为另一个智能指针赋值。
⭐️ 其他shared_ptr操作
shared_ptr
还定义了其他一些操作,参见表12.2和表12.3所示。我们可以用 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; //现在我们知道自己是唯一的用户,可以改变对象的值
12.1.4、智能指针和异常
我们知道使用异常处理的程序能在异常发生后令程序流程继续,我们注意到,这种程序需要确保在异常发生后资源能被正确地释放。一个简单的确保资源被释放的方法是使用智能指针。
如果使用智能指针,即使程序块过早结束,智能指针类也能确保在内存不再需要时将其释放,函数的退出有两种可能:
- 正常处理结束或者发生了异常,无论哪种情况,局部对象都会被销毁。
- 在下面的程序中,
sp
是一个shared_ptr
,因此sp
销毁时会检查引用计数。在此例中,sp
是指向这块内存的唯一指针,因此内存会被释放掉。
void f()
{
shared_ptr<int> sp (new int(42)); //分配一个新对象
//这段代码抛出一个异常,且在f中未被捕获
}//在函数结束时shared_ptr自动释放内存
与之相对的,当发生异常时,我们直接管理的内存是不会自动释放的。如果使用内置指针管理内存,且在 new
之后在对应的 delete
之前发生了异常,则内存不会被释放:
void f()
{
int *ip = new int(42);//动态分配一个新对象
//这段代码抛出一个异常,且在f中未被捕获
delete ip; //在退出之前释放内存
}
如果在 new
和 delete
之间发生异常,且异常未在 f
中被捕获,则内存就永远不会被释放了。在函数 f
之外没有指针指向这块内存,因此就无法释放它了。
⭐️ 智能指针和哑类
包括所有标准库类在内的很多C++类都定义了析构函数,负责清理对象使用的资源。但是,不是所有的类都是这样良好定义的。特别是
那些为C和C++两种语言设计的类,通常都要求用户显式地释放所使用的任何资源。
那些分配了资源,而又没有定义析构函数来释放这些资源的类,可能会遇到与使用动态内存相同的错误——程序员非常容易忘记释放资源。类似的,如果在资源分配和释放之间发生了异常,程序也会发生资源泄漏。
与管理动态内存类似,我们通常可以使用类似的技术来管理不具有良好定义的析构函数的类。例如,假定我们正在使用一个C和C++都使用的网络库,使用这个库的代码可能是这样的:
struct destination; //表示我们正在连接什么
struct connection; //使用连接所需的信息
connection connect(destination*); //打开连接
void disconnect (connection); //关闭给定的连接
void f(destination &d /*其他参数*/)
{
//获得一个连接;记住使用完后要关闭它
connection c = connect(&d);
//使用连接
//如果我们在f退出前忘记调用disconnect,就无法关闭c了
}
- 如果
connection
有一个析构函数,就可以在f
结束时由析构函数自动关闭连接。 - 但是,
connection
没有析构函数。这个问题与我们上一个程序中使用shared_ptr
避免内存泄漏几乎是等价的。 - 使用
shared_ptr
来保证connection
被正确关闭,已被证明是一种有效的方法。
⭐️ 使用我们自己的释放操作
默认情况下,shared_ptr
假定它们指向的是动态内存。因此,当一个 shared_ptr
被销毁时,它默认地对它管理的指针进行 delete
操作。
- 为了用
shared _ptr
来管理一个connection
,我们必须首先定义一个函数来代替delete
。 - 这个删除器(
deleter
)函数必须能够完成对shared_ptr
中保存的指针进行释放的操作。 - 当我们创建一个
shared_ptr
时,可以传递一个(可选的)指向删除器函数的参数:
在本例中,我们的删除器必须接受单个类型为connection*
的参数:
//删除器
void end_connection(connection *p){ disconnect(*p);}
void f(destination &d /*其他参数*/)
{
connection c = connect(&d);
shared_ptr<connection> p(&c, end_connection);
//使用连接
//当f退出时(即使是由于异常而退出), connection会被正确关闭
}
- 当
p
被销毁时,它不会对自己保存的指针执行delete
,而是调用end_connection
。 - 接下来,
end_connection
会调用disconnect
,从而确保连接被关闭。 - 如果
f
正常退出,那么p
的销毁会作为结束处理的一部分。如果发生了异常,p
同样会被销毁,从而连接被关闭。
👉 注意:智能指针陷阱 👈
智能指针可以提供对动态分配的内存安全而又方便的管理,但这建立在正确使用的前提下。为了正确使用智能指针,我们必须坚持一些基本规范:
❗️ 不使用相同的内置指针值初始化(或reset
)多个智能指针。
❗️ 不delete
get()返回的指针
。
❗️ 不使用get()
初始化或reset
另一个智能指针。
❗️ 如果你使用get()
返回的指针,记住当最后一个对应的智能指针销毁后,你的指针就变为无效了。
❗️ 如果你使用智能指针管理的资源不是new
分配的内存,记住传递给它一个删除器(参见12.1.4节)。
12.1.5、unique_ptr
⭐️ 传递unique_ptr参数和返回unique_ptr
⭐️ 向unique_ptr传递删除器
12.1.6、weak_ptr
⭐️ 核查指针类
⭐️ 指针操作