19-1 使用 new 和 delete 进行动态内存分配
动态内存分配的必要性
C++支持三种基本内存分配方式,其中两种你已经见过。
- 静态内存分配
Static memory allocation用于静态变量和全局变量。此类变量的内存仅在程序运行时分配一次,并在整个程序生命周期内持续存在。 - 自动内存分配
Automatic memory allocation适用于函数参数和局部变量。此类变量的内存会在进入相关代码块时分配,退出代码块时释放,循环执行直至需求结束。 - 动态内存分配
Dynamic memory allocation正是本文探讨的核心主题。
静态分配与自动分配具有两个共同点:
- 变量/数组的大小必须在编译时确定。
- 内存分配与释放自动完成(变量实例化/销毁时)。
多数情况下这完全足够。但当处理外部输入(用户或文件输入)时,常会遇到其中一项或两项限制引发的问题。
例如:我们可能需要用字符串存储用户姓名,但姓名长度需待用户输入后才能确定;又如需从磁盘读取多条记录,却无法预知记录总数;再如开发游戏时,怪物数量会随时间动态变化(部分死亡,部分新生),这些怪物正试图攻击玩家。
若强制要求在编译时声明所有变量的大小,我们只能尽力估算所需变量的最大容量,并祈祷其足够使用:
char name[25]; // let's hope their name is less than 25 chars!
Record record[500]; // let's hope there are less than 500 records!
Monster monster[40]; // 40 monsters maximum
Polygon rendering[30000]; // this 3d rendering better not have more than 30,000 polygons!
这至少有四个原因使其成为一个糟糕的解决方案:
首先,若变量实际未被使用,将导致内存浪费。例如,若为每个名称分配25个字符,但名称平均仅12字符长,则实际使用量超过需求两倍以上。再看上文的渲染数组:若渲染仅使用10,000个多边形,则有20,000个多边形对应的内存处于闲置状态!
其次,如何判断内存中哪些区域被实际使用?字符串易于识别:以\0结尾的字符串显然未被使用。但像monster[24]这样的变量呢?它当前是活跃还是已释放?甚至是否被初始化过?这要求我们建立机制来追踪每个怪物的状态,从而增加复杂度并消耗额外内存。
第三,多数普通变量(包括固定数组)都分配在栈内存区域stack。程序的栈内存通常非常有限——Visual Studio默认栈大小仅为1MB。若超出此限制,将导致栈溢出,操作系统很可能终止程序运行。
在Visual Studio中运行此程序时,可观察到该现象:
int main()
{
int array[1000000]; // allocate 1 million integers (probably 4MB of memory)
}
仅限于1MB内存对许多程序而言都将造成问题,尤其是涉及图形处理的程序。
第四点,也是最重要的一点,这可能导致人为限制和/或数组溢出。当用户试图从磁盘读取600条记录,而我们仅分配了最多500条记录的内存时会发生什么?要么向用户报错,要么仅读取500条记录,要么(在最糟糕的情况下完全未处理此情形)导致记录数组溢出并引发严重错误。
所幸这些问题可通过动态内存分配轻松解决。动态内存分配Dynamic memory allocation允许运行中的程序在需要时向操作系统申请内存。这种内存并非来自程序有限的栈内存,而是从操作系统管理的更大内存池——堆中分配heap。在现代计算机上,堆的容量可达数十亿字节。
动态分配单个变量
要动态分配单个变量,我们使用new运算符的标量(非数组)形式:
new int; // dynamically allocate an integer (and discard the result)
在上例中,我们向操作系统请求了一段整数大小的内存空间。new 运算符利用这段内存创建对象,随后返回一个包含已分配内存地址的指针。
通常情况下,我们会将返回值赋给自己的指针变量,以便后续访问已分配的内存区域。
int* ptr{ new int }; // dynamically allocate an integer and assign the address to ptr so we can access it later
然后我们可以解引用该指针来访问内存:
*ptr = 7; // assign value of 7 to allocated memory
如果之前还不清楚,现在至少应该明白指针在某种情况下是有用的。如果没有指针来保存刚刚分配的内存地址,我们就无法访问为我们分配的内存!
请注意,访问堆分配对象通常比访问栈分配对象更慢。因为编译器知道栈分配对象的地址,可以直接跳转到该地址获取值。而堆分配对象通常需要通过指针访问,这需要两个步骤:一是获取对象的地址(通过指针),二是获取值。
动态内存分配是如何工作的?
你的计算机拥有可供应用程序使用的内存(通常容量很大)。当你运行应用程序时,操作系统会将该应用加载到部分内存中。应用程序使用的内存被划分为不同区域,每个区域承担特定功能:一个区域存放程序代码,另一个区域用于常规操作(如追踪函数调用、创建和销毁全局/局部变量等)。这些细节我们稍后详述。然而,大部分可用内存处于闲置状态,等待分配给请求的程序。
当你进行动态内存分配时,实际上是在请求操作系统为你的程序预留部分内存。若系统能满足该请求,便会将该内存地址返回给应用程序。此后,应用程序可自由调用该内存。当应用程序不再需要该内存时,可将其归还操作系统供其他程序使用。
与静态或自动内存不同,程序本身需负责动态分配内存的请求与释放。
关键要点:
栈对象的分配与释放是自动完成的。我们无需处理内存地址——编译器生成的代码会自动完成这些操作。堆对象的分配与释放并非自动完成。我们需要主动参与其中。这意味着我们需要一种明确的方式来引用特定的堆分配对象,以便在需要时请求其销毁。而引用这类对象的方式正是通过内存地址。
当使用new操作符时,它会返回一个包含新分配对象内存地址的指针。我们通常需要将该地址存储在指针中,以便后续通过该地址访问对象(最终请求其销毁)。
初始化动态分配的变量
当动态分配变量时,也可通过直接初始化或统一初始化进行初始化:
int* ptr1{ new int (5) }; // use direct initialization
int* ptr2{ new int { 6 } }; // use uniform initialization
删除单个变量
当动态分配的变量不再需要时,我们需要明确告知C++释放内存以便重复利用。对于单个变量,可通过删除运算delete符的标量形式(非数组形式)实现:
// assume ptr has previously been allocated with operator new
delete ptr; // return the memory pointed to by ptr to the operating system
ptr = nullptr; // set ptr to be a null pointer
删除内存意味着什么?
delete 操作符实际上并未真正删除任何内容。它只是将被引用的内存空间归还给操作系统。操作系统随后可以自由地将该内存重新分配给其他应用程序(或稍后再次分配给当前应用程序)。
尽管语法看似在删除变量,实则不然!该指针变量仍保留原有作用域,可像普通变量一样赋予新值(例如nullptr)。
需注意:若删除的指针未指向动态分配的内存,可能引发严重问题。
悬空指针
C++ 对于已释放内存的内容或被删除指针的值不会作任何保证。在大多数情况下,返回给操作系统的内存将包含其被释放前的相同值,而指针仍指向现已释放的内存区域。
指向已释放内存的指针称为悬空指针dangling pointer。对悬空指针进行解引用或删除操作将导致未定义行为。请看以下程序:
#include <iostream>
int main()
{
int* ptr{ new int }; // dynamically allocate an integer
*ptr = 7; // put a value in that memory location
delete ptr; // return the memory to the operating system. ptr is now a dangling pointer.
std::cout << *ptr; // Dereferencing a dangling pointer will cause undefined behavior
delete ptr; // trying to deallocate the memory again will also lead to undefined behavior.
return 0;
}

