11. 动态存储与所有权
动态内存管理让对象的创建时机不再受限于代码块作用域。它是构建容器、缓存和运行期可变数据结构的基础能力,同时也是 C 程序最常见的错误来源之一。
1. 四个核心接口
malloc 申请一段未初始化存储,calloc 申请并清零,realloc 调整已分配存储大小,free 释放已分配存储。它们都定义在 <stdlib.h> 中,返回值与失败语义必须显式检查。
#include <stdlib.h>
int *buf = malloc(16 * sizeof *buf);
if (buf == NULL) {
return;
}
free(buf);
buf = NULL;2
3
4
5
6
7
8
9
把指针置为 NULL 不是语义必须,但它可以减少重复释放和悬垂访问风险。
2. realloc 的安全模式
realloc 失败时会返回 NULL,同时原指针仍然有效。因此不能直接覆盖原指针,应该先用临时指针接收结果。
#include <stdlib.h>
int *grow(int *old_buf, size_t new_count) {
int *new_buf = realloc(old_buf, new_count * sizeof *new_buf);
if (new_buf == NULL) {
return old_buf;
}
return new_buf;
}2
3
4
5
6
7
8
9
这种写法可以确保失败时不丢失原有存储地址。
3. 常见错误与规避
最常见问题包括:忘记释放导致泄漏,释放后继续访问导致悬垂引用,重复释放导致运行时崩溃,越界写入破坏数据。规避思路很直接:统一所有权、固定释放路径、在接口层写清责任边界,并使用工具链做持续检测(如 ASan、Valgrind)。
4. 大小计算溢出检查
申请数组对象时,除了检查返回值,还应先检查“元素个数 × 元素大小”是否溢出。若乘法先溢出再传给 malloc,得到的存储大小会小于预期,后续写入极易越界。
#include <stddef.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,释放本次调用已经申请的全部存储,并保持两个输出对象不变。 - 函数不关闭流,也不清除流的文件结束指示器或错误指示器。
- 存储容量采用几何增长,总时间复杂度为
。
从对象视角说明所有权
以对象为中心设计函数接口时,协作边界会更清晰。
从固定数组过渡到动态存储
当数组长度需要在运行时决定时,常见做法是使用动态分配并通过指针访问。此时虽然语法上看起来像“数组”,本质上是“指向首元素的指针 + 长度约束”。如果缺少长度信息,边界检查就无法保证。
#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。在真实项目中,推荐把“指针 + 长度”封装成结构体接口,避免散落的边界错误。
用复合语句限定资源范围
复合语句还是“资源生命周期贴近使用点”的基础工具。把对象声明放进更小的块,能让初始化、使用和收尾在同一视野内完成,减少状态扩散。
#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) {
puts(buf);
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 的可见范围都被限制在内层块中,后续代码不可能误用它们,结构会更稳。