第 12 章 动态内存

第 12 章 动态内存

标签: C++Primer 学习记录 动态内存



12.1 动态内存与智能指针

  1. 不同的存储区域对应着不同生存周期的变量。
    • 静态内存——保存局部 static对象、类 static数据成员和定义在任何函数之外的变量,在第一次使用之前分配内存,在程序结束时销毁。
    • 栈内存——定义在函数内的非 static对象,当进入其定义所在的程序块时被创建,在离开块时被销毁。
    • 堆内存——存储动态分配的对象,即那些在程序运行时分配的对象。当动态对象不再使用时,必须由代码显式地销毁它们。
  2. 动态内存的使用很容易出问题。有时我们会忘记释放内存,在这种情况下就会产生内存泄漏;有时在尚有指针引用内存的情况下我们就释放了它,在这种情况下就会产生引用非法内存的指针。
  3. 为了更容易和安全地使用动态内存,新标准库提供了智能指针类型来管理动态对象。
    • shared_ptr,允许多个指针指向同一个对象。
    • unique_ptr,“独占”所指向的对象。
    • weak_ptr,弱引用,不控制所指向对象的生存期,指向 shared_ptr所管理的对象。
  4. 默认初始化的 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"));
  5. 因为在最后一个 shared_ptr销毁前,内存都不会释放,因此如果忘记销毁程序不再需要的 shared_ptr,程序仍会正确执行,但会浪费运行内存。一个例子就是将 shared_ptr存放于一个容器中,而后不再需要全部元素,而只是使用其中一部分,要记得掉用容器的 erase操作删除不再需要的元素。
  6. 程序使用动态内存,往往出于以下三种原因之一:
    • 程序不知道自己需要使用多少对象,比如说容器类。
    • 程序不知道所需对象的准确类型,可以 new一个基类指针用来指向派生类对象。
    • 程序需要在多个对象间共享数据,一般情况下对象的拷贝都是类值拷贝,会发生对象的拷贝构造和析构;而使用动态内存共享数据,则是类指针拷贝,所存储的数据没有发生变化,只是新定义一个指针来指向这些已有数据。
  7. 在自由空间分配的内存是无名的,因此 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();

  8. 释放一块并非 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; // 正确,释放一个空指针总是没有错误的
  9. 动态内存的管理非常容易出错,存在三个常见问题:
    • 忘记 delete内存。
    • 使用已释放掉的对象。通过在释放内存后将指针置为空,在使用前检测指针是否为空,可以避免这种错误。
    • 同一块内存被释放两次。
  10. 空悬指针,指向一块曾经保存数据对象但现在已经无效的内存的指针。当我们 delete一个指针后,指针值就无效了。虽然指针已经无效,但在很多机器上指针仍然保存在(已经释放了的)动态内存的地址。有一种方法可以避免空悬指针的问题:在指针即将离开其作用域之前释放掉它所关联的内存,而如果需要保留指针,可以在 delete之后将 nullptr赋予指针,这样就清楚地指出指针不指向任何对象。
int *p(new int(42));
delete p;
p = nullptr;
  1. 可以用 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;   // 现在可以确定自己确定是唯一用户,可以改变对象的值
  1. 使用智能指针可以确保程序在异常发生后资源能被正确地释放,与之相对,直接使用内置指针管理动态内存,当在 new之后且对应的 delete之前发生了异常,则内存不会被释放,造成内存泄漏。另外,对于没有良好定义的析构函数的类对象,也可以使用智能指针来管理,不管是否发生异常,当智能指针类对象不再使用时,会调用相应的删除器函数进行内存回收。
