章节目录

使用new和delete进行动态内存分配

本节阅读量:

为什么需要动态内存分配?

C++支持三种基本类型的内存分配,您已经见过其中两种。

  1. 静态内存分配用于静态变量和全局变量。这些变量的内存在程序运行时分配一次,并在程序的整个生命周期中保持不变。
  2. 自动内存分配用于函数参数和局部变量。这些变量的内存在进入相关代码块时分配,在退出该代码块时释放。
  3. 动态内存分配,这是本文的主题。

静态分配和自动分配有两个共同点:

  1. 在编译时必须知道变量/数组的大小。
  2. 内存分配和释放自动发生(当变量被实例化/销毁时)。

大多数时候,这些机制都很好用。然而,在处理外部输入(来自用户或文件)时,您可能会遇到其中一个或两个约束无法满足的情况。

例如,我们可能希望使用字符串来保存某人的姓名,但在对方输入姓名之前,我们不知道姓名有多长。或者,我们可能想从磁盘中读入许多记录,但事先不知道有多少条记录。又或者,我们可能正在创建一个游戏,让数量可变的怪物(随着时间推移,一些怪物死亡,同时又有新怪物生成)试图杀死玩家。

如果必须在编译时声明所有内容的大小,我们能做的最好选择就是猜测所需变量的最大大小,并希望它足够使用:

1
2
3
4
char name[25]; // 假设名字只有25个字节
Record record[500]; // 假设不会有超过500条记录
Monster monster[40]; // 假设最多只有40个怪物
Polygon rendering[30000]; // 假设3d渲染时最多只有30000个多边形

这是一个糟糕的解决方案,至少有四个原因:

首先,如果变量没有被实际使用,就会导致内存浪费。例如,如果我们为每个名称分配25个字符,但名称平均只有12个字符长,那么使用的内存就超过实际所需的两倍。或者考虑上面的渲染数组:如果渲染只使用10000个多边形,那么就有20000个多边形的内存未被使用!

第二,我们如何知道哪些内存实际被使用了?对于字符串,这很容易:以\0开头的字符串显然没有被使用。但怪物呢?第24个位置的怪物现在是活着还是死了?它一开始是否被初始化过?这就需要某种方法来记录每个怪物的状态,从而增加复杂性,并可能占用额外内存。

第三,大多数普通变量(包括固定数组)都分配在称为栈的内存区域中。程序的栈内存通常相当小——Visual Studio默认堆栈大小为1MB。如果超过这个数字,将导致栈溢出,操作系统可能会关闭该程序。

在Visual Studio上,您可以尝试编写这样的程序,看看会发生什么:

1
2
3
4
int main()
{
    int array[1000000]; // 分配100万个整数 (大概 4MB 内存)
}

对于许多程序来说,仅限于1MB的内存是有问题的,特别是那些处理图形的程序。

第四,也是最重要的一点,它可能导致人为限制或数组溢出。当用户试图从磁盘读取600条记录,但我们只为最多500条记录分配了内存时,会发生什么?要么我们必须向用户报告错误,只读取500条记录;要么(在最坏的情况下,如果我们根本不处理这种情况)记录数组会溢出,并引发一些糟糕的问题。

幸运的是,这些问题很容易通过动态内存分配来解决。动态内存分配是一种在需要时向操作系统请求内存的方法。这些内存不是来自程序有限的栈内存,而是从操作系统管理的更大内存池(称为堆)中分配。在现代机器上,堆的大小可以达到GB级别。


动态分配单个变量

要动态分配单个变量,我们使用new运算符:

1
new int; // 动态分配一个整型(返回结果这里丢弃了)

在上面的例子中,我们向操作系统请求一块用于存放整数的内存。new操作符会使用该内存创建对象,然后返回一个指针,该指针包含已分配内存的地址。

通常,我们将返回值分配给指针变量,以便稍后访问分配的内存。

1
int* ptr{ new int }; // 动态分配一个整型,并将结果保存在指针中,以便稍后使用

然后,我们可以解引用指针以访问内存:

1
*ptr = 7; // 为申请的内存赋值 7

现在,您应该至少看到了一种指针有用的场景。如果没有指针来保存刚刚分配的内存地址,我们将无法访问这块内存!

