[关闭]
@taqikema 2018-01-09T13:24:18.000000Z 字数 7388 阅读 1186

第 12 章 动态内存

C++Primer 学习记录 动态内存



12.1 动态内存与智能指针

  1. 不同的存储区域对应着不同生存周期的变量。

    • 静态内存——保存局部 static对象、类 static数据成员和定义在任何函数之外的变量,在第一次使用之前分配内存,在程序结束时销毁。
    • 栈内存——定义在函数内的非 static对象,当进入其定义所在的程序块时被创建,在离开块时被销毁。
    • 堆内存——存储动态分配的对象,即那些在程序运行时分配的对象。当动态对象不再使用时,必须由代码显式地销毁它们。
  2. 动态内存的使用很容易出问题。有时我们会忘记释放内存,在这种情况下就会产生内存泄漏;有时在尚有指针引用内存的情况下我们就释放了它,在这种情况下就会产生引用非法内存的指针。

  3. 为了更容易和安全地使用动态内存,新标准库提供了智能指针类型来管理动态对象。

    • shared_ptr,允许多个指针指向同一个对象。
    • unique_ptr,“独占”所指向的对象。
    • weak_ptr,弱引用,不控制所指向对象的生存期,指向 shared_ptr所管理的对象。
  4. 默认初始化的 shared_ptr对象是一个空指针,在使用之前需要进行初始化

    1. shared_ptr<string> p1; // 空指针,使用之前需要初始化
    2. shared_ptr<string> p2 = make_shared<string>("temp");
    3. auto p3 = make_shared<string>("temp");
    4. shared_ptr<string> p4(new string("temp"));
  5. 因为在最后一个 shared_ptr销毁前,内存都不会释放,因此如果忘记销毁程序不再需要的 shared_ptr,程序仍会正确执行,但会浪费运行内存。一个例子就是将 shared_ptr存放于一个容器中,而后不再需要全部元素,而只是使用其中一部分,要记得掉用容器的 erase操作删除不再需要的元素。

  6. 程序使用动态内存,往往出于以下三种原因之一:

    • 程序不知道自己需要使用多少对象,比如说容器类。
    • 程序不知道所需对象的准确类型,可以 new一个基类指针用来指向派生类对象。
    • 程序需要在多个对象间共享数据,一般情况下对象的拷贝都是类值拷贝,会发生对象的拷贝构造和析构;而使用动态内存共享数据,则是类指针拷贝,所存储的数据没有发生变化,只是新定义一个指针来指向这些已有数据。
  7. 在自由空间分配的内存是无名的,因此 new无法为其分配的对象命名,而是返回一个指向该对象的指针。

    1. int *pi = new int; // pi是一个指向动态分配的、未初始化的无名对象
    • 默认情况下,动态分配的对象是默认初始化的,这意味着内置类型或组合类型的对象的值将是未定义的,而类类型对象将用默认构造函数进行初始化。因此,对动态分配的对象进行初始化通常是个好主意。

      1. string *ps = new string; // 初始化为空 string
      2. int *pi = new int; // pi指向一个未初始化的 int
    • 可以使用直接初始化(圆括号、花括号)的方式或值初始化(空的圆括号)来初始化一个动态分配的对象。

      1. int *pi = new int(1024);
      2. int *pi2 = new int(); // 值初始化为 0,*pi2的值为 0
      3. vector<int> *ps = new vector<int>{1, 2, 3};
    • 如果提供了一个括号包围的初始化器,就可以使用 auto从此初始化器来推断出我们想要分配的对象的类型。也因为编译器要用初始化器来推断出想要分配的对象的类型,括号中只能有一个初始化器。

      1. auto p1 = new auto(obj); // p1指向一个与 obj类型相同的对象
      2. auto p2 = new auto{a, b}; // 错误
    • 用 new分配 const对象是合法的,但是动态分配的 const对象必须进行初始化。对于一个定义了默认构造函数的类类型,其 const动态对象可以隐式初始化,而其他类型的对象就必须显式初始化。

      1. const int *pci = new const int(1024); // const作为类型的一部分,也要出现在 new的后面
      2. const string *pcs = new const string; // 默认初始化一个 const的空 string
    • 默认情况下,如果 new不能分配所要求的内存空间,会抛出一个类型为 bad_alloc的异常,可以使用定位 new形式并向其传递参数 nothrow来阻止它抛出异常。此时它会返回一个空指针。

      1. // 如果分配失败,抛出bad_alloc异常
      2. int *p1 = new int();
      3. // 如果分配失败,返回空指针
      4. int *p2 = new (nothrow) int();
  8. 释放一块并非 new分配的内存,或者将相同的指针释放多次,其行为是未定义的。通常情况下,编译器不能分辨一个指针指向的是静态还是动态分配的对象。类似的,编译器也不能分辨一个指针所指向的内存是否已经被释放了。

    1. int i, *pi1 = &i, *pi2 = nullptr;
    2. double *pd = new double(33), *pd2 = pd;
    3. delete i; // 错误,i不是一个指针
    4. delete pi1; // 错误,pi1指向静态分配的对象
    5. delete pd; // 正确
    6. delete pd2; // 错误,pd2指向的内存已经被释放掉了
    7. delete pi2; // 正确,释放一个空指针总是没有错误的
  9. 动态内存的管理非常容易出错,存在三个常见问题:

    • 忘记 delete内存。
    • 使用已释放掉的对象。通过在释放内存后将指针置为空,在使用前检测指针是否为空,可以避免这种错误。
    • 同一块内存被释放两次。
  10. 空悬指针,指向一块曾经保存数据对象但现在已经无效的内存的指针。当我们 delete一个指针后,指针值就无效了。虽然指针已经无效,但在很多机器上指针仍然保存在(已经释放了的)动态内存的地址。有一种方法可以避免空悬指针的问题:在指针即将离开其作用域之前释放掉它所关联的内存,而如果需要保留指针,可以在 delete之后将 nullptr赋予指针,这样就清楚地指出指针不指向任何对象。

    1. int *p(new int(42));
    2. delete p;
    3. p = nullptr;
  11. 可以用 new返回的指针来初始化智能指针,但该接受指针参数的智能指针构造函数是 explicit的。因此,不能将一个内置指针隐式转换为一个智能指针,必须使用直接初始化形式来初始化一个普通指针

    1. shared_ptr<int> p1 = new int(1024); // 错误,必须使用直接初始化形式
    2. shared_ptr<int> p2(new int(1024)); // 正确
    3. shared_ptr<int> clone(int p) {
    4. return new int(p); // 错误,隐式转换为 shared_ptr<int>
    5. }
    • 默认情况下,一个用来初始化智能指针的普通指针必须指向动态内存,因为智能指针默认使用 delete释放它所关联的对象。也可以将智能指针绑定到一个指向其他类型的资源的指针上,但是我们必须提供自己的操作来代替 delete。
    • 轻易不要使用一个内置指针来访问一个智能指针所负责的对象,因为我们无法知道对象何时会被销毁。

      1. // 在函数被调用时 ptr被创建并初始化
      2. void process(shared_ptr<int> ptr)
      3. {
      4. // 使用 ptr
      5. } // ptr离开作用域,被销毁
      6. // 使用此函数的正确方法是给它传递一个 shared_ptr
      7. shared_ptr<int> p(new int(42)); // 引用计数为 1
      8. process(p); // 值拷贝 p会递增它的引用计数;在 process中引用计数值为 2
      9. int i = *p; // 正确,引用计数为 1
      10. // 在传递一个临时的 shared_ptr后,就不能再用内置指针访问之前的内存了
      11. int *x(new int(1024));
      12. process(x); // 错误,不能将 int*转换为一个 shared_ptr<int>
      13. process(shared_ptr<int>(x)) // 合法,但执行完此行代码后,智能指针所指向的内存会被释放!
      14. int j = *x; // 错误, x是一个空悬指针
    • get用来将指针的访问权限传递给代码,只有在确定代码不会 delete指针的情况下,才能使用 get。特别是,永远不要用 get初始化另一个智能指针或者为另一个智能指针赋值。

      1. shared_ptr<int> p(new int(42)); // 引用计数为 1
      2. int *q = p.get(); // 正确,但使用 q时要注意,不要让它管理的指针被释放
      3. {
      4. // 未定义,两个独立的 shared_ptr指向相同的内存
      5. shared_ptr<int> (q);
      6. } // 程序块结束,q被销毁,它所指向的内存被释放
      7. int foo = *p; // 未定义,p所指向的内存已经被释放了
    • 可以用 reset来将一个新的指针赋予一个 shared_ptr。在改变底层对象之前,要检查自己是否是当前对象仅有的用户,可以通过unique来完成。如果不是,在改变之前要制作一份新的拷贝。

      1. if (!p.unique())
      2. p.reset(new string(*p)); // 不是唯一用户,需要分配新的拷贝
      3. *p += newVal; // 现在可以确定自己确定是唯一用户,可以改变对象的值
  12. 使用智能指针可以确保程序在异常发生后资源能被正确地释放,与之相对,直接使用内置指针管理动态内存,当在 new之后且对应的 delete之前发生了异常,则内存不会被释放,造成内存泄漏。另外,对于没有良好定义的析构函数的类对象,也可以使用智能指针来管理,不管是否发生异常,当智能指针类对象不再使用时,会调用相应的删除器函数进行内存回收。

    1. void f()
    2. {
    3. shared_ptr<int> sp(new int(42));
    4. // 这段代码抛出一个异常,且在 f中未捕获
    5. } // 在函数结束时 shared_ptr自动释放内存
    6. void f()
    7. {
    8. int *ip = new int(42);
    9. // 这段代码抛出一个异常,且在 f中未捕获
    10. delete ip; // 在退出之前释放内存
    11. } // 内存将永远都不会被释放
  13. 智能指针可以提供对动态分配的内存安全而又方便的管理,但这也需要坚持一些基本规范:

    • 不使用相同的内置指针初始化(或 reset)多个智能指针
    • 不 delete get()返回的指针
    • 不使用 get()初始化或 reset另一个智能指针,这可能会造成二次 delete
    • 当使用 get()返回的指针时,当最后一个对应的智能指针销毁后,get()返回的指针就变为无效了
    • 当使用智能指针来管理不是 new分配的内存资源时,记住传递给它一个删除器
  14. 对于 shared_ptr类模板,删除器是类模板的 function数据成员,可以通过拷贝构造函数或 reset函数进行更改。而 unique_ptr的删除器是一个具有默认模板实参的模板类型参数,在定义一个 unique_ptr时就要一并给出。

  15. 在某个时刻只能有一个 unique_ptr指向一个给定对象。当定义一个 unique_ptr时,需要将其绑定到一个 new返回的指针上。由于一个 unique_ptr独占它所指向的对象,因此 unique_ptr不支持普通的拷贝或赋值操作

    1. unique_ptr<int> p1(new int(42));
    2. unique_ptr<int> p2(p1); // 错误, unique_ptr不支持拷贝
    3. unique_ptr<int> p3;
    4. p3 = p2; // 错误, unique_ptr不支持赋值
    • 虽然 unique_ptr不能被拷贝或赋值,但可以通过 release或 reset来将指针的所有权从一个 unique_ptr转移到另一个 unique_ptr。

      1. unique_ptr<int> p1(new int(42));
      2. // release将 p1置为空,将所有权从 p1转移给 p2
      3. unique_ptr<int> p2(p1.release());
      4. unique_ptr<int> p3(new int(0));
      5. // release将 p1置为空,reset将 p2置为空,再将所有权从 p3转移给 p2
      6. p2.reset(p3.release());
      7. p2.release(); // 错误, p2不会释放内存,而且丢失了指针
      8. auto p = p2.release(); // 正确,但是要记得 delete(p)
    • 不能拷贝 unique_ptr的规则有一个例外:可以拷贝或赋值一个将要被销毁的 unique_ptr,此时执行的是类对象的移动操作。因为移后源会被析构,所以还是只有一个 unique_ptr独占对象。

      1. unique_ptr<int> clone(int p) {
      2. // 正确,从 int*创建一个 unique_ptr<int>
      3. return unique_ptr<int> (new int(p));
      4. }
    • 对于 unique_ptr,删除器是类型的一部分,默认的删除器是 delete。但是要想重载删除器,必须在创建 unique_ptr对象时,就要提供一个指定类型的可调用对象(删除器)。

      1. // p指向一个类型为 objT的对象,并使用一个类型为 delT的对象释放 objT对象
      2. // 它会调用一个名为 fcn的 delT类型对象
      3. unique_ptr<objT, delT> p (new objT, fcn);
  16. weak_ptr,不控制所指向对象生存期的智能指针,指向由一个 shared_ptr管理的对象。将一个 weak_ptr绑定到一个 shared_ptr,不会改变 shared_ptr的引用计数。一旦最后一个指向对象的 shared_ptr被销毁,对象就会被释放,而不管是否有 weak_ptr指向该对象

    • 创建一个 weak_ptr时,要用一个 shared_ptr来初始化它。

      1. auto p = make_shared<int>(42);
      2. weak_ptr<int> wp(p); // wp弱共享,p的引用计数为改变
    • 由于对象可能不存在,因此我们不能够使用 weak_ptr直接访问对象,而必须调用 lock来检查 weak_ptr指向的对象是否存在。

      1. if (shared_ptr<int> np = wp.lock()) { // 如果 np不为空,则条件成立
      2. // 在 if中,np与 p共享对象
      3. }

