C++ 复习教程第七章(内存管理)

第7章 —— 内存管理

C++ 十分灵活,为了保证这一点,是对程序员采取不干预策略的,即,C++假定程序员知道自己在做什么,即使程序员没有意识到自己做错了什么,也就是说它允许采用一些可能出错的领域。总之,C++ 为了灵活性,而牺牲了部分安全性。内存的分配和管理是 C++ 编程中最容易出错的一个领域,接下来,我们将对它的内存幕后工作原理进行了解,以写出高质量 C++ 程序。

本章讨论底层内存处理,因为专业的 C++ 程序员将遇到此类代码。但在现代 C++ 中,应尽可能避免底层内存操作。例如:

  • 不应使用动态分配内存的 C 风格数组,而应使用标准库容器,例如 vector, 它会自动处理所有内存分配操作。
  • 不应使用裸指针,而应使用智能指针,例如 unique_ptr 和 shared_ptr, 它们会自动释放不再需要的底层资源,例如内存。

基本上,应尝试避免在代码中调用内存分配例程,例如new/new[]和 delete/delete[]。当然,这并不总是可行的,在现有的代码中,很可能并非如此,所以专业 C++程序员仍需要了解内存在幕后的工作原理。

警告:

在现代 C++中,应尽可能避免底层内存操作,而使用现代结构,例如容器和智能指针。


7.1使用动态内存

内存是计算机的低级组件,遗憾的是,即使在 C++这样的高级语言中也仍要面对内存的问题。很多程序员只是对动态内存有基本的了解。他们回避使用动态内存的数据结构,或通过试错法让程序能正常工作。扎实理解 C++动态内存的工作原理对于成为一名专业的 C++程序员至关重要。


1.1如何描绘内存

堆栈(Stack)和堆(Heap)是计算机内存中用于存储数据的两个主要区域,它们有一些关键的区别:

  1. 分配方式:
    • 堆栈: 数据在堆栈上分配,以一种后进先出(LIFO)的方式进行管理。当一个函数被调用时,其局部变量和函数调用信息被压入堆栈,函数执行结束时,这些数据从堆栈中弹出。这样的分配和释放是自动进行的。
    • 堆: 堆上的内存分配和释放是手动进行的。在堆上分配内存需要明确的请求和释放过程。通常使用new(C++)或malloc(C语言)来在堆上分配内存,而使用delete(C++)或free(C语言)来释放堆上的内存。
  2. 大小:
    • 堆栈: 通常较小,其大小受限于系统设置的栈大小。堆栈主要用于存储函数调用和局部变量等较小的数据。
    • 堆: 可以比较大,通常受限于系统总体内存大小。堆主要用于存储动态分配的数据,例如通过newmalloc分配的对象。
  3. 生存期:
    • 堆栈: 数据的生存期与其所在函数的执行周期相关。当函数执行结束时,堆栈上的数据被自动释放。
    • 堆: 数据的生存期可以长于其分配它的函数执行周期,因为堆上的数据需要手动释放。
  4. 管理:
    • 堆栈: 由编译器自动管理,无需手动干预。
    • 堆: 开发人员需要手动管理内存,确保在不再需要时释放分配的内存,以防止内存泄漏。
1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

int main() {
int* ptr = new int; // 在堆上分配内存

// 假设这里有一些代码,然后我们忘记释放 ptr 指向的内存

// 当 main 函数结束时,ptr 指针变量会被销毁,但指向的堆内存没有被释放
return 0; // 内存泄漏发生
}

// 对就是这样使得这个指针丢失,然后那个堆上内存没释放的

警告:

作为经验法则,每次声明一个指针变量时,务必立即用适当的指针或 nullptr 进行初始化!

下一个例子展示了指针既可以在堆栈中,也可在堆中:

1
2
3
4
5
6
7
8
int** handle = nullptr;
// (int*)* handle = nullptr;
// 这样就很好理解,指向 int 型指针的指针 handle 了
// handle 是在堆栈中的
handle = new int*;
// 给它分配出来一个 指向 int 型变量指针 的空间
*handle = new int;
// 对 handle 进行解引用,也就是得到了当初 handle 所指空间中的那个 指向 int 型变量的指针,再为它分配空间
  1. int** handle = nullptr;:声明了一个指向指针的指针变量 handle,并将其初始化为 nullptr,即空指针。int** 表示指向 int 类型指针的指针。
  2. handle = new int*;:在堆上分配了一个 int* 类型的内存,并将其地址赋给 handle。这个 int* 类型指针用来存储 int 类型的地址。
  3. *handle = new int;:在堆上分配了一个 int 类型的内存,并将其地址存储在 handle 指向的位置(*handle)。现在,handle 指向的是一个 int* 类型的指针,而这个指针指向的是一个动态分配的 int 类型的内存。

image-20240220143956527


1.2分配和释放

  1. 使用 new 和 delete

    由于堆栈和栈的区别,那么就代表着可能在指针失效,而其所指空间未释放,这叫做内存泄漏。

    经验:

    • 一个 new 对应后面一个 delete;
    • ptr = nullptr 将上述 一个 new 和 一个 delelte 包裹起来;
    1
    2
    3
    4
    5
    int* prt = nullptr;
    ptr = new int;
    ...
    delete ptr;
    ptr = nullptr;
  2. 关于 malloc() 函数

    虽然 C++ 中也存在 malloc(),但应该避免使用它,new 相比 malloc() 的主要好处式:new 不仅分配内存,还构建对象;例如:

    1
    2
    3
    4
    Foo* myFoo = (Foo*)malloc(sizeof(Foo));
    // 只分配空间,不创建对象
    Foo* myOtherFoo = new Foo();
    // 分配空间,也创建对象

    执行这些代码行后,myFoo 和 myOtherFoo 将指向堆中足以保存 Foo 对象的内存区域。通过这两个指针可访问 Foo 的数据成员和方法。不同之处在于,myFoo 指向的 Foo 对象不是一个正常的对象,因为这个对象从未构建。malloco函数只负责留出一块一定大小的内存。它不知道或关心对象本身。相反,调用 new 不仅会分配正确大小的内存,还会调用相应的构造函数以构建对象。

  3. 当内存分配失败时

    很多程序员会假设 new 总是会成功。他们的理由是,如果 new 失败了,则意味着内存量非常低,情况就非常糟糕了。这是一个无法预知的状态,因为不知道程序在这种情况下可能做什么。

    默认情况下,如果 new 失败了,程序会终止。在许多程序中,这种行为是可以接受的。当 new 因为没有足以满足请求的内存而抛出异常失败时,程序退出。第 14 章将讲解如何在内存不足的情况下正常地恢复。

    也有不抛出异常的 new 版本。相反,它会返回 nullptr, 这类似于 C 语言中 malloc()的行为。使用这个版本的语法如下所示:

    1
    int* ptr = new(nothrow) int;

    当然,仍然要面对与抛出异常的版本同样的问题——如果结果是 nullptr, 怎么办?编译器不要求检查结果,因此 new 的 nothrow 版本可能导致除了抛出异常的版本遇到的 bug 之外的其他 bug。下面给出示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    #include <iostream>
    #include <new>

    int main() {
    int* ptr = new(std::nothrow) int;
    if (ptr == nullptr) {
    // 处理内存分配失败的情况
    std::cerr << "Memory allocation failed!" << std::endl;
    } else {
    // 继续执行程序
    *ptr = 42;
    // 其他代码依赖于 ptr 不为空
    std::cout << "Value at ptr: " << *ptr << std::endl; // 这里依赖于 ptr 不为空
    delete ptr; // 注意:确保在不再需要时释放内存
    }

    // 其他代码,继续依赖于 ptr 不为空
    int result = *ptr; // 这里依赖于 ptr 不为空,但实际上 ptr 是空指针
    // 这里的解引用,如果是 nullptr 的话,是错误的

    std::cout << "Result: " << result << std::endl;

    return 0;
    }

    因此,建议使用标准版本的 new。如果内存不足的恢复对程序非常重要,请参阅第 14 章,该章给出了需要的所有工具。


1.3数组

