第十二章——动态内存
到目前为止,我们编写的程序中所使用的对象都有着严格定义的生存期。全局对象在程序启动时分配,在程序结束时销毁。对于局部自动对象,当我们进入其定义所在的程序块时被创建,在离开块时销毁。局部static
对象在第一次使用前分配,在程序结束时销毁。
除了自动和static对象外,C++还支持动态分配对象。动态分配的对象的生存期与它们在哪里创建是无关的,只有当显式地被释放时,这些对象才会销毁。
动态对象的正确释放被证明是编程中极其容易出错的地方。为了更安全地使用动态对象,标准库定义了两个智能指针类型来管理动态分配的对象。当一个对象应该被释放时,指向它的智能指针可以确保自动地释放它。
- 静态内存用来保存局部
static
对象、类static
数据成员以及定义在任何函数之外的变量。static对象在使用之前分配,在程序结束时销毁 - 栈内存用来保存定义在函数内的非static对象。栈对象,仅在其定义的程序块运行时才存在
分配在静态或栈内存中的对象由编译器自动创建和销毁。
除了静态内存和栈内存,每个程序还拥有一个内存池。这部分内存被称作自由空间(free store)或堆(heap)。程序用堆来存储动态分配(dynamically allocate)的对象——即,那些在程序运行时分配的对象。动态对象的生存期由程序来控制,也就是说,当动态对象不再使用时,我们的代码必须显式地销毁它们。
12.1 动态内存与智能指针
为了更容易(同时也更安全)地使用动态内存,新的标准库提供了两种智能指针(smartpointer)类型来管理动态对象。智能指针的行为类似常规指针,重要的区别是它负责自动释放所指向的对象。新标准库提供的这两种智能指针的区别在于管理底层指针的方式:shared_ptr
允许多个指针指向同一个对象;unique_ptr
则“独占”所指向的对象。标准库还定义了一个名为weak_ptr
的伴随类,它是一种弱引用,指向shared_ptr
所管理的对象。这三种类型都定义在memory
头文件中。
12.1.1 shared_ptr类
类似vector
,智能指针也是模板。因此我们创建智能指针时必须提供额外信息——指针可以指向的类型
shared_ptr<string> p1;
shared_ptr<list<int>> p2;
默认初始化的智能指针中保存一个空指针
下表列出了shared_ptr
和unique_ptr
都支持的操作与只支持shared_ptr
的操作
make_shared函数
最安全的分配和使用动态内存的方法是调用一个名为make_shared
的标准库函数。此函数在动态内存中分配一个对象并初始化它,返回指向此对象的shared_ptr
。与智能指针一样,make_shared 也定义在头文件memory
中。
shared_ptr<int>p3 = make_shared<int>(42);
shared_ptr<string>p4 = make_shared<string>(10, '9');
shared_ptr<int>p5 = make_shared<int>();
类似顺序容器的emplace
成员(与push_back对应,前者插入构造元素,后者插入拷贝元素。所以在调用emplace成员函数时,是将参数传递给元素类型的构造函数),make_shared
用其参数来构造给定类型的对象。例如,调用make_shared<string>
时传递的参数必须与string的某个构造函数相匹配,调用make_shared<int>
时传递的参数必须能用来初始化一个int,依此类推。如果我们不传递任何参数,对象就会进行值初始化。
当然,我们通常用auto
定义一个对象来保存make_ shared
的结果,这种方式较为简单:
auto p6 = make_shared<vector<string>>();
shared_ptr的拷贝和赋值
当进行拷贝或赋值操作时,每个shared_ptr
都会记录有多少个其他shared_ptr指向相同的对象:
auto p = make_shared<int>(42); // p指向的对象只有p一个引用者
atuo q(p); // p和q指向相同对象,此对象有两个引用者
我们可以认为每个shared_ptr
都有一个关联的计数器,通常称其为引用计数。无论何时我们拷贝一个shared_ptr,计数器都会递增。例如,当用一个shared_ptr初始化另一个shared_ptr,或将它作为参数传递给一个函数以及作为函数的返回值时,它所关联的计数器就会递增。当我们给shared_ptr赋予一个新值或是shared_ptr被销毁(例如一个局部的shared_ptr离开其作用域)时,计数器就会递减。
一旦一个shared_ptr的计数器变为0,它就会自动释放自己所管理的对象:
auto r = make_shared<int>(42);
r = q;
// 给r赋值,令它指向另一个地址
// 递增q指向的对象的引用计数
// 递减r原来指向的对象的引用计数
// r原来指向的对象已经没有引用者,会自动释放
此例中我们分配了一个int,将其指针保存在r中。接下来,我们将一个新值赋予r。在此情况下,r是唯一指向此int的shared_ptr,在把q赋给r的过程中,此int被自动释放。
shared_ptr自动销毁所管理的对象
当指向一个对象的最后一个shared_ptr
被销毁时,shared_ptr类会自动销毁此对象。它是通过另一个特殊的成员函数——析构函数完成销毁工作的。类似于构造函数,每个类都有一个析构函数。就像构造函数控制初始化一样, 析构函数控制此类型的对象销毁时做什么操作。
shared_ptr的析构函数会递减它所指向的对象的引用计数。如果引用计数变为0,shared_ptr的析构函数就会销毁对象,并释放它占用的内存。
shared_ptr还会自动释放相关联的内存
// factory返回一个shared_ptr,指向一个动态分配的对象
shared_ptr<Foo> factory(T arg){
// 恰当地处理arg
// shared_ptr负责释放内存
return make_shared<Foo>(arg);
}
由于在最后一个shared_ptr
销毁前内存都不会释放,保证shared_ptr在无用之后不再保留就非常重要了。如果你忘记了销毁程序不再需要的shared_ptr,程序仍会正确执行,但会浪费内存。share_ptr在无用之后仍然保留的一种可能情况是,你将shared_ptr存放在一个容器中,随后重排了容器,从而不再需要某些元素。在这种情况下,你应该确保用erase
删除那些不再需要的shared_ptr 元素。
使用了动态生存期的资源的类
程序使用动态内存出于以下三种原因之一:
- 程序不知道自己需要使用多少对象
- 程序不知道所需对象的准确类型
- 程序需要在多个对象间共享数据
容器类是出于第一种原因而使用动态内存的典型例子。
vector<string> vs;
void func() {
vector<string> vs2 = { "a", "an", "the" };
vs = vs2;
}
此例中,vs与vs2共享相同的元素,当vs2离开作用域时,这些元素必须保留,因为vs仍在使用它们
定义StrBlob类
但是,我们不能在一个Blob对象内直接保存vector,因为一个对象的成员在对象销毁时也会被销毁。例如,假定b1和b2是两个Blob对象,共享相同的vector。如果此vector保存在其中一个Blob中——例如b2中,那么当b2离开作用域时,此vector也将被销毁,也就是说其中的元素都将不复存在。为了保证vector中的元素继续存在,我们将vector保存在动态内存中。
为了实现我们所希望的数据共享,我们为每个StrBlob设置一个shared_ptr
来管理动态分配的vector。此shared_ptr的成员将记录有多少个StrBlob共享相同的vector,并在vector的最后一个使用者被销毁时释放vector。
我们还需要确定这个类应该提供什么操作。当前,我们将实现一个vector操作的小的子集。我们会修改访问元素的操作(如front
和back
);在我们的类中,如果用户试图访问不存在的元素,这些操作会抛出一个异常。
我们的类有一个默认构造函数和一个构造函数,接受单一的initializer_list<string>
类型参数。此构造函数可以接受一个初始化器的花括号列表。
class StrBlob {
public:
typedef std::vector<std::string>::size_type size_type;
StrBlob();
StrBlob(std::initializer_list<std::string> il);
size_type size() const { return data->size(); }
bool empty() const { return data->empty(); }
// 添加删除元素
void push_back(const std::string &t) { data->push_back(t); }
void pop_back();
// 元素访问
std::string& front();
std::string& back();
private:
std::shared_ptr<std::vector<std::string>> data;
// 如果data[i]不合法,抛出一个异常
void check(size_type i, const std::string &msg) const;
}
在此类中,我们实现了size、empty和push_back 成员。这些成员通过指向底层vector的data成员来完成它们的工作。例如,对一个StrBlob对象调用size()会调用data->size(),依此类推。
StrBlob构造函数
StrBlob::StrBlob(): data(std::make_shared<std::vector<std::string>>()) {}
StrBlob::StrBlob(std::initializer_list<std::string> il):
data(std::make_shared<std::vector<std::string>>(il)) {}
接受一个initializer_list
的构造函数将其参数传递给对应的vector构造函数。次构造函数通过拷贝列表中的值来初始化vector元素
元素访问成员函数
pop_back、front和back操作访问vector中的元素。这些操作在试图访问元素之前必须检查元素是否存在。由于这些成员函数需要做相同的检查操作,我们为strBlob定义了一个名为check的private工具函数,它检查一个给定索引是否在合法范围内。除了索引,check还接受一个string参数,它会将此参数传递给异常处理程序,这个string描述了错误内容:
void StrBlob::check(size_type i, const std::string &msg) const {
if (i >= data->size())
throw out_of_range(msg);
}
pop_back和元素访问成员函数首先调用check。如果check成功,这些成员函数继续利用底层vector的操作来完成自己的工作:
string& StrBlob::front() {
// 如果vector为空,check会抛出一个异常
check(0, "front on empty StrBlob");
return data->front();
}
string& StrBlob::bakc() {
check(0, "back on empty StrBlob");
return data->back();
}
void StrBlob::pop_back() {
check(0, "pop_back on empty StrBlob");
data->pop_back();
}
StrBlob的拷贝、赋值和销毁
类似Sales_data类,StrBlob使用默认版本的拷贝、赋值和销毁成员函数来对此类型的对象进行这些操作。默认情况下,这些操作拷贝、赋值和销毁类的数据成员。我们的StrBlob类只有一个数据成员,它是shared_ptr类型。因此,当我们拷贝、赋值或销毁一个StrBlob对象时,它的shared_ptr成员会被拷贝、赋值或销毁。
如前所见,拷贝一个shared_ptr会递增其引用计数:将一个shared_ptr赋予另一个shared_ptr:会递增赋值号右侧shared_ptr的引用计数,而递减左侧shared_ptr的引用计数。如果-一个shared_ptr的引用计数变为0,它所指向的对象会被自动销毁。因此,对于由StrBlob构造函数分配的vector,当最后一个指向它的StrBlob对象被销毁时,它会随之被自动销毁。
12.1.2 直接管理内存
使用new动态分配和初始化对象
new
返回一个指向该对象的指针:
int *pi = new int;
可以直接初始化方式初始化一个对象:
int *pi1 = new int();
int *pi2 = new int(1024);
vector<int> *pv = new vector<int> {0,1,2,3};
如果我们提供了一个括号包围的初始化器,就可以使用auto从此初始化器来推断我们想要分配的对象的类型。但是,由于编译器要用初始化器的类型来推断要分配的类型,只有当括号中仅有单一初始化器时才可以使用auto:
auto p1 = new auto(obj); // p指向一个与obj类型相同的对象,该对象用obj进行初始化
auto p2 = new auto(a, b, c); // 错误:括号中只能有单个初始化器
动态分配的const对象
用new
分配const
对象是合法的:
const int *pci = new const int(1024);
cibst string *pcs = new const string;
new返回的是一个指向const
的指针
内存耗尽
一单一个程序用光了它所有可用的内存,new表达式就会失败,抛出bad_alloc
的异常。我们可以改变使用new的方式来阻止他抛出异常:
int *p1 = new int; // 如果失败抛出异常
int *p2 = new (nothrow) int; // 如果失败,返回空指针
我们称这种形式的new为定位new(placement new)。定位new表达式允许我们向new传递额外的参数。在此例中,我们传递给它一个由标准库定义的名为nothrow
的对象。如果将nothrow传递给new,我们的意图是告诉它不能抛出异常。如果这种形式的new不能分配所需内存,它会返回一个空指针。bad_alloc
和nothrow
都定义在头文件new
中。
释放动态内存
delete p;
销毁给定的指针指向的对象,释放对应内存
指针值和delete
我们传递给delete的指针必须指向动态分配的内存,或者是一个空指针。释放一块并非new分配的内存,或者将相同的指针值释放多次,其行为是未定义的
虽然一个const
对象的值不能被改变,但它本身是可以被销毁的。如同任何其他动态对象一样,想要释放一个const动态对象,只要delete指向它的指针即可
动态对象的生存期直到被释放为止
由shared_ptr
管理的内存在最后一个shared_ptr销毁时会被自动释放。但对于通过内置指针类型来管理的内存,就不是这样了。对于一个由内置指针管理的动态对象,直到被显式释放之前它都是存在的。
返回指向动态内存的指针(而不是智能指针)的函数给其调用者增加了个额外负担一调用者必须记得释放内存
特别是,当一个指针离开其作用域时,它所指向的对象什么也不会发生。如果这个指针指向的是动态内存,那么内存将不会被自动释放。
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不绑定任何对象
12.1.3 shared_ptr和new结合使用
我们可以用new返回的指针初始化智能指针:
shared_ptr<double> p1;
shared_ptr<int> p2(new int(42));
shared_ptr<int> p3 = new int(42); // 错误:必须使用直接初始化形式
我们不能进行内置指针到智能指针间的隐式类型转换,所以这条语句是错误的
不要混合使用普通指针和智能指针
shared_ptr
可以协调对象的析构,但这仅限于其自身的拷贝(也是shared_ptr)之间。这也是为什么尽量使用make_shared
而不是new的原因。这样,我们就能在分配对象的同时就将shared_ptr与之绑定,从而避免了无意中将同一块内存绑定到多个独立创建的shared_ptr上。
使用一个内置指针来访问一个智能指针所负责的对象是很危险的,因为我们无法知道对象何时会被销毁。
也不要使用get初始化另一个智能指针或为智能指针赋值
智能指针类型定义了一个名为get
的函数,它返回一个内置指针,指向智能指针管理的对象。此函数是为了这样-种情况而设计的:我们需要向不能使用智能指针的代码传递一个内置指针。使用get返回的指针的代码不能delete此指针。
虽然编译器不会给出错误信息,但将另一个智能指针也绑定到get返回的指针上是错误的:
shared_ptr<int> p(new int(42));
int *q = p.get;
{
// 新程序块
// 未定义:两个独立的shared_ptr指向相同的内存
shared_ptr<int>(q);
} // 程序块结束,q被销毁,它指向的内存被释放
int foo = *p; // 未定义:p指向的内存已经被释放
get用来将指针的访问权限传递给代码,你只有在确定代码不会delete指针的情况下,才能使用get。
特别是,永远不要用get初始化另一个智能指针或者为另一个智能指针赋值。
其他shared_ptr操作
shared_ptr还定义了其他一些操作。我们可以使用reset
来将一个新的指针赋予一个shared_ptr:
p = new int(1024); // 错误:不能将一个指针赋予shared_ptr
p.reset(new 1024); // 正确:p指向一个新对象
与赋值类似,reset会更新引用计数,如果需要的话,会释放p指向的对象。reset 成员经常与unique
一起使用,来控制多个shared_ptr共享的对象。在改变底层对象之前,我们检查自己是否是当前对象仅有的用户。如果不是,在改变之前要制作一份新的拷贝:
if (!p.unique())
p.reset(new string(*p)); // 我们不是唯一用户;分配新的拷贝
*p += newVal; // 现在我们知道自己是唯一的用户
12.1.4 智能指针和异常
之前介绍了使用异常处理的程序能在异常发生后令程序流程继续,我们注意到,这种程序需要确保在异常发生后资源能被正确地释放。一个简单的确保资源被释放的方法是使用智能指针。
如果使用智能指针,即使程序块过早结束,智能指针类也能确保在内存不再需要时将其释放:
void f() {
shared_ptr<int> sp(new int(42)); // 分配一个新对象
// 这段代码抛出一个异常,且在f中未被捕获
} // 在函数结束时shared_ptr自动释放内存
函数的退出有两种可能,正常处理结束或者发生了异常,无论哪种情况,局部对象都会被销毁。在上面的程序中,sp是一个shared_ptr,因此sp销毁时会检查引用计数。在此例中,sp是指向这块内存的唯一指针, 因此内存会被释放掉。
与之相对的,当发生异常时,我们直接管理的内存是不会自动释放的。如果使用内置指针管理内存,且在new之后在对应的delete之前发生了异常,则内存不会被释放
智能指针和哑类
包括所有标准库类在内的很多C++类都定义了析构函数,负责清理对象使用的资源。但是,不是所有的类都是这样良好定义的。特别是那些为C和C++两种语言设计的类,通常都要求用户显式地释放所使用的任何资源。
那些分配了资源,而又没有定义析构函数来释放这些资源的类,可能会遇到与使用动态内存相同的错误一程序员非常容易忘记释放资源。类似的,如果在资源分配和释放之间发生了异常,程序也会发生资源泄漏。
struct destinaion;
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中保存的指针进行释放的操作。在本例中,我们的删除器必须接受单个类型为connection*
的参数:
void end_connection(connection *p) { disconnect(*p); }
当我们创建一个shared_ptr时,可以传递一个(可选的)指向删除器函数的参数:
void f(destionation &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.5 unique_ptr
一个unique_ptr
拥有它所指向的对象。与shared_ptr
不同,某个时刻只能有一个unique_ptr指向一个给定对象。当unique_ptr 被销毁时,它所指向的对象也被销毁。
与shared_ptr不同,没有类似make_shared的标准库函数返回一个unique_ptr。当我们定义一个unique_ptr时,需要将其绑定到一个new返回的指针上。类似shared_ptr,初始化unique_ptr必须采用直接初始化形式:
unique_ptr<double> p1; // 可以指向一个double的unique_ptr
unique_ptr<int> p2(new int(42)); // p2指向一个值为42的int
由于一个unique_ptr拥有它指向的对象,因此unique_ptr不支持普通的拷贝或赋值操作
虽然我们不能拷贝或赋值unique_ptr
,但可以通过调用release
或reset
将指针的所有权从一个(非const
)unique_ptr
转移给另一个unique:
// 所有权从p1转移给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
和它原来管理的对象间的联系。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));
}
还可以返回一个局部对象的拷贝:
```cpp
unique_ptr<int> clone(int p) {
unique_ptr<int> ret(new int (p));
// ...
return ret;
}
对于两段代码,编译器都知道要返回的对象将要被销毁。在此情况下,编译器执行一种特殊的“拷贝”
向unique_ptr传递删除器
类似shared_ptr
,unique_ptr
默认情况下用delete
释放它指向的对象。与shared_ptr一样,我们可以重载一个unique_ptr中默认的删除器。但是,unique_ptr管理删除器的方式与shared_ptr不同。
重载一个unique_ptr中的删除器会影响到unique_ptr类型以及如何构造(或reset
)该类型的对象。与重载关联容器的比较操作类似,我们必须在尖括号中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
来指明函数指针类型。由于decltype(end_connection)
返回一个函数类型,所以我们必须添加一个*
来指出我们正在使用该类型的一个指针。
12.1.6 weak_ptr
weak_ptr
是一种不控制所指向对象生存期的智能指针,它指向由一个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);
由于对象可能不存在,我们不能使用weak_ptr直接访问对象,而必须调用lock
。此函数检查weak_ptr指向的对象是否仍存在。如果存在,lock 返回一个指向共享对象的shared_ptr与任何其他shared_ptr类似,只要此shared_ptr存在,它所指向的底层对象也就会一直存在。 例如:
if (shared_ptr<int> np = wp.lock()) {
// 在if中,np与p共享对象
}
12.2 动态数组
C++语言和标准库提供了两种一次分配一个对象数组的方法。C++语言定义了另一种new
表达式语法,可以分配并初始化一个对象数组。标准库中包含一个名为allocator
的类,允许我们将分配和初始化分离。使用allocator通常会提供更好的性能和更灵活的内存管理能力。
12.2.1 new和数组
为了让new
分配一个对象数组,我们要在类型名之后跟一对方括号,在其中指明要分配的对象的数目。在下例中,new分配要求数量的对象并(假定分配成功后)返回指向第一个对象的指针:
int *pia = new int[get_size()];
分配一个数组会得到一个元素类型的指针
虽然我们通常称new T[]分配的内存为“动态数组”,但这种叫法某种程度上有些误导。当用new分配一个数组时,我们并未得到一个数组类型的对象,而是得到一个数组元素类型的指针。new返回的是一个元素类型的指针。
由于分配的内存并不是一个数组类型,因此不能对动态数组调用begin
或end
。这些函数使用数组维度来返回指向首元素和尾后元素的指针。出于相同的原因,也不能用范围for语句来处理(所谓的)动态数组中的元素。
初始化动态分配对象的数组
默认情况下,new分配的对象,不管是单个分配的还是数组中的,都是默认初始化的。可以对数组中的元素进行值初始化,方法是在大小之后跟一对空括号。
int *pia = new int[10];
int *pia2 = new int[10]();
string *pas = new string[10];
string *pas2 = new string[10]();
新标准中,我们还可以提供一个元素初始化器的花括号列表:
int *pia3 = new int[10]{0,1,2,3,4,5,6,7,8,9};
string *psa3 = new string[10]{"a", "an", "the", string(3, 'x')};
动态分配一个空数组是违法的
可以用任意表达式来确定要分配的对象的数目,但其值为0呢?
char arr[0]; // 错误
char *cp = new char[0]; // 正确:但cp不能解引用
对于零长度的数组来说,此指针就像尾后指针一样,我们可以想使用尾后迭代器一样使用中这个指针。
释放动态数组
为了释放动态数组,我们使用一种特殊形式的delete
在指针前加上一个空方括号对:
delete [] pa;
当我们使用一个类型别名来定义一个数组类型时,在new
表达式中不使用[ ]
。即使是这样,在释放一个数组指针时也必须使用方括号:
typedef int arrT[42];
int *p = new arrT;
delete [] p; // 方括号是必须的
不管外表如何,p指向一个对象数组的首元素,而不是一个类型为arrT的单一对象,因此我们在释放p是必须使用[ ]
智能指针与动态数组、
标准库提供了一个可以管理new
分配的数组的unique_ptr
版本。为了用一个unique_ptr管理动态数组,我们必须在对象类型后面跟一对空方括号:
unique_ptr<int[]> up(new int[10]);
up.release(); // 自动调用delete[]销毁其指针
指向数组的unique_ptr提供的操作与之前使用的那些操作有一些不同。当一个unique_ptr指向一个数组时,我们不能使用点和箭头成员运算符。毕竞unique_ptr指向的是一个数组而不是单个对象,因此这些运算符是无意义的。另一方面,当一个unique_ptr指向一个数组时,我们可以使用下标运算符来访问数组中的元素:
for (size_t i = 0; i != 10; ++i)
up[i] = i; // 赋予新值
与unique_ptr不同,shared_ptr
不直接支持管理动态数组。如果希望使用shared_ptr管理一个动态数组,必须提供自己定义的删除器:
share_ptr<int> sp(new int[10], [](int *p)) { delete[] p; };
sp.reset(); // 使用lambda释放数组,它使用delete[]
我们传递给shared_ptr一个lambda作为删除器,它使用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类
- 将内存分配和对象构造组合在一起可能会导致不必要的浪费(比如申请长度为10的数组空间只用了5个,之后还需要将剩余未使用的空间释放掉)
- 每个使用到的元素都赋值了两次,第一次是在默认初始化时,第二次是在赋值时
- 更重要的是,那些没有默认构造函数的类就不能动态分配数组了
allocator类
标准库allocator
类定义在头文件memory
中,它帮助我们将内存分配和对象构造分离开来。它提供一种类型感知的内存分配方法,它分配的内存是原始的、未构造的。在本节中,我们将介绍这些allocator操作。
类似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
的参数,这些额外参数必须是与构造的对象的类型相匹配的合法的初始化器:
auto q = p;
alloc.construct(q++);
alloc.construct(q++, 10, 'c');
alloc.construct(q++, "hi");
还未构造对象的情况下就使用原始内存时错误的:
cout << *p << endl;
cout << *q << endl; // 错误:q指向未构造的内存
当用完对象之后,必须对每个构造的元素调用destroy
来销毁它们。destroy接受一个指针,对指向的对象执行析构函数。
while(q != p);
alloc.destroy(--q);
一旦元素被销毁后,就可以重新使用这部分内存来保存其他string,也可以将其归还给系统。释放内存通过调用deallocate
来完成
alloc.deallocate(p, n);
拷贝和填充未初始化内存的算法
标准库还未allocator
类定义了两个伴随算法,可以在未初始化内存中创建对象,他们都定义在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);
12.3 使用标准库:文本查询程序
我们将实现一个简单的文本查询程序,作为标准库相关内容学习的总结。我们的程序允许用户在一个给定文件中查询单词。查询结果是单词在文件中出现的次数及其所在行的列表。如果一个单词在一行中出现多次,此行只列出一次。行会按照升序输出一一即,第7行会在第9行之前显示,依此类推。
12.3.1 文本查询程序设计
开始一个程序的设计的一种好方法是列出程序的操作。了解需要哪些操作会帮助我们分析出需要什么样的数据结构。从需求入手,我们的文本查询程序需要完成如下任务:
- 当程序读取输入文件时,它必须记住单词出现的每一行。因此程序需要逐行读取输入文件,并将每一行分解为独立的单词
- 当程序生成输出时:(1)它必须能提取每个单词所关联的行号(2)行号必须按程序出现且无重复(3)它必须能打印给给定行号的文本
利用多种标准库设施,得出如下结果:
- 我们将使用一个
vector<string>
来保存整个输入文件的一份拷贝。输入文件中的每行保存为vector中的一个元素。当需要打印一行时,可以用行号作为下标来提取行文本。 - 我们使用一个
istringstream
来将每行分解为单词。 - 我们使用一个
set
来保存每个单词在输入文本中出现的行号。这保证了每行只出现一次且行号按升序保存。 - 我们使用一个
map
来将每个单词与它出现的行号set
关联起来。这样我们就可以方便地提取任意单词的set
解决方案中还需使用shared_ptr
数据结构
我们定义一个保存输入文件的类,这会令查询更为容易。
这个类名为TextQuery
,它包含一个vector
和一个map
。vector用来保存输入文件的文本,map用来关联每个单词和它出现的行号set
。
这个类将会有一个用来入去给定输入文件的构造函数和一个执行查询的操作。
查询操作:查找map成员,检查给定单词是否出现。一旦找到一个单词,我们需要知道它出现的次数,出现的行号以及每行的文本。
返回这些内容的最简单的方法是定义另一个类,可以命名为QueryResult
,来保存查询结果。这个类会有一个print
函数,来完成打印结果。
类之间共享数据
我们的QueryResult
类要表达查询的结果。这些结果包括与给定单词关联的行号的set
和这些行对应的文本。这些数据都保存在TextQuery类型的对象中。
由于QueryResult所需要的数据都保存在一个TextQuery
对象中,我们就必须确定如何访问它们。我们可以拷贝行号的set,但这样做可能很耗时。而且,我们当然不希望拷贝vector,因为这可能会引起整个文件的拷贝,而目标只不过是为了打印文件的一小部分而已。
通过返回指向TextQuery 对象内部的迭代器(或指针),我们可以避免拷贝操作。但是,这种方法开启了一个陷阱:如果TextQuery对象在对应的QueryResult对象之前被销毁,会发生什么?在此情况下,QueryResult 就将引用一个不再存在的对象中的数据。
对于QueryResult对象和对应的TextQuery对象的生存期应该同步这一观察结果,其实已经暗示了问题的解决方案。考虑到这两个类概念上“共享”了数据,可以使用shared_ptr
来反映数据结构中的这种共享关系。
使用TextQuery类
这个函数接受一个指向要处理的文件的ifstream
并与用户交互,打印给定单词的查询结果
void runQueries(ifstream &infile) {
// infile是一个ifstream,指向我们要处理的文件
TextQuery tq(infile); // 保存文件并建立查询map
// 与用户交互:提示用户输入要查询的单词,完成查询并打印结果
while (true) {
cout << "enter word to look for, or q to quit: ";
string s;
// 若遇到文件尾或用户输入了`q`是循环停止
if (!(cin >> s) || s == "q") break;
// 指向查询并打印结果
print(cout, tq.query(s)) << endl;
}
}
12.3.2 文本查询程序类的定义
class QueryResult; // 为了定义函数query的返回类型,这个定义是必须的
class TextQuery {
public:
using line_no = std::vector<std::string>::size_type;
TextQuery(std::ifstream&);
QueryResult query(const std::string&) const
private:
std::shared_ptr<std::vector<std::string>> file; // 输入文件
// 每个单词到它所在的行号的集合的映射
std::map<std::string, std::shared_ptr<std::set<line_no>>> wm;
};
TextQuery构造函数
接受一个ifstream
,逐行读取输入文件:
// 读取驶入稳健并建立单词到行号的映射
TextQuery::TextQuery(ifstream &is): file(new vector<string>){
string text;
while (getline(is, text)) { // 对文件中每一行
file->push_back(text); // 保存此行文本
int n = file->size() - 1; // 当前行号
istringstream line(text); // 将文本分解为单词
string word;
while (line >> word) { // 对行中每个单词
// 如果单词不在wm中,以之为下标在wm中添加一项
auto &lines = wm[word]; // lines是一个shared_ptr
if (!lines) // 在我们第一次遇到这个单词时,此指针为空
lines.reset(new set<line_no>); // 分配一个新的set
lines->insert(n); // 将此行号插入set中
}
}
}
QueryResult类
class QueryResult{
friend std::ostream& print(std::ostream&, const QueryResult&);
public:
QueryResult(std::string s,
std::shared_ptr<std::set<line_no>> p,
std::shared_ptr<std::vector<std::string>> f):
sought(s), lines(p), file(f) { }
private:
std::string sought; // 查询单词
std::shared_ptr<std::set<line_no>> lines; // 出现的行号
std::shared_ptr<std::vector<std::string>> file; // 输入文件
};
query函数
query函数接受一个string参数,即查询单词,query 用它来在map中定位对应的行号set.如果找到了这个string,query函数构造一个QueryResult,保存给定string、TetQuery的file成员以及从wm中提取的set。
QueryResult TextQuery::query(const string &sought) const {
// 如果为找到sought,我们将返回一个指向次set的指针
static shared_ptr<set<line_no>> nodata(new set<line_no>);
// 使用find而不是下标运算符来查找单词,避免将单词添加到wm中
auto loc = wm.find(sought);
if (loc == wm.end())
return QueryResult(sought, nodata, file); // 未找到
else
return QueryResult(sought, loc->second, flie);
}
打印结果
ostream &print(ostream &os, const QueryResult &qr) {
// 如果找到了单词,打印出现次数和所有出现的位置
os << qr.sought << " occurs " << qr.lines->size() << " "
<< make_plural(qr.lines->size(), "time", "s") << endl;
// 打印单词出现的一行
for (auto num : *qr.lines) // 对set中每个单词
// 避免行号从0开始给用户带来的困惑
os << "\t(line )" << num + 1 << ")"
<< *(qr.file->begin() + num) << endl;
return os;
}