C Programming Language

Basics #08: Dynamic Memory Allocation

# Basic Tutorial,  # Programming Language,  # C Programming Language

Last Updated: 2020-07-01


前置知识

在阅读本文档前,请确保你已经掌握函数的基础知识,详见 Basics #02: Function

在阅读本文档前,请确保你已经掌握作用域和生命周期的基础知识,详见 Basics #05: Scope and Lifetime


前言

动态内存分配是 C 语言语法上必不可少的重要组成部分。我们先考虑两个实际问题,以便先了解动态分配内存的意义和作用。

其一,如果我们希望定义一个数组,其长度由用户的输入决定,应该如何编写程序实现。

在 C89 标准中,直接声明并定义的数组无法做到,因为 C89 标准不仅禁止混合声明和代码,而且禁止使用变长数组。例如:

int n;
scanf("%d", &n);
int a[n];

上述代码在 C89 标准下会得到如下编译错误:

| | In function 'main':
|3| error: ISO C90 forbids mixed declarations and code [-Wpedantic]
|3| error: ISO C90 forbids variable length array 'a' [-Wvla]

其二,如果我们希望定义一个数组,它不是全局的,但它的生存期能超出其所在的作用域,应该如何编写程序实现。

以下面的代码为例,我们试图编写一个能够创建数组并返回其首地址的函数,其它函数可以通过调用该函数来定义一些新的数组,然而这是错误且危险的:

int * get_array() {
    int a[5] = {0, 1, 2, 3, 4};
    int * p = &a[0];
    return p;
}
int main(void) {
    int * p = get_array();
    p[0] = 5;
    p[2] = 8;
    return 0;
}

虽然函数 get_array 返回了一个长为 55 的数组 aa 的首地址,但是当函数执行完毕时,数组 aa 的生存期已经结束,编译器可以选择将其销毁,因而 pp 事实上已经成为野指针,在主函数中对 pp 的任何解引用操作都属于未定义行为。程序可能会崩溃,或者产生预料之外的运行结果,但也可能一切正常。

显然现有的语法知识无法解决这两个问题,为此我们需要掌握动态分配内存的方法。


变量的生命周期

变量的 生命周期 (lifetime) 指的是变量从被创建(被分配一块内存)到被销毁(内存被回收)的起止时间。根据变量的生命周期,可以将 C 程序中定义的各种变量分为三大类:

  • 静态变量 (static variables) 通常在程序的初始化阶段分配好内存,并在程序的整个生命周期内持续存在,包括全局变量、静态变量和字符串常量等;

  • 自动变量 (automatic variables) 随着函数调用自动地分配,并随着函数返回自动地被回收,包括非静态局部变量等;

  • 动态变量 (dynamic variables) 由动态内存管理函数手动地分配和回收。

其一,在 C 语言中,静态变量和自动变量的内存大小必须是在编译时刻可以确定的(除 C99 变长数组外)。也就是说,你不能在程序的运行阶段去指定这两类变量的大小,哪怕是输入一个整数 nn 然后定义一个长为 nn 的普通数组这么简单的事情。

其二,在 C 语言中,静态变量的生命周期几乎和整个程序等长,在大型程序中滥用静态变量会危害系统的可扩展性,并且过大的静态变量会浪费内存资源;而自动变量的生命周期无法超出当次函数调用的生命周期,当函数返回时,在当次函数调用中分配的自动变量均会消亡。在许多情况下,我们在变量的生命周期方面都需要更大的灵活性。


动态内存管理

基本概念

在 C 语言中, 动态内存分配 (dynamic memory allocation) 是指通过 C 标准库中的一组特定的函数,在一块特定的内存区域中进行 手动的内存管理 (manual memory management) 。使用动态分配的内存可以规避静态变量和自动变量的限制。

