指针的高级应用
Beyond the address.
指针不仅用于访问变量,也把动态内存、字符串、链表和回调连接起来。理解指针指向什么、谁负责释放内存,以及函数能修改哪一层数据,是写好这些代码的起点。
先理解内存的分配和释放,再看指针如何组织数据,最后认识二级指针与函数指针。本文完整程序示例采用 C11 写法,不需要额外库。
01 动态存储分配
固定大小的数组适合容量已知的场景。程序需要根据运行时的数据量申请或调整空间时,可以使用 <stdlib.h> 中的内存管理函数。它们返回的 void * 可以在 C 中直接赋给相应的对象指针,无需强制类型转换。
| 函数 | 用途 | 使用前提 |
|---|---|---|
malloc | 分配指定字节数 | 内容未初始化,读取前先赋值 |
calloc | 为一组元素分配空间 | 将分配的存储空间按字节清零 |
realloc | 调整已有内存块的大小 | 地址可能改变,新增长的部分未初始化 |
free | 释放已分配的内存 | 传入有效的分配起始地址,或 NULL |
内存分配可能失败。先检查返回值,再通过指针访问对象。malloc 不负责清零,但不能据此断定它在所有实现和场景中都比 calloc 更快。
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *value = malloc(sizeof *value);
// 分配失败时先处理错误,再使用指针。
if (value == NULL) {
fputs("无法为整数分配内存。\n", stderr);
return EXIT_FAILURE;
}
*value = 42;
printf("value = %d\n", *value);
free(value);
value = NULL;
return EXIT_SUCCESS;
}02 动态分配字符串
C 字符串以 '\0' 结尾。为长度为 n 的字符串分配空间时,要预留 n + 1 个字节;长度用 size_t 表示。拼接两个字符串时,还要检查长度相加是否溢出。
下面的 concat 不修改两个输入字符串,而是创建一个新字符串。发生输入或分配错误时返回 NULL,由调用者决定如何处理。
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static char *concat(const char *left, const char *right) {
if (left == NULL || right == NULL) {
return NULL;
}
size_t left_len = strlen(left);
size_t right_len = strlen(right);
// 先检查长度相加是否溢出,并为结尾的 '\0' 留一个字节。
if (left_len == SIZE_MAX ||
right_len > SIZE_MAX - left_len - 1) {
return NULL;
}
char *result = malloc(left_len + right_len + 1);
if (result == NULL) {
return NULL;
}
memcpy(result, left, left_len);
memcpy(result + left_len, right, right_len + 1);
return result;
}
int main(void) {
char *message = concat("Hello, ", "C!");
if (message == NULL) {
fputs("字符串拼接失败。\n", stderr);
return EXIT_FAILURE;
}
puts(message);
// 返回的字符串归调用者所有,由调用者负责释放。
free(message);
return EXIT_SUCCESS;
}char *reminders[50] 保存的是 50 个指针,每个元素可以指向单独分配的字符串。它与存储字符本身的二维数组不同。分配长度应按实际内容计算;用完后逐条释放已经成功分配的字符串。
03 动态数组与 realloc
数组申请的字节数是“元素数 × 单个元素大小”。使用 sizeof *values 可以让分配表达式跟随指针类型变化。乘法前检查范围,避免整数溢出导致实际分配空间过小。
realloc 扩容可能移动内存。用临时指针接收结果:当非零大小的分配失败时,原内存仍有效;成功后再更新原指针,并重新建立指向数组内部的指针。新增元素需要单独初始化。
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
int main(void) {
size_t count = 4;
int *values = calloc(count, sizeof *values);
if (values == NULL) {
fputs("数组分配失败。\n", stderr);
return EXIT_FAILURE;
}
values[0] = 10;
size_t new_count = 8;
if (new_count > SIZE_MAX / sizeof *values) {
fputs("数组大小超出可分配范围。\n", stderr);
free(values);
return EXIT_FAILURE;
}
// 用临时指针接收结果,避免失败时丢失原来的内存地址。
int *next = realloc(values, new_count * sizeof *values);
if (next == NULL) {
fputs("扩容失败,原数组仍然有效。\n", stderr);
free(values);
return EXIT_FAILURE;
}
values = next;
// realloc 不初始化新增部分,使用前需要显式赋值。
for (size_t i = count; i < new_count; ++i) {
values[i] = 0;
}
count = new_count;
for (size_t i = 0; i < count; ++i) {
printf("%d%c", values[i], i + 1 == count ? '\n' : ' ');
}
free(values);
return EXIT_SUCCESS;
}不要依赖 realloc(ptr, 0) 释放内存,不同标准版本与实现的规则存在差异。需要释放时调用 free(ptr);realloc(NULL, 非零大小) 则可以用于申请新的内存。
04 释放存储空间与所有权
free 的函数原型是 void free(void *ptr);。分配得到的空间应有明确的所有者,并在最后一次使用后释放。丢失唯一的地址记录,内存就无法再由程序主动释放,形成内存泄漏。
- 释放前保留需要使用的其他地址,例如链表中的下一个节点。
- 不能释放栈上变量、字符串字面量、内存块内部地址或已经释放过的地址。
free(NULL)不执行任何操作,可以用于失败路径的统一清理。- 释放后不再解引用旧地址,也不重复释放。必要时将持有者设为
NULL,让状态更清晰。
把一个指针设为 NULL,不会同步改变其他指向同一内存的指针。其他别名也必须停止使用;否则仍可能访问已经释放的对象。
05 用指针组织链表
链表节点保存数据和下一个节点的地址。新增节点时按需申请空间,无需提前选择整个列表的容量,但每次分配仍有可能失败。
下面以按数值升序排列的链表为例,实现插入、查找和释放。插入时先寻找目标位置,再申请节点;退出前遍历释放全部节点。
#include <stdio.h>
#include <stdlib.h>
typedef struct node {
int value;
struct node *next; // 指向下一个节点;末尾节点为 NULL。
} Node;
static int insert_sorted(Node **head, int value) {
if (head == NULL) {
return 0;
}
// link 指向待更新的链接,统一处理头部、中间和尾部插入。
Node **link = head;
while (*link != NULL && (*link)->value < value) {
link = &(*link)->next;
}
if (*link != NULL && (*link)->value == value) {
return 0; // 本示例不接收重复值。
}
Node *node = malloc(sizeof *node);
if (node == NULL) {
return 0;
}
node->value = value;
node->next = *link;
*link = node;
return 1;
}
static const Node *find_node(const Node *head, int value) {
while (head != NULL && head->value < value) {
head = head->next;
}
return head != NULL && head->value == value ? head : NULL;
}
static void free_list(Node **head) {
if (head == NULL) {
return;
}
// 先保留下一个节点的地址,再释放当前节点。
while (*head != NULL) {
Node *next = (*head)->next;
free(*head);
*head = next;
}
}
int main(void) {
Node *head = NULL;
const int numbers[] = {20, 5, 11};
for (size_t i = 0; i < sizeof numbers / sizeof numbers[0]; ++i) {
if (!insert_sorted(&head, numbers[i])) {
fputs("插入失败:内存不足或数值重复。\n", stderr);
free_list(&head);
return EXIT_FAILURE;
}
}
for (const Node *node = head; node != NULL; node = node->next) {
printf("%d\n", node->value);
}
const Node *found = find_node(head, 11);
if (found != NULL) {
printf("找到节点:%d\n", found->value);
}
free_list(&head);
return EXIT_SUCCESS;
}在字符串提醒列表或零件记录中,也可以采用相同的组织方式。区别是节点的数据成员更多,需要为输入长度、重复编号和数量范围分别制定校验规则。
06 指向指针的指针
指针作为函数参数时,同样按值传递。函数接收 Node *head,可以修改它指向的节点,但只给局部变量 head 赋一个新地址,不会改变调用者的头指针。
要修改调用者保存的指针,就传递这个指针变量的地址。上例使用 Node **head,并以 insert_sorted(&head, value) 调用。
| 表达式 | 指向或表示什么 |
|---|---|
head | 调用者头指针变量的地址 |
*head | 调用者当前保存的首节点地址 |
(*head)->value | 首节点的数据,需要先确认 *head 非空 |
Node **link 进一步把“头指针”和“某个节点的 next 指针”统一为待修改的链接。执行 *link = node,就能连接新节点,减少头部、中间与尾部插入的分支。
07 指向函数的指针
函数指针保存可调用的函数地址。它的返回类型和参数类型必须匹配目标函数,可用于回调和按条件选择处理方式。用 typedef 为函数指针类型命名,通常更容易阅读。
#include <stdio.h>
#include <stdlib.h>
typedef int (*Operation)(int, int);
static int add(int left, int right) {
return left + right;
}
static int subtract(int left, int right) {
return left - right;
}
int main(void) {
// 函数指针的参数和返回类型必须与目标函数匹配。
Operation operation = add;
printf("12 + 7 = %d\n", operation(12, 7));
operation = subtract;
printf("12 - 7 = %d\n", operation(12, 7));
return EXIT_SUCCESS;
}Operation 表示“接收两个 int 并返回 int 的函数指针”。上例中的数值计算不会超出 int 范围;用于外部输入时,还要为运算溢出制定处理策略。
SOURCE & REFERENCE
参考与整理
本文根据学习资料《指针的高级应用》整理,参考《C 语言程序设计:现代方法》。示例补充了分配失败处理、长度检查和资源释放,并修正了资料中的部分笔误。
延伸阅读:realloc 的接口规则