void f()
{
    shared_ptr<int> sp(new int(42));
    // 这段代码抛出一个异常,且在 f中未捕获
}   // 在函数结束时 shared_ptr自动释放内存
void f()
{
    int *ip = new int(42);
    // 这段代码抛出一个异常,且在 f中未捕获
    delete ip;    // 在退出之前释放内存
}   // 内存将永远都不会被释放
  1. 智能指针可以提供对动态分配的内存安全而又方便的管理,但这也需要坚持一些基本规范:
  • 不使用相同的内置指针初始化(或 reset)多个智能指针
  • 不 delete get()返回的指针
  • 不使用 get()初始化或 reset另一个智能指针,这可能会造成二次 delete
  • 当使用 get()返回的指针时,当最后一个对应的智能指针销毁后,get()返回的指针就变为无效了
  • 当使用智能指针来管理不是 new分配的内存资源时,记住传递给它一个删除器
  1. 对于 shared_ptr类模板,删除器是类模板的 function数据成员,可以通过拷贝构造函数或 reset函数进行更改。而 unique_ptr的删除器是一个具有默认模板实参的模板类型参数,在定义一个 unique_ptr时就要一并给出。
  2. 在某个时刻只能有一个 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);
  1. 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 动态数组

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

  3. unique_ptr可以直接管理动态数组,但必须在对象类型后面跟上一对空方括号。unique_ptr不支持点和箭头运算符,因为其指向的是一个数组而不是元素,这些操作没有意义。unique_ptr支持下标运算符。 unique_ptr<int[]> up(new int[10]); up[1] = 2; // 使用下标运算符访问元素
  4. 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()返回内置指针,用这个指针来访问元素
  5. new将内存分配和对象构造组合在了一起,delete将对象析构和内存释放组合在了一起。再分配单个对象时,因为几乎知道对象应该有什么值,所以我们希望将内存分配和对象构造组合在一起。而对于大块内存分配时,将内存分配和对象构造组合在一起,可能会造成不必要的浪费(多次赋值,一次在默认初始化时,一次在使用时)。更重要的是,如果一个类没有默认构造函数,就无法为其分配动态数组!
  6. 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);

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏浪淘沙

关于队列的几个小算法

  思路 :创建3个变量,start,end,size; size用来查看数组中数据的数量,从而实现添加和删除的长度控制。当添加数据时,如果end=size-...

1533
来自专栏pangguoming

JS原型继承和类式继承

类式继承(构造函数) JS中其实是没有类的概念的,所谓的类也是模拟出来的。特别是当我们是用new 关键字的时候,就使得“类”的概念就越像其他语言中的类了。类式继...

5289
来自专栏河湾欢儿的专栏

第二节单利、工厂、构造函数、原型链、call、bind、apply、sort

1072
来自专栏柠檬先生

es6 Promise

Promise 是异步编程的一种方案,简单说就是一个容器,里面保存着某个未来才会结束的事件的 结果,Promise 是一个对象,从它,可以获取异步操作的消息。 ...

2176
来自专栏C/C++基础

C++的数据类型

C++是一种强类型语言。C++程序中的任何变量(或函数)必须遵循“先说明后使用”的原则。定义数据类型有两个方面的作用:一是决定该类型的数据在内存中如何存储,二是...

922
来自专栏C/C++基础

如何禁止函数的传值调用

按照参数形式的不同,C++应该有三种函数调用方式:传值调用、引用调用和指针调用。对于基本数据类型的变量作为实参进行参数传递时,采用传值调用与引用调用和指针调用的...

711
来自专栏程序员互动联盟

【编程基础】如何赢得C++面试

1.new、delete、malloc、free关系 delete会调用对象的析构函数,和new对应的是free,free只会释放内存,new调用构造函数。m...

3957
来自专栏desperate633

浅谈javascript中的的闭包作用域链引出闭包利用闭包突破作用域链的三种方法小结

闭包可以说是javascript中最令人迷惑的概念了。需要我们在实践中去慢慢理解,在实际编码中,由于闭包的效率和会产生大量无法销毁的内存,所以原则是尽量少使用闭...

731
来自专栏开发与安全

虚析构函数? vptr? 指针偏移?多态数组? delete 基类指针 内存泄漏?崩溃?

五条基本规则: 1、如果基类已经插入了vptr, 则派生类将继承和重用该vptr。vptr(一般在对象内存模型的顶部)必须随着对象类型的变化而不断地改变它的指向...

2460
来自专栏java工会

Java基础第一阶段知识点,招实习的面试官都在问这些

2229

扫码关注云+社区

领取腾讯云代金券