用于处理动态内存分配的函数可以分为分配和回收两大类,其中用于分配的函数包括 mallocrealloccalloc 等,用于回收的函数为 free 。以 malloc 函数为例,该函数可以分配一个指定大小的内存块,并返回指向内存首部的指针,后续程序可以通过该指针访问该内存块。当不再需要内存时,我们可以以该指针作为参数调用 free 函数,从而释放内存。

不难看到,我们可以在运行时刻指定动态分配的内存大小,例如 malloc 函数的一个参数就是内存块的大小。此外,动态分配的内存的生存期也非常灵活,只能通过调用 free 函数来释放内存,否则内存就会一直存在。

在 C 语言中,动态分配的内存一般位于被称为“堆”的特定内存空间中。

动态内存分配并不是完美的,它引入了发生内存泄漏的风险,为自己的灵活性付出了相应的代价。有关内存泄漏的更多细节详见下文。

为便于描述,我们会先以 malloc 函数为例介绍基本的动态内存分配方法,并在后文单独介绍 realloccallocmalloc 的区别。

内存分配

malloc 函数分配指定大小的内存空间,并返回指向该空间首地址的指针。使用该函数分配的空间是未初始化的,在使用前必须显式地将其初始化。

void * malloc(size_t size);

malloc 函数接收一个 size_t 类型的参数来指定内存大小,其返回的指针类型为 void * ,因为它并不清楚程序会用这块内存去做什么,例如是作为整型数组还是字符数组,又或者是作为单个整数或是一个 struct 对象。应当使用合理类型的变量来接收该返回值。

注: size_t 类型是 C 语言中被赋予了特殊语义的一种无符号整数类型,常用于数组索引和循环计数,其具体长度是实现定义的。初学者可近似将其视为 unsigned int 类型,但事实并非如此。

为准确利用动态分配的内存空间,程序员通常会分配形如 n * sizeof(T) 的内存大小,其中 n 为数组长度, T 为某种类型或某个对象。

例如下面的代码中,为 p1 分配了一个整数大小的空间,为 p2 分配了一个长为 nn 的整数数组对应大小的空间,为 p3 分配了一个结构体类型 Block 对应大小的空间:

int n;
int *p1, *p2, *p3;
scanf("%d", &n);
p1 = malloc(sizeof(int));
p2 = malloc(n * sizeof(int));
p3 = malloc(sizeof(struct Block));

分配好内存之后,我们可以像通常那样对待这些指针,例如对于单个变量:

int * p = malloc(sizeof(int));
int * q;
int x;
q = p;
*q = 1;
x = *p;

再例如对于数组:

int n = 5;
int * p = malloc(n * sizeof(int));
int * q;
int x;
int i;
for (i = 0; i < n; i ++) {
    p[i] = i * i;
}
q = p + 3;
*q = -1;
x = p[2] + *(p + 3);

当然,要想获取指定内存大小的具体数值有多种方法,并没有什么唯一或最好的方法,选择合适的或自己偏好的写法即可。例如下面的两组代码展示了其它获取内存大小的写法:

int n;
int * p1 = malloc(sizeof(n));   // n is an int-type-variable
int * p2 = malloc(sizeof(*p2)); // p2 is pointer-to-int so *p2 is int
int n;
int *p1, *p2;
scanf("%d", &n);
p1 = malloc(n * sizeof(int));
p2 = malloc(*p1);

再例如对于已知长度的数组我们可以用这样的写法来指定长度:

int n;
int *p1, *p2;
scanf("%d", &n);
p1 = malloc(sizeof(int[4])); // same as 4 * sizeof(int)
p2 = malloc(sizeof(int[n])); // illegal before C99

再举一个二级指针的例子:

int x = 1;
int * p = &x;
int ** pp1 = malloc(sizeof(int *));
int ** pp2 = malloc(sizeof(p));

现在我们可以很轻松地实现前文中所述的 get_array 函数的功能了:

int * get_array(int n) {
    return malloc(n * sizeof(int));
}

当然,要想投入实际工程使用,上述实现是有很多问题的,后文会对此给出详细解释。

内存回收