12.2 动态数组

  1. 在新标准下,当一个应用需要可变数量的对象时,应该使用标准库容器而不是动态分配的数组,使用容器更为简单、更不容易出现内存管理错误并且可能有着更好的性能。

  2. 可以使用 new T[]或类型别名的形式分配一个动态对象数组,默认情况下,该数组是未初始化的。方括号中的大小必须是整数,但不必是常量。

    1. // pia指向第一个 int
    2. int *pia = new int[get_size()];
    3. typedef int arrT[42];
    4. int *p = new arrT; // 分配一个 42个 int的数组,p指向第一个 int
    • 使用 new分配一个数组会得到一个元素类型的指针,动态数组的长度可变,而对于普通数组类型而言,维度是数组类型的一部分,因此动态数组并不是数组类型。不能对动态数组调用 begin或 end函数,也不能用范围 for语句来处理动态数组中的元素
    • 普通数组的长度不能为 0,而动态数组的长度可以为 0。相当于定义了一个尾后指针,此指针可以执行比较操作,但是不能解引用。

      1. char arr[0]; // 错误,不能定义长度为 0的数组
      2. char *cp = new char[0]; // 正确,但 cp不能解引用
    • 默认情况下,new分配的对象,不管是单个分配的还是数组中的,都是默认初始化的。对数组中的元素进行值初始化,可以再大小之后跟一对空括号。与分配单个对象不同,分配数组对象,不能在圆括号内指定初始值。但是可以在花括号内提供元素初始化器,具体规则与使用大括号初始化内置数组类似。无法用 auto分配数组。

      1. int *pia = new int[10]; // 10个未初始化的 int
      2. int *pia2 = new int[10](); // 10个值初始化为 0的 int
      3. int *pia3 = new int[10](1); // 错误,不能在圆括号内指定初始值
      4. int *pia4 = new int[10]{0, 1, 2}; // 在列表中给定初始化器
      5. auto *pia5 = new auto[10](); // 错误,未给出初始化器
      6. auto *pia6 = new auto[10]{0, 1, 2}; // 错误,花括号括起来的初始值无法与 new auto配合使用
  3. unique_ptr可以直接管理动态数组,但必须在对象类型后面跟上一对空方括号。unique_ptr不支持点和箭头运算符,因为其指向的是一个数组而不是元素,这些操作没有意义。unique_ptr支持下标运算符。

    1. unique_ptr<int[]> up(new int[10]);
    2. up[1] = 2; // 使用下标运算符访问元素
  4. shared_ptr不直接支持管理动态数组,这是因为 shared_ptr默认是用 delete作为删除器,而动态数组的析构,需要使用 delete[]。因此,在使用 shared_ptr管理动态数组时,必须提供自己的删除器。另外,shared_ptr不支持下标运算。

    1. shared_ptr<int> sp(new int[10], [](int *p) { delete [] p; });
    2. *(sp.get() + 1) = 2; // 使用 get()返回内置指针,用这个指针来访问元素
  5. new将内存分配和对象构造组合在了一起,delete将对象析构和内存释放组合在了一起。再分配单个对象时,因为几乎知道对象应该有什么值,所以我们希望将内存分配和对象构造组合在一起。而对于大块内存分配时,将内存分配和对象构造组合在一起,可能会造成不必要的浪费(多次赋值,一次在默认初始化时,一次在使用时)。更重要的是,如果一个类没有默认构造函数,就无法为其分配动态数组!

  6. allocator类将 new和 delete的功能都分了开来,主要包括分配内存、构造对象、对象析构和内存释放。

    1. allocator<string> alloc;
    2. auto const p = alloc.allocate(n); // 分配 n个未初始化的 string
    3. auto q = p;
    4. // 构造 string对象后,将 q后移一位,使 q永远指向最后构造的元素之后的位置
    5. alloc.construct(q++, "hi");
    6. // 对象析构,只能对真正构造了的元素进行 destroy操作
    7. while (q != p)
    8. alloc.destroy(--q);
    9. // 释放内存
    10. alloc.deallocate(p, n);
添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注