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;
}

image

在上面的程序中,先前分配给内存的值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 操作符分配与释放数组。

posted @ 2026-01-16 18:28  游翔  阅读(3)  评论(0)    收藏  举报