动态内存、生命周期与所有权
本节目标
查询 C23 动态存储、分配大小、分配失败、realloc、释放、生存期与所有权约定。
本章用于查询动态分配对象从取得、使用、调整到释放的边界。C 不提供语言级所有权类型系统;这里的“持有”“借用”和“转移”都是 API 与接口文档采用的责任约定,编译器不会替接口自动执行它们。
检查一段动态内存代码时,按“分配 → 使用 → 转移 → 释放”逐项核对:
- 分配:元素数量与字节数是否可表示,返回值是否已检查?
- 使用:对象是否仍在生存期内,待读内容是否已经初始化,索引是否在范围内?
- 转移:谁仍负责释放,旧持有者与借用者从哪一刻起不得继续使用?
- 释放:每条成功或失败路径是否恰好释放一次,释放后是否还存在会被使用的别名?
动态存储与分配对象的生存期
分配函数成功返回时,调用方取得一块已分配存储以及指向它的指针;相应分配对象的生存期持续到这块存储被释放。自动对象随块作用域结束而结束生存期,已分配对象则不会因为保存指针的局部变量离开作用域而自动释放。
| 查询点 | 判断边界 |
|---|---|
| 生存期何时开始 | 分配调用成功并返回可用的非空指针后 |
| 生存期何时结束 | 对相应分配调用 free,或一次成功的 realloc 释放旧对象时 |
| 指针变量离开作用域 | 只结束该指针对象的生存期,不会自动释放它指向的分配对象 |
| 复制指针 | 只增加别名,不会复制分配对象,也不会产生新的释放责任事实 |
对象到达生存期末尾后,使用指向它或单尾位置的指针值参与求值,行为未定义;相关指针对象的表示也变为不确定。因此,必须同时追踪“分配对象是否存在”和“哪些指针值仍可使用”。仅看某个指针变量是否为 nullptr,不能推出其他别名的状态。
分配大小、元素数量与溢出
分配数组通常要把元素数量换算为字节数。count * sizeof element 必须在乘法前检查是否超过 SIZE_MAX;先乘再比较会让无符号运算已经发生回绕,失去检查意义。
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
static bool checked_bytes(size_t count, size_t element_size, size_t *bytes) {
if (
bytes == nullptr ||
(element_size != 0 && count > SIZE_MAX / element_size)
) {
return false;
}
*bytes = count * element_size;
return true;
}
把数量和容量保存为 size_t 可以与 sizeof 和分配接口的参数类型一致,但类型一致不等于计算自动安全。接口还应明确是否接受零个元素;示例包装函数选择拒绝零数量,以免把零大小分配的边界传播到普通调用路径。
malloc、calloc 与初始状态
malloc(bytes) 和 calloc(count, element_size) 都可能返回空指针。它们最关键的差别是成功后的初始字节状态:
| 接口 | 成功后得到的区域 | 读取前的动作 |
|---|---|---|
malloc(bytes) | 成功分配空间的对象表示不确定 | 先按目标对象与范围写入,再读取 |
calloc(count, element_size) | 区域的所有位被置零 | 仍要判断目标类型能否把全零位模式解释为所需值 |
calloc 在 count * element_size 将回绕 size_t 时返回空指针。calloc 对分配区域做字节零填充,不能把这一规则泛化为任意类型的语义零值。尤其不要据此假定所有实现的空指针或浮点零都必然采用全零位模式。若接口需要明确的元素值,逐元素初始化能直接表达该语义。
选择接口时先问“调用方需要什么初始状态”,再问“怎样建立这些值”;不要把 calloc 当成跳过类型语义检查的捷径。
分配失败与空指针
分配失败必须在解引用或写入之前处理。只有成功分支才能把返回值当作对象地址使用:
size_t bytes = 0;
if (!checked_bytes(count, sizeof(int), &bytes)) {
return false;
}
int *values = malloc(bytes);
if (values == nullptr) {
return false;
}
values[0] = 1;
| 接口选择 | 失败约定 |
|---|---|
| 返回空指针 | 调用方在继续前检查,并保留足够状态执行清理 |
| 返回状态并使用输出参数 | 失败时不把未建立的对象交给调用方 |
| 由上层终止操作 | 明确记录这是应用策略,不冒充 C 分配接口的默认行为 |
错误分支不能在报告失败前丢失既有资源;当一次操作包含多次分配时,后续分配失败还要回滚此前已经取得的资源。
realloc 与安全扩缩容
realloc 可能把对象移动到新地址,也可能在原地址完成调整。成功时旧分配对象结束,返回值指向调整后的分配对象。当 ptr 非空且请求大小为零时,调用 realloc 的行为未定义。只有请求大小非零时,若新对象未分配成功,旧对象才保证不被释放且其值不变。realloc 的结果先保存到临时指针,失败时原持有指针仍可用于清理。
size_t bytes = 0;
if (!checked_bytes(grown_count, sizeof *values, &bytes)) {
return false;
}
int *resized = realloc(values, bytes);
if (resized == nullptr) {
free(values);
return false;
}
values = resized;
values[old_count] = 5;
values[old_count + 1] = 9;
扩容保留旧大小范围内的内容,但不会替新增加的字节建立应用需要的值。扩容得到的新增区域必须在读取前初始化。缩容则使新大小之外的旧范围不再可访问;成功调整后,所有依据旧分配对象保存的别名都不能继续用来访问,即使实现恰好返回相同地址也不能把旧别名当作仍有效的授权。
下面的完整代表程序检查分配字节数、验证 calloc 的整数数组初始值、用临时指针扩容、初始化两个新增元素,并通过持有者接口释放:
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
static bool checked_bytes(size_t count, size_t element_size, size_t *bytes) {
if (
bytes == nullptr ||
(element_size != 0 && count > SIZE_MAX / element_size)
) {
return false;
}
*bytes = count * element_size;
return true;
}
static int *allocate_values(size_t count) {
size_t bytes = 0;
if (count == 0 || !checked_bytes(count, sizeof(int), &bytes)) {
return nullptr;
}
return calloc(1, bytes);
}
static bool values_are_zero(const int *values, size_t count) {
for (size_t index = 0; index < count; ++index) {
if (values[index] != 0) {
return false;
}
}
return true;
}
static int sum_values(const int *values, size_t count) {
int total = 0;
for (size_t index = 0; index < count; ++index) {
total += values[index];
}
return total;
}
static bool resize_values(int **owner, size_t count) {
size_t bytes = 0;
if (
owner == nullptr ||
count == 0 ||
!checked_bytes(count, sizeof **owner, &bytes)
) {
return false;
}
int *resized = realloc(*owner, bytes);
if (resized == nullptr) {
return false;
}
*owner = resized;
return true;
}
static void release_values(int **owner) {
if (owner == nullptr) {
return;
}
free(*owner);
*owner = nullptr;
}
int main(void) {
size_t count = 4;
int *values = allocate_values(count);
if (values == nullptr) {
fputs("allocation failed\n", stderr);
return EXIT_FAILURE;
}
printf("initial-zeroed=%s\n", values_are_zero(values, count) ? "yes" : "no");
values[0] = 3;
values[1] = 1;
values[2] = 4;
values[3] = 1;
printf("initial-count=%zu\n", count);
printf("initial-sum=%d\n", sum_values(values, count));
const size_t grown_count = 6;
if (!resize_values(&values, grown_count)) {
fputs("resize failed\n", stderr);
release_values(&values);
return EXIT_FAILURE;
}
values[4] = 5;
values[5] = 9;
count = grown_count;
printf("grown-count=%zu\n", count);
printf("grown-sum=%d\n", sum_values(values, count));
release_values(&values);
printf("released=%s\n", values == nullptr ? "yes" : "no");
return EXIT_SUCCESS;
}
输出:
initial-zeroed=yes
initial-count=4
initial-sum=9
grown-count=6
grown-sum=23
released=yes
free、生存期结束与悬空指针
free 结束相应分配对象的生存期,把一个变量置为 nullptr 不会修复其他别名。free(nullptr) 不执行动作,但对已经释放的地址再次调用 free 的行为未定义;通过悬空指针读取或写入同样是未定义行为。
int *owner = malloc(sizeof *owner);
if (owner != nullptr) {
int *alias = owner;
free(owner);
owner = nullptr;
// alias 现在悬空;不能解引用,也不能再次释放。
}
释放后的置空只对那个指针对象有局部价值,例如让同一变量的后续检查更明确。它不是别名追踪机制,也不能把已经发生的释放后使用变成安全操作。
持有、借用与所有权转移
“唯一持有者”描述的是接口约定:某一方负责保证分配对象最终被释放一次;它不表示 C 类型系统禁止复制指针。借用和所有权转移必须由接口文档明确释放责任。
| 接口关系 | 调用前责任 | 调用后责任 | 文档必须说明 |
|---|---|---|---|
| 持有者传入可变借用 | 持有者保持对象存活 | 持有者仍负责释放 | 借用长度、可写范围、是否允许空指针 |
| 持有者传入只读借用 | 持有者保持对象存活 | 持有者仍负责释放 | 只读约束和借用有效期 |
| 所有权转移给被调用方 | 调用方在转移前负责释放 | 成功转移后被调用方负责释放 | 失败时是否发生转移、调用方何时停止使用 |
| 函数返回新持有指针 | 函数在返回前负责失败清理 | 成功后调用方负责释放 | 配对的释放接口与失败表示 |
一个清晰接口可以约定“成功返回后转移,失败时调用方仍持有”,也可以选择“函数总是接管”;两者都能实现,但不能靠函数名或参数类型猜测。若返回内部借用指针,还必须说明它不会比所属对象活得更久。
清理路径与失败回滚
当函数依次取得多个资源时,集中清理可以让每个失败分支只记录状态并跳到共同出口。goto cleanup 是可审查的集中清理模式之一,不是唯一正确风格;少量资源也可以用结构化的早返回,只要所有路径的释放责任同样清晰。
static bool build_buffers(size_t count) {
int *left = nullptr;
int *right = nullptr;
bool succeeded = false;
size_t bytes = 0;
if (!checked_bytes(count, sizeof *left, &bytes)) {
goto cleanup;
}
left = malloc(bytes);
if (left == nullptr) {
goto cleanup;
}
right = malloc(bytes);
if (right == nullptr) {
goto cleanup;
}
succeeded = true;
cleanup:
free(right);
free(left);
return succeeded;
}
这个片段复用前文的 checked_bytes,并把控制流集中到一个清理出口。审查时从每个分配成功点沿所有后继路径检查:资源是否进入唯一清理出口,是否会提前转移给其他持有者,以及回滚是否会碰到已经转移或释放的对象。
零大小请求、对齐与接口边界
零大小请求与特殊对齐必须按具体接口和实现边界处理。不要把某台机器的一次返回值观察写成跨实现保证,也不要让普通数组接口含糊地接受零数量后再猜测返回指针能否使用。
| 边界 | 可移植的处理策略 |
|---|---|
malloc(0)、calloc(0, size) | 按标准与目标实现规定处理返回值;无论返回形式如何,都不通过它访问元素 |
realloc(pointer, 0) | pointer 非空时行为未定义;在调用 realloc 前把零容量分支写成明确的 free 与状态更新 |
aligned_alloc(alignment, size) | alignment 不是实现支持的有效对齐值时返回空指针;size 为零仍遵循零大小分配规则,并始终检查失败返回 |
| 普通分配的对齐 | 只把结果用于该分配接口保证能够承载的对象类型;更严格对齐使用相应专用接口 |
实现是否支持所需对齐、本次分配是否成功以及零大小请求采用哪一种结果都不是跨实现常量,所以这里不展示固定地址或固定的成功结果。把这些选择封装在边界函数中,可以让业务代码只接收“成功得到可访问的 N 个元素”或“失败”两种明确结果,而不是在每个调用点重复解释特殊请求。
常见陷阱
以下反例都假定相应的初始 malloc 调用成功并返回非空指针,因此才分别构成泄漏、重复释放、释放后使用或原持有指针丢失;它们只用于审查,绝不能执行:
int *leaked = malloc(sizeof *leaked);
leaked = nullptr; // 泄漏:最后一个持有地址丢失。
int *twice = malloc(sizeof *twice);
free(twice);
free(twice); // 重复释放。
int *expired = malloc(sizeof *expired);
free(expired);
int value = *expired; // 释放后使用。
int *lost = malloc(4 * sizeof *lost);
lost = realloc(lost, 8 * sizeof *lost); // 失败会丢失原持有指针。
| 症状 | 直接原因 | 修正方向 |
|---|---|---|
| 内存泄漏 | 最后一个持有指针被覆盖或离开作用域 | 覆盖前释放,或明确转移给新持有者 |
| 重复释放 | 多条路径都把自己当成释放责任方 | 统一持有约定并让成功转移点清晰可见 |
| 释放后使用 | 别名在对象生存期结束后继续访问 | 让借用不越过释放或成功调整点 |
| 丢失原指针 | 把 realloc 结果直接写回唯一持有变量 | 先接收临时结果,成功后再更新持有者 |
最终审查顺序仍是“大小计算 → 失败检查 → 初始化 → 借用范围 → 转移时点 → 每条路径恰好释放一次”。看到 free 并不等于清理正确;必须同时验证它释放的是当前仍由本路径持有、仍处于生存期内的分配对象。