11. 动态存储与所有权
动态内存管理让对象的创建时机不再受限于代码块作用域。容器、缓存以及大小在运行时才能确定的数据结构都依赖动态分配;同时,内存管理错误也是 C 程序中最常见的错误之一。
本章的完整目录:
- 11.1 柔性数组成员:让结构体以固定头部加变长尾部的方式布局。
1. 四个核心接口
malloc 申请一段未初始化的存储,calloc 申请存储并将其所有字节清零,realloc 调整已分配存储的大小,free 释放已分配的存储。它们都定义在 <stdlib.h> 中。malloc、calloc 和 realloc 失败时返回空指针,因此每次调用后都必须检查返回值。
#include <stdlib.h>
void demo(void) {
int *buf = malloc(16 * sizeof *buf);
if (buf == NULL) {
return;
}
/* 使用 buf[0] 至 buf[15] */
free(buf);
buf = NULL;
}2
3
4
5
6
7
8
9
10
11
12
13
把指针置为 NULL 不是必需的,但可以减少重复释放和释放后误用的风险。
2. realloc 的安全模式
realloc 失败时会返回 NULL,同时原指针仍然有效。因此不能直接覆盖原指针,应该先用临时指针接收结果;同时,调用方必须能够知道扩容是否成功。
#include <stdbool.h>
#include <stdlib.h>
bool grow(int **buf, size_t new_count) {
int *new_buf = realloc(*buf, new_count * sizeof **buf);
if (new_buf == NULL) {
return false; /* *buf 仍指向原来的存储 */
}
*buf = new_buf;
return true;
}2
3
4
5
6
7
8
9
10
11
12
失败时 *buf 保持不变,原有存储不会丢失,调用方也能根据返回值知道扩容没有成功。
3. 常见错误与规避
常见的错误有:忘记释放,造成内存泄漏;释放后继续通过原指针访问,即使用悬垂指针 (Dangling Pointer);对同一块存储释放两次,往往导致程序崩溃;越界写入,破坏其他数据。
避免这些错误的办法是:为每块动态存储明确一个负责释放它的所有者;让释放发生在固定的位置,例如函数的统一出口;在接口文档中写明调用方与被调用方各自的责任;并用 AddressSanitizer (ASan)、Valgrind 等工具持续检测。
4. 大小计算溢出检查
申请数组对象时,除了检查返回值,还应先检查“元素个数 × 元素大小”是否溢出。若乘法先溢出再传给 malloc,得到的存储大小会小于预期,后续写入极易越界。
#include <stdint.h>
#include <stdlib.h>
int *alloc_n(size_t n) {
if (n > SIZE_MAX / sizeof(int)) {
return NULL;
}
return malloc(n * sizeof(int));
}2
3
4
5
6
7
8
9
5. 习题
实现一个固定容量的整数环形缓冲区。接口如下:
#include <stdbool.h>
#include <stddef.h>
typedef struct IntRing IntRing;
IntRing *ring_create(size_t capacity);
void ring_destroy(IntRing *ring);
bool ring_push(IntRing *ring, int value);
bool ring_push_overwrite(IntRing *ring, int value);
bool ring_pop(IntRing *ring, int *out);
void ring_clear(IntRing *ring);
size_t ring_size(const IntRing *ring);2
3
4
5
6
7
8
9
10
11
12
要求:
ring_create(0)、大小计算溢出或申请失败时返回NULL。ring_push按先进先出顺序写入;缓冲区已满时返回false,并保持原状态。ring_push_overwrite在未满时等同于ring_push;已满时覆盖并丢弃最早写入的元素。ring_pop通过out返回最早写入的元素。参数无效或缓冲区为空时返回false,并保持原状态。ring_clear只清空逻辑内容,不释放缓冲区;ring_destroy(NULL)应当安全。- 除创建和销毁外,每项操作的时间复杂度均为
,操作过程中不得搬移已有元素。
调用方拥有 ring_create 返回的对象,并负责用 ring_destroy 释放它。
实现可增长的字节缓冲区:
#include <stdbool.h>
#include <stddef.h>
typedef struct {
unsigned char *data;
size_t size;
size_t capacity;
} ByteBuffer;
void byte_buffer_init(ByteBuffer *buffer);
void byte_buffer_destroy(ByteBuffer *buffer);
bool byte_buffer_reserve(ByteBuffer *buffer, size_t min_capacity);
bool byte_buffer_append(
ByteBuffer *buffer,
const void *source,
size_t count
);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
要求:
- 初始化后满足
data == NULL、size == 0、capacity == 0;销毁后恢复这一状态,byte_buffer_destroy(NULL)应当安全。 - 始终保持
size <= capacity,并保证capacity == 0当且仅当data == NULL。 reserve只扩容,不缩容;成功后容量至少为min_capacity。append把count个字节追加到已有内容末尾。count为 0 时应成功,此时source可以为NULL。- 当
count > 0时,source必须有效,且不得与缓冲区拥有的存储区域重叠。 - 参数无效、大小计算溢出或分配失败时返回
false,并保持缓冲区的三个字段及已有字节内容不变。 - 容量采用几何增长,使连续追加的扩容成本具有摊还保证。
实现二进制流的完整读取:
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
bool read_all(
FILE *stream,
unsigned char **out_data,
size_t *out_size
);2
3
4
5
6
7
8
9
要求:
- 从流的当前位置开始读取,直至文件结束;不得假设流支持定位。
- 成功时按原顺序返回全部字节,内容中可以包含零字节。
- 空输入成功时设置
*out_data = NULL、*out_size = 0。 - 调用方拥有成功返回的存储,并负责用
free释放。 - 参数无效、读取失败、大小计算溢出或分配失败时返回
false,释放本次调用已经申请的全部存储,并保持两个输出对象不变。 - 函数不关闭流,也不清除流的文件结束指示器或错误指示器。
- 存储容量采用几何增长,总时间复杂度为
。
6. 从对象视角说明所有权
设计函数接口时,应当从对象的角度写清楚四个问题:谁负责创建对象,谁负责释放对象,谁可以修改对象,谁只能读取对象。
7. 从固定数组过渡到动态存储
当数组长度需要在运行时决定时,常见做法是动态分配一块存储,再通过指向其首元素的指针访问。这个指针可以像数组名一样使用下标,但它不携带长度信息,因此程序必须另外保存元素个数,并据此检查下标。
#include <stdio.h>
#include <stdlib.h>
int main(void) {
size_t n = 5;
int *arr = malloc(n * sizeof *arr);
if (arr == NULL) {
return 1;
}
for (size_t i = 0; i < n; ++i) {
arr[i] = (int)(i * i);
}
for (size_t i = 0; i < n; ++i) {
printf("%d ", arr[i]);
}
printf("\n");
free(arr);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
输出:
0 1 4 9 16
与在代码块内定义的数组不同,动态分配的对象不会在离开作用域时自动释放,必须显式调用 free。实际项目中,常把指针和长度一起放进一个结构体,再通过函数访问其中的元素,由这些函数统一检查下标。
8. 用复合语句限定资源范围
把对象声明在更小的复合语句中,可以让它的初始化、使用和清理集中在一处。
#include <stdio.h>
int print_first_line(const char *path) {
int rc = 1;
{
FILE *fp = fopen(path, "r");
if (fp == NULL) {
return rc;
}
char buf[256];
if (fgets(buf, sizeof buf, fp) != NULL) {
fputs(buf, stdout);
rc = 0;
}
fclose(fp);
}
return rc;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
这里 fp 和 buf 的作用域只限于内层块,块之后的代码无法再访问它们。fgets 读入的内容保留了行尾的换行符,所以用 fputs 原样输出。