计算机不会像对待静态变量和自动变量那样对待动态变量,由你手动分配的动态内存必须手动回收。当我们不再需要这些空间时,需要用 free 函数将其回收,例如:

int n;
int * p;
scanf("%d", &n);
p = malloc(n * sizeof(int));
/*
* maybe a large amount of codes ...
*/
free(p);

回收内存需要确保以下几点:

  • 任何动态分配的内存都必须回收。如果程序失去了回收某块内存的能力(例如丢失了所有指向该内存块的指针),将会导致内存泄漏。

  • 任何内存都仅能回收一次。如果多次回收同一块内存(且期间未再次在同一位置分配内存),将会触发未定义行为,程序可能崩溃,也可能会导致程序状态发生意料之外的变化。

  • 任何情况下不得回收静态内存或自动内存。如果错误回收,将会触发未定义行为。

上述条例看似容易维持,但是实际操作起来却有极大概率出现问题,尤其是在复杂的大型程序中,内存泄漏总是与 C/C++ 实现的软件“紧密相伴”。更多具体细节详见下文。

处理分配失败

malloc 函数(及其它分配函数)并不总是能正确地分配程序所要求的内存空间,存在失败的可能性。当系统无法满足程序的内存分配请求,或系统出现故障时, malloc 函数将会分配失败,并返回一个空指针(即 NULL)。因此一个健壮的程序应该为潜在的内存分配失败做好准备,例如:

int * get_array(int n) {
    int * p = malloc(n * sizeof(int));
    if (p == NULL) {
        printf("error: failed to allocate.\n");
        exit(-1);
    }
}

否则一旦分配失败,后续程序对无效指针的解引用很可能会触发未定义行为,且由于 get_array 函数可能在程序的各个位置被调用,排查错误的难度将会急剧上升。

除系统故障外,无法满足内存分配请求的情况主要有两种:一是申请空间过大(比如二话不说先要个 10 GB 什么的),二是内存泄漏太过严重以至于进程的可用空间耗尽。有关内存泄漏的更多细节详见下文。

另外一个需要注意的问题是,如果我们试图分配一块大小为零的内存,那么将会触发未指定行为,结果将是不确定的。对于上面的例子,如果传入参数 n=0n = 0 ,那么 malloc 函数可能会真的返回一块大小为 00 的内存空间的首地址(即便这毫无用处)而不是一个空指针。不仅 if 表达式不会起作用,而且接下来任何试图利用 p 访问内存的行为都将引起非法内存访问,进而触发未定义行为。

因此我们必须专门处理 n==0n == 0 的情况。例如:

int * get_array(int n) {
    if (n == 0) {
        printf("error: try to invoke get_array(0).\n");
        exit(-1);
    }
    int * p = malloc(n * sizeof(int));
    if (p == NULL) {
        printf("error: failed to allocate.\n");
        exit(-1);
    }
}

良好的代码风格

如果目标变量的指针类型在程序的维护过程中发生了变化,但是因为疏忽而没有同步更新 sizeof 表达式的内容,很可能会导致内存大小的计算出错。因此在有必要时,我们建议选择更便捷且更安全的实现,例如:

int n;
int * p1;
int * p2;
scanf("%d", &n);
p1 = malloc(sizeof(*p1));
p2 = malloc(n * sizeof(*p2));

这样一来,指针声明和分配函数调用之间的耦合性将被削弱。通俗地讲,当指针 p1 , p2 的类型发生变化(例如由 int * 类型改为 double * 类型)时,我们无需对 sizeof 表达式进行任何修改。

下面是一个简单的例子。想象一下 p 的类型被修改为 double * 类型,那么每一条调用 malloc 函数并将指针赋给 p 的程序语句都需要被修改。但是如果使用耦合性弱的写法(即上面的示例;或下面示例中的注释),我们就无需为散步在程序各处的 malloc 调用感到头疼。

