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
“拥有”它所指向的对象。与 shared_ptr
不同,某个时刻只能有一个 unique_ptr
指向一个给定对象。
- 当
unique_ptr
被销毁时,它所指向的对象也被销毁。 - 表 12.4列出了
unique_ptr
特有的操作。与shared_ptr
相同的操作列在表12.1中。
与 shared_ptr
不同,没有类似make_shared
的标准库函数返回一个unique_ptr
。当我们定义一个unique_ptr
时,需要将其绑定到一个new
返回的指针上。
- 类似
shared_ptr
,初始化unique_ptr
必须采用直接初始化形式: - 由于一个
unique_ptr
拥有它指向的对象,因此unique_ptr
不支持普通的拷贝或赋值操作:
unique_ptr<double> pl; // 可以指向一个double 的unique_ptr
unique_ptr<int> p2(new int(42)); // p2指向一个值为42的int
unique_ptr<string> p3(new string("Stegosaurus"));
unique_ptr<string> p4(p3); //错误:unique_ptr不支持拷贝
unique_ptr<string> p5;
p5 = p4; //错误:unique _ptr不支持赋值
虽然我们不能拷贝或赋值 unique_ptr
,但可以通过调用 release
或 reset
将指针的所有权从一个(非 const
) unique_ptr
转移给另一个unique
:
//将所有权从p1(指向string stegosaurus)转移给p2
unique_ptr<string> p2(p1.release()); // release 将p1置为空
unique_ptr<string> p3(new string("Trex"));
//将所有权从p3转移给p2
p2.reset(p3.release());// reset释放了p2原来指向的内存
release
成员返回unique_ptr
当前保存的指针并将其置为空。因此,p2
被初始化为p1
原来保存的指针,而p1
被置为空。reset
成员接受一个可选的指针参数,令unique_ptr
重新指向给定的指针。如果unique_ptr
不为空,它原来指向的对象被释放。因此,对p2
调用reset
释放了用"Stegosaurus"
初始化的string
所使用的内存,将p3
对指针的所有权转移给p2
,并将p3
置为空。
调用 release
会切断 unique_ptr
和它原来管理的对象间的联系。release
返回的指针通常被用来初始化另一个智能指针或给另一个智能指针赋值。在本例中,管理内存的责任简单地从一个智能指针转移给另一个。但是,如果我们不用另一个智能指针来保存 release
返回的指针,我们的程序就要负责资源的释放:
p2.release(); //错误:p2不会释放内存,而且我们丢失了指针
auto p = p2.release(); //正确,但我们必须记得delete(p)
⭐️ 传递unique_ptr参数和返回unique_ptr
不能拷贝 unique_ptr
的规则有一个例外:我们可以拷贝或赋值一个将要被销毁的unique_ptr。最常见的例子是从函数返回一个unique_ptr
:
unique_ptr<int> clone(int p){
//正确:从int*创建一个unique_ptr<int>
return unique_ptr<int>(new int(p));
)
//还可以返回一个局部对象的拷贝:
unique_ptr<int> clone(int p){
unique_ptr<int> ret(new int(p));
// ...
return ret;
}
对于两段代码,编译器都知道要返回的对象将要被销毁。在此情况下,编译器执行一种特殊的“拷贝”,我们将在13.6.2节中介绍它。
向后兼容:auto_ptr
标准库的较早版本包含了一个名为auto_ptr
的类,它具有unique_ptr
的部分特性,但不是全部。
特别是,我们不能在容器中保存auto_ ptr
,也不能从函数中返回auto_ptr
。
虽然auto_ptr
仍是标准库的一部分,但编写程序时应该使用unique_ptr
。
⭐️ 向unique_ptr传递删除器
类似 shared_ptr
,unique_ptr
默认情况下用 delete
释放它指向的对象。与 shared_ptr
一样,我们可以重载一个 unique_ptr
中默认的删除器(参见12.1.4节)。但是,unique_ptr
管理删除器的方式与 shared_ptr
不同,其原因我们将在16.1.6节中介绍。
重载一个 unique_ptr
中的删除器会影响到unique_ptr
类型以及如何构造(或 reset
)该类型的对象。与重载关联容器的操作比较(参见11.2.2节)类似,我们必须在尖括号中unique_ptr
指向类型之后提供删除器类型。在创建或 reset
一个这种 unique_ptr
类型的对象时,必须提供一个指定类型的可调用对象(删除器):
// p指向一个类型为objT的对象,并使用一个类型为delT的对象释放objT对象
// 它会调用一个名为fcn的delT类型对象
unique_ptr<objT,delT> p (new objT, fcn);
作为一个更具体的例子,我们将重写连接程序,用 unique_ptr
来代替shared_ptr
,如下所示:
void f(destination &d /*其他需要的参数*/){
connection c = connect(&d); //打开连接
//当p被销毁时,连接将会关闭
unique_ptr<connection, decltype(end_connection)*> p (&c, end_connection);
//使用连接
//当f退出时(即使是由于异常而退出),connection会被正确关闭
}
在本例中我们使用了decltype
(参见2.5.3节)来指明函数指针类型。由于decltype
(end_connection
)返回一个函数类型,所以我们必须添加一个 *
来指出我们正在使用该类型的一个指针(参见6.7节,第223页)。
12.1.6、weak_ptr
weak_ptr
(见表12.5)是一种不控制所指向对象生存期的智能指针,它指向由一个 shared_ptr
管理的对象。将一个 weak_ptr
绑定到一个 shared_ptr
不会改变 shared_ptr
的引用计数。一旦最后一个指向对象的 shared_ptr
被销毁,对象就会被释放。即使有weak_ptr
指向对象,对象也还是会被释放,因此,weak_ptr
的名字抓住了这种智能指针**“弱”共享对象**的特点。
当我们创建一个weak_ptr
时,要用一个 shared_ptr
来初始化它:
auto p = make_shared<int> (42);
weak_ptr<int> wp(p); // wp弱共享p;p的引用计数未改变
本例中 wp
和 p
指向相同的对象。由于是弱共享,创建 wp
不会改变 p
的引用计数; wp
指向的对象可能被释放掉。
由于对象可能不存在,我们不能使用weak_ptr
直接访问对象,而必须调用 lock
。此函数检查weak_ptr
指向的对象是否仍存在。如果存在,lock
返回一个指向共享对象的 shared_ptr
。与任何其他 shared_ptr
类似,只要此 shared_ptr
存在,它所指向的底层对象也就会一直存在。例如:
if (shared_ptr<int> np = wp.lock()) {//如果np不为空则条件成立
//在if中,np与p共享对象
}
在这段代码中,只有当 lock
调用返回 true
时我们才会进入 if
语句体。在 if
中,使用 np
访问共享对象是安全的。
⭐️ 核查指针类
作为 weak_ptr
用途的一个展示,我们将为 strBlob
类定义一个伴随指针类。我们的指针类将命名为 StrBlobPtr
,会保存一个weak_ptr
,指向StrBlob
的data
成员,这是初始化时提供给它的。
- 通过使用
weak_ptr
,不会影响一个给定的StrBlob
所指向的vector
的生存期。 - 但是,可以阻止用户访问一个不再存在的
vector
的企图。
StrBlobPtr
会有两个数据成员: wptr
,或者为空,或者指向一个StrBlob
中的vector
; curr
,保存当前对象所表示的元素的下标。类似它的伴随类strBlob
,我们的指针类也有一个 check
成员来检查解引用 strBlobPtr
是否安全:
//对于访问一个不存在元素的尝试,StrBlobPtr抛出一个异常
class StrBlobPtr {
public:
StrBlobPtr() : curr(0) {}
StrBlobPtr(StrBlob &a, size_t sz = 0): wptr(a.data), curr(sz) {}
string& deref() const;
StrBlobPtr& incr ();//前缀递增
private:
//若检查成功,check 返回一个指向vector的shared_ptr
shared_ptr<vector<string>>
check(size_t, const string&) const;
//保存一个weak_ptr,意味着底层 vector可能会被销毁
weak_ptr<vector<string>> wptr;
size_t curr;//在数组中的当前位置
};
- 默认构造函数生成一个空的
StrBlobPtr
。其构造函数初始化列表将curr
显式初始化为0
,并将wptr
隐式初始化为一个空weak_ptr
。 - 第二个构造函数接受一个
StrBlob
引用和一个可选的索引值。此构造函数初始化wptr
,令其指向给定StrBlob
对象的shared_ptr
中的vector
,并将curr
初始化为sz
的值。我们使用了默认参数,表示默认情况下将curr
初始化为第一个元素的下标。我们将会看到,StrBlob
的end
成员将会用到参数sz
。
值得注意的是,我们不能将strBlobPtr
绑定到一个const StrBlob
对象。这个限制是由于构造函数接受一个非const StrBlob
对象的引用而导致的。
StrBlobPtr
的check
成员与strBlob
中的同名成员不同,它还要检查指针指向的vector
是否还存在:
shared_ptr<vector<string>>
StrBlobPtr::check(size_t i,const string &msg) const
{
auto ret = wptr.lock(); // vector还存在吗?
if (!ret)
throw runtime_error("unbound StrBlobPtr");
if(i >= ret->size())
throw out_of_range(msg);
return ret; //否则,返回指向vector的shared_ptr
}
由于一个 weak_ptr
不参与其对应的 shared_ptr
的引用计数,StrBlobPtr
指向的 vector
可能已经被释放了。
- 如果
vector
已销毁,lock
将返回一个空指针。 - 在本例中,任何
vector
的引用都会失败,于是抛出一个异常。 - 否则,
check
会检查给定索引,如果索引值合法,check
返回从lock
获得的shared_ptr
。
⭐️ 指针操作
我们将在第14章学习如何定义自己的运算符。现在,我们将定义名为 deref
和 incr
的函数,分别用来解引用和递增StrBlobPtr
。
deref
成员调用check
,检查使用vector
是否安全以及curr
是否在合法范围内:
std::string& strBlobPtr::deref() const
{
auto p = check(curr, "dereference past end" );
return (*p)[curr]; //(*p)是对象所指向的vector
}
如果 check
成功,p
就是一个 shared_ptr
,指向 StrBlobPtr
所指向的 vector
。表达式 (*p)[curr]
解引用 shared_ptr
来获得 vector
,然后使用下标运算符提取并返回 curr
位置上的元素。
incr
成员也调用 check
:
//前缀递增:返回递增后的对象的引用
StrBlobPtr& StrBlobPtr::incr()
{
//如果curr已经指向容器的尾后位置,就不能递增它
check(curr, "increment past end of strB1obPtr");
++curr; //推进当前位置
return *this;
}
当然,为了访问 data
成员,我们的指针类必须声明为 StrBlob
的 friend
。我们还要为 strBlob
类定义 begin
和 end
操作,返回
一个指向它自身的StrBlobPtr
:
//对于StrBlob中的友元声明来说,此前置声明是必要的
class strBlobPtr;
class StrBlob{
friend class strBlobPtr;
//其他成员与12.1.1节中声明相同
//返回指向首元素和尾后元素的StrBlobPtr
StrBlobPtr begin() { return strBlobPtr(*this); }
StrBlobPtr end()
{ auto ret = StrBlobPtr(*this, data->size ());
return ret; }
};
12.2、动态数组
new
和 delete
运算符一次分配/释放一个对象,但某些应用需要一次为很多对象分配内存的功能。例如,vector
和 string
都是在连续内存中保存它们的元素,因此,当容器需要重新分配内存时(参见9.4节),必须一次性为很多元素分配内存。
为了支持这种需求,C++语言和标准库提供了两种一次分配一个对象数组的方法。C+语言定义了另一种 new
表达式语法,可以分配并初始化一个对象数组。标准库中包含一个名为 allocator
的类,允许我们将分配和初始化分离。使用 allocator
通常会提供更好的性能和更灵活的内存管理能力。
大多数应用应该使用标准库容器而不是动态分配的数组。使用容器更为简单更不容易出现内存管理错误并且可能有更好的性能。
- 如前所述,使用容器的类可以使用默认版本的拷贝、赋值和析构操作。
- 分配动态数组的类则必须定义自己版本的操作,在拷贝、复制以及销毁对象时管理所关联的内存。
12.2.1、new和数组
为了让 new
分配一个对象数组,我们要在类型名之后跟一对方括号,在其中指明要分配的对象的数目(方括号中的大小必须是整型,但不必是常量)。在下例中,new
分配要求数量的对象并(假定分配成功后)返回指向第一个对象的指针:
//调用get_size确定分配多少个int
int *pia = new int[get_size()]; // pia指向第一个int
也可以用一个表示数组类型的类型别名(参见2.5.1节)来分配一个数组,这样,new
表达式中就不需要方括号了:
typedef int arrT[42]; // arrT表示42个int的数组类型
int *p = new arrT; //分配一个42个int的数组;p指向第一个int
在本例中,new
分配一个 int
数组,并返回指向第一个 int
的指针。即使这段代码中没有方括号,编译器执行这个表达式时还是会用new[]
。即,编译器执行如下形式:
int *p = new int[42];
⭐️ 分配一个数组会得到一个元素类型的指针
虽然我们通常称 new T[]
分配的内存为“动态数组”,但这种叫法某种程度上有些误导。当用 new
分配一个数组时,我们并未得到一个数组类型的对象,而是得到一个数组元素类型的指针。即使我们使用类型别名定义了一个数组类型,new
也不会分配一个数组类型的对象。在上例中,我们正在分配一个数组的事实甚至都是不可见的——连 [num]
都没有。new
返回的是一个元素类型的指针。
由于分配的内存并不是一个数组类型,因此不能对动态数组调用 begin
或 end
。这些函数使用数组维度(回忆一下,维度是数组类型的一部分)来返回指向首元素和尾后元素的指针。出于相同的原因,也不能用范围 for
语句来处理(所谓的)动态数组中的元素。
要记住我们所说的==动态数组并不是数组类型==,这是很重要的。
⭐️ 初始化动态分配对象的数组
默认情况下,new
分配的对象,不管是单个分配的还是数组中的,都是默认初始化的。可以对数组中的元素进行值初始化,方法是在大小之后跟一对空括号。
int *pia = new int[10]; // 10个未初始化的int
int *pia2 = new int[10](); // 10个值初始化为0的int
string *psa = new string[10]; // 10个空string
string *psa2 = new string[10](); // 10个空string
//在新标准中,我们还可以提供一个元素初始化器的花括号列表:
// 10个int分别用列表中对应的初始化器初始化
int *pia3 = new int[10]{0,1,2,3,4,5,6,7,8,9};
// 10个string,前4个用给定的初始化器初始化,剩余的进行值初始化
string *psa3 = new string[10]{"a", "an", "the", string(3,'x')};
与内置数组对象的列表初始化一样,初始化器会用来初始化动态数组中开始部分的元素。
- 如果初始化器数目小于元素数目,剩余元素将进行值初始化。
- 如果初始化器数目大于元素数目,则
new
表达式失败,不会分配任何内存。在本例中,new
会抛出一个类型为bad_array_new_length
的异常。类似bad_alloc
,此类型定义在头文件new
中。
虽然我们用空括号对数组中元素进行值初始化,但不能在括号中给出初始化器,这意味着不能用
auto
分配数组(参见12.1.2节)。
⭐️ 动态分配一个空数组是合法的
可以用任意表达式来确定要分配的对象的数目:
size_t n = get_size(); // get_size返回需要的元素的数目
int* p = new int[n]; //分配数组保存元素
for (int* q = p; q != p + n; ++q)
/*处理数组*/ ;
这产生了一个有意思的问题:如果 get_size
返回 0
,会发生什么?
- 答案是代码仍能正常工作。虽然我们不能创建一个大小为
0
的==静态数组对象==,但当n
等于0
时,调用new[n]
是合法的:
char arr[0]; //错误:不能定义长度为0的数组
char *cp = new char[0]; //正确:但cp不能解引用
当我们用 new
分配一个大小为 0
的数组时,new
返回一个合法的非空指针。此指针保证与 new
返回的其他任何指针都不相同。
- 对于零长度的数组来说,此指针就像尾后指针一样,我们可以像使用尾后迭代器一样使用这个指针。
- 可以用此指针进行比较操作,就像上面循环代码中那样。可以向此指针加上(或从此指针减去)
0
,也可以从此指针减去自身从而得到0
。 - 但此指针不能解引用——毕竟它不指向任何元素。
在我们假想的循环中,若get_size
返回 0
,则 n
也是0
, new
会分配0
个对象。for
循环中的条件会失败(p
等于q+n
,因为n
为0
)。因此,循环体不会被执行。
⭐️ 释放动态数组
为了释放动态数组,我们使用一种特殊形式的 delete
——在指针前加上一个空方括号对:
delete p; // p必须指向一个动态分配的对象或为空
delete [] pa; // pa必须指向一个动态分配的数组或为空
第二条语句销毁 pa
指向的数组中的元素,并释放对应的内存。数组中的元素按逆序销毁,即,最后一个元素首先被销毁,然后是倒数第二个,依此类推。
当我们释放一个指向数组的指针时,空方括号对是必需的: 它指示编译器此指针指向一个对象数组的第一个元素。
- 如果我们在
delete
一个指向数组的指针时忽略了方括号 (或者在delete
一个指向单一对象的指针时使用了方括号),其行为是未定义的。
回忆一下,当我们使用一个类型别名来定义一个数组类型时,在 new
表达式中不使用 []
。即使是这样,在释放一个数组指针时也必须使用方括号:
typedef int arrT[42]; // arrT是42个int的数组的类型别名
int *p = new arrT; //分配一个42个int的数组;p指向第一个元素
delete [] p; //方括号是必需的,因为我们当初分配的是一个数组
不管外表如何,p
指向一个对象数组的首元素,而不是一个类型为 arrT
的单对象。因此,在释放p时我们必须使用 []
。
如果我们在
delete
一个数组指针时忘记了方括号,或者在delete
一个单一对象的指针时使用了方括号,编译器很可能不会给出警告。我们的程序可能在执行过程中在没有任何警告的情况下行为异常。
⭐️ 智能指针和动态数组
标准库提供了一个可以管理 new
分配的数组的 unique_ptr
版本。为了用一个unique_ptr
管理动态数组,我们必须在对象类型后面跟一对空方括号:
// up指向一个包含10个未初始化int的数组
unique_ptr<int[]> up(new int[10]);
up.release(); //自动用delete[]销毁其指针
类型说明符中的方括号( <int[ ]>
)指出 up
指向一个int 数组
而不是一个int
。由于up
指向一个数组,当up
销毁它管理的指针时,会自动使用delete[ ]
。
指向数组的 unique_ptr
提供的操作与我们在12.1.5节中使用的那些操作有一些不同,我们在表12.6中描述了这些操作。
- 当一个
unique_ptr
指向一个数组时,我们不能使用点和箭头成员运算符。毕竟unique_ptr
指向的是一个数组而不是单个对象,因此这些运算符是无意义的。 - 另一方面,当一个
unique_ptr
指向一个数组时,我们可以使用 下标运算符 来访问数组中的元素:
for (size_t i = 0; i != 10; ++i)
up[i] = i; //为每个元素赋予一个新值
与unique_ptr
不同,shared_ptr
不直接支持管理动态数组。如果希望使用 shared_ptr
管理一个动态数组,必须提供自己定义的删除器:
//为了使用shared_ptr,必须提供一个删除器
shared ptr<int> sp(new int[10], [](int *p){ delete[] p;});
sp.reset(); //使用我们提供的lambda释放数组,它使用delete[]
本例中我们传递给shared_ptr
一个 lambda
(参见10.3.2节)作为删除器,它使用 delete[]
释放数组。
如果未提供删除器,这段代码将是未定义的。默认情况下, shared_ptr
使用delete
销毁它指向的对象。如果此对象是一个动态数组,对其使用delete
所产生的问题与释放一个动态数组指针时忘记[]
产生的问题一样。
shared_ptr
不直接支持动态数组管理这一特性会影响我们如何访问数组中的元素:
// shared_ptr未定义下标运算符,并且不支持指针的算术运算
for (size_t i = 0; i != 10; ++i)
*(sp.get() + i) = i; //使用get获取一个内置指针
shared_ptr
未定义下标运算符,而且智能指针类型不支持指针算术运算。因此,为了访问数组中的元素,必须用 get
获取一个内置指针,然后用它来访问数组元素。
12.2.2、allocator类
new
有一些灵活性上的局限,其中一方面表现在它将内存分配和对象构造组合在了一起。类似的,delete
将对象析构和内存释放组合在了一起。我们分配单个对象时,通常希望将内存分配和对象初始化组合在一起。因为在这种情况下,我们几乎肯定知道对象应有什么值。
当分配一大块内存时,我们通常计划在这块内存上按需构造对象。在此情况下,我们希望将 内存分配 和 对象构造 分离。这意味着我们可以分配大块内存,但只在真正需要时才真正执行对象创建操作(同时付出一定开销)。
一般情况下,将内存分配和对象构造组合在一起可能会导致不必要的浪费。例如:
string *const p = new string [n]; //构造n个空string
string s;
string *q = p; //q指向第一个string
while (cin >> s && q!= p + n)
*q++ = s; //赋予*q一个新值
const size_t size = q - p; //记住我们读取了多少个string
//使用数组
delete[] p;// p指向一个数组;记得用delete[]来释放
new
表达式分配并初始化了n
个string
。但是,我们可能不需要n
个string
,少量string
可能就足够了。这样,我们就可能创建了一些永远也用不到的对象。而且,对于那些确实要使用的对象,我们也在初始化之后立即赋予了它们新值。每个使用到的元素都被赋值了两次:第一次是在默认初始化时,随后是在赋值时。
更重要的是,那些没有默认构造函数的类就不能动态分配数组了。
⭐️ allocator类
标准库 allocator
类定义在头文件 memory
中,它帮助我们将内存分配和对象构造分离开来。
- 它提供一种类型感知的内存分配方法;
- 它分配的内存是原始的、未构造的。
表12.7概述了allocator
支持的操作。在本节中,我们将介绍这些 allocator
操作。在13.5节,我们将看到如何使用这个类的典型例子。
类似 vector
,allocator
是一个模板。为了定义一个allocator
对象,我们必须指明这个allocator
可以分配的对象类型。当一个allocator
对象分配内存时,它会根据给定的对象类型来确定恰当的内存大小和对齐位置:
allocator<string> alloc; //可以分配string 的allocator对象
auto const p = alloc.allocate(n); //分配n个未初始化的string
这个 allocate
调用为 n
个 string
分配了内存。
⭐️ allocator分配未构造的内存
allocator
分配的内存是未构造的(unconstructed
)。我们按需要在此内存中构造对象。
- 在新标准库中,
construct
成员函数接受一个指针和零个或多个额外参数在给定位置构造一个元素。 - 额外参数用来初始化构造的对象。类似
make_shared
的参数(参见12.1.1节),这些额外参数必须是与构造的对象的类型相匹配的合法的初始化器:
auto q = p; // q 指向最后构造的元素之后的位置
alloc.construct(q++); // *q 为空字符串
alloc.construct(q++, 10, 'c'); // *q 为cccccccccc
alloc.construct(q++, "hi"); // *q 为hi!
- 在早期版本的标准库中,
construct
只接受两个参数: 指向创建对象位置的指针和一个元素类型的值。 - 因此,我们只能将一个元素拷贝到未构造空间中,而不能用元素类型的任何其他构造函数来构造一个元素。
还未构造对象的情况下就使用原始内存是错误的:
cout << *p << endl; //正确:使用string的输出运算符
cout << *q << endl; //灾难:q指向未构造的内存!
为了使用
allocate
返回的内存,我们必须用construct
构造对象。使用未构造的内存,其行为是未定义的。
当我们用完对象后,必须对每个构造的元素调用 destroy
来销毁它们。函数 destroy
接受一个指针,对指向的对象执行析构函数(参见12.1.1节):
while (q != p)
alloc.destroy(--q); //释放我们真正构造的string
在循环开始处,q
指向最后构造的元素之后的位置。我们在调用destroy
之前对q
进行了递减操作。因此,第一次调用destroy
时,q
指向最后一个构造的元素。最后一步循环中我们destroy
了第一个构造的元素,随后 q
将与 p
相等,循环结束。
我们只能对真正构造了的元素进行
destroy
操作。
一旦元素被销毁后,就可以重新使用这部分内存来保存其他string
,也可以将其归还给系统。释放内存通过调用 deallocate
来完成:
alloc.deallocate(p, n);
我们传递给 deallocate
的指针不能为空,它必须指向由 allocate
分配的内存。而且,传递给 deallocate
的大小参数必须与调用 allocated
分配内存时提供的大小参数具有一样的值。
⭐️ 拷贝和填充未初始化内存的算法
标准库还为allocator
类定义了两个伴随算法,可以在未初始化内存中创建对象。表12.8描述了这些函数,它们都定义在头文件memory
中。
作为一个例子,假定有一个 int
的 vector
,希望将其内容拷贝到动态内存中。我们将分配一块比 vector
中元素所占用空间大一倍的动态内存,然后将原 vector
中的元素拷贝到前一半空间,对后一半空间用一个给定值进行填充:
//分配比vi中元素所占用空间大一倍的动态内存
auto p = alloc.allocate(vi.size() * 2);
//通过拷贝vi中的元素来构造从p开始的元素
auto q = uninitialized_copy(vi.begin(), vi.end(), p);
//将剩余元素初始化为42
uninitialized_fill_n(q, vi.size(), 42);
类似拷贝算法,uninitialized_copy
接受三个迭代器参数。前两个表示输入序列,第三个表示这些元素将要拷贝到的目的空间。传递给uninitialized_copy
的目的位置迭代器必须指向未构造的内存。与 copy
不同,uninitialized_copy
在给定目的位置构造元素。
类似 copy
,uninitialized_copy
返回(递增后的)目的位置迭代器。因此,一次 uninitialized_copy
调用会返回一个指针,指向最后一个构造的元素之后的位置。在本例中,我们将此指针保存在 q
中,然后将q
传递给uninitialized_fill_n
。此函数类似fill_n
,接受一个指向目的位置的指针、一个计数和一个值。它会在目的位置指针指向的内存中创建给定数目个对象,用给定值对它们进行初始化。