数组将多个同一类型的变量封装在一个通过索引访问的变量中。

  1. 基本类型的数组

    • 大小不变。区分动态数组和动态分配的数组。
    • 在 C++ 中有一个继承自 C 的函数 realloc()。不要使用它!在 C 中,realloc() 用于改变数组的大小,采用的方式是分配新大小的新内存块,然后将所有旧数据内存块复制到新位置,再删除就内存块。在 C++ 中这是极为危险的,因为用户定义的对象不能很好地适应按位复制。
  2. 对象的数组

    • class Simple
      {
      public:
          Simple() {std::cout << "Simple constructor called!" << std::endl;}
          ~Simple() {std::cout << "Simple destructor called!" << std::endl;}
      };
      // 给出这个类,后面会用
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38

      - 与 简单类型 的数组没什么区别。

      - 通过 new[N]分配 N 个对象的数组时,实际上分配了 N 个连续的内存块,每一块足以容纳单个对象。使用 new[] 时,每个对象的无参构造函数(= default)会自动调用。这样,通过 new[] 分配对象数组时,会返回一个指向数组的指针,这个数组中的所有对象都被初始化了。

      - 当然在数组元素是对象的时候,才调用析构函数。

      3. 删除数组

      - 和上面说的一样,先用 delete,再将指针指向 nullptr

      - new <---> delete;

      - new [] <---> delete []

      - 总是使得上面这两个对应。

      - ```c++
      // 为指向 Simple指针 的数组分配空间,来存储 Simple指针
      const size_t size = 4;
      Simple** mySimplePtrArray = new Simple*[size];

      // Allocate an object for each pointer.
      for (size_t i = 0; i < size; i++)
      {
      mySimplePtrArray[i] = new Simple();
      }
      // Use mySimplePtrArray...

      // Delete each allocated object.
      for (size_t i = 0; i < size; i++)
      {
      delete mySimplePtrArray[i];
      }

      // Delete the array itself.
      delete [] mySimplePtrArray;
      mySimplePtrArray = nullptr;
    • 注意:

      在现代 C++中,应避免使用 C 风格的裸指针。所以,不要在 C 风格的数组中保存旧式的普通指针,而应在现代的标准库容器中保存智能指针。本章后面讨论这些智能指针,并且会在适当时候自动释放与其关联的内存。

  3. 多维堆栈数组【array[] []】

    • 额,复杂,随便吧,反正我不用,书上也没讲得很仔细。
    • 就是区分数组的级数吧,就是:array[0] 和 array[0] [0] 的区别。
  4. 多维堆数组【new array[] []】

    • 如果需要在运行时确定多维数组的维数,可以使用堆数组。正如动态分配的一维数组是通过指针访问一样,动态分配的多维数组也通过指针访问。唯一的区别在于,在二维数组中,需要使用指针的指针:在 N 维数组中,需要使用 N 级指针。下面这种声明并动态分配多维数组的方式初看上去是正确的:

      1
      char** board = new char[i][j];
    • 这段代码无法成功编译,因为堆数组和堆栈数组的工作方式不一样。多维数组的内存布局是不连续的,所以为基于堆栈的多维数组分配足够内存的方法是不正确的。

    • 可以首先为堆数组的第一个下标分配一个连续的数组。

    • 该数组的每个元素实际上是指向另一个数组的指针,另一个数组保存的是第二个下标维度的元素。

    • 上述代码只能分配第一层指针,还必须显式地分配第二层指针,如下:

      image-20240222224018635

      1
      2
      3
      4
      5
      6
      7
      8
      9
      char** allocateCharacterBoard(size_t xDimension,size_t yDimension)
      {
      char** myArray = new char*[xDimension];
      for (size_t i = 0; i < xDimension; i++)
      {
      myArray[i] = new char[yDimension];
      }
      return myArray;
      }
    • 释放多维堆数组的内存,也必须类似与分配数组时的代码:

      1
      2
      3
      4
      5
      6
      7
      8
      void releaseCharacterBoard(char** myArray, size_t xDimension)
      {
      for (size_t = 0; i < xDimension; i++)
      {
      delete [] myArray[i];
      }
      delete [] myArray;
      }

知道了使用数组的细节后,我们就知道了 C 风格的数组是多么不应该受欢迎。因为这种数组完全没有提供任何安全性。这里解释它们是因为可能在旧代码中会遇到。在新代码中,应该用 C++ 的标准库容器,例如 std::array、std::vector 等。例如,用 vector 表示一维动态数组,用 vector<vector>表示二维动态数组等。当然,直接使用诸如 vector<vector>的数据结构仍然是繁杂的,构建时尤其如此。如果应用程序中需要 N 维动态数组,建议编写帮助类,以方便使用接口。例如,要使用行长相等的二维数据,应当考虑编写(也可以重用) Matrix 或 Table 类模板,该模板在内部使用 vector<vector> 数据结构。有关编写类模板的信息,请参阅第 12 章。

警告:

​ 不要 TM 闲的蛋疼使用 C 风格。

​ 可是,我真贱死了……C 语言好像用的地方真不少,可是我就是更喜欢 C++,因为它太优雅了,什么 Python,真垃圾,真疯了。


1.4使用指针

因为指针很容易被滥用,所以名声不佳。因为指针只是一个内存地址,所以理论上可以手动修改那个地址,甚至像下面这行代码一样做一些很可怕的事情:

1
char* scaryPointer = (char* )7;

为什么可怕?因为它在一个地址为 7 的地方,构建了一个 char 类型指针,这个地方可能时内存随机垃圾,或其他应用程序中使用的内存。如果开始使用为通过 new 分配的内存区域,那么最终将损坏与对象相关联的内存,或者破坏堆管理相关的内存,使得程序无法正常工作。这种故障可体现在几个方面:例如,可表现为无效结果,因为数据已损坏,或因为访问不存在的内存或写入受保护的内存而引发硬件异常。重则得到错误结果,轻则出现严重错误,导致操作系统或 C++运行时库终止程序。

指针理解方式:

  • 数学头脑下:看作地址,将其理解为 内存位置的数字;
  • 空间表示法下:看作一个“箭头”,一个间接层,告诉程序“看向那个地方”;
  • 通过 * 运算符解除对一个指针的引用时,实际上让程序在内存中更深一步,即,从地址角度看指针:把解除引用想象为跳到与那个指针表示的地址相对应的内存。使用 图形视图 时,每次解引用都对应从针尾到针头的过程;
  • 通过 & 运算符取一个位置的地址时,在内存中添加了一个间接层,即,从地址的角度看:程序只不过是表示那个位置的数值,这个数值可保存为指针形式。在 图形视图 中,& 运算符创建了一个新箭头,其头部终止于表达式表示的位置,其尾部可以保存为一个指针。

指针的类型转换:

  • 指针的类型事实上是比较弱的,这是什么意思?意思是说,例如,指向 XML 文档的指针和指向 整数 的指针大小完全相同。这就可能造成错误转换。

  • 编译器允许通过使用 C 风格的类型转换将任意指针类型方便地转换为任意类型:

    1
    2
    Document* documentPtr = getDocument();
    char* myCharPtr = (char*)documentPtr;

    静态类型转换的安全性更高。编译器将拒绝执行不同数据类型的指针的静态类型转换:

    1
    2
    Document* documentPtr = getDocument();
    char* myCharPtr = static_cast<char*>(documentPtr); // Bug! Won't compile!

    静态类型转换的安全性体现在:两个完全无关的指针不能被转换;而两个指针值之间如果存在“是一个”,也就是继承关系,那么这种转换是可以执行的。然而,在继承层次中完成转换的更安全方式是动态类型转换。那么这里就不扯淡了,之后再详细说明。


7.2数组-指针的对偶性

正如我们所看到的,尤其在 C 中,我们会混淆(准确说,不是混淆,而是看作相同的事物)数组和指针。它们在功能和用法上,存在着重叠性。在堆上分配的数组通过指向该数组中第一个元素的指针来引用。基于堆栈的指针通过数组语法 ([]) 和普通的变量声明来引用。然而,它们之间的关系不止于此,见下。


2.1数组就是指针

通过指针不仅能指向基于堆的数组,也可以通过指针语法来访问基于堆栈的数组的元素。数组的地址就是第一个元素(索引 0 )的地址。

上面这段话说明了——数组上的每个元素都能用它的指针寻访到。

1
2
3
4
5
int myIntArray[10] = {};
int* myIntPtr = myIntArray;

// Access the array through the pointer.
myIntPtr[4] = 5;

向函数传递数组时,通过指针引用用基于堆栈的数组的能力非常有用。下面的函数以指针的方式接收一个整数数组。请注意,调用者需要显式地传入数组的大小,因为指针没有包含于大小有关的信息。事实上,任何形式的 C++ 数组,不论是不是指针,都没有内涵大小信息。这是应使用现代容器(例如,标准库中提供的容器)的另一个原因:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void doubleInts(int* theArray, size_t size)
{
for (size_t i = 0; i < size; i++)
{
theArray[i] *= 2;
}
}

等价于:

void doubleInts(int theArray[], size_t size)
{
for (size_t i = 0; i < size; i++)
{
theArray[i] *= 2;
}
}

都代表输入整数数组

请记住,指针本身就意味着传引用,但传引用不代表着就是指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 对于一维数组存在以下三种方式:
size_t arrSize = 4;
int* heapArray = new int[arrSize]{ 1, 5, 3, 4 };
doublelnts(heapArray, arrSize);
delete [] heapArray;
heapArray = nullptr;
int stackArray[] = { 5, 7, 9, 11 };
arrSize = std::size(stackArray); // Since C++17, requires <array>
// arrSize = sizeof(stackArray) / sizeof(stackArray[0]); // Pre-C++17, see Chi
doublelnts(stackArray, arrSize);
doublelnts(&stackArray[0], arrSize);

/*在函数原型中,theArray的后面方括号中数字被忽略,原因在于只强调是数组(指针),其他都不关心。下面的 3 个版本是等价的:*/
void doublelnts(int* theArray, size_t size);
void doublelnts(int theArray[], size_t size);
void doublelnts(int theArray[2], size_t size);
/* 在 C 和 C++ 中,函数参数中的数组声明经常会退化(decay)为指向数组第一个元素的指针,这种退化让人可能一开始很难接收,可是不要这样去想,因为最后一种可能会引起人疑问的用法,只有有毛病的人才会用,完全是无用功,仅此而已*/

其背后机理在于,传入的参数是一个指针(如果传入的是数组,那么就会将其指针化)。

可“按引用”给函数传递长度已知的基于堆栈的数组,但其语法并不明显。它不适用于基于堆的数组。例如,下面示例中的 doubleIntsStack() 仅接收大小为 4 的基于堆栈的数组:

引用(Reference):

  • 引用是一个别名,一旦引用被初始化,它就一直引用同一个对象。
  • 引用在声明时必须初始化,之后不能再引用其他对象。
  • 引用本身没有”重新绑定”的语法,因此无法修改引用指向的对象。
  • 引用无法直接进行内存管理的操作,如释放内存。
1
2
int x = 42;
int& ref = x; // ref 是 x 的引用

在C++中,引用和指针是两种不同的语言特性,它们有着不同的语法和语义。

  1. 引用(Reference):
    • 引用是一个别名,一旦引用被初始化,它就一直引用同一个对象。
    • 引用在声明时必须初始化,之后不能再引用其他对象。
    • 引用本身没有”重新绑定”的语法,因此无法修改引用指向的对象。
    • 引用无法直接进行内存管理的操作,如释放内存。
1
2
cppCopy codeint x = 42;
int& ref = x; // ref 是 x 的引用
  1. 指针(Pointer):

    • 指针是一个变量,用来存储某个对象的地址。

    • 指针可以在初始化后指向不同的对象,可以通过赋值修改指针所指的对象。

    • 指针提供了直接的内存管理操作,例如使用 deletefree 释放内存。

1
2
int* ptr = new int(42); // ptr 是指向动态分配的整数的指针
delete ptr; // 释放内存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 传引用
void doubleIntsStack(int (&theArray)[4]);
// 传指针
void doubleIntsStack(int theArray[4]);

/*一定有人想知道这两者为啥不一样,我一开始也感觉这不就是一样吗,但是,仔细观察,理解定义就可以知道——传引用意味着一切都不变;传指针则意味着可以透过这个间接层作用于原本位置的元素。是不是这样一说,就很清楚了:前者是作为一个整体传入的;后者是传入一个地址来用于穿透到原始数据位置的*/

// 传引用
void doubleIntsStack(int (&theArray)[4]);
/* 至于为什么可以起到限定数组大小的作用:
首先看括号内(&theArray),传入了一个数组的引用;
然后限定这个传引用数组的大小为 4;
然后这个数组,作为一个整体被视为参数引用式传入;
*/

// 传引用
void doubleIntsStack(int &(theArray[4]);
/* 首先,将(theArray[4])传入,这是将第五个元素引用式传入,后来退化为第五个元素的指针传入;
*/
1
2
3
// 作为防御性编程的一种保险手段,使用以下方法限定引用式传入数组长度:
void function(int (&theArray)[N]);
// N 为限定长度
1
2
3
4
5
6
7
8
9
// 模板尝试:
template<size_t N>
void doubleIntsStack(int (&theArray)[N])
{
for (size_t i = 0; i < N; i++)
{
theArray[i] *= 2;
}
}

2.2并非所有指针都是数组

额,没什么说的,就是标题所代表的意思,指针只有确定是有效的才能访问它的值,虽然可以很勉强地理解为数组,但是不要这样做,这样可能会导致 bug.

警告:

​ 通过指针可自动引用数组,但并非所有指针都是数组;


7.3低级内存操作

额,这东西就了解就行,因为啊,C++ 之所以是 C++ 就是不需要像 C 那样考虑那样底层的事情,通过构造和析构从理论来说,内存管理就被隐藏在类中得到极大的可用性的实现。


3.1指针运算

1
2
3
int* myArray = new int[8];
myArray[2] = 33; <===等价于===> *(myArray + 2) = 3
// 这一开始似乎很不合理,但是看多,就没啥了

宽字符串将在第 19 章讨论,但此时不必了解其细节。此处只需要了解宽字符串支持 Unicode 字符来扩大表示范围(如表示日语字符串)。wchar_t 类型是字符类型,可容纳此类 Unicode 字符,而且通常比 char(1字节)更大。要告知编译器一个字符串字面量是宽字符串字面量,可加上前缀 L 。假设有以下宽字符串:

1
const wchar_t* myString = L"Hello,World";

假设还有一个函数,这个函数接收一个宽字符串,然后返回一个新字符串,新字符串是输入字符串的大写版本:

1
wchar_t* toCaps(const wchar_t* inString);

将 myString 传入这个函数,可将 myString 大写化。不过,如果只想大写化 myString 的一部分,可以通过指针运算引用这个字符串后面的一部分。下面的代码给指针加7, 对宽字符串中的 “World” 部分调用 toCaps(),但 wchar_t 通常超过 1 个字节。

1
toCaps(myString + 7);

指针运算的另一个有用应用是减法运算。将一个指针减去另一个同类型的指针,得到的是两个指针之间指针指向的类型的元素个数,而不是两个指针之间字节数的绝对值。


3.2自定义内存管理

在 99%的情况下(有人可能会说在 100%的情况下),C++中内置的内存分配设施是足够使用的。new 和delete 在后台完成了所有相关工作:分配正确大小的内存块、管理可用的内存区域列表以及释放内存时将内存块释放回可用内存列表。

资源非常紧张时,或在非常特殊的情况下,例如管理共享内存时,实现自定义的内存管理是一个可行的方案。不必担心,实际没有听起来那样可怕。基本上,自己管理内存通常意味着编写一些分配大块内存,并在需要的情况下使用大块内存中片段的类。

为什么这种方法更好?自行管理内存可能减少开销。当使用 new 分配内存时,程序还需要预留少量的空间来记录分配了多少内存。这样,当调用 delete 时,可以释放正确数量的内存。对于大多数对象,这个开销比实际分配的内存小得多,所以差别不大。然而,对于很小的对象或分配了大量对象的程序来说,这个开销的影响可能会很大。

当自行管理内存时,可事先了解每个对象的大小,因此可避免每个对象的开销。对于大量小对象而言,这个差别可能会很大。第 15 章将讲解自定义内存管理的语法。

对于上面内容,一句话,扯淡。。。


3.3垃圾回收

看不懂,什么东西,不关心,略过先。


3.4对象池

略过。


7.4智能指针

所以,思考智能指针的心理应该被视为 “一个带着编号的房产证”,这样我们就很容易理解所谓“所有权”的问题:

  • unique_ptr 指针,相当于私有房产的房产证,房产证只限家人有;

    • 创建房产证和房子:auto mySimpleSmartPtr = std::make_unique();

    • 返回房屋的钥匙【房产证依旧有效】:mySimpleSmartPtr.get()

    • 拆迁换房:

      mySimpleSmartPtr.reset(); mySimpleSmartPtr.reset(new Simple);

    • 获取房屋钥匙并且销毁房产证【房产证不再具有法律效益】:mySimpleSmartPtr.release()

    • unique_ptr 代表唯一拥有权

    • 房产证转移户主:move(mySimpleSmartPtr)

    • 自定义拆迁方法 :std::unique_ptr<int, decltype(free)*> myIntSmartPtr(malloc_int(), free);

  • shared_ptr 指针,相当于公共场所的复数人拥有的房产证,公共的房产证可以相关负责人拥有

    • 创建房产证和房子:auto mySimpleSmartPtr = std::make_shared();

    • 返回房屋的钥匙【房产证依旧有效】:mySimpleSmartPtr.get()

    • 拆迁换房:

      mySimpleSmartPtr.reset(); mySimpleSmartPtr.reset(new Simple);

    • 无法 获取房屋钥匙并且销毁房产证【房产证不再具有法律效益】:mySimpleSmartPtr.release()

    • 房产证中的所有人数量:mySimpleSmartPtr.use_count()

    • 房产证转义户主:move(mySimpleSmartPtr)

    • 自定义拆迁方法 :std::shared_ptr myIntSmartPtr(malloc_int(42), free);

手动管理动态内存分配缺点:

  • 指针丢失
  • 忘记释放内存
  • 多次释放内存

智能指针优点:

  • 避免内存泄漏
  • 避免多次释放,但是可能会出现循环引用,即两个或多个对象之间相互持有对方的 shared_ptr,导致它们的引用计数永远不会减为零,对象永远不会被销毁的情况

智能指针特性:

  • 可通过模板为任何指针类型编写类型安全的智能指针类

  • 可使用运算重载为智能指针对象提供一个接口,使得智能指针对象的使用和普通指针一样。确切地讲,可重载 * 和 -> 运算符,使得客户代码解除对智能指针对象的引用的方式和解除对普通指针的引用相同。

  • 使用类似于 Python 的 “引用计数” 方法来跟踪指针资源的所有者,这样实现对资源的完全利用,以及利用完全后的释放。


  • 使用智能指针需要引入 头文件 .

  • 将 unique_ptr 视作默认智能指针,只有真正需要共享资源时再使用 shared_ptr.

  • 永远不要把资源分配结果指定给普通指针。永远不要将资源分配结果指定给普通指针。无论使用哪种资源分配方法,都应当立即将资源指针存储在智能指针 unique_ptr 或 shared_ptr 中,或使用其他 RAII 类。RAII 代表 Resource Acquisition Is Initialization(资源获取即初始化)。 RAII 类获取某个资源的所有权,并在适当的时候进行释放。第 28 章将讨论这种设计技术。


4.1unique_ptr

  1. 创建:auto mySimpleSmartPtr = std::make_unique();

  2. 返回裸指针:mySimpleSmartPtr.get()

  3. 利用 reset() 方法可释放 unique_ptr 的底层指针,并使用 reset() 根据需要将其改为另一个指针:

    mySimpleSmartPtr.reset(); mySimpleSmartPtr.reset(new Simple);

  4. 断开 unique_ptr 和 底层指针的连接:mySimpleSmartPtr.release()

  5. unique_ptr 代表唯一拥有权,因此无法复制它!

  6. 使用 std::move() 方法进行移动语义:move(mySimpleSmartPtr)

  7. 自定义 deleter :std::unique_ptr<int, decltype(free)*> myIntSmartPtr(malloc_int(), free);

  1. 创建 unique_ptrs

    考虑下面函数,这个函数在堆上分配了一个 Simple 对象,但是不释放这个对象,故意产生内存泄漏:

    1
    2
    3
    4
    5
    void leaky()
    {
    Simple* mySimplePtr = new Simple();
    mySimplePtr->go();
    }

    有时,可能接下来你考虑了要内存释放,可是写出以下代码:

    1
    2
    3
    4
    5
    6
    void leaky()
    {
    Simple* mySimplePtr = new Simple();
    mySimplePtr->go();
    delete mySimplePtr;
    }

    在正常情况下,这样的代码当然没什么问题,可是,一旦在调用 go() 方法时,抛出了一个异常,那么将永远不会调用 delete,从而导致了内存泄漏。

    而使用了 unique_ptr 时,会将这两种情况都规避掉:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    #include <memory>

    void leaky()
    {
    auto mySimpleSmartPtr = std::make_unique<Simple>();
    // 注意,不使用 std::make_unique<Simple()>();
    // 注意,将创建好的类当作类型处理
    // 尤其注意:类 <==> (自定义的)类型 </=> 构造函数
    mySimpleSmartPtr->go();
    // 或写作 (*mySimpleSmartPtr).go()
    }

    当实例 mySimpleSmartPtr 离开作用域(函数结束或弹出异常),不需要显式地删除,会自动调用其析构函数进行释放对象;

    这段代码使用 C++14 中的 make_unique()和 auto 关键字,所以只需要指定指针的类型,本例中是 Simple。如果 Simple 构造函数需要参数,就把它们放在 make_unique() 调用的圆括号中。

    在 C++ 17 之前,必须使用 make_unique(),一是因为只能将类型指定一次,二是出于安全考虑!考虑下面函数,对 foo() 函数的调用:

    1
    2
    3
    foo(std::unique_ptr<Simple>(new Simple()), std::unique_ptr<Bar>(new Bar(data())));

    /*这种形式的初始化是直接使用 new 运算符创建对象,并将其所有权转交给 std::unique_ptr。如果在这个过程中发生了异常(例如构造函数抛出异常),那么 std::unique_ptr 将无法获取对动态分配内存的控制权,从而导致内存泄漏。*/

    由于这种方式容易造成内存泄漏,所以不要使用,不是迫不得已绝对不要用!

    注意:

    始终使用 make_unique() 来创建 unique_ptr.

  2. 使用 unique_ptrs

    NB 之处在于:不用学习多少语法,就能享受很多好处。

    利用 get() 方法可用于直接访问底层指针。这可将指针传递给需要普通指针的函数。例如,加入具有以下函数:

    1
    void processData(Simple* simple);

    可采用以下方法进行调用:

    1
    2
    3
    4
    5
    auto mySimpleSmartPtr = std::make_unique<Simple>();
    processData(mySimpleSmartPtr.get());
    /* 为什么这里是 .get() ?
    我在之前有这样的疑问,但是这样解释——我不是要对 mySimpleSmartPtr 所指对象进行取值运算,也就是 (*mySimpleSmartPtr).get();而是将 mySimpleSmartPtr 看做一个整体或者说看成一个类,这个类内自带有的方法是将自己的智能指针转化为裸指针的方法 .get()
    */

    利用 reset() 方法可释放 unique_ptr 的底层指针,并使用 reset() 根据需要将其改为另一个指针。例如:

    1
    2
    3
    4
    5
    // Free resourse and set to nullptr
    mySimpleSmartPtr.reset();

    // Free resourse and set to a new Simple instance
    mySimpleSmartPtr.reset(new Simple);

    利用 release() 方法可断开 unique_ptr 和 底层指针 的连接。 release() 方法返回资源的底层指针,然后将智能指针设置为 nullptr.实际上,智能指针失去对资源的所有权,负责在你用完资源时释放资源。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    // Release ownership
    Simple* simple = mySimpleSmartPtr.release();

    // 此时 mySimpleSmartPtr 不再拥有资源的所有权
    // 需要手动释放资源,否则可能会发生内存泄漏
    // Use the simple pointer
    delete simple;
    simple = nullptr;

    利用 std::move() 实现移动语义,实例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class Foo
    {
    public:
    Foo(unique_ptr<int> data/*可以在这里设定默认值*/): mData(std::move(data)){};
    private:
    unique_ptr<int> mData;
    };

    auto myIntSmartPtr = make_unique<int>(42);
    Foo f(std::move(myIntSmartPtr));

    /* 我来复习一下,成员初始化列表执行:首先 auto myIntSmartPtr = make_unique<int>(42); 给了一个被初始化为 42 的整数型智能指针。然后 Foo f(std::move(myIntSmartPtr)); ,也就是说 std::move(myIntSmartPtr) 先被给了 unique_ptr<int> data ,接下来由于成员初始化列表,再把值给 mData(std::move(data)) ,最后赋给 private 中的mData, 然后执行后面的初始化构造程序*/
  3. unique_ptr 和 C 风格数组

    unique_ptr 适用于存储动态分配的旧式 C 风格数组。下例创建了一个 unique_ptr 来保存动态分配的、包含 10 个整数的 C 风格数组:

    1
    auto myVariableSizedArray = make_unique<int[]]>(10);

    即使可使用 unique_ptr 存储动态分配的 C 风格数组,也建议改用标准库容器,例如 std::array 和 std::vector 等。

  4. 自定义 deleter

    默认情况下,unique_ptr 使用标准的 new 和 delete 运算符来分配和释放内存。可将此行改为:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    int* malloc_int(int value)
    {
    int* p = (*int)malloc(sizeof(int));
    *p = value;
    return p;
    }

    int main()
    {
    std::unique_ptr<int, decltype(free)*> myIntSmartPtr(malloc_int(42), free);
    return 0;
    }

    // 模板类型参数自定义 deleter:
    std::unique_ptr<T, Deleter> myPtr(new T, myCustomDeleter);
    // T 是智能指针所管理资源的类型。
    // Deleter 是一个类型,用于指定自定义的 deleter,可以是函数指针、函数对象等。
    // myCustomDeleter 是一个实例,用于执行实际的资源释放操作。
    // decltype(free)* 是一个表达式,用于获取 free 函数的类型,然后再声明一个指向该类型的函数指针。
    // decltype(free): decltype 是一个C++关键字,用于获取一个表达式的类型而不实际计算其值。在这里,decltype(free) 获取了 free 函数的类型。
    // decltype(free)*: 加上 *,表示声明一个指针,该指针指向 decltype(free) 所获得的函数类型。
    // 这部分告诉编译器:我们正在声明一个指针,该指针指向 free 函数的类型。

    这段代码使用 malloc_int() 给整数分配内存。unique_ptr 调用标准的 free() 函数来释放内存。如前所述,在 C++ 中不应该使用 malloc(),而应该用 new。然而,unique_ptr 的这项特性时很有用的,因为还可管理其他类型的资源而不仅是内存。例如,当 unique_ptr 离开作用域时,可自动关闭文件或网络套接字以及岐然任何资源。

    自定义 deleter 主要是为了让 std::unique_ptr 能够管理除了内存之外的其他资源,例如文件句柄、数据库连接、网络套接字等。通过使用自定义的 deleter 函数,你可以确保在释放 std::unique_ptr 持有的资源时执行特定的清理操作。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    #include <iostream>
    #include <memory>

    // 虚拟文件类
    class VirtualFile {
    public:
    VirtualFile(const std::string& filename) : filename(filename) {
    std::cout << "Opening file: " << filename << std::endl;
    }

    ~VirtualFile() {
    std::cout << "Closing file: " << filename << std::endl;
    }

    void write(const std::string& data) {
    std::cout << "Writing to file: " << filename << std::endl;
    // 写入操作
    }

    private:
    std::string filename;
    };

    // 自定义 deleter 函数
    void closeVirtualFile(VirtualFile* file)
    {
    if (file)
    {
    delete file;
    }
    }

    int main() {
    // 使用自定义 deleter 的 unique_ptr
    std::unique_ptr<VirtualFile, decltype(&closeVirtualFile)> filePtr(new VirtualFile("example.txt"), &closeVirtualFile);

    // 使用文件句柄进行操作
    filePtr->write("Hello, World!");

    // unique_ptr 离开作用域时,closeVirtualFile 将被调用
    return 0;
    }

    但是,unique_ptr 的自定义 deleter 的语法有些费解。需要将自定义 deleter 的类型指定为模板类型参数。在本例中,decltype(free) 用于返回 free() 类型。模板类型参数应当是函数指针的类型,因此另外附加一个 * ,如 decltype(free)*。使用shared_ptr 的自定义 deleter 就容易多了,下面将讨论这点。


4.2shared_ptr

  1. 创建:auto mySimpleSmartPtr = std::make_shared();

  2. 返回裸指针:mySimpleSmartPtr.get()

  3. 利用 reset() 方法可释放 shared_ptr 的底层指针,并使用 reset() 根据需要将其改为另一个指针:

    mySimpleSmartPtr.reset(); mySimpleSmartPtr.reset(new Simple);

  4. 没有这种断开 shared_ptr 和 底层指针的连接的用法:mySimpleSmartPtr.release()

  5. shared_ptr 的引用计数方法:mySimpleSmartPtr.use_count()

  6. 使用 std::move() 方法进行移动语义:move(mySimpleSmartPtr)

  7. 自定义 deleter :std::shared_ptr myIntSmartPtr(malloc_int(42), free);

shared_ptr 与 unique_ptr 类似。

.get() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <memory>

int main() {
std::shared_ptr<int> sharedPtr = std::make_shared<int>(42);

int* rawPtr = sharedPtr.get(); // 获取裸指针

// 使用 rawPtr 操作资源,但要注意生命周期
// 不要对裸指针进行 delete 和 new 操作,这应当由智能指针自动进行,否则可能错误
std::cout << "Value through rawPtr: " << *rawPtr << std::endl;

// sharedPtr 离开作用域,资源会被正确释放
return 0;
}

其余方法略。

.use_count 引用计数获取方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 实现资源共享
#include <iostream>
#include <memory>

int main() {
// 创建一个 shared_ptr,引用计数为1
std::shared_ptr<int> ptr1 = std::make_shared<int>(42);
std::cout << "ptr1 use count: " << ptr1.use_count() << std::endl;

// 复制构造一个 shared_ptr,引用计数增加为2
std::shared_ptr<int> ptr2 = ptr1;
std::cout << "ptr1 use count: " << ptr1.use_count() << std::endl;
std::cout << "ptr2 use count: " << ptr2.use_count() << std::endl;

// 通过拷贝赋值操作符,引用计数继续增加为3
std::shared_ptr<int> ptr3;
ptr3 = ptr1;
std::cout << "ptr1 use count: " << ptr1.use_count() << std::endl;
std::cout << "ptr3 use count: " << ptr3.use_count() << std::endl;

// 当 shared_ptr 被销毁时,引用计数减少
// 在 ptr2、ptr3 离开作用域时,引用计数减为1,然后在 ptr1 离开作用域时,引用计数降为0,资源被释放
return 0;
}

对比不可复制的 unique_ptr:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include <memory>

int main() {
// 创建一个 unique_ptr
std::unique_ptr<int> uniquePtr1 = std::make_unique<int>(42);

// 尝试复制,会导致编译错误
// std::unique_ptr<int> uniquePtr2 = uniquePtr1; // 错误!

return 0;
}

自定义 deleter:

1
2
// Implementation of malloc_int() as before.
shared_ptr<int> myIntSmartPtr(malloc_int(42), free);

下面示例使用 shared_ptr 存储文件指针。当 shared_ptr 脱离作用域时,会调用 CloseFile() 函数来自动关闭文件指针。回顾一下, C++ 由可操作文件的面向对象的类(详细在第 13 章)。这些类在脱离作用域会自动关闭文件。这个例子使用了旧式 C 语言的 fopen() 和 fclose() 函数,只是为了演示 shared_ptr 除了管理纯粹的内存之外还可以用于其他目的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void CloseFile(FILE* filePtr)
{
if (filePtr == nullptr)
{
return;
}
fclose(filePtr);
std::cout << "File closed!" << std::endl;
}

int main()
{
FILE* f = fopen("data.txt", "w");
shared_ptr<FILE> filePtr(f, CloseFile);
if (filePtr = nullptr)
{
std::cerr << "Error opening file." << std::endl;
}
else
{
std::cout << "File opened." << std::endl;
// Use filePtr
}

return 0;
}
  1. 强制类型转化 shared_ptr:
    可用于强制转换的 shared_ptr 的函数是:

    • const_pointer_cast()
    • dynamic_pointer_cast()
    • static_pointer_cast()
    • reinterpret_pointer_cast()

    它们的行为和工作方式类似于非智能指针转换函数:

    • const_cast()
    • dynamic_cast()
    • static_cast()
    • reinterpret_cast()

    这里先不讲了,到第 11 章再仔细讨论这些方法。

  2. 引用计数的必要性:

    作为一般概念,引用计数(reference counting)用于追踪正在使用的某个类的实例或特定对象的个数。引用计数的智能指针跟踪为引用一个真实指针(或某个对象)而建立的智能指针的数目。通过这种方法,智能指针可以避免双重删除。

    双重删除的问题很容易出现。考虑到前面引入的 Simple 类,这个类只是打印出创建或销毁一个对象的消息。如果要创建两个标准的 shared_ptrs,并将它们都指向同一个 Simple 对象,如下面代码所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // 在销毁时,两个智能指针将尝试删除同一个对象
    // 但是,创建此对象只调用了一次构造函数,但是要调用两次析构函数
    // 根据编译器,这段代码可能导致崩溃!
    void doubleDelete()
    {
    Simple* mySimple = new Simple();
    std::shared_ptr<Simple> smartPtr1(mySimple);
    std::shared_ptr<Simple> smartPtr2(mySimple);
    }

    // 如果没有崩溃,那么输出:
    Simple constructor called!
    Simple destructor called!
    Simple destructor called!

    // 上述情况对于 unique_ptr 也是一样的
    // 但是 unique_ptr 难以提供出类似于 shared_ptr 的解决方案
    // 也就是说,它不允许复制,也就不允许复制构造函数的存在

    所以,对于这种情况,应当使用:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    void doubleShare() 
    {
    // 创建一个 shared_ptr 对象,管理动态分配的 Simple 对象
    std::shared_ptr<Simple> smartPtr1 = std::make_shared<Simple>();

    // 通过拷贝构造函数创建另一个 shared_ptr,共享同一个 Simple 对象的所有权
    std::shared_ptr<Simple> smartPtr2 = smartPtr1;
    // 或者使用拷贝构造函数:
    // std::shared_ptr<Simple> smartPtr2(smartPtr1);
    // 两者都是复制 原智能指针

    // 在这里,smartPtr1 和 smartPtr2 共享对同一个 Simple 对象的所有权
    // 当它们离开作用域时,将正确地释放 Simple 对象的内存
    }

    // 输出:
    Simple constructor called!
    Simple destructor called!

    之前,一直说共享所有权,但可能并不太明确,我在这里给出通常发挥作用的场景:

    1. 多个对象需要访问和共享同一块资源: 如果有多个对象需要使用相同的资源,而不是每个对象都拥有独立的资源副本,那么 shared_ptr 是一种合适的选择。这种情况下,通过增加引用计数,可以确保资源在最后一个持有者释放它时才被销毁。

      1
      2
      3
      4
      5
      6
      cppCopy codeclass Resource {
      // Resource class definition
      };

      std::shared_ptr<Resource> sharedResource1 = std::make_shared<Resource>();
      std::shared_ptr<Resource> sharedResource2 = sharedResource1; // 共享所有权
    2. 观察者模式: 当一个对象(被观察者)的状态变化需要通知多个其他对象(观察者)时,使用 shared_ptr 可以确保观察者不会在被观察者销毁后继续引用它。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      #include <iostream>
      #include <vector>
      #include <memory>

      class Observer
      {
      public:
      void update()
      {
      // 实现更新逻辑
      std::cout << "Observer updated" << std::endl;
      }
      };

      class Subject
      {
      private:
      std::vector<std::shared_ptr<Observer>> observers;

      public:
      void addObserver(std::shared_ptr<Observer> observer)
      {
      // 添加观察者到列表
      observers.push_back(observer);
      }

      void notifyObservers()
      {
      // 通知所有观察者
      for (const auto& observer : observers)
      {
      observer->update();
      }
      }
      };

      int main()
      {
      // 创建被观察者对象
      std::shared_ptr<Subject> subject = std::make_shared<Subject>();

      // 创建观察者对象
      std::shared_ptr<Observer> observer1 = std::make_shared<Observer>();

      // 共享所有权,observer2和observer1指向相同的对象
      std::shared_ptr<Observer> observer2 = observer1;

      // 注册观察者到被观察者
      subject->addObserver(observer1);
      subject->addObserver(observer2); // 这句话事实上是多余的,因为上面它们是同一个智能指针。

      // 通知所有观察者
      subject->notifyObservers();

      return 0;
      }

    3. 复杂资源管理: 当资源的生命周期不仅仅由一个对象的作用域决定,而是由多个对象协同管理时,shared_ptr 可以确保资源在所有引用都不再需要时才被释放。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      cppCopy codeclass ComplexResource 
      {
      // ComplexResource class definition
      };

      class ResourceManager
      {
      private:
      std::shared_ptr<ComplexResource> sharedResource;

      public:
      void initialize()
      {
      sharedResource = std::make_shared<ComplexResource>();
      }

      void useResource()
      {
      // 使用资源的逻辑
      }

      // 其他复杂的资源管理逻辑
      };

      ResourceManager manager1;
      ResourceManager manager2 = manager1; // 共享所有权

    这些场景中,shared_ptr 提供了一种方便且相对安全的方式来处理共享资源的所有权。但请注意,过度使用共享所有权可能导致循环引用,因此需要谨慎设计和管理。在某些情况下,还可以使用 std::weak_ptr 作为 shared_ptr 的辅助,以避免循环引用问题。

  3. 别名

    shared_ptr 支持所谓的别名。这允许一个 shared_ptr 与另一个 shared_ptr 共享一个指针(拥有的指针),但指向不同的对象(存储的指针)。例如,这可用于使用一个 shared_ptr 指向一个对象的成员,同时拥有该对象本身,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Foo
    {
    public:
    Foo(int value) : mData(value) {}
    int mData;
    }

    auto foo = make_shared<Foo>(42);
    auto aliasing = shared_ptr<int> (foo, &foo->mData);

    我喜欢 chatGPT 的这种比喻,很好:

    当我们说 fooaliasing 共享同一个对象的所有权时,我们可以把这个对象比喻成一个大房子,而 fooaliasing 就是两个不同的人共同拥有这个房子的一种方式。

    • foo 有一把大钥匙,可以打开这个房子的门,而这个房子是 Foo 类型的。
    • aliasing 也有一把特殊的钥匙,可以打开同一个房子的门,但是这个钥匙是专门打开房子里某个小房间(mData)的。

    虽然它们都能打开同一个房子,但是它们所关心的部分是不同的。foo 关心整个房子(Foo 对象),而 aliasing 关心房子里的一个小房间(mData 成员)。

    那么,这里的比喻中,“共享同一个对象的所有权”表示这两个人都有权力进入这个房子,而它们所关心的部分不同,一个关心整个房子,另一个关心房子里的一个小房间。

    仅当两个 shared_ptr (foo 和 aliasing) 都销毁时,才销毁 Foo 对象;

    “拥有的指针” 用于引用计数;当对指针解引用或调用它的 get() 时,将返回“存储的指针”。存储的指针用于大多数操作,如比较运算符。可以使用 owner_before() 方法 或 std::owner_less 类,基于拥有的指针执行比较。在某些情况下(例如在 std::set 中存储 shared_ptr),这很有用。第 17 章将详细讨论 set 容器;


4.3weak_ptr

  • 获取 shared_ptr 的资源所有权,也就是将资源、状态等都可以拿来调用;

  • 是一种引用,但是引用时又不造成原指针对象的引用计数增加。当然,引用计数不增加也说明了它不是创建了 shared_ptr 的副本,而是获取的它的引用权限,也仅仅是获取了引用权限,但是是一种弱弱的引用,很暧昧的那种。

    上面这句话我想强调一点:获取引用权限,但不获取所有权。

    套在循环链表【拥有头结点】中是说:头节点拥有首元节点,首元结点拥有后继结点,… ,后继节点可以获取头结点的引用权限【进而使用头结点的相关内容】;

    套在树结构上:根有孩子,孩子有它的孩子,… ,孩子可以使用双亲结点的引用权限【进而得到一种探查到双亲的能力】

    这里我们看出,“有一个” 关系的维系指针【间接层】 是 要强于 “能知道” 的维系指针的;

  • 它跳出作用域时,并不会销毁原本的指针,因为上面都说了没有引用计数增加,也就是说它对原指针是否销毁不产生什么影响。

在 C++ 中还有一个类与 shared_ptr 模板有关,那就是 weak_ptr。weak_ptr 可包括有 shared_ptr 管理的资源的引用。 weak_ptr 不拥有这个资源,所以不能阻止 shared_ptr 释放资源。weak_ptr 销毁时(例如离开作用域时),不会销毁它指向的资源;然而,它可以用于判断资源是否已经被关联的 shared_ptr 释放了。weak_ptr 的构造函数要求将一个 shared_ptr 或另一个 weak_ptr 作为参数。为了访问 weak_ptr 中保护的指针,需要将 weak_ptr 转换为 shared_ptr。这有两种方法:

  1. 使用 weak_ptr 实例的 lock()方法,这个方法返回一个 shared_ptr。如果同时释放了与 weak_ptr 关联的 shared_ptr, 返回的 shared_ptr 是 nullptr。
  2. 创建一个新的 shared_ptr 实例,将 weak_ptr 作为 shared_ptr 构造函数的参数。如果释放了与 weak_ptr 关联的 shared_ptr,将抛出 std::bad_weak_ptr 异常。

下例演示了 weak_ptr 的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
#include <memory>

class Simple
{
public:
Simple() { std::cout << "Simple constructor called!" << std::endl; }
~Simple() { std::cout << "Simple destructor called." << std::endl; }
};

void useResource(std::weak_ptr<Simple>& weakSimple)
{
auto resource = weakSimple.lock();
if (resource)
{
std::cout << "Resource still alive." << std::endl;
}
else
{
std::cout << "Resource has been freed!" << std::endl;
}
}

int main()
{
auto sharedSimple = std::make_shared<Simple>();
std::weak_ptr<Simple> weakSimple(sharedSimple);

// Try to use the weak_ptr.
useResource(weakSimple);

// Reset the shared_ptr.
// Since there is only 1 shared_ptr to the Simple resource, this will
// free the resource, even though is still a weak_ptr alive.
sharedSimple.reset();

// Try to use the weak_ptr a second time.
useResource(weakSimple);

return 0;
}

// 上述代码的输出为:
Simple constructor called!
Resource still alive.
Simple destructor called.
Resource has been freed!

std::weak_ptr 在工程实践中有几个重要的用途:

  1. 避免循环引用: 一个常见的问题是循环引用,即两个或多个对象之间相互持有对方的 shared_ptr,导致它们的引用计数永远不会减为零,对象永远不会被销毁。使用 std::weak_ptr 可以打破这种循环引用,避免内存泄漏。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    cppCopy codeclass Node 
    {
    public:
    std::shared_ptr<Node> next;
    };

    std::shared_ptr<Node> node1 = std::make_shared<Node>();
    std::shared_ptr<Node> node2 = std::make_shared<Node>();

    node1->next = node2;
    node2->next = node1; // 循环引用

    // 解决方案:将其中一个改为 weak_ptr
    node1->next = node2;
    node2->next = std::weak_ptr<Node>(node1); // 将其中一个改为 weak_ptr

    1.node1 的后继指向 node2;
    2.node2 的后继指向 node1;
    3.如果两者均为
  2. 避免 shared_ptr 所有权影响对象生命周期: std::weak_ptr 不会增加对象的引用计数,因此它不会影响对象的生命周期。当对象的最后一个 shared_ptr 被释放时,即使有相关的 weak_ptr,对象也会被正确销毁。

    1
    2
    3
    4
    5
    cppCopy codestd::shared_ptr<int> sharedPtr = std::make_shared<int>(42);
    std::weak_ptr<int> weakPtr = sharedPtr;

    sharedPtr.reset(); // 释放最后一个 shared_ptr
    // 在这里,weakPtr.lock() 会返回一个空的 shared_ptr,因为对象已被销毁
  3. 延迟初始化或加载: 在某些情况下,对象的创建或加载可能是昂贵的操作。使用 std::weak_ptr 可以实现延迟初始化或加载,只有在需要的时候才创建或加载对象。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    cppCopy codeclass ResourceManager 
    {
    private:
    std::weak_ptr<Resource> cachedResource;

    public:
    std::shared_ptr<Resource> getResource()
    {
    std::shared_ptr<Resource> resource = cachedResource.lock();
    if (!resource)
    {
    // 如果资源不存在,则创建一个新的
    resource = std::make_shared<Resource>();
    cachedResource = resource;
    }
    return resource;
    }
    };
  4. 观察者模式: 在观察者模式中,通常有一个被观察者和多个观察者。被观察者持有观察者的指针,而观察者持有被观察者的指针。使用 std::weak_ptr 可以避免观察者持有被观察者的强引用,从而防止循环引用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    cppCopy codeclass Observer;

    class Subject
    {
    public:
    void addObserver(std::weak_ptr<Observer> observer);
    void notifyObservers();
    };

    class Observer
    {
    public:
    // ...
    };
    1. 缓存或资源管理:** 在某些情况下,你可能想要对对象的生命周期进行一些控制,但又不希望通过强引用导致对象一直存活。使用 std::weak_ptr 可以在需要时获取一个强引用,而不会影响对象的生命周期。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    cppCopy codeclass ResourceManager 
    {
    private:
    std::weak_ptr<Resource> cachedResource;

    public:
    std::shared_ptr<Resource> getResource()
    {
    std::shared_ptr<Resource> resource = cachedResource.lock();
    if (!resource)
    {
    // 如果资源不存在,则创建一个新的
    resource = std::make_shared<Resource>();
    cachedResource = resource;
    }
    return resource;
    }
    };

总的来说,std::weak_ptr 用于处理 std::shared_ptr 的循环引用问题,并提供一种不影响对象生命周期的方式来引用对象。

给出一个数据结构中的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
#include <iostream>
#include <memory>
#include <vector>

class TreeNode;

// 具体下方的这种用法见下面会有不太全面的解释:
class TreeNode : public std::enable_shared_from_this<TreeNode>
{
private:
int data;
// 这里的双亲结点使用 弱(引用)指针,使得对双亲结点的引用不再计数
std::weak_ptr<TreeNode> parent;
std::vector<std::shared_ptr<TreeNode>> children;

public:
TreeNode(int val) : data(val) {}

// 设置父节点
void setParent(std::shared_ptr<TreeNode> parentNode)
{
parent = parentNode;
}

// 添加子节点
void addChild(std::shared_ptr<TreeNode> childNode)
{
children.push_back(childNode);
childNode->setParent(shared_from_this());
}

// 获取父节点
std::shared_ptr<TreeNode> getParent()
{
return parent.lock();
}

// 获取节点数据
int getData()
{
return data;
}

// 获取子节点列表
const std::vector<std::shared_ptr<TreeNode>>& getChildren() const
{
return children;
}
};

int main()
{
// 创建树形结构
auto root = std::make_shared<TreeNode>(1);
auto child1 = std::make_shared<TreeNode>(2);
auto child2 = std::make_shared<TreeNode>(3);

root->addChild(child1);
root->addChild(child2);

// 上述代码可以用以下代码替代:
// child1->setParent(root);
// child2->setParent(root);

// 获取子节点的父节点
for (const auto& child : root->getChildren())
{
std::shared_ptr<TreeNode> parent = child->getParent();
if (parent)
{
std::cout << "子节点 " << child->getData() << " 的父节点是 " << parent->getData() << std::endl;
}
else
{
std::cout << "子节点 " << child->getData() << " 没有父节点。" << std::endl;
}
}

return 0;
}

4.4移动语义

shared_ptr、unique_ptr 和 weak_ptr 都支持移动语义,使得它们特别高效。第 9 章将详细讲解移动语义,此处不详细叙述。这里只需要了解,从函数返回此类指针也很高效。例如,可编写以下函数 create(),并像在 main() 函数中演示的那样使用这个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Simple {
// Simple class definition...
};

std::unique_ptr<Simple> create()
{
auto ptr = std::make_unique<Simple>();
// Do something with ptr...
return ptr;
}

int main()
{
// 对于临时对象的所有权转移不需要 std::move
std::unique_ptr<Simple> mySmartPtr1 = create();
auto mySmartPtr2 = create();

return 0;
}

// 区别
std::unique_ptr<Simple> create(std::unique_ptr<Simple> ptr)
{
// 在这里对 ptr 进行一些操作...
return ptr; // 返回 ptr,发生所有权的转移
}

int main()
{
std::unique_ptr<Simple> mySmartPtr1 = std::make_unique<Simple>();

// 传递 mySmartPtr1 给 create 函数,并接收返回值
std::unique_ptr<Simple> mySmartPtr2 = create(std::move(mySmartPtr1));

return 0;
}

4.5enable_shared_from_this

std::enable_shared_from_this 混入类 允许对象上的方法给自身安全地返回 shared_ptr 或 weak_ptr。第 28 章将讨论混合类,这里先不详述。enable_shared_from_this 混合类给类添加了以下两个方法:

  • shared_form_this() : 返回一个 shared_ptr,它共享对象的所有权。
  • weak_from_this() : 返回一个 weak_ptr,它跟踪对象的所有权。

这是一项高级功能,此处不做详述,下面的代码简单地演示了它的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Foo : public std::enable_shared_form_this<Foo>
{
public:
std::shared_ptr<Foo> getPointer()
{
return shared_from_this();
}
};

int main()
{
auto ptr1 = std::make_shared<Foo>();
auto ptr2 = ptr->getPointer();
}

注意,仅当对象的指针已经储存在 shared_ptr 时,才能使用对象上的 shared_from_this()。意思如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <memory>

class MyClass : public std::enable_shared_from_this<MyClass>
{
public:
std::shared_ptr<MyClass> getShared()
{
return shared_from_this();
}
};

int main()
{
// 在栈上创建对象,不由 shared_ptr 管理
MyClass myObject;

// 尝试调用 shared_from_this(),这是不安全的
// std::shared_ptr<MyClass> sharedPtr = myObject.getShared(); // 这行代码会导致运行时错误

// 在堆上创建对象,由 shared_ptr 管理
std::shared_ptr<MyClass> sharedPtr = std::make_shared<MyClass>();
// 写成下面的方式更好:
// auto sharedPtr = std::make_shared<MyClass>();

// 调用 shared_from_this(),这是安全的
std::shared_ptr<MyClass> sharedPtr2 = sharedPtr->getShared();

return 0;
}

这就让我们有了一个疑问为什么要大费周章地使用 enable_shared_from_this?一定很令人疑惑吧?因为貌似我们可以复写上面的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Foo
{
public:
shared_ptr<Foo> getPointer()
{
return shared_ptr<Foo>(this);
}
}

int main()
{
MyClass myObject;
auto sharedPtr = std::make_shared<MyClass>();

std::shared_ptr<MyClass> sharedPtr2 = sharedPtr->getShared();

return 0;
}

// 这样写貌似才是一种自然的写法,但是注意:我们在之前说过的重复删除错误
// 为了提醒,我把那段代码再贴过来:
void doubleDelete()
{
Simple* mySimple = new Simple();
std::shared_ptr<Simple> smartPtr1(mySimple);
std::shared_ptr<Simple> smartPtr2(mySimple);
}
// 这样我们可以看出 Foo 类内成员函数 getPointer() 返回的是一个临时的 shared_ptr<Foo>,它使用传递给构造函数的裸指针(this)来创建一个 shared_ptr,我们就遇到了双重析构问题。
// 有两个完全独立的 shared_ptr(ptrl 和 ptr2)指向同 一对象,在超出作用域时,它们都会尝试删除该对象。
// 所以,使用 enable_shared_from_this 的里有就有了,用于返回一个临时的原智能指针副本,而不是一个单纯的临时智能指针。

4.6旧的、过时的/取消的 auto_ptr

知道有这疙瘩事,就行了。

没犯病的话,别用。


7.5常见的内存陷阱

一句话,错误往往很微妙,但有常见类型的问题是可以检测和解决的。


5.1分配不足的字符串

与 C 风格字符串相关的最常见问题是分配不足。大多数情况下,都是因为程序员没有分配尾部的’(T终止字符。当程序员假设某个固定的最大大小时,也会发生字符串分配不足的情况。基本的内置 C 风格字符串函数不会针对固定的大小操作一而是有多少写多少,如果超出字符串的末尾,就写入未分配的内存。

以下代码演示了字符串分配不足的情况。它从网络连接读取数据,然后写入一个 C 风格的字符串。这个过程在一个循环中完成,因为网络连接一次只接收少量的数据。在每个循环中调用 getMoreData()。函数,这个函数返回一个指向动态分配内存的指针。当 getMoreData() 返回 nullptr 时,表示己收到所有数据。strcat() 是一个C 函数,它把第二个参数的C 风格字符串连接到第一个参数的 C 风格字符串的尾部。它要求目标缓存区足够大。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
char buffer[1024] = {0}; // Allocate a whole bunch of memory.

while (true)
{
char* nextChunk = getMoreData();
if (nextChunk == nullptr)
{
break;
}
else
{
strcat(buffer, nextChunk); // BUG! No guarantees against buffer overrun!
delete [] nextChunk;
}
}

有三种方法解决这个问题,按优解级排序:

  • 使用 string;
  • 使用动态的堆存储追加长度;
  • 创建另一个版本的 getMoreData(),这个版本接收一个最大计数值(包括 ‘\0’ 字符),返回的字符不多于这个值;然后跟踪剩余的空间数以及缓冲区中当前的位置;

5.2访问内存越界

本章前面提到,指针只不过是一个内存地址,因此指针可能指向内存中的任何一个位置。这种情况很容易出现。例如,考虑一个 C 风格的字符串,它不小心丢失了 (V终止字符。下面这个函数试图将字符串填满 m 字符,但实际上可能会继续在字符串后面填充 m:

1
2
3
4
5
6
7
8
9
void fillWithM(char* inStr)
{
int i = 0;
while (inStr[i] != '\0')
{
inStr[i] = 'm';
i++;
}
}

注意:如果把不正确的终止字符串传入这个函数,那么内存的重要部分被改写而导致程序崩溃只是时间问题。考虑到如果程序中与对象关联的内存突然被 m 改写那么会发生什么?这很糟糕!!!

写入数组尾部的内存而产生的 bug 被称为 缓冲区溢出错误。这种 bug 已经被一些高危的恶意程序使用,例如病毒和蠕虫。狡猾的黑客可利用改写部分内存的能力,来将代码注入正在运行的程序中。【我们看到它也不完全是废物,也可以用于别的地方】

许多内存检测工具也能检测缓存区溢出。使用像 C++ string 和 vector 这样的高级结构有助于避免产生一些和 C 风格字符串和数组相关的 bug.

其实就一句话,用 string 和 vector。


5.3内存泄漏

C 和 C++ 编程中遇到的另一个令人沮丧的问题是找到和修复内存泄漏。程序终于开始工作,看上去能给出正确结果。然后,随着程序的运行,吞掉的内存越来越多。这是因为程序有内存泄漏。通过智能指针避免内存泄漏是解决这个问题的首选方法。

分配了内存,但没有释放,就会发生内存泄漏。起初,这听上来好像是粗心编程的结果,应该很容易避免。毕竟,如果在编写的每个类中,每个 new 都对应一个 delete,那么应该不会出现内存泄漏,对不对?实际上,绝对不会这样简单啊!在下面的代码中,Simple 类编写正确,释放了每一处分配的内存。

当调用 doSomething() 函数时,outSimplePtr 指针修改为指向另一个 Simple 对象,但是没有释放原来 Simple 对象。为演示内存泄漏,doSomething() 函数故意没有删除旧的对象。一旦失去对象的指针,就几乎不可能删除它了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Simple
{
public:
Simple() { mIntPtr = new int;}
~Simple() { delete mIntPtr;}
void setValue(int value) { *mIntPtr = value;}

private:
int* mIntPtr;
};

void doSomthing(Simple*& outSimplePtr)
{
outSimplePtr = new Simple();
}

int main()
{
// Allocate a Simple object.
Simple* simplePtr = new Simple();
dosomething(simplePtr);
// Only Clean up the second object.
delete simplePtr;

return 0;
}

警告:

​ 记住,上述代码仅用于演示!在生产环境的代码中,应当使 mIntPtr 和 simplePtr 成为 unique_ptr,使 outSimplePtr 成为 unique_ptr 的引用。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <memory>

class Simple
{
public:
Simple() { mIntPtr = std::make_unique<int>();}
void setValue(int value) { *mIntPtr = value;}

private:
std::unique_ptr<int> mIntPtr;
};

void doSomething(std::unique_ptr<Simple>& outSimplePtr)
{
outSimplePtr = std::make_unique<Simple>();
}

int main()
{
auto simplePtr = std::make_unique<Simple>();
doSomthing(simplePtr);

return 0;
}

上例中的内存泄漏可能来自程序员之间的沟通不畅或糟糕的代码文档。 doSomething() 的调用者可能没有意识到该变量是通过传引用的,因此,没有理由期望该指针会重新赋值。如果他们没有注意到这个参数是一个指针的非 const 引用,就可能怀疑会发生奇怪的事情,但是 doSomething() 周围并没有说明这个行为的注释。

暂时略过 MFC 部分,之后专门学。


5.4双重删除和无效指针

通过 delete 释放某个指针关联的内存时,这个内存就可以被其他程序使用了。然而,无法禁止再次使用这个指针,这个指针就成为了悬空指针(dangling pointer)。双重释放也是如此,由于可能已被分配另一对象内存,所以可能会被删掉,崩溃。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>

int* createInt()
{
int* ptr = new int(42);
return ptr;
}

int main()
{
int* myPointer = createInt(); // 创建一个动态分配的整数,并将其地址赋给指针

// 假设在某个时刻释放了指针所指向的内存
delete myPointer;

// 在这之后,myPointer成为悬空指针,因为它仍然包含已释放的内存地址

// 错误的使用悬空指针
std::cout << *myPointer << std::endl; // 这里可能导致未定义行为
// 错误双重释放
// delete myPointer;

return 0;
}

双重删除和使用已释放的内存都是很难追查的问题,因为症状可能不会立即显现。如果双重删除在较短的时间内发生,程序可能产生未定义的行为,因为关联的内存可能不会那么快重用。同样,如果删除的对象在删除后立即使用,这个对象很有可能仍然完好无缺。

当然,无法保证这种行为会继续出现。一旦删除对象,内存分配器就没有义务保存任何对象。即使程序能正常工作,使用已删除的对象也是极糟糕的编程风格。

多内存泄漏检测程序(例如 Visual C++和 Valgrind), 也会检测双重删除和已释放对象的使用。

如果不按推荐的方式使用智能指针而是使用普通指针,至少在释放指针关联的内存后,将指针设置为nullptro 这样能防止不小心两次删除同一个指针和使用无效的指针。注意,在 nullptr 指针上调用 delete 是允许的,只是这样没有任何效果。


7.6本章小节

总结起来:

  • 引入 ,用 智能指针;
  • 引入 ,用 string;
  • 引入 ,用 vector;

C++ 复习教程第七章(内存管理)
http://example.com/2024/03/14/C++ 复习教程第七章(内存管理)/
作者
yanhuigang
发布于
2024年3月14日
许可协议