int * p;                     // type modified
// ...
p = malloc(sizeof(int));     // malloc(sizeof(*p));
// ...
free(p);
p = malloc(n * sizeof(int)); // malloc(n * sizeof(*p));
// ...
free(p);
p = malloc(2 * sizeof(int)); // malloc(2 * sizeof(*p));
// ...

类型安全

malloc 函数返回的是 void * 类型的指针,这表明它是指向未知数据类型的指针。

在具有强类型系统的 C++ 语言中,我们必须使用强制类型转换来将这样的指针转换为指向具体数据类型的指针。而在 C 语言中 void * 类型可以无阻碍地自动转换为指向任意数据类型的指针,强制类型转换不是必要的。

int * p = malloc(n * sizeof(int));        // without a cast
int * p = (int *)malloc(n * sizeof(int)); // with a cast

在 C 语言中,是否在接收 malloc 及其它分配函数传回的指针时使用强制类型转换是可选的,我们应当根据实际情况和个人喜好来决定是否使用。

使用强制类型转换的优点包括:

  • 易于将程序移植到 C++ 语言。在 C++ 中,此种情况下必须使用强制类型转换。

  • 可兼容早期版本的 C 语言。在 C89 之前,这些分配函数返回的是 char * 类型而不是 void * 类型,无法隐式转换。

  • 如果目标变量的指针类型在程序的维护过程中发生了变化,强制类型转换可以识别出类型大小的不一致性。

使用强制类型转换的缺点包括:

  • 在 C89 及更新的标准下,此种情况下使用强制类型转换是多余的。

  • 可能会在未能正确包含头文件 stdlib.h 时掩盖错误事实。在 C89 标准下,如果未能正确包含头文件,编译器找不到 malloc 函数原型,将会假定 malloc 函数的返回值为 int 类型(这在 C 语言的 03 - Function 文档中已有提及)。如果没有强制类型转换,则在将 int 类型的对象赋值给某个指针类型的变量时,编译器会发出警告,而如果使用了强制类型转换,那么编译器显然不会发出警告,导致错误被掩盖。

  • 指针声明和分配函数调用之间的耦合性将被增强。

优缺点的第三点可能看起来很晦涩,下面我们将给出详细的解释。

假设我们使用强制类型转换,那么如果目标变量的指针类型在程序的维护过程中发生了变化,所有与之相关的使用了强制类型转换的 malloc 调用语句都需要被修改。相反,假设我们不使用强制类型转换,那么基于前文中“良好的代码风格”一节的前提,当目标变量的指针类型发生变化时,我们无需为散步在程序各处的 malloc 调用感到头疼。这解释了缺点中的第三点。

但是相对地,如果你的 sizeof 表达式是与类型说明符相关联的(例如 n * sizeof(int) 等),那么使用强制类型转换可以帮助你检测出所有需要修改的程序语句,不使用反而会为程序埋下修改时发生遗漏的隐患。这解释了优点中的第三点。

double * p;                     // type modified
p = (int *)malloc(sizeof(int)); // will get a compile-error

动态内存的匿名性

在 C 语言中,动态分配的内存往往是 匿名的 (anonymous) ,这意味着我们不能通过某些标识符直接访问这些内存。

静态或自动管理的内存都可以通过至少一种标识符(例如变量名或 typedef 名称等)直接访问,当然,需要在变量的作用域内,而动态管理的内存则不然。当我们使用分配函数动态地分配好内存块时,会获得该内存块的地址值,我们可以将该值赋给一个指针变量,并通过指针进行间接访问,但是却没有可以直接访问这块内存的标识符(变量名)。

事实上,动态内存的匿名性也是 C 语言容易发生内存泄漏的重要原因之一。

常用分配函数

常用的分配的函数包括 mallocrealloccalloc 。三者的共同点是返回值均为 void * 类型,且在分配失败时均会返回空指针,但它们之间还是有很大区别的。

malloc

malloc 函数用于分配大小为 size 字节的内存空间。该函数分配的内存是未初始化的,在使用前必须显式地将其初始化。

void * malloc(size_t size);

calloc