在上面的程序中,先前分配给内存的值7可能仍然存在,但该内存地址处的值也可能已发生改变。此外,该内存也可能被分配给其他应用程序(或操作系统自身使用),此时尝试访问该内存将导致操作系统终止程序运行。
释放内存可能产生多个悬空指针。请看以下示例:
#include <iostream>
int main()
{
int* ptr{ new int{} }; // dynamically allocate an integer
int* otherPtr{ ptr }; // otherPtr is now pointed at that same memory location
delete ptr; // return the memory to the operating system. ptr and otherPtr are now dangling pointers.
ptr = nullptr; // ptr is now a nullptr
// however, otherPtr is still a dangling pointer!
return 0;
}
在此有几个最佳实践可供参考。
首先,尽量避免多个指针指向同一块动态内存。若无法避免,需明确哪个指针“拥有”该内存(并负责释放),哪些指针仅访问该内存。
其次,删除指针时若该指针不会立即退出作用域,请将其设置为nullptr。稍后我们将详细探讨空指针及其实用价值。
最佳实践:
除非指针即将退出作用域,否则应将其设置为nullptr。
操作符 new 可能失败
向操作系统请求内存时,在极少数情况下,操作系统可能没有可用内存来满足请求。
默认情况下,如果 new 失败,将抛出 bad_alloc 异常。如果未正确处理此异常(由于我们尚未讲解异常或异常处理,因此不会处理),程序将直接因未处理异常错误而终止(崩溃)。
在多数情况下,new抛出异常(或导致程序崩溃)并非理想结果。因此存在替代形式的new操作符,可在内存分配失败时返回空指针。实现方法是在new关键字与分配类型之间添加常量std::nothrow:
int* value { new (std::nothrow) int }; // value will be set to a null pointer if the integer allocation fails
在上例中,若 new 操作未能成功分配内存,则会返回空指针而非已分配内存的地址。
请注意,若此时尝试解引用该指针,将导致未定义行为(很可能导致程序崩溃)。因此最佳实践是在使用分配的内存前,先检查所有内存请求是否确实成功。
int* value { new (std::nothrow) int{} }; // ask for an integer's worth of memory
if (!value) // handle case where new returned null
{
// Do error handling here
std::cerr << "Could not allocate memory\n";
}
由于申请新内存的情况很少失败(在开发环境中几乎从未发生),人们常会忘记执行这项检查!
空指针与动态内存分配
空指针(设置为nullptr的指针)在处理动态内存分配时尤为有用。在动态内存分配的语境中,空指针基本表示“此指针尚未分配内存”。这使我们能够实现条件内存分配等操作:
// If ptr isn't already allocated, allocate it
if (!ptr)
ptr = new int;
删除空指针不会产生任何效果。因此,以下操作是没有必要的:
if (ptr) // if ptr is not a null pointer
delete ptr; // delete it
// otherwise do nothing
相反,你只需写:
delete ptr;
如果 ptr 不为空,则动态分配的内存将被删除。如果 ptr 为空,则不会发生任何操作。
最佳实践:
删除空指针是安全的,且不会产生任何影响。无需对 delete 语句添加条件判断。
内存泄漏
动态分配的内存会一直保留,直到显式释放或程序结束(此时操作系统会清理内存,前提是你的操作系统具备此功能)。然而,用于存储动态分配内存地址的指针遵循局部变量的常规作用域规则。这种不匹配可能引发有趣的问题。
请看以下函数:
void doSomething()
{
int* ptr{ new int{} };
}
该函数动态分配了一个整数,但从未使用delete释放它。由于指针变量只是普通变量,当函数结束时,ptr将超出作用域。而由于ptr是唯一持有动态分配整数地址的变量,当ptr被销毁时,动态分配的内存便失去了所有引用。这意味着程序现在已“丢失”了动态分配内存的地址。因此该动态分配的整数无法被删除。
此现象称为内存泄漏memory leak。当程序在将动态分配的内存归还操作系统前丢失其地址时,就会发生内存泄漏。此时程序无法删除动态分配的内存,因为它已不知该内存所在位置;操作系统也无法使用该内存,因为系统认为该内存仍在被程序占用。
内存泄漏会在程序运行期间不断消耗可用内存,不仅削弱当前程序的可用空间,更会挤占其他程序的资源。严重内存泄漏的程序可能耗尽所有可用内存,导致整台机器运行缓慢甚至崩溃。唯有在程序终止后,操作系统才能清理并“回收”所有泄漏的内存。
虽然内存泄漏可能由指针超出作用域引发,但存在其他导致内存泄漏的途径。例如,当持有动态分配内存地址的指针被赋予新值时,便可能发生内存泄漏:
int value = 5;
int* ptr{ new int{} }; // allocate memory
ptr = &value; // old address lost, memory leak results
通过在重新赋值前删除该指针即可解决此问题:
int value{ 5 };
int* ptr{ new int{} }; // allocate memory
delete ptr; // return memory back to operating system
ptr = &value; // reassign pointer to address of value
相关地,也可能通过双重分配导致内存泄漏:
int* ptr{ new int{} };
ptr = new int{}; // old address lost, memory leak results
第二次分配返回的地址覆盖了第一次分配的地址。因此,第一次分配导致内存泄漏!
同样地,通过确保在重新分配前删除指针即可避免此问题。
结论
new 和 delete 操作符使我们能够为程序动态分配单个变量。
动态分配的内存具有动态存续期,将持续保留直至被释放或程序终止。
请注意避免对悬空指针或空指针进行解引用操作。
在下一课中,我们将探讨如何使用 new 和 delete 操作符分配与释放数组。

浙公网安备 33010602011771号