请注意,访问堆分配的对象通常比访问栈分配的对象慢。因为编译器知道栈上分配对象的地址,所以可以直接访问该地址来获取值。堆分配的对象通常通过指针访问。这需要两个步骤:先从指针获取对象地址,再通过该地址获得值。


动态内存分配是如何工作的?

您的计算机拥有可供应用程序使用的内存(可能很多)。运行应用程序时,操作系统会将应用程序加载到其中的某些区域。应用程序使用的内存被划分为不同区域,每个区域都有不同用途。一个区域包含您的代码。另一个区域用于记录程序执行状态(跟踪调用了哪些函数,创建和销毁全局变量与局部变量等…)。我们稍后会详细讨论这些内容。当然,还有许多可用内存处于空闲状态,等待分配给请求它的程序。

当动态分配内存时,您是在要求操作系统分配一些内存供程序使用。如果操作系统可以满足这个请求,它会将该内存的地址返回给您的应用程序。从那时起,应用程序就可以根据需要使用这块内存。当应用程序使用完这块内存后,可以将其返回给操作系统,以便提供给其他程序。

与静态或自动内存不同,程序本身负责请求和处理动态分配的内存。


初始化动态分配的变量

动态分配变量时,还可以通过直接初始化或统一初始化对其进行初始化:

1
2
int* ptr1{ new int (5) }; // 使用直接初始化
int* ptr2{ new int { 6 } }; // 使用统一初始化

销毁单个变量

当我们处理完动态分配的变量后,需要显式地告诉C++释放内存,以便重用。对于单个变量,这是通过delete运算符完成的:

1
2
3
// 假设 ptr 之前是通过new分配出来的
delete ptr; // 将ptr指向的地址归还给操作系统
ptr = nullptr; // ptr赋值为nullptr

delete内存意味着什么?

delete操作符实际上并不删除任何内容。它只是将所指向的内存返回给操作系统。之后,操作系统可以将该内存重新分配给另一个应用程序(或稍后再次分配给本应用程序)。

尽管看起来我们正在删除一个变量,但事实并非如此!指针变量仍然具有与以前相同的作用域,并且可以像其他变量一样被赋予新值。

请注意,删除不指向动态分配的内存的指针可能会导致糟糕的事情发生。


悬空指针

C++不保证使用指向已删除内存的指针时会发生什么。在大多数情况下,返回给操作系统的内存仍会包含归还之前的相同值。

指向已释放内存的指针称为悬空指针。解引用或删除悬空指针将导致未定义的行为。请看以下程序:

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

int main()
{
    int* ptr{ new int }; // 动态分配一个整数
    *ptr = 7; // 给对应的内存赋值

    delete ptr; // 将内存归还给操作系统.  ptr 现在是悬空指针.

    std::cout << *ptr; // 解引用悬空指针,会导致未定义的行为
    delete ptr; // 再次删除悬空指针,也会导致未定义的行为.

    return 0;
}

在上面的程序中,ptr指向的内存已经归还给操作系统,此时再尝试访问该内存将导致未定义的行为。

一次delete操作可能会导致多个悬空指针。请看以下示例:

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

int main()
{
    int* ptr{ new int{} }; // 动态分配一个整数
    int* otherPtr{ ptr }; // otherPtr 现在也指向同样的内存

    delete ptr; // 将内存归还给操作系统.  ptr 和 otherPtr 现在是悬空指针.
    ptr = nullptr; // ptr 设置为 nullptr

    // 然而, otherPtr 仍然是悬空指针

    return 0;
}

以下是一些有帮助的最佳实践。

首先,尽量避免让多个指针指向同一块动态内存。如果一定要这么做,请明确哪个指针“拥有”这块内存(并负责删除它),以及哪些指针只是访问它。

其次,当您删除指针时,如果该指针随后不会立即超出作用域,请将其设置为nullptr。后续我们将详细讨论空指针,以及它们为什么有用。


new操作可能会失败

从操作系统请求内存时,在极少数情况下,操作系统可能没有足够的内存来满足请求。

默认情况下,如果new失败,则会抛出bad_alloc异常。如果这个异常没有被正确处理(而它通常不会被正确处理,因为我们还没有讨论异常或异常处理),程序将直接终止(崩溃),并显示未处理的异常错误。