calloc 函数用于分配一个长为 num 的数组,数组中每个元素的大小为 size 字节。该函数分配的内存是自动初始化为 00 的。

void * calloc(size_t num, size_t size);

需要注意的是,由于内存对齐,calloc 函数的语义并不等价于“分配大小为 num * size 字节的内存空间”。有关内存对齐的更多细节,详见 C 语言的 05 - Memory Layout and Rules 文档。

calloc 函数的使用示例如下:

int * p1 = calloc(4, sizeof(int));    // allocate and zero out an array of 4 int
int * p2 = calloc(1, sizeof(int[4])); // same, naming the array type directly
int * p3 = calloc(4, sizeof *p3);     // same, without repeating the type name

realloc

realloc 函数用于重新分配指针 ptr 所指定的内存区域,将其大小重置为 new_size 字节。该函数只能用于动态分配的、且尚未被释放的内存空间,否则将会触发未定义行为。

void * realloc(void * ptr, size_t new_size);

realloc 函数需要注意的细节较多,包括:

  • 如果内存空间扩大,则新增部分的内存是未初始化的;如果内存空间缩小,则剩余部分的内存中的内容与原先保持一致。

  • 重新分配可能会直接在旧内存块的位置处直接扩大或缩小;也可能重新寻找另外的地址整个重新分配,并将旧内存块中的内容按照上一条所述的规则复制到新内存块中。因此 ptr 所指的地址值可能会发生变化、也可能不会。

  • 调用 calloc(NULL, size) 等同于调用 malloc(size)

  • 如果分配失败,虽然函数返回空指针,但是 ptr 所指向的旧内存块不会被释放。


内存泄漏

内存泄漏 (Memory Leak) 是指程序未能释放已经不再使用的内存的情况。内存泄漏通常都指代动态分配的内存空间未能正确释放,因为在正常情况下,静态或自动管理的内存空间都能被程序正确回收。实际上,内存泄漏通常意味着程序失去了访问某块动态分配的内存的能力。

由于 C 语言的语法没有提供任何识别某块动态内存的状态的方法,而这些内存又必须手动地回收,所以我们必须确保对每一块动态分配的内存空间保持追踪。一旦运行中的程序丢失了指向某块空间的所有指针,将会导致内存不能正确回收(即导致内存泄漏),例如:

int a[10];
int * p;
p = malloc(10 * sizeof(*p));
/*
* maybe a large amount of codes ...
*/
p = a + 4;
// now, how to free the dynamic-allocated memory ?

另外,如果程序忘记了某块空间是否已经被释放,也可能导致内存泄漏,或导致内存的重复释放。

内存泄漏会给运行中的程序留下潜在的巨大隐患。这绝不是危言耸听,不要以为少量内存的泄漏无伤大雅,积少成多就可能会导致不可挽回的后果。

由于动态内存具有匿名性,并且 C 语言不支持在语法层面识别一块动态内存的状态(例如是否已经被释放),我们必须谨慎地维护那些指向动态分配的内存块的指针,以避免内存泄漏或内存重复释放。这说起来容易,但在实践中却极易出错。事实上,许多 C/C++ 实现的大型软件系统都饱受内存泄漏问题的困扰,且问题大多是由动态分配内存的处理不当导致的,可见即便是经验丰富的程序员也必须对此保持谨慎。

我们在此只举一个简单的例子来说明维护这些指针并不是看起来那么简单:

void f(int n) {
    // allocate
    int * p = malloc(n * sizeof(*p));
    // some other codes
    // ...
    // when we need to check something
    if (xxx) {
        return;
    }
    // some other codes
    // ...
    // dispose
    free(p);
}

你以为你正确地释放了内存,但是实际上并没有。实践经验表明,初学者极易犯下类似的错误,正确的处理方式是在函数的每个返回点释放内存:

// when we need to check something
if (xxx) {
    free(p);
    return;
}

还存在其它多种可能,例如因为异常捕获导致内存未能正确释放等等,就不再一一列举。