第 12 章 动态内存
标签: C++Primer 学习记录 动态内存
12.1 动态内存与智能指针
-
不同的存储区域对应着不同生存周期的变量。
- 静态内存——保存局部 static对象、类 static数据成员和定义在任何函数之外的变量,在第一次使用之前分配内存,在程序结束时销毁。
- 栈内存——定义在函数内的非 static对象,当进入其定义所在的程序块时被创建,在离开块时被销毁。
- 堆内存——存储动态分配的对象,即那些在程序运行时分配的对象。当动态对象不再使用时,必须由代码显式地销毁它们。
-
动态内存的使用很容易出问题。有时我们会忘记释放内存,在这种情况下就会产生内存泄漏;有时在尚有指针引用内存的情况下我们就释放了它,在这种情况下就会产生引用非法内存的指针。
-
为了更容易和安全地使用动态内存,新标准库提供了智能指针类型来管理动态对象。
- shared_ptr,允许多个指针指向同一个对象。
- unique_ptr,“独占”所指向的对象。
- weak_ptr,弱引用,不控制所指向对象的生存期,指向 shared_ptr所管理的对象。
-
默认初始化的 shared_ptr对象是一个空指针,在使用之前需要进行初始化。
shared_ptr<string> p1; // 空指针,使用之前需要初始化 shared_ptr<string> p2 = make_shared<string>("temp"); auto p3 = make_shared<string>("temp"); shared_ptr<string> p4(new string("temp"));
-
因为在最后一个 shared_ptr销毁前,内存都不会释放,因此如果忘记销毁程序不再需要的 shared_ptr,程序仍会正确执行,但会浪费运行内存。一个例子就是将 shared_ptr存放于一个容器中,而后不再需要全部元素,而只是使用其中一部分,要记得掉用容器的 erase操作删除不再需要的元素。
-
程序使用动态内存,往往出于以下三种原因之一:
- 程序不知道自己需要使用多少对象,比如说容器类。
- 程序不知道所需对象的准确类型,可以 new一个基类指针用来指向派生类对象。
- 程序需要在多个对象间共享数据,一般情况下对象的拷贝都是类值拷贝,会发生对象的拷贝构造和析构;而使用动态内存共享数据,则是类指针拷贝,所存储的数据没有发生变化,只是新定义一个指针来指向这些已有数据。
-
在自由空间分配的内存是无名的,因此 new无法为其分配的对象命名,而是返回一个指向该对象的指针。
int *pi = new int; // pi是一个指向动态分配的、未初始化的无名对象
默认情况下,动态分配的对象是默认初始化的,这意味着内置类型或组合类型的对象的值将是未定义的,而类类型对象将用默认构造函数进行初始化。因此,对动态分配的对象进行初始化通常是个好主意。
string *ps = new string; // 初始化为空 string int *pi = new int; // pi指向一个未初始化的 int
- 可以使用直接初始化(圆括号、花括号)的方式或值初始化(空的圆括号)来初始化一个动态分配的对象。
int *pi = new int(1024); int *pi2 = new int(); // 值初始化为 0,*pi2的值为 0 vector<int> *ps = new vector<int>{1, 2, 3};
- 如果提供了一个括号包围的初始化器,就可以使用 auto从此初始化器来推断出我们想要分配的对象的类型。也因为编译器要用初始化器来推断出想要分配的对象的类型,括号中只能有一个初始化器。
auto p1 = new auto(obj); // p1指向一个与 obj类型相同的对象 auto p2 = new auto{a, b}; // 错误
- 用 new分配 const对象是合法的,但是动态分配的 const对象必须进行初始化。对于一个定义了默认构造函数的类类型,其 const动态对象可以隐式初始化,而其他类型的对象就必须显式初始化。
const int *pci = new const int(1024); // const作为类型的一部分,也要出现在 new的后面 const string *pcs = new const string; // 默认初始化一个 const的空 string
- 默认情况下,如果 new不能分配所要求的内存空间,会抛出一个类型为 bad_alloc的异常,可以使用定位 new形式并向其传递参数 nothrow来阻止它抛出异常。此时它会返回一个空指针。
// 如果分配失败,抛出bad_alloc异常 int *p1 = new int(); // 如果分配失败,返回空指针 int *p2 = new (nothrow) int();
-
释放一块并非 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内存。
- 使用已释放掉的对象。通过在释放内存后将指针置为空,在使用前检测指针是否为空,可以避免这种错误。
- 同一块内存被释放两次。
-
空悬指针,指向一块曾经保存数据对象但现在已经无效的内存的指针。当我们 delete一个指针后,指针值就无效了。虽然指针已经无效,但在很多机器上指针仍然保存在(已经释放了的)动态内存的地址。有一种方法可以避免空悬指针的问题:在指针即将离开其作用域之前释放掉它所关联的内存,而如果需要保留指针,可以在 delete之后将 nullptr赋予指针,这样就清楚地指出指针不指向任何对象。
int *p(new int(42));
delete p;
p = nullptr;
- 可以用 new返回的指针来初始化智能指针,但该接受指针参数的智能指针构造函数是 explicit的。因此,不能将一个内置指针隐式转换为一个智能指针,必须使用直接初始化形式来初始化一个普通指针。
shared_ptr<int> p1 = new int(1024); // 错误,必须使用直接初始化形式
shared_ptr<int> p2(new int(1024)); // 正确
shared_ptr<int> clone(int p) {
return new int(p); // 错误,隐式转换为 shared_ptr<int>
}
- 默认情况下,一个用来初始化智能指针的普通指针必须指向动态内存,因为智能指针默认使用 delete释放它所关联的对象。也可以将智能指针绑定到一个指向其他类型的资源的指针上,但是我们必须提供自己的操作来代替 delete。
- 轻易不要使用一个内置指针来访问一个智能指针所负责的对象,因为我们无法知道对象何时会被销毁。
// 在函数被调用时 ptr被创建并初始化
void process(shared_ptr<int> ptr)
{
// 使用 ptr
} // ptr离开作用域,被销毁
// 使用此函数的正确方法是给它传递一个 shared_ptr
shared_ptr<int> p(new int(42)); // 引用计数为 1
process(p); // 值拷贝 p会递增它的引用计数;在 process中引用计数值为 2
int i = *p; // 正确,引用计数为 1
// 在传递一个临时的 shared_ptr后,就不能再用内置指针访问之前的内存了
int *x(new int(1024));
process(x); // 错误,不能将 int*转换为一个 shared_ptr<int>
process(shared_ptr<int>(x)) // 合法,但执行完此行代码后,智能指针所指向的内存会被释放!
int j = *x; // 错误, x是一个空悬指针
- get用来将指针的访问权限传递给代码,只有在确定代码不会 delete指针的情况下,才能使用 get。特别是,永远不要用 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所指向的内存已经被释放了
- 可以用 reset来将一个新的指针赋予一个 shared_ptr。在改变底层对象之前,要检查自己是否是当前对象仅有的用户,可以通过
unique
来完成。如果不是,在改变之前要制作一份新的拷贝。
if (!p.unique())
p.reset(new string(*p)); // 不是唯一用户,需要分配新的拷贝
*p += newVal; // 现在可以确定自己确定是唯一用户,可以改变对象的值
- 使用智能指针可以确保程序在异常发生后资源能被正确地释放,与之相对,直接使用内置指针管理动态内存,当在 new之后且对应的 delete之前发生了异常,则内存不会被释放,造成内存泄漏。另外,对于没有良好定义的析构函数的类对象,也可以使用智能指针来管理,不管是否发生异常,当智能指针类对象不再使用时,会调用相应的删除器函数进行内存回收。
void f()
{
shared_ptr<int> sp(new int(42));
// 这段代码抛出一个异常,且在 f中未捕获
} // 在函数结束时 shared_ptr自动释放内存
void f()
{
int *ip = new int(42);
// 这段代码抛出一个异常,且在 f中未捕获
delete ip; // 在退出之前释放内存
} // 内存将永远都不会被释放
- 智能指针可以提供对动态分配的内存安全而又方便的管理,但这也需要坚持一些基本规范:
- 不使用相同的内置指针初始化(或 reset)多个智能指针。
- 不 delete get()返回的指针。
- 不使用 get()初始化或 reset另一个智能指针,这可能会造成二次 delete。
- 当使用 get()返回的指针时,当最后一个对应的智能指针销毁后,get()返回的指针就变为无效了。
- 当使用智能指针来管理不是 new分配的内存资源时,记住传递给它一个删除器。
-
对于 shared_ptr类模板,删除器是类模板的 function数据成员,可以通过拷贝构造函数或 reset函数进行更改。而 unique_ptr的删除器是一个具有默认模板实参的模板类型参数,在定义一个 unique_ptr时就要一并给出。
-
在某个时刻只能有一个 unique_ptr指向一个给定对象。当定义一个 unique_ptr时,需要将其绑定到一个 new返回的指针上。由于一个 unique_ptr独占它所指向的对象,因此 unique_ptr不支持普通的拷贝或赋值操作。
unique_ptr<int> p1(new int(42));
unique_ptr<int> p2(p1); // 错误, unique_ptr不支持拷贝
unique_ptr<int> p3;
p3 = p2; // 错误, unique_ptr不支持赋值
- 虽然 unique_ptr不能被拷贝或赋值,但可以通过 release或 reset来将指针的所有权从一个 unique_ptr转移到另一个 unique_ptr。
unique_ptr<int> p1(new int(42));
// release将 p1置为空,将所有权从 p1转移给 p2
unique_ptr<int> p2(p1.release());
unique_ptr<int> p3(new int(0));
// release将 p1置为空,reset将 p2置为空,再将所有权从 p3转移给 p2
p2.reset(p3.release());
p2.release(); // 错误, p2不会释放内存,而且丢失了指针
auto p = p2.release(); // 正确,但是要记得 delete(p)
- 不能拷贝 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,删除器是类型的一部分,默认的删除器是 delete。但是要想重载删除器,必须在创建 unique_ptr对象时,就要提供一个指定类型的可调用对象(删除器)。
// p指向一个类型为 objT的对象,并使用一个类型为 delT的对象释放 objT对象
// 它会调用一个名为 fcn的 delT类型对象
unique_ptr<objT, delT> p (new objT, fcn);
- weak_ptr,不控制所指向对象生存期的智能指针,指向由一个 shared_ptr管理的对象。将一个 weak_ptr绑定到一个 shared_ptr,不会改变 shared_ptr的引用计数。一旦最后一个指向对象的 shared_ptr被销毁,对象就会被释放,而不管是否有 weak_ptr指向该对象。
- 创建一个 weak_ptr时,要用一个 shared_ptr来初始化它。
auto p = make_shared<int>(42);
weak_ptr<int> wp(p); // wp弱共享,p的引用计数为改变
- 由于对象可能不存在,因此我们不能够使用 weak_ptr直接访问对象,而必须调用 lock来检查 weak_ptr指向的对象是否存在。
if (shared_ptr<int> np = wp.lock()) { // 如果 np不为空,则条件成立
// 在 if中,np与 p共享对象
}
12.2 动态数组
-
在新标准下,当一个应用需要可变数量的对象时,应该使用标准库容器而不是动态分配的数组,使用容器更为简单、更不容易出现内存管理错误并且可能有着更好的性能。
-
可以使用 new T[]或类型别名的形式分配一个动态对象数组,默认情况下,该数组是未初始化的。方括号中的大小必须是整数,但不必是常量。
// pia指向第一个 int int *pia = new int[get_size()]; typedef int arrT[42]; int *p = new arrT; // 分配一个 42个 int的数组,p指向第一个 int
- 使用 new分配一个数组会得到一个元素类型的指针,动态数组的长度可变,而对于普通数组类型而言,维度是数组类型的一部分,因此动态数组并不是数组类型。不能对动态数组调用 begin或 end函数,也不能用范围 for语句来处理动态数组中的元素。
- 普通数组的长度不能为 0,而动态数组的长度可以为 0。相当于定义了一个尾后指针,此指针可以执行比较操作,但是不能解引用。
char arr[0]; // 错误,不能定义长度为 0的数组 char *cp = new char[0]; // 正确,但 cp不能解引用
- 默认情况下,new分配的对象,不管是单个分配的还是数组中的,都是默认初始化的。对数组中的元素进行值初始化,可以再大小之后跟一对空括号。与分配单个对象不同,分配数组对象,不能在圆括号内指定初始值。但是可以在花括号内提供元素初始化器,具体规则与使用大括号初始化内置数组类似。无法用 auto分配数组。
int *pia = new int[10]; // 10个未初始化的 int int *pia2 = new int[10](); // 10个值初始化为 0的 int int *pia3 = new int[10](1); // 错误,不能在圆括号内指定初始值 int *pia4 = new int[10]{0, 1, 2}; // 在列表中给定初始化器 auto *pia5 = new auto[10](); // 错误,未给出初始化器 auto *pia6 = new auto[10]{0, 1, 2}; // 错误,花括号括起来的初始值无法与 new auto配合使用
-
unique_ptr可以直接管理动态数组,但必须在对象类型后面跟上一对空方括号。unique_ptr不支持点和箭头运算符,因为其指向的是一个数组而不是元素,这些操作没有意义。unique_ptr支持下标运算符。
unique_ptr<int[]> up(new int[10]); up[1] = 2; // 使用下标运算符访问元素
-
shared_ptr不直接支持管理动态数组,这是因为 shared_ptr默认是用 delete作为删除器,而动态数组的析构,需要使用 delete[]。因此,在使用 shared_ptr管理动态数组时,必须提供自己的删除器。另外,shared_ptr不支持下标运算。
shared_ptr<int> sp(new int[10], [](int *p) { delete [] p; }); *(sp.get() + 1) = 2; // 使用 get()返回内置指针,用这个指针来访问元素
-
new将内存分配和对象构造组合在了一起,delete将对象析构和内存释放组合在了一起。再分配单个对象时,因为几乎知道对象应该有什么值,所以我们希望将内存分配和对象构造组合在一起。而对于大块内存分配时,将内存分配和对象构造组合在一起,可能会造成不必要的浪费(多次赋值,一次在默认初始化时,一次在使用时)。更重要的是,如果一个类没有默认构造函数,就无法为其分配动态数组!
-
allocator类将 new和 delete的功能都分了开来,主要包括分配内存、构造对象、对象析构和内存释放。
allocator<string> alloc; auto const p = alloc.allocate(n); // 分配 n个未初始化的 string auto q = p; // 构造 string对象后,将 q后移一位,使 q永远指向最后构造的元素之后的位置 alloc.construct(q++, "hi"); // 对象析构,只能对真正构造了的元素进行 destroy操作 while (q != p) alloc.destroy(--q); // 释放内存 alloc.deallocate(p, n);