动态内存
动态对象的正确释放被证明是编程中极其容易出错的地方。为了安全地使用动态对象,标准库定义了两个智能指针类型来管理动态分配的对象。当一个对象应该被释放时,指向它的智能指针可以确保自动地释放它。
静态内存用来保存局部 static 对象、类 static 数据成员以及定义在任何函数之外的变量。栈内存用来保存定义在函数内的非 static 对象。分配在静态或栈内存中的对象由编译器自动创建和销毁。对于栈对象,仅在其定义的程序块运行时才存在;static 对象在使用之前分配,在程序结束时销毁。
除了静态内存和栈内存,每个程序还拥有一个内存池。这部分内存被称作自由空间 (free store) 或堆 (heap)。
程序用堆来存储动态分配 (dynamically allocate) 的对象——即,那些在程序运行时分配的对象。动态对象的生存期由程序来控制,也就是说,当动态对象不再使用时,我们的代码必须显式地销毁它们。
虽然使用动态内存有时是必要的,正确地管理动态内存是非常棘手的。
动态内存与智能指针
在 C++ 中,动态内存的管理是通过一对运算符来完成的:new,在动态内存中为对象分配空间并返回一个指向该对象的指针,我们可以选择对对象进行初始化;delete,接受一个动态对象的指针,销毁该对象,并释放与之关联的内存。
动态内存的使用很容易出问题,因为确保在正确的时间释放内存是极其困难的。
为了更容易(同时也更安全)地使用动态内存,新的标准库提供了两种智能指针 (smart pointer) 类型来管理动态对象。智能指针的行为类似常规指针,重要的区别是它负责自动释放所指向的对象。新标准库提供的这两种智能指针的区别在于管理底层指针的方式: shared_ptr 允许多个指针指向同一个对象;unique_ptr 则“独占”所指向的对象。标准库还定义了一个名为 weak_ptr 的伴随类,它是一种弱引用,指向 shared_ptr 所管理的对象。这三种类型都定义在 memory 头文件中。
shared_ptr 类
智能指针也是模板,我们需要在尖括号内给出类型,之后是所定义的这种智能指针的名字:
shared_ptr<string> p1; // shared_ptr 指向 string
shared_ptr<list<int>> p2; // shared_ptr 指向 int 的 list
默认初始化的智能指针中保存着一个空指针。
智能指针的使用方式与普通指针类似。解引用一个智能指针返回它指向的对象。如果在一个条件判断中使用智能指针,效果就是检测它是否为空:
// 如果 p1 不为空,且检查它是否指向一个空 string
if (p1 && p1->empty()) {
// 如果 p1 指向一个空 string 解引用 p1 将一个新值赋予 p1
*p1 = "hi";
}
下表列出了 shared_ptr 和 unique_ptr 都支持的操作。
shared_ptr 和 unique_ptr 都支持的操作 | ||
---|---|---|
shared_ptr unique_ptr |
空智能指针,可以指向类型为 T 的对象 | |
p | 将 p 用作一个条件判断,若 p 指向一个对象,则为 true | |
*p | 解引用 p,获得它指向的对象 | |
p->m | 等价于 (*p).m | |
p.get() | 返回 p 中保存的指针。要小心使用,若智能指针释放了其对象,返回的指针所指向的对象也就消失了 | |
swap(p, q) p.swap(q) |
交换 p 和 q 中的指针 |
下表列出了只适用于 shared_ptr 的操作。
shared_ptr 独有的操作 | ||
---|---|---|
make_shared |
返回一个 shared_ptr 指向一个动态分配的类型为 T 的对象。使用 args 初始化此对象 | |
shared_ptr |
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 时,必须指定想要创建的对象的类型。定义方式与模板类相同,在函数名之后跟一个尖括号,在其中给出类型:
// 指向一个值为 42 的 int 的 shared_ptrshared_ptr<int> p3 = make_shared<int>(42);
// p4 指向一个值为 "9999999999" 的 stringshared_ptr<string> p4 = make_shared<string>(10, '9');
// p5 指向一个值初始化的 int ,即,值为 0shared_ptr<int> p5 = make_shared<int>();
类似顺序容器的 emplace 成员,make_shared 用其参数来构造给定类型的对象。例如,调用 make_shared
shared_ptr 的拷贝与赋值
当进行拷贝或赋值操作时,每个 shared_ptr 都会记录有多少个其他 shared_ptr 指向相同的对象:
auto p = make_shared<int>(42);
auto q(p); // p 和 q 指向相同的对象,此对象有两个引用者
我们可以认为每个 shared_ptr 都有一个关联的计数器,通常称其为引用计数 (reference count)。无论何时我们拷贝一个 shared_ptr,计数器都会递增。例如,当用一个 shared_ptr 初始化另一个 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 原来指向的对象已没有引用者,会自动释放
此例中我们分配了一个 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 销毁前内存都不会释放,保证 shared_ptr 在无用之后不再保留就非常重要了。如果忘记了销毁程序不再需要的 shared_ptr,程序仍会正确执行,但会浪费内存。shared_ptr 在无用之后仍然保留的一种可能情况是,将 shared_ptr 存放在一个容器中,随后重排了容器,从而不再需要某些元素。在这种情况下,应该确保用 erase 删除那些不再需要的 shared_ptr 元素。
使用了动态生存期的资源的类
程序使用动态内存出于以下三种原因之一:
- 程序不知道自己需要使用多少对象
- 程序不知道所需对象的准确类型
- 程序需要在多个对象间共享数据
使用动态内存的一个常见原因就是允许多个对象共享相同的状态。
class StrBlob {
public:
typedef vector<string>::size_type size_type;
StrBlob() :data(make_shared<vector<string>>()) {};
StrBlob(initializer_list<string>& il) :data(make_shared<vector<string>>(il)) {};
size_type size() const { return data->size(); };
void push_back(const string& s);
void pop_back();
string& front();
const string& front() const;
string& back();
const string& back() const;
private:
shared_ptr<vector<string>> data;
};
initializer_list<string>&
接受一个初始化器的花括号列表。
直接管理内存
C++ 语言定义了两个运算符来分配和释放动态内存。运算符 new 分配内存,delete 释放 new 分配的内存。
相对于智能指针,使用这两个运算符管理内存非常容易出错,自己直接管理内存的类与使用智能指针的类不同,他们不能依赖类对象拷贝、赋值和销毁操作的任何默认定义。因此,使用智能指针的程序更容易编写和调试。
使用 new 动态分配和初始化对象
在自由空间分配的内存是无名的,因此 new 无法为其分配的对象命名,而是返回一个指向该对象的指针。int* pi = new int;
pi 指向一个动态分配的、未初始化的无名对象
此 new 表达式在内存堆中构造一个 int 型对象,并返回指向该对象的指针。
默认情况下,动态分配的对象是默认初始化的。例如 string* ps = new string;
我们可以使用直接初始化方式来初始化一个动态分配的对象。可以使用传统的构造方式(使用圆括号),在新标准下,也可以使用列表初始化(使用花括号):
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 };
也可以对动态分配的对象进行值初始化,只需在类型名之后跟一对空括号即可:
string* ps1 = new string; // 默认初始化空的 string
string* ps = new string(); // 值初始化空的 string
int* pi1 = new int; // 默认初始化 *pi1 的值未定义
int* pi2 = new int(); // 值初始化为 0; *pi2 为 0
对于定义了自己的构造函数的类类型来说,要求值初始化是没有意义的;不管采用什么形式,对象都会通过默认构造函数来初始化。
但对于内置类型,两种形式的差别就很大了;值初始化的内置类型对象有着良好定义的值,而默认初始化的对象的值则是未定义的。类似的,对于类中那些依赖于编译器合成的默认构造函数的内置数据成员,如果它们未在类内被初始化,那么它们的值也是未定义的。
出于与变量初始化相同的原因,对动态分配的对象进行初始化通常是个好主意。
如果我们提供了一个括号包围的初始化器,就可以使用 auto 从初始化器来推断我们想要分配的对象的类型。但是,由于编译器要用初始化器的类型推断要分配的类型,只有当括号中仅有单一初始化器时才可以使用 auto:
auto p1 = new auto(obj); // p 指向一个与 obj 类型相同的对象
auto p2 = new auto{ a, b, c }; // 错误 {a, b, c} 可以初始化很多类型
p1 的类型是一个指针,指向从 obj 自动推断的类型。若 obj 是一个 int,那么 p1 就是 int;若 obj 是一个 string,那么 p1 是一个 string,以此类推。新分配的对象用 obj 的值进行初始化。
动态分配的 const 对象
用 new 分配 const 对象是合法的:
// 分配并初始化一个 const int
const int* pic = new const int(1024);
// 分配并默认初始化一个 const 的空 string
const string* pcs = new const string;
类似其他任何 const 对象,一个动态分配的 const 对象必须进行初始化。对于一个定义了默认构造函数的类类型,其 const 动态对象可以隐式初始化,而其他类型的对象就必须显式初始化。由于分配的对象是 const 的,new 返回的指针是一个指向 const 的指针。
内存耗尽
虽然现代计算机通常都配备大容量内存,但是内存堆被耗尽的情况还是有可能发生。一旦一个程序用光了它所有可用的内存,new 表达式就会失败。默认情况下,如果 new 不能分配所要求的内存空间,它会抛出一个类型为 bad_alloc 的异常。我们可以改变使用 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 必须指向一个动态分配的对象或是一个空指针
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; // 未定义的错误 存在重复释放的问题
delete pi2; // 正确 在 C++ 中,释放一个空指针总是没有错误的
对于 delete i 的请求,编译器会生成一个错误信息,因为它知道 i 不是一个指针。执行 delete pi1 和 pd2 所产生的错误则更具潜在危害:通常情况下,编译器不能分辨一个指针指向的是静态还是动态分配的对象。类似的,编译器也不能分辨一个指针所指向的内存是否已经被释放了。对于这些 delete 表达式,大多数编译器会编译通过,尽管它们是错误的。
虽然一个 const 对象的值不能被改变,但它本身是可以被销毁的。如同任何其他动态对象一样,想要释放一个 const 动态对象,只要 delete 指向它的指针即可:
const int* pci = new const int(1024);
delete pci;
动态对象的生存期直到被释放时为止
由 shared_ptr 管理的内存在最后一个 shared_ptr 销毁时会被自动释放。但对于通过内置指针类型来管理的内存,就不是这样了。对于一个由内置指针管理的动态对象,直到被显式释放之前它都是存在的。
返回指向由动态内存的指针(而不是智能指针)的函数给调用者增加了一个额外的负担——调用者必须记得释放内存:
Foo* factory(T arg)
{
// 视情况处理 arg
return new Foo(arg); // 调用者负责释放此内存
}
factory 分配一个对象,但并不 delete 它。factory 的调用者负责在不需要此对象时释放它。不幸的是,调用者经常忘记释放对象。
void use_factory()
{
Foo* p = factory(arg);
// 使用 p 但不 delete 它
} // p 离开了作用域 但它所指向的内存没有被释放
此处,use_factory 函数调用 factory,后者分配一个类型为 Foo 的新对象。当 use_factory 返回时,局部变量 p 被销毁,此变量是一个内置指针,而不是一个智能指针。
与类类型不同,内置的对象被销毁时什么也不会发生。特别是,当一个指针离开其作用域时,它所指向的对象什么也不会发生。如果这个指针指向的是动态内存,那么内存你将不会被自动释放。
有内置指针(不是智能指针)管理的动态内存在被显式释放前一直都会存在。
小心:动态内存的关联非常容易出错
使用 new 和 delete 管理动态内存存在三个常见问题:
1、忘记 delete 内存。
2、使用已经释放掉的对象。
3、同一块内存释放两次。
坚持只使用智能指针,就可以避免所有这些问题。对于一块内存,只有在没有任何智能指针指向它的情况下,智能指针才会自动释放它。
delete 之后重置指针值
当我们 delete 一个指针后,指针值就变为无效了。虽然指针已经失效,但在很多机器上指针仍然保存着(已经释放了的)动态内存的地址。在 delete 之后,指针就变成了人们所说的空悬指针 (dangling pointer),即,指向一块曾经保存数据对象但现在已经无效的内存的指针。
未初始化指针的所有缺点空悬指针也都有。有一种方法可以避免空悬指针的问题:在指针即将要离开其作用域之前释放掉它所关联的内存。这样,在指针管理的内存被释放掉之后,就没有机会继续使用指针了。如果我们需要保留指针,可以在 delete 之后将 nullptr 赋予指针,这样就清楚地指出指针不指向任何对象。
if(ptr)
{
delete ptr;
ptr = nullptr;
}
这只是提供了有限的保护
动态内存的一个基本问题是可能有多个指针指向相同的内存。在 delete 内存之后重置指针的方法只对这个指针有效,对其他任何仍指向(已释放的)内存的指针是没有作用的。例如:
int* p(new int(42)); // p 指向动态分配的内存
auto q = p; // q 和 p 指向相同的内存
delete p; // p 和 q 均变得无效
p = nullptr; // 指出 p 不再绑定到任何对象
本例中,p 和 q 指向相同的动态分配的对象。我们 delete 此内存,然后将 p 置为 nullptr 指出它不再指向任何对象。但是,重置 p 对 q 没有任何作用,在我们释放 p 所指向的(同时也是 q 所指向的!)内存时,q 也变为无效了。在实际系统中,查找指向相同内存的所有指针是异常困难的。
shared_ptr 和 new 结合使用
如果我们不初始化一个智能指针,它就会被初始化为一个空指针。
我们还可以用 new 返回的指针来初始化智能指针:
shared_ptr<double> p1; // p1 是空指针
shared_ptr<int> p2(new int(42)); // p2 指向一个值为 42 的 int
接受指针参数的智能指针构造函数是 explict 的,因此,我们不能将一个内置指针隐式转换为一个智能指针,必须使用直接初始化形式来初始化一个智能指针:
shared_ptr<int> p1 = new int(1024); // 编译错误 必须使用直接初始化
shared_ptr<int> p2(new int(1024)); // compile ok
出于相同的原因,一个返回 shared_ptr 的函数不能在其返回语句中隐式转换一个普通指针。
默认情况下,一个用来初始化智能指针的普通指针必须指向动态内存,因为智能指针默认使用 delete 释放它所关联的对象。
我们可以将智能指针绑定到一个指向其他类型的资源的指针上,但是为了这样做,必须提供自己的操作来替代 delete。
定义和改变 shared_ptr 的其他方法 | ||
---|---|---|
shared_ptr |
p 管理内置指针 q 所指向的对象;q 必须指向 new 分配的内存,且能够转换为 T* 类型 | |
shared_ptr |
p 从 unique_ptr 哪里接管了对象的所有权,将 u 置为空 | |
shared_ptr |
p 接管了内置指针 q 所指向的对象的所有权。q 必须能转换为 T* 类型。p 将使用可调用对象 d 来代替 delete d 的作用在于扩宽了 shared_ptr 的使用场景,有特殊析构的要求的对象,都可以交给 shared_ptr 关联 |
|
shared_ptr |
p 是 shared_ptr p2 的拷贝,唯一的区别是 p 将用可调用对象 d 来代替 delete | |
p.reset() p.reset(q) p.reset(q, d) |
若 p 是唯一指向其对象的 shared_ptr,reset 会释放此对象。若传递了可选的参数内置指针 q,会令 p 指向 q,否则会将 p 置为空。若还传递了参数 d,将会调用 d 而不是 delete 来释放 q 指向的对象 reset 重新为 shared_ptr 指定指向的对象 |
不要混合使用普通指针和智能指针
shared_ptr 可以协调对象的析构,但这仅限于其自身的拷贝(也是 shared_ptr)之间。这也是为什么我们推荐 make_shared 而不是 new 的原因。这样,我们就能在分配对象的同时就将 shared_ptr 与之绑定,从而避免了无意中将同一块内存绑定到多个独立创建的 shared_ptr 上。
考虑下面对 shared_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 = make_shared<int>(42);
process(p);
int i = *p; // 正确 引用计数值为 1
虽然不能传递给 process 一个内置指针,但可以传递给它一个(临时的)shared_ptr,这个 shared_ptr 使用一个内置指针显示构造的。但是,这样做很可能会导致错误:
int* x(new int(42));
process(shared_ptr<int>(x)); // process 结束后 shared_ptr 会释放 x 指向的对象
int i = *x; // 未定义的行为, x 现在是一个空悬指针
在上面的调用中,我们将一个临时的 shared_ptr 传递给 process。当这个调用所在的表达式结束时,这个临时对象就被销毁了。销毁这个临时变量会递减引用计数,此时引用计数就变为 0 了。因此,当临时对象被销毁时,它所指向的内存会被释放。
但 x 继续指向(已经释放的)内存,从而变成一个空悬指针。如果试图使用 x 的值,其行为是未定义的。
当将一个 shared_ptr 绑定到一个普通指针时,我们就将内存的管理责任交给了这个 shared_ptr。一旦这样做了,我们就不应该再使用内置指针来访问 shared_ptr 所指向的内存了。
使用一个内置指针来访问一个智能指针所负责的对象是很危险的,因为我们无法知道对象何时会被销毁。
也不要使用 get 初始化另一个智能指针或为智能指针赋值
智能指针类型定义了一个名为 get 的函数,它返回一个内置指针,指向智能指针管理的对象。
此函数是为了这样一种情况设计的:我们需要向不能使用智能指针的代码传递一个内置指针。使用 get 返回的指针的代码不能 delete 此指针。
虽然编译器不会给出错误信息,但将另一个智能指针也绑定到 get 返回的指针是错误的:
shared_ptr<int> p(new int(42));
int* q = p.get();
{
// 新程序块
shared_ptr<int>(q);
} // 程序块结束 q 被销毁,它指向的内存被释放
int foo = *p; // 产生未定义的结果
在本例中, p 和 q 指向相同的内存。由于它们是相互独立创建的,因此各自的引用计数都是 1。当 q 所在的程序块结束时,q 被销毁,这会导致 q 指向的内存被释放。从而 p 变成一个空悬指针,意味着当我们试图使用 p 时,将发生未定义的行为。而且,当 p 被销毁时,这块内存会被第二次 delete。
get 用来将指针的访问权限传递给代码,只有在确定代码不会 delete 指针的情况下,才能使用 get。特别是,永远不要用 get 初始化另一个智能指针,或者为另一个智能指针赋值。
其他 shared_ptr 操作
shared_ptr 还定义了其他一些操作,我们可以用 reset 来将一个新的指针赋予一个 shared_ptr:
sp = new int(1024); // 错误 不能将一个内置指针赋值给 shared_ptr
sp.reset(new int(1024)); // 正确 sp 指向新的对象
与赋值类型,reset 会更新引用计数,如果需要的话,会释放 p 指向的对象。reset 成员经常与 unique 一起使用,来控制多个 shared_ptr 共享的对象。在改变底层对象之前,我们检查自己是否是当前对象仅有的用户,如果不是,在改变之前要制作一份新的拷贝。
if(!sp.unique())
{
sp.reset(new string(*sp)); // 如果 sp 不是唯一的值,那么为 sp 单独做一份拷贝
}
*sp += new_val; // 这样在修改 sp 的值的时候,不会影响到与之前 sp 指向相同对象的 shared_ptr
智能指针和异常
在异常处理过程中,程序能够在异常发生后令程序流程继续。我们注意到,这种程序需要确保在异常发生后资源能被正确的释放。一个简单的确保资源被释放的方法是使用智能指针。
如果使用智能指针,即使程序块过早结束,智能指针类也能确保在内存不在需要时将其释放。
智能指针和哑类
包括所有标准库在内的很多 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 中保存的指针进行释放的操作。在本例中,我们的删除器必须接受单个类型为 connection* 的参数:void end_connection(connection* p){ disconnect(*p); }
当我们创建一个 shared_ptr 时,可以传递一个(可选的)指向删除器函数的参数:
void f(destionation& d)
{
connection c = connect(d);
shared_ptr<connection> cp(&c, end_connection);
// 使用连接
// 当 f 退出时(即使是由于异常而退出),connection 会被正确关闭
}
当 cp 被销毁时,它不会对自己保存的指针执行 delete,而是调用 end_connection。接下来,end_connection 会调用 disconnect,从而确保连接被关闭。如果 f 正常退出,那么 p 的销毁会作为结束处理的一部分。如果发生了异常, p 同样会被销毁,从而连接被关闭。
注意:智能指针陷阱
智能指针可以提供对动态分配的内存安全而又方便的管理,但这建立在正确使用的前提下。为了正确使用智能指针,我们必须坚持一些基本规范:
- 不使用相同的内置指针值初始化(或 reset)多个智能指针。
- 不 delete get() 返回的指针。
- 不使用 get() 初始化或 reset 另一个智能指针。
- 如果使用 get() 返回的指针,记住当最后一个对应的智能指针销毁后,这个指针就变为无效了。
- 如果使用智能指针管理的资源不是 new 分配的内存,需要传递给它一个删除器。
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<string> p1(new string("hello world"));
unique_ptr<string> p2(p1); // 错误 unique_ptr 不支持拷贝
unique_ptr<string> p3;
p3 = p1; // 错误 unique_ptr 不支持赋值
unique_ptr 操作 | ||
---|---|---|
unique_ptr unique_ptr<T, D> u2 |
空 unique_ptr,可以指向类型为 T 的对象。u1 会使用 delete 来释放它的指针; u2 会使用一个类型为 D 的可调用对象来释放它的指针 |
|
unique_ptr<T, D> u(d) | 空 unqiue_ptr,指向类型为 T 的对象,用类型为 D 的对象 d 代替 delete | |
u = nullptr | 释放 u 指向的对象,将 u 置为空 | |
u.release() | u 放弃对指针的控制权,返回指针,并将 u 置为空 | |
u.reset() u.reset(q) u.reset(nullptr) |
释放 u 指向的对象 如果提供了内置指针 q,令 u 指向这个对象;否则将 u 置为空 |
虽然我们不能拷贝或赋值 unique_ptr,但是可以通过调用 release 或 reset 将指针的所有权从一个(非 const)unique_ptr 转移给另一个 unique_ptr:
unique_ptr<string> p2(p1.release()); // release 将 p1 置为空,将 p1 的对象所有权转移给 p2
unique_ptr<string> p3(new string("moring"));
// p3 将所有权转移给 p2
p2.reset(p3.release()); // reset 会释放原来 p2 指向的内存
release 成员返回 unique_ptr 当前保存的指针并将其置为空。
reset 成员接受一个可选的指针参数,令 unique_ptr 重新指向给定的指针。如果 unique_ptr 不为空,他原来指向的对象被释放。
调用 release 会切断 unique_ptr 和它原来管理的对象间的联系。release 返回的指针通常被用来初始化另一个智能指针或给另一个智能指针赋值。
传递 unique_ptr 参数和返回 unique_ptr
不能拷贝 unique_ptr 的规则有一个例外:我们可以拷贝或赋值一个将要被销毁的 unique_ptr。最常见的例子是从函数返回一个 unique_ptr。
向 unique_ptr 传递删除器
类似 shared_ptr,unique_ptr 默认情况下用 delete 释放它指向的对象。与 shared_ptr 一样,我们可以重载一个 unique_ptr 中默认的删除器。
重载一个 unique_ptr 中的删除器会影响到 unique_ptr 类型以及如何构造(或 reset)该类型的对象。与重载关联容器的比较操作类似,我们必须在尖括号中 unique_ptr 指向的类型之后提供删除器类型。在创建或 reset 一个这种 unique_ptr 类型的对象时,必须提供一个指定类型的可调用对象(删除器):unique_ptr<objT, delT> p (new objT, fcn);
示例
void f(destination& d) {
auto c = connect(&d);
unique_ptr<connection, decltype(end_connection)*> p(&c, end_connection);
// 使用 p
// 当 f 结束时,p 指向的 connection 会正确关闭
}
在上面的例子中使用了 decltype 来指明函数指针类型。由于 decltype(end_connection)
返回一个函数类型,所以我们必须添加一个 * 来指出正在使用的是该类型的一个指针。
weak_ptr
weak_ptr 是一种不控制所指向对象生存期的智能指针,它指向一个 shared_ptr 管理的对象。将一个 weak_ptr 绑定到一个 shared_ptr 不会改变 shared_ptr 的引用计数。一旦最后一个指向对象的 shared_ptr 被销毁,对象就会被释放。
因此,weak_ptr 的名字抓住了这种智能指针“弱”共享对象的特点。
weak_ptr | ||
---|---|---|
weak_ptr |
空 weak_ptr 可以指向类型为 T 的对象 | |
weak_ptr |
与 shared_ptr sp 指向相同对象的 weak_ptr,T 必须能转换为 sp 指向的类型 | |
w = p | p 可以是一个 shared_ptr 或一个 weak_ptr。赋值后 w 与 p 共享对象 | |
w.reset() | 将 w 置为空 | |
w.use_count() | 与 w 共享对象的 shared_ptr 的数量 | |
w.expired() | 若 w.use_count() 为 0,返回 true,否则返回 false | |
w.lock() | 如果 expired 为 true,返回一个空 shared_ptr;否则返回一个指向 w 的对象的 shared_ptr |
当我们创建一个 weak_ptr 时,要用一个 shared_ptr 来初始化它:
auto p = make_shared(42);
weak_ptr<int> wp(p);
wp 和 p 指向相同的对象。由于是弱共享,创建 wp 不会改变 p 的引用计数;wp 指向的对象可能被释放掉。
由于对象可能不存在,我们不能使用 weak_ptr 直接访问对象,而必须使用 lock。
lock 函数检查 weak_ptr 指向的对象是否仍存在。如果存在,lock 返回一个指向共享对象的 shared_ptr。与其他 shared_ptr 类似,只要此 shared_ptr 存在,它所指向的底层对象也就会一直存在。例如:if(shared_ptr<int> np = wp.lock())
如果 np 不为空则条件成立,在 if 条件中,np 和 wp 指向相同的对象。
上面这个判断语句是经典代码, C++ Primer 推荐在使用 weak_ptr 时进行校验的判断。
在这段代码中,只有当 lock 调用返回 true 我们才会进入 if 的语句体,在 if 中,使用 np 访问共享对象是安全的。
weak_ptr 是很有必要的,避免重复的 shared_ptr 造成循环引用。
动态数组
new 和 delete 运算符一次分配/释放一个对象,但某些应用需要一次为很多对象分配内存的功能。例如:vector 和 string 都是在连续内存中保存它们的元素,因此,当容器需要重新分配内存时,必须一次性为很多元素分配内存。
C++ 语言和标准库提供了两种一次分配一个对象数组的方法。C++ 语言定义了另一种 new 表达语法,分配并初始化一个对象数组。标准库中包含一个名为 allocator 的类,允许我们将分配和初始化分离。使用 allocator 通常会提供更好的性能和更灵活的内存管理能力。
大多数应用应该使用标准容器而不是动态分配的数组。使用容器更为简单、更不容易出现内存管理错误并且可以有更好的性能。
new 和数组
为了让 new 分配一个对象数组,我们要在类型名之后跟一对括号,在其中指明要分配的对象的数目。
new 分配要求数量的对象并(假定分配成功后)返回指向第一个对象的指针:int* pia = new int[get_size()];
方括号中的大小必须是整型,但不必是常量。
分配一个数组会得到一个元素类型的指针
我们通常称 new T[] 分配的内存为“动态数组”。但当用 new 分配一个数组时,我我们并未得到一个数组类型的对象,而是得到一个数组元素类型的指针。
由于分配的内存并不是一个数组类型,因此不能对动态数组调用 begin 或 end。这些函数使用数组维度来返回首元素和尾后元素的指针。
要记住我们所说的动态数组并不是数组类型,这是很重要的。
初始化动态分配对象的数组
new 分配的对象,不管是单个分配的还是数组中的,都是默认初始化的。可以对数组中的元素进行值初始化,方法是在大小之后跟一对空括号:
// 10 个未初始化的 int
int* pia = new int[10];
// 10 个值初始化为 0 的 int
int* pia2 = new int[10]();
// 10 个空 string
string* psa = new string[10];
// 10 个空 string
string* psa2 = new string[10]();
在新标准中,我们还可以提供一个元素初始化器的花括号列表:
// 列表初始化
int* pia3 = new int[10]{ 0,1,2,3,4,5,6,7,8,9 };
// 前 4 个 string 用列表初始化,后续的是值初始化为空 string
string* psa3 = new string[10]{ "a","an","the",string(3,'a') };
与内置数组的对象初始化一样,初始化器会用来初始化动态数组中开始部分的元素。如果初始化器数目小于元素数目,剩余元素将进行值初始化,如果列表初始化数目大于元素数目,则 new 表达式失败,不会分配任何内存。
动态分配一个空数组是合法的
当我们用 new 分配一个大小为 0 的数组时,new 返回一个合法的非空指针。
对于零长度的数组来说,此指针就像尾后指针一样,我们可以像使用尾后迭代器一样使用这个指针。
释放动态数组
为了释放动态数组,我们使用一种特殊形式的 delete ——在指针前加上一个方括号对:
delete p; // p 必须指向一个动态分配的对象或为空
delete[] pa; // p 必须指向一个动态分配的数组或为空
第二条语句销毁 pa 指向的数组中的元素,并释放对应的内存。数组中的元素按逆序销毁,即,最后一个元素首先被销毁,然后是倒数第二个,以此类推。
当我们释放一个指向数组的指针时,空方括号是必需的:它指示编译器此指针指向一个对象数组的第一个元素。如果我们在 delete 一个指向数组的指针时忽略了方括号(或者在 delete 一个指向单一对象的指针时使用了方括号),其行为是未定义的。
如果我们在 delete 一个数组指针时忘记了方括号,或者在 delete 一个单一对象的指针时使用了方括号,编译器很可能不会给出警告。我们的程序可能在执行过程中在没有任何警告的情况下行为异常。
智能指针和动态数组
标准库提供了一个可以管理 new 分配的数组的 unique_ptr 版本。为了用一个 unique_ptr 管理动态数组,我们必须在对象类型后面跟一对空方括号。
unique_ptr<int[]> up(new int[10]);
up.release();
类型说明符中的方括号<int[]>
指出 up 指向一个 int 数组而不是一个 int。由于 up 指向一个数组,当 up 销毁它管理的指针时,会自动使用 delete []
。
指向数组的 unique_ptr 提供的操作与常规的 unique_ptr 有所不同。当 unique_ptr 指向动态数组时,我们不能使用点和箭头运算符。毕竟,unique_ptr 指向的是一个数组而不是单个对象,因此这些运算符是无意义的。另一方面,当一个 unique_ptr 指向一个数组时,我们可以使用下标运算符来访问数组中的元素。
unique_ptr<int[]> up(new int[10] {0, 1, 2, 3, 4, 5});
for (size_t i = 0; i < 10; i++)
{
cout << up[i] << endl;
}
指向数组的 unique_ptr 支持的操作 | ||
---|---|---|
指向数组的 unique_ptr 不支持成员访问运算符(点和箭头运算符) | ||
unique_ptr<T[ ]> u | u 可以指向一个动态分配的数组,数组元素类型为 T | |
unique_ptr<T[ ]> u(p) | u 指向内置指针 p 所指向的动态分配的数组。p 必须能转换为类型 T* | |
u[i] | 返回 u 拥有的数组中位置 i 处的对象 u 必须指向一个数组 |
与 unique_ptr 不同,shared_ptr 不直接支持管理动态数组。如果希望使用 shared_ptr 管理一个动态数组,必须提供自己定义的删除器:shared_ptr<int> sp(new int[10], [](int* p){delete [] p;});
shared_ptr 是 <int>
区别于 unique_ptr 的 <int[]>
如果未提供删除器,这段代码将是未定义的。默认情况下,shared_ptr 使用 delete 销毁它的对象。如果此对象是一个动态数组,对其使用 delete 所产生的问题与释放一个动态数组指针时忘记 [] 产生的问题一样。
shared_ptr 不直接支持动态数组管理这一特性会影响我们如何访问数组中的元素:
shared_ptr<int> sp(new int[10] {0, 1, 2, 3, 4, 5}, [](int* p) {delete[] p; });
for (size_t i = 0; i < 10; i++)
{
cout << *(sp.get() + i) << endl;
}
shared_ptr 未定义下标运算符,而且智能指针不支持指针算术运算。因此,为了访问数组中的元素,必须用 get 获取一个内置指针,然后用它来访问数组元素。
allocator 类
new 有一些灵活性上的局限,其中一方面表现在它将内存分配和对象构造组合在了一起。类似的,delete 将对象析构和内存释放组合在了一起。
这样会导致那些没有默认构造函数的类不能动态分配数组。
allocator 类
标准库 allocator 类定义在头文件 memory 中,它帮助我们将内存分配和对象构造分离开来。它提供一种类型感知的内存分配方法,它分配的内存是原始的、未构造的。
allocator 是一个模板。为了定义一个 allocator 对象,我们必须指明这个 allocator 可以分配的对象类型。当一个allocator 对象分配内存时,它会根据给定的对象类型来确定恰当的内存大小和对齐位置:
allocator<string> alloc;
auto const p = alloc.allocate(n);
这个 allocate 调用为 n 个 string 分配了内存。
标准库 allocator 类及其算法 | ||
---|---|---|
allocator |
定义了一个名为 a 的 allocator 对象,它可以为类型为 T 的对象分配内存 | |
a.allocate(n) | 分配一段原始的,未构造的内存,保存 n 个类型为 T 的对象 | |
a.deallocate(p, n) | 释放从 T* 指针 p 中地址开始的内存,这块内存保存了 n 个类型为 T 的对象;p 必须是一个先前由 allocate 返回的指针,且 n 必须是 p 创建时所要求的大小。在调用 deallocate 之前,用户必须对每个在这块内存中创建的对象调用 destory | |
a.construst(p, args) | p 必须是一个类型为 T* 的指针,指向一块原始内存;args 被传递给类型为 T 的构造函数,用来在 p 指向的内存中构造一个对象 | |
a.destory(p) | p 为 T* 类型的指针,此算法对 p 指向的对象执行析构函数 |
allocator 分配未构造的内存
allocator 分配的内存是未构造的 (unconstructed)。我们按需要在此内存中构造对象。在新标准中,construct 成员函数接收一个指针和零个或多个额外参数,在给定位置构造一个元素。额外参数用来初始化构造的对象。
auto q = p;
alloc.construct(q++);
alloc.construct(q++,10,'c');
alloc.construct(q++, "hi");
为了使用 allocate 返回的内存,我们必须用 construct 构造对象。使用未构造的内存,其行为是未定义的。
当我们用完对象后,必须对每个构造的元素调用 destory 来销毁它们。函数 destory 接受一个指针,对指向的对象执行析构函数。
while (q != p) {
alloc.destroy(--q);
}
我们只能对真正构造了的元素进行 destory 操作。
一旦元素被销毁后,就可以重新使用这部分内存来保存其他 string 也可以将其归还给系统。释放内存通过调用 deallocate 来完成:
alloc.deallocate(p, n);
我们传递给 deallocate 的指针不能为空,它必须执行由 allocate 分配的内存。而且,传递给 deallocate 的大小参数必须与调用 allocate 分配内存时提供的大小参数具有一样的值。
拷贝和填充未初始化内存的算法
标准库还为 allocator 类定义了两个伴随算法,可以在未初始化内存中创建对象。下表描述了这些函数,都定义在 memory 头文件中。
allocator 算法 这些函数在给定目的位置创造元素,而不是由系统分配内存给它们 |
||
---|---|---|
uninitialized_copy(b, e, b2) | 从迭代器 b 和 e 指出的输入范围中拷贝元素到迭代器 b2 指定的未构造的原始内存中。b2 指向的内存必须足够大,能容纳输入序列中元素的拷贝 | |
uninitialized_copy_n(b, n, b2) | 从迭代器 b 指向的元素开始,拷贝 n 个元素到 b2 开始的内存中 | |
uninitialized_fill(b, e, t) | 在迭代器 b 和 e 指定的原始内存范围中创建对象,对象的值均为 t 的拷贝 | |
uninitialized_fill_n(b, n, t) | 从迭代器 b 指向的内存地址开始创建 n 个对象。b 必须指向足够大的未构造的原始内存,能够容纳给定数量的对象 |
作为一个例子,假定一个 int 的 vector,希望将其内容拷贝到动态内存中。我们将分配一块比 vector 中元素所占用空间大一倍的动态内存,然后将原 vector 中的元素拷贝到前一半空间,对后一半空间用一个给定值进行填充:
vector<int> vec{ 1,3,5,7,9 };
allocator<int> alloc;
auto p = alloc.allocate(vec.size() + vec.size());
auto q = uninitialized_copy(vec.cbegin(), vec.cend(), p);
uninitialized_fill_n(q, vec.size(), 10);
类似拷贝算法,uninitialized_copy 接受三个迭代器参数。前两个表示输入序列,第三个表示这些元素将要拷贝到的目的空间。传递给 uninitialized_copy 的目的位置迭代器必须指向未构造的内存。与 copy 不同,uninitialized_copy 在给定目的位置构造元素。
类似 copy,uninitialized_cpoy 返回(递增后的)目的位置迭代器。因此,一次 uninitialized_copy 调用会返回一个指针,指向最后一个构造的元素之后的位置。
示例代码
// initialize a method object
allocator<int> allc;
int n = 10;
// allocate a memeroy
int* const p = allc.allocate(n);
int* q = p;
for (size_t i = 0; i < n; i++)
{
// construct
allc.construct(q++, i);
}
for (auto beg = p; beg < q; beg++)
{
// do something
cout << *beg << endl;
}
while (q != p) {
// destory
allc.destroy(--q);
}
// deallocate
allc.deallocate(p, n);
分配动态内存的程序应负责释放它所分配的内存。