在许多情况下,让new引发异常(或让程序崩溃)是不可取的,因此还有另一种形式的new,可以让new在无法分配内存时返回空指针。这是通过在new关键字和分配类型之间添加std::nothrow来完成的:

1
int* value { new (std::nothrow) int }; // 如果分配失败,value将是空指针

在上面的示例中,如果new未能分配内存,它将返回空指针,而不是已分配内存的地址。

请注意,如果随后尝试解引用该指针,将导致未定义的行为(很可能是程序崩溃)。因此,如果使用nothrow,最佳实践是检查所有内存请求,确保它们在使用所分配内存之前确实成功。

1
2
3
4
5
6
int* value { new (std::nothrow) int{} }; // 请求分配内存
if (!value) // 处理返回为空的情况
{
    // 做错误处理
    std::cerr << "Could not allocate memory\n";
}

因为请求新内存很少失败(在开发环境中几乎永远不会失败),所以人们通常会忘记执行此检查!


空指针和动态内存分配

空指针(设置为nullptr的指针)在处理动态内存分配时特别有用。在动态内存分配的上下文中,空指针基本上表示“没有内存分配给该指针”。这允许我们执行类似“指针是否有效”的逻辑:

1
2
3
// 如果ptr没有分配内存,分配一下
if (!ptr)
    ptr = new int;

删除空指针没有任何问题。因此,不需要这样写:

1
2
3
if (ptr) // 如果ptr不为空
    delete ptr; // 删除它
// 否则做其他事情

相反,您可以直接写:

1
delete ptr;

如果ptr不为nullptr,则会删除动态分配的内存。如果ptr为nullptr,则不会发生任何事情。


内存泄漏

动态分配的内存会保持分配状态,直到它被显式释放或程序结束(此时操作系统会清理它)。然而,用于保存动态分配内存地址的指针遵循局部变量的正常作用域规则。这种不匹配可能会产生一些问题。

请看以下函数:

1
2
3
4
void doSomething()
{
    int* ptr{ new int{} };
}

该函数动态分配了一个整数,但从未使用delete释放它。因为指针变量ptr只是普通变量,所以当函数结束时,ptr会超出作用域。由于ptr是唯一保存动态分配整数地址的变量,因此当ptr被销毁时,就不再有任何引用指向这块动态分配的内存。这意味着程序现在“丢失”了动态分配内存的地址。因此,无法再删除这个动态分配的整数。

这称为内存泄漏。当程序在将动态分配的内存返回给操作系统之前丢失其地址时,就会发生内存泄漏。发生这种情况时,程序无法删除动态分配的内存,因为它不再知道这块内存在哪里。操作系统也无法使用该内存,因为该内存仍被认为正在由程序使用。

内存泄漏会在程序运行时逐渐耗尽可用内存,从而减少当前程序以及其他正在运行程序可用的内存。存在严重内存泄漏的程序可能会占用所有可用内存,导致整台计算机运行缓慢,甚至崩溃。只有在程序终止后,操作系统才能清理并“回收”所有泄漏的内存。

尽管指针超出作用域可能会导致内存泄漏,但还有其他方式也可能导致内存泄漏。例如,如果把另一个值赋给保存动态分配内存地址的指针,就可能发生内存泄漏:

1
2
3
int value = 5;
int* ptr{ new int{} }; // 分配内存
ptr = &value; // 老的地址丢失, 导致内存泄漏

可以通过在重新给指针赋值之前删除指针来解决此问题:

1
2
3
4
int value{ 5 };
int* ptr{ new int{} }; // 分配内存
delete ptr; // 将内存归还给操作系统
ptr = &value; // ptr重新赋值

类似地,还可以通过重复分配造成内存泄漏:

1
2
int* ptr{ new int{} };
ptr = new int{}; // 老的地址丢失, 导致内存泄漏

第二次分配返回的地址会覆盖第一次分配的地址。因此,第一次分配的内存就泄漏了!

同样,可以通过确保在重新分配之前先删除指针来避免这种情况。


结论

运算符new和delete允许我们为程序动态分配单个变量。

动态分配的内存具有动态持续时间,并会保持分配状态,直到您将它归还给操作系统或程序终止。

注意不要对悬空指针或空指针执行解引用操作。

在下一课中,我们将了解如何使用new和delete来分配和删除数组。


18.3 为代码计时

上一节

19.1 动态分配数组

下一节