万字详解:C 指针与动态内存分配
指针是 C 语言中的重要知识点。理解指针,不能只记住某种写法,还需要分清指针变量、指针值、所指对象,以及它们各自的类型和生命周期。本文针对指针相关容易混淆的问题,通过说明、对比和问答形式讲解这些知识。 本篇之后还有更多其他内容:一文详解:C结构体、一文详解:C枚举、一文详解:C共用体
目录
- 一、指针含义与类型
- 二、指针的赋值
- 不同类型的指针为什么不能随意赋值?
- 三、void 指针
- 为什么可以定义 void 指针,却不能定义 void 变量?
- 四、空指针、野指针与悬空指针
- 1. 空指针
- 2. 野指针
- 3. 悬空指针
- 4. 悬空指针与内存泄漏的区别
- 5. 不为空,不代表有效
- 五、解引用与取地址
- 六、const 与指针
- 1. 指向 const 限定类型的指针
- 2. 指针本身带 const 限定
- 3. 两边都加 const
- 七、二级指针
- 八、指针与数组
- 指向首元素与指向整个数组
- 数组下标与指针表达式
- 数组作为函数参数
- 九、指针的应用:函数传参
- 十、动态内存管理
- 1. 栈、堆与生命周期
- 2. 指针变量与动态内存的生命周期不同
- 3. 内存泄漏
- 4. malloc函数
- 5. calloc函数
- 6. realloc函数
- 7. free
- 十一、函数指针与回调
- 回调示例
- 十二、补充问答
- 1. 指针类型和指针值有什么区别?
- 2. p = q 与 *p = *q 分别改变什么?
- 3. 函数传参是副本,为什么还能修改外面的对象?
- 4. 如果想修改外面的指针变量呢?
- 5. 两级解引用中,操作顺序为什么重要?
- 6. 只读取大对象,也值得传指针吗?
- 7. 指针怎么让函数提供多个结果?
- 8. void * 接收 int 的地址,会自动变成 int * 吗?
- 9. 为什么不能把 int 的地址转成 double *,再读出 10.0?
- 10. void * 为什么不能直接加一?它有什么用?
- 11. 指针上的 const 会把原对象变成常量吗?
- 12. 指向数组的指针与指针数组有什么区别?
- 13. 数组字节数与元素个数怎么求?
- 14. 函数需要数组大小时怎么办?
- 15. 指针自增与所指数据自增怎么区分?
- 16. 指针相减得到的是字节数吗?
- 17. 尾后指针能读取吗?
- 18. 动态内存还在,为什么会找不到?
- 19. realloc 失败和成功分别怎样处理?
- 20. malloc 和 calloc 得到的空间能直接读取吗?
- 21. sizeof *p 为什么不需要先初始化 p?
- 22. 函数返回的地址什么时候仍然有效?【重点】
- 23. 查找函数返回的指针能 free 吗?【重点】
- 24. 释放内存会影响其他指针和数据副本吗?
- 25. 函数指针与函数调用有什么区别?
- 26. 回调为什么有用?类型要满足什么要求?
- 27.指针使用中三个容易遗漏的问题
- (1) 声明指针,不会自动创建它指向的对象
- (2) 下标访问与指针访问的关系
- (3) 字符数组与指向字符串字面量的指针
一、指针含义与类型
指针变量保存指针值,可以指向对象或函数,也可以保存空指针值。
int a = 10;
int *p = &a;
这里:
- a 是 int 类型的变量,保存整数 10。
- p 是 int * 类型的指针变量,保存 a 的地址。
- *p 指代 a。
指针类型、指针值和所指对象的类型是不同概念。
| 指针变量 | p |
| 指针类型 | int * |
| 指针值 | p 保存的 &a |
| 所指对象 | a |
| 所指对象的类型 | int |
指针类型是对指针这一变量的一般描述,它告诉我们某个指针的详细内容。
-
int p; 定义一个整型变量。
-
int *p p先与*结合,说明p是一个指针变量,再与int结合,说明指针指向的类型是int型。所以我们说,p是一个指向int型对象的指针,指向类型是int型,指针类型读作“指向int型对象的指针”,p是指针变量名。
-
int p[3]; p先与[3]结合,说明p是一个含3个元素的数组,再与int结合,说明数组中的元素是int型。所以我们说,p是一个由3个int型数据组成的数组,p是数组名。
-
int *p[3]; p先与[3]结合,说明p是一个含3个元素的数组,再与*结合,说明数组中的元素是指针,然后与int结合,说明指针指向的类型是int型。所以我们说,p是一个由3个指向int型对象的指针组成的数组,p是数组名。像这样,元素是指针的数组称为指针数组。
-
int (*p)[3]; p先与*结合,说明p是一个指针变量,再与[3]结合,说明指针指向的对象是一个数组,然后与int结合,说明数组中的元素是int型。所以我们说,p是一个指向由3个int型元素组成的数组的指针,指针类型是“指向由3个int型元素组成的数组的指针”,指向类型是数组,p是指针变量名。像这样,指向数组的指针称为数组指针。
-
int **p; p先与结合,说明p是一个指针变量,再与结合,说明指针(*p,一级指针)指向的类型是指针(**p,二级指针),然后再与int结合,说明(二级)指针指向的类型是int型。所以我们说,p是一个指向二级指针的一级指针,其中,二级指针指向的类型是int型,二级指针的指针类型是“指向int型对象的指针”,指向类型是int型,一级指针的指针类型是“指向指针的指针”,指向类型是指针,p是一级指针名,*p是二级指针名。
-
int p(int); p先与(int)结合,说明p是一个含1个int型参数的函数,再与int结合,说明函数的返回值是int型。所以我们说,p是一个含1个int型参数且返回值为int型的函数。
-
int (*p)(int) p先与*结合,说明p是一个指针,再与(int)结合,说明指针指向一个函数,函数的参数为1个int型参数,然后与int结合,说明函数的返回值是int型。所以我们说,p是一个指向一个函数的指针,其中,函数的参数和返回值是int型,指针类型是“指向含1个int型参数且返回值为int型的函数的指针”,指向类型为函数。
-
int *(*p(int))[3]; p先与(int)结合,说明p是一个含1个int型参数的函数,再与*结合,说明函数返回的是一个指针,然后与[3]结合,说明指针指向一个含3个元素的数组,最后与int结合,说明元素的类型是int型。所以我们说,p是一个含1个int型参数且返回一个指针的函数,其中,指针指向一个由3个int型元素组成的数组。(完全长难句来的)
指针所指类型决定编译器如何看待它指向的对象,也决定指针加减整数时的步长。
int a[5] = {0};
int *p = a;
int (*q)[5] = &a;
- p + 1 跨过一个 int,步长为 sizeof(int) 字节。
- q + 1 跨过整个数组,步长为 sizeof(int[5]) 字节。
不能把 int 固定写成 4 字节。指针本身占多少字节,与指针加一的步长是两件事。指针运算还必须遵守数组边界等规则,不能随意移动。
指针的值是指针本身存储的值,这个值被编译系统解释为地址,这个地址可以看作内存编号。可以说,指针的值就是它指向的内存区(地址)。指针指向的是内存中某片内存区的首地址,指针的指向类型决定了编译系统怎样看待这片内存区,若指向类型为int型,则编译系统将这片内存区看作int型,如果我们要访问这片内存区并读取数据,那么编译系统将会连续读取包括首地址在内的4个字节的存储单元。同理,若指向类型为double型,那么连续读取8个字节的存储单元。(通常在32位程序中,int型数据的长度为4字节,double型数据的长度为8字节。)
二、指针的赋值
类型兼容的指针可以相互赋值。多数表达式中的数组会转换为指向首元素的指针,也可以用来给类型合适的指针赋值。
int a = 10;
int *p = &a;
int *q = p;
此时,p 和 q 都指向 a。
复制的是地址,不会复制所指对象。
*q = 20;
修改的是 a,因此通过 p 读取也会得到 20。
但是:
q = NULL;
只改变 q,不会让 p 也变成空指针。
两个指针保存相同地址,仍然是两个独立变量。指针赋值也不只限于完全相同的类型,例如可以给所指类型增加 const 限定,以及进行 C 语言允许的 void * 转换。
不同类型的指针为什么不能随意赋值?
char c = 10;
char *pc = &c;
// int *pi = pc; // 类型不兼容,不能这样直接赋值
pc 指向的是一个 char 对象。即使使用强制类型转换,也不会把这个对象变成 int,更不会为它增加存储空间。
假设某个实现中的 int 占 4 字节,而 char 占 1 字节,那么通过 int * 访问这个 char 对象,就不能简单理解为“把 10 当作 int 读出来”。
更准确地说:问题不只在于字节数,还涉及对齐要求和允许的访问类型。强制类型转换不会创建新对象,也不是让错误访问变合法的办法。
(在C语言中,对齐指的是数据在内存中存放时,其起始地址必须是某个值的整数倍。这个值通常是2、4、8等,具体取决于数据类型和硬件平台)
三、void 指针
void * 是通用对象指针类型,不指定所指对象的具体类型。
int a = 10;
void *p = &a;
int *q = p;
这里:
- p 的类型始终是 void *。
- q 的类型是 int *。
- a 的类型仍然是 int。
把 int 对象的地址交给 void *,不会使这个指针变量自动变成 int *。
void * 本身就是确定的指针类型,不是“类型尚未确定”。它不会自动记录对象类型或元素个数,这些信息需要通过接口约定或额外参数传递。(这里的接口,指的是函数调用的入口)
在 C 中,void * 与其他对象指针之间的许多转换可以隐式完成,不必总写强制转换。
int *p = malloc(5 * sizeof *p);
这在 C 中不需要写成:
int *p = (int *)malloc(5 * sizeof *p);
使用 malloc 时需要包含 <stdlib.h>。(malloc函数将在本文后半部分讲解)
为什么可以定义 void 指针,却不能定义 void 变量?
void *p; // 合法
// void a; // 不合法
编译器知道 void * 类型需要多少存储空间,因此可以定义这种指针变量。
而 void 是不能补全的不完整类型,不能用来定义对象。
标准 C 不允许对 void * 直接进行加减运算,也不能通过它直接读取某个具体类型的对象值。使用前,应转换为合适的对象指针类型。
void * 适合需要支持多种对象类型的接口,例如动态内存分配函数。若函数明确只处理 int,直接使用 int * 通常更清楚,也能保留更多类型检查。
四、空指针、野指针与悬空指针
三者主要区别是:空指针明确不指向对象;未初始化的“野指针”没有可用的确定指向;悬空指针曾经指向有效对象,但那个对象已经结束生命周期。
| 空指针 | int *p = NULL; | 不能 |
| 野指针 | 普通局部指针未初始化 | 不能 |
| 悬空指针 | 所指内存已释放,或局部对象已失效 | 不能 |
“野指针”是非正式叫法,有时也泛指其他无效指针,包括悬空指针。这里主要用它描述未初始化的指针。
1. 空指针
空指针明确表示暂时没有指向对象:
int *p = NULL;
p仍然是int*类型,只是保存了空指针值。可以判断它是否为空,但不能通过它读取或写入:
if (p == NULL) {
printf("目前没有指向对象\\n");
}
// *p = 10; // 错误
malloc申请失败也会返回空指针。free(NULL)合法,不执行释放。
不要把空指针简单理解为“可以访问的编号为0的内存”,也不必假定它在内存中一定以全零位表示。
2. 野指针
野指针的常见来源是普通局部指针没有初始化:
void test(void)
{
int *p;
// *p = 10; // 错误
}
声明p只创建了指针变量,没有自动创建一个供它指向的int对象。此时不能读取p的值来判断它“碰巧指向哪里”,应先赋予有效地址或NULL。
int a = 0;
p = &a;
*p = 10;
初始化为NULL便于明确状态,但仍不能解引用;只有取得合适的有效地址后,才能进行对应访问。
3. 悬空指针
悬空指针曾经指向有效对象,但对象已经结束生命周期。
一种情况是动态内存被释放:
void test(void)
{
int *p = malloc(sizeof *p);
if (p == NULL) {
return;
}
int *q = p;
free(p);
p = NULL;
// q不会自动变成NULL,但已不能访问原对象
q = NULL;
}
free释放的是内存块,不会自动修改所有保存了相关地址的指针。释放后不能通过q访问原对象,也不能再次free(q)。
另一种情况是普通局部对象结束生命周期:
int *p = NULL;
{
int a = 10;
p = &a; // 此时有效
}
// a的生命周期结束,p成为悬空指针
p = NULL;
保存对象的地址不会延长对象的生命周期。即使原来的数据碰巧还残留在内存中,也不能据此继续访问。
4. 悬空指针与内存泄漏的区别
- 悬空指针:所指对象已经失效,指针没有自动变成NULL。
- 典型内存泄漏:动态内存还没释放,却丢失了所有访问途径。
另外,不再需要的动态内存因程序疏漏长期未释放,也可能构成泄漏,即使地址仍然可达。(内存泄漏将在本文后半部分详细讲解)
5. 不为空,不代表有效
if (p != NULL) {
// 不能仅凭这个条件,就断定*p可以访问
}
判断不为空,只能排除空指针,不能证明指针有效。
它不能排除悬空、越界等问题;未初始化的指针更不能先拿来做这种检查。指针是否可用,还要看所指对象的生命周期、访问范围、类型等条件。
五、解引用与取地址
声明中的 * 与表达式中的 *,作用不同。
int *p = &a;
这里的 * 是声明的一部分,说明 p 是指针变量。
*p = 20;
这里的 * 是解引用运算符,*p 指代 p 指向的对象。
| p | 指针变量保存的指针值 |
| *p | 指针指向的对象 |
| &p | 指针变量 p 自身的地址 |
int a = 10;
int *p = &a;
int b = *p; // 读取 a 的值,初始化 b
*p = 20; // 修改 a
解引用不一定是在“取出一个数”,也可以用来指代一个对象,再对这个对象进行操作。
解引用后指代的对象可以是整数、结构体、指针变量,甚至整个数组。是否实际读取所指对象,还要看表达式上下文。例如,当 p 是 int * 时,sizeof *p 不会实际读取 p 指向的对象。
下面提供一个理解模型:
int a[10];//先定义一个整型数组
int *p=&a;//定义指针变量p,并将变量a的地址存入指针变量p
int ptr=*p;//定义整型变量ptr,并将指针变量的值存入整型变量ptr
int *ptr;
int **ptr;
这里又是ptr又是*ptr的,甚至还有**ptr,我们该怎样理解它们呢?我们可以这样去理解:*p就是前往p指向的地址,即a[0]的地址,读取数据,得到a[0]存放的整数,ptr就是前往ptr指向的地址,即p的地址,读取数据,得到a[0]的地址,**ptr就是前往ptr指向的地址,即p的地址,读取数据,得到a[0]的地址,再前往a[0]的地址,读取数据,得到a[0]存放的整数,更简单地说,**ptr=*(*ptr),**ptr就是在、*ptr得到的结果的基础上再前往地址并读取数据。也就是:
p=&a[0];
*p=a[0];
ptr=&p;
*ptr=p=&a[0];
**ptr=*p=a[0];
由此,我们可以提炼出一个简单的理解模型,即“前往地址并读取数据”。当然,更准确地说,解引用是“根据地址找到对象”,找到后既可以读取,也可以修改。
注意,上面的例子中,int *ptr和int **ptr是一种声明,分别声明ptr解引用后的结果是int型,*ptr解引用后的结果是int *型。
六、const 与指针
带const关键字的指针有不同种。
1. 指向 const 限定类型的指针
int a = 10;
int b = 20;
const int *p = &a;
不能通过 p 修改所指对象:
// *p = 30; // 不合法
但是可以修改 p 的指向:
p = &b;
也可以通过其他合法方式修改原本可修改的对象:
a = 30;
这里限制的是通过 p 修改对象,不是把 a 本身变成常量。
2. 指针本身带 const 限定
int a = 10;
int *const p = &a;
p 本身不能再保存其他地址,但可以通过它修改 a:
*p = 20; // 合法
// p = NULL; // 不合法
3. 两边都加 const
const int a = 10;
const int *const p = &a;
既不能修改 p,也不能通过 p 修改 a。
| const int *p | 可以 | 不可以 |
| int *const p | 不可以 | 可以,前提是对象本身可修改 |
| const int *const p | 不可以 | 不可以 |
const int a 不能直接配合 int *const p = &a 使用,因为这会丢失所指对象的 const 限定。固定指针自身,不能弥补这个类型问题。即使通过强制转换去掉限定,也不能合法修改原本定义为 const 的对象。
七、二级指针
二级指针是指向指针的指针。
int a = 10;
int *p = &a;
int **pp = &p;
| pp | 保存 p 自身的地址 |
| *pp | 指代指针变量 p |
| **pp | 指代 p 指向的整数对象 a |
下面三种操作分别从上述初始状态独立开始:
pp = NULL; // 修改 pp
*pp = NULL; // 修改 p
**pp = 20; // 修改 a
判断修改了谁,就看赋值左边最终指代哪个对象。
每一层都需要满足对应的访问条件。pp 有效,不代表 *pp 保存的指针也一定能用于访问整数。改变指针的值不会改变它的声明类型。
八、指针与数组
多数表达式中,数组会转换为指向首元素的指针。
int a[5] = {10, 20, 30, 40, 50};
int *p = a;
这里的 a 转换为指向 a[0] 的指针。
但是,在下面这些表达式中,不发生这种转换:
sizeof a
&a
- sizeof a 得到整个数组占用的字节数。
- &a 取得整个数组的地址。
数组本身不是指针变量。
// a++; // 不合法
// a = p; // 不合法
不能用“数组名在左边还是右边”判断是否转换。数组有自己的类型和存储空间,不能通过普通赋值改变指向,也不能通过 a = b 整体复制数组。
指向首元素与指向整个数组
int a[5] = {0};
int *p = a;
int (*q)[5] = &a;
- p 指向首元素 a[0]。
- q 指向整个数组 a。
- *q 指代整个数组。
- (*q)[2] 就是 a[2]。
两者指向的对象从同一个位置开始,但对象范围与指针运算步长不同。
假设 int 占 4 字节:
- p + 1 前进 4 字节。
- q + 1 前进 20 字节。
数组下标与指针表达式
a[i]
按 C 语言的定义,等价于:
*(a + i)
含义是:先找到第 i 个下标对应的位置,再访问该元素。
a[2] = 99;
*(a + 2) = 99;
这两句修改的是同一个元素。
数组作为函数参数
void test(int a[5]);
其中的数组形参会调整为:
void test(int *a);
不会自动保留数组长度,也不意味着函数接收到整个数组的副本。
需要元素个数时,通常显式传入:
void test(int *a, size_t count);
九、指针的应用:函数传参
函数传参可以理解为:建立形参变量,并用实参的值初始化它;必要时进行类型转换。
C 的函数参数都是值传递,指针也不例外。
void change(int *p)
{
*p = 20;
p = NULL;
}
调用:
int a = 10;
int *q = &a;
change(q);
函数中的 p 接收的是 q 保存的地址的副本。
- *p = 20 修改原对象 a。
- p = NULL 只修改形参 p,不会修改外面的 q。
地址虽然是副本,但它仍然指向原对象。因此,“参数是副本”与“可以修改外面的对象”并不矛盾。
传指针主要有三种用途:
即使只读取大对象,也可以传指针,并用 const 限制修改:
struct Data {
int values[1000];
};
void read_data(const struct Data *p);
十、动态内存管理
1. 栈、堆与生命周期
程序内存常用代码段、数据段、栈、堆进行简化说明:
- 代码段通常存放程序指令。
- 具有静态存储期的对象通常位于数据段、BSS 段等区域。
- 栈通常保存函数调用信息和部分局部变量。
- 堆通常用于动态内存分配。
这是常见的实现模型,但不是 C 标准要求所有程序必须采用的固定布局。
栈和堆的可用空间都有限。
普通自动局部变量通常在离开所属代码块时结束生命周期,静态局部变量例外。
栈帧是一组函数调用相关的栈上信息,不是每个变量各有一个栈帧。
递归调用尚未返回时,先前调用所需的信息仍要保留,持续累积可能导致栈溢出。
2. 指针变量与动态内存的生命周期不同
int *p = malloc(5 * sizeof *p);
这里要区分:
- 指针变量 p。
- malloc 申请的动态内存。
局部指针变量结束生命周期,不会自动释放它指向的动态内存。
同样,动态内存被释放后,也不会自动把所有相关指针设为 NULL。
释放表示这块内存不再属于程序中这次分配所建立的对象,不能继续访问。注意,释放不保证清零,也不能依赖原数据仍然保留。
3. 内存泄漏
内存泄漏的本质是动态申请的内存没有被正确释放。
常见情况包括:
- 已经丢失所有访问途径,无法正常释放。
- 虽然仍能找到,但已经不再需要,因程序疏漏长期没有释放。
int *p = malloc(5 * sizeof *p);
p = NULL;
假设申请成功,且没有其他指针保存这块内存的地址,那么第二句会丢失唯一的访问途径。
把指针设为 NULL 不等于释放内存。
不能仅凭内存占用增长判断泄漏。仍然需要的数据可以合理占用空间;申请失败也可能只是资源不足。
4. malloc函数
malloc 申请指定字节数的空间,成功返回指向所分配空间的指针,失败返回空指针。
#include <stdlib.h>
int *p = malloc(5 * sizeof *p);
申请成功后,这里可以存放 5 个 int。
使用前需要检查:
if (p == NULL) {
// 处理申请失败,不能继续通过 p 访问元素
}
malloc 不负责初始化所分配空间中的值。
为了表述严谨,不要把未初始化的内容说成“一组随机数”。它们具有不确定的内容,不能作为已经初始化的有效数据直接使用;某些读取会产生未定义行为。应先写入有效值,再读取。
5. calloc函数
calloc 按元素个数和每个元素的字节数申请空间,并将分配空间的所有位清零。
int *p = calloc(5, sizeof *p);
申请成功后,对于这里的 int 元素,可以直接读取到整数 0。
所有位清零,不等于对所有类型都进行了对应的语义初始化。
例如,若分配的空间用于存放指针,不能仅凭所有位为零,就保证每个指针都是空指针。
更准确地说:C 不要求空指针的底层表示一定是全零位模式。p = NULL 是按语言规则给指针赋空指针值,与直接把存储空间的每一位清零不是同一件事。
6. realloc函数
realloc 调整已分配空间的大小,第二个参数是新的总字节数。
int *tmp = realloc(p, 10 * sizeof *p);
if (tmp != NULL) {
p = tmp;
} else {
// 调整失败,p 仍保存原分配块的地址
}
这里假设 p 是当前有效分配块的首地址。
扩大和缩小都可能原地完成,也可能移动到其他位置。
成功后:
- 保留新旧大小中较小范围内的原有内容。
- 扩大后新增的部分未初始化。
- 缩小后舍弃的部分不能再访问。
不要直接用唯一保存旧地址的指针接收返回值。
p = realloc(p, 10 * sizeof *p);
如果失败返回 NULL,这句会覆盖旧地址,可能造成内存泄漏。
更准确地说:请求的新大小大于零时,失败返回空指针,旧内存及其内容保留。成功后应使用返回的新指针;即使地址看起来没变,也不应继续使用调用前保留的旧指针或内部指针。需要内部指针时,应根据新指针重新计算。
缩小后再扩大,不保证恢复之前被舍弃的数据。
这里讨论的新大小均大于零。需要释放整块空间时,直接使用 free。
7. free
free 释放动态内存块。
free(p);
p = NULL;
两句分别做不同的事:
- free(p) 释放动态内存。
- p = NULL 修改指针变量,避免它继续保留失效的指针值。
能否 free,要看它是不是当前有效分配块的首地址,而不是哪个变量最初接收了申请结果。
int *p = malloc(5 * sizeof *p);
int *q = p;
free(q);
假设申请成功,通过 q 释放同样合法。但释放后不能再通过 p 或 q 访问这块内存,也不能再次释放它。
free(NULL);
合法,不执行释放。
指向分配块内部非首位置的指针不能传给 free;普通局部变量、普通数组等对象的地址也不能传给 free。同一块分配空间只能释放一次。
十一、函数指针与回调
函数指针可以指向函数,并通过它调用函数。
int add(int a, int b)
{
return a + b;
}
int sub(int a, int b)
{
return a – b;
}
定义一个函数指针:
int (*p)(int, int) = add;
p 指向“接收两个 int 参数、返回 int”的函数。
int result = p(3, 4); // 结果为 7
也可以写成:
int result = (*p)(3, 4);
回调示例
#include <stdio.h>
int add(int a, int b)
{
return a + b;
}
int sub(int a, int b)
{
return a – b;
}
int calculate(int x, int y, int (*op)(int, int))
{
return op(x, y);
}
int main(void)
{
printf("%d\\n", calculate(3, 4, add)); // 7
printf("%d\\n", calculate(3, 4, sub)); // -1
return 0;
}
calculate 接收函数指针,通过 op 调用传入的函数。
- 传入 add,执行加法。
- 传入 sub,执行减法。
把函数交给另一个函数,由后者在需要时调用,是回调的典型用法。
回调不仅要满足函数类型要求,还要遵守接口对返回值含义等方面的约定。排序比较函数就是一个典型例子。
十二、补充问答
下面解释常见的疑问与易混淆的知识。
1. 指针类型和指针值有什么区别?
int a = 10;
int *p = &a;
p = NULL;
p 的类型始终是 int *,赋值只改变它保存的值。
把指针设为空指针,不会改变它的类型。
2. p = q 与 *p = *q 分别改变什么?
分别从下面的状态独立开始:
int a = 10;
int b = 20;
int *p = &a;
int *q = &b;
执行:
p = q;
p 改为指向 b,a 和 b 的值不变。
执行:
*p = *q;
把 b 的值 20 赋给 a,两个指针的指向不变。
前者复制指针值,后者复制所指对象的值。
3. 函数传参是副本,为什么还能修改外面的对象?
因为地址虽然是副本,但它仍然指向原对象。
void change(int *p)
{
*p = 20;
p = NULL;
}
int main(void)
{
int a = 10;
int *q = &a;
change(q);
// a 为 20
// q 仍然指向 a
return 0;
}
p 与 q 是不同的指针变量,但最初保存相同的地址。
4. 如果想修改外面的指针变量呢?
把指针变量自身的地址传进去。
#include <stddef.h>
void clear_pointer(int **pp)
{
*pp = NULL;
}
int main(void)
{
int a = 10;
int *q = &a;
clear_pointer(&q);
// q 已经变成空指针
return 0;
}
pp 保存 q 的地址,因此 *pp 指代外面的 q。
如果写成:
pp = NULL;
才只是修改局部形参。
5. 两级解引用中,操作顺序为什么重要?
int a = 10;
int *q = &a;
int **pp = &q;
*pp = NULL;
**pp = 20; // 错误
第一句使 q 变为空指针。
第二句相当于:
*q = 20;
通过空指针写入,行为未定义。
pp 仍然指向有效的指针变量 q,但 q 已经不再指向整数对象。
6. 只读取大对象,也值得传指针吗?
值得,因为可以避免在语言语义上按值复制整个对象。
struct Data {
int values[1000];
};
void f(struct Data x);
void g(const struct Data *p);
- f 接收结构体副本。
- g 接收结构体地址,通过它读取原对象。
两种方式都能读取成员,但传指针可以表达“访问现有对象”的意图。
实际机器代码还可能经过优化,不能仅根据源代码就断言具体发生了多少次内存复制。
7. 指针怎么让函数提供多个结果?
通过输出参数写回结果。
#include <stddef.h>
void minmax(const int *a, size_t n,
int *min_out, int *max_out)
{
// 约定:n > 0,a 指向至少 n 个有效 int,
// min_out 和 max_out 指向两个不同的可写 int 对象
int low = a[0];
int high = a[0];
for (size_t i = 1; i < n; i++) {
if (a[i] < low) {
low = a[i];
}
if (a[i] > high) {
high = a[i];
}
}
*min_out = low;
*max_out = high;
}
调用:
int a[] = {10, 3, 28, 6};
int low;
int high;
minmax(a, 4, &low, &high);
调用后:
- low 为 3。
- high 为 28。
输入数组加 const,表示函数不通过该指针修改数组;输出对象则必须允许写入。
8. void * 接收 int 的地址,会自动变成 int * 吗?
不会。
int a = 10;
void *p = &a;
int *q = p;
p 仍然是 void *,q 才是 int *。
赋值可以发生类型转换,但不会改变接收变量已经声明的类型。
9. 为什么不能把 int 的地址转成 double *,再读出 10.0?
因为指针转换不会转换所指数据,也不会创建 double 对象。
int a = 10;
// 不能靠把 &a 转换为 double *,再解引用来得到 10.0
真正的数值转换是:
double d = (double)a;
这里把整数值 10 转换为 double 值 10.0,并存入新的对象 d。
同样:
char c = 10;
int x = c;
是把整数值 10 转换为 int,不是“转成 ASCII 码”。
10. void * 为什么不能直接加一?它有什么用?
指针加一需要根据所指类型确定步长,而 void 没有可用于这种运算的对象大小。
因此标准 C 不允许:
void *p = NULL;
// p++; // 不合法
void * 的价值在于让接口接收或返回不同类型对象的地址。
例如,malloc 接收的是字节数,不需要知道调用者打算存放 int 还是 double。
11. 指针上的 const 会把原对象变成常量吗?
不会。
int a = 10;
const int *p = &a;
a = 20; // 合法
不能通过 p 修改 a,但 a 本身仍然是可修改的普通变量。
对比:
const int a = 10;
这里才是把 a 定义为 const 对象,不能通过强制转换后的指针合法修改它。
12. 指向数组的指针与指针数组有什么区别?
int (*p)[3];
int *q[3];
| int (*p)[3] | p 是指针,指向含 3 个 int 的数组 |
| int *q[3] | q 是数组,包含 3 个 int * 元素 |
一个是“指针,指向数组”,另一个是“数组,里面放指针”。
int a[3] = {10, 20, 30};
int (*p)[3] = &a;
int x = 1;
int y = 2;
int z = 3;
int *q[3] = {&x, &y, &z};
- (*p)[1] 是 a[1],值为 20。
- *q[1] 是 y,值为 2。
13. 数组字节数与元素个数怎么求?
在数组类型完整、表达式仍然表示数组的地方:
int a[5] = {0};
sizeof a // 整个数组的字节数
sizeof a / sizeof a[0] // 元素个数
如果 p 是指针:
sizeof p / sizeof p[0]
得到的只是“指针大小除以元素大小”,不能用于求它所指数组的长度。
14. 函数需要数组大小时怎么办?
通常由调用者传入:
void test(int *p, size_t count);
调用:
int a[5] = {0};
test(a, sizeof a / sizeof a[0]);
若接口明确接收含 5 个 int 的数组,也可以使用数组指针:
void test_array(int (*p)[5])
{
printf("%zu\\n", sizeof *p);
}
调用:
test_array(&a);
这里:
- sizeof *p 是整个数组的大小。
- sizeof p 仍然是指针本身的大小。
15. 指针自增与所指数据自增怎么区分?
每一行都从下面的状态独立开始:
int a[] = {10, 20, 30};
int *p = a;
| *p++,即 *(p++) | 10 | p 指向第二个元素 |
| (*p)++ | 10 | 第一个元素变成 11 |
| ++(*p) | 11 | 第一个元素变成 11 |
| *++p,即 *(++p) | 20 | p 指向第二个元素 |
后缀自增提供旧值,不代表对象没有改变。
(*p)++;
即使单独写成一句,第一个元素同样会自增,只是表达式提供的旧值没有被使用。
16. 指针相减得到的是字节数吗?
不是,得到的是元素距离。
int a[3];
int *p = a;
int *q = a + 2;
q – p // 2
即使 int 占 4 字节,结果也是 2,不是 8。
两个指针必须指向同一数组中的元素或尾后位置,且差值能由 ptrdiff_t 表示。不同数组的指针不能仅凭类型相同就相减。
17. 尾后指针能读取吗?
不能,但可以作为边界。
int a[5];
int *p = a;
for (int *q = p; q != p + 5; q++) {
*q = 0;
}
最后一次写入发生在 p + 4,到达 p + 5 时停止。
p + 5 是尾后指针,不指向数组中的有效元素。
能合法形成一个指针,不代表能通过它访问对象。
18. 动态内存还在,为什么会找不到?
int *p = malloc(5 * sizeof *p);
p = NULL;
假设申请成功,又没有其他地址副本,那么赋值会丢失唯一的访问途径,但没有释放内存。
如果先保存副本:
int *p = malloc(5 * sizeof *p);
int *q = p;
p = NULL;
free(q);
仍然可以通过 q 释放。
复制地址不会另外申请一块内存。
19. realloc 失败和成功分别怎样处理?
int *tmp = realloc(p, 10 * sizeof *p);
if (tmp != NULL) {
p = tmp;
} else {
printf("调整失败\\n");
}
这里的新大小大于零:
- 失败:旧空间及其内容保留,继续由原来的 p 管理。
- 成功:使用返回值更新 p,之后通过更新后的指针访问。
成功后,旧分配已经由 realloc 处理,不能再次释放旧地址。
即使没有搬到不同的地址,也应使用返回的指针,不能继续依赖调用前保存的旧指针。
20. malloc 和 calloc 得到的空间能直接读取吗?
对于用于存放 int 的空间:
- malloc 未初始化,应先赋值再读取。
- calloc 将所有位清零,申请成功后各元素的整数值为 0。
下面这句:
*p = 0;
只修改第一个元素,不会初始化整块空间。
如果使用 malloc 后要初始化 5 个元素:
for (size_t i = 0; i < 5; i++) {
p[i] = 0;
}
以上操作都以申请成功为前提。
21. sizeof *p 为什么不需要先初始化 p?
int *p = malloc(5 * sizeof *p);
这里 sizeof 根据 *p 的类型计算大小,不实际求值 *p,因此不会读取 p,也不会访问它指向的内存。
结果等于:
sizeof(int)
对比:
sizeof p // 指针本身的大小
sizeof *p // 所指 int 类型的大小
这里不涉及变长数组类型的特殊规则,不能把“sizeof 不求值”不加条件地推广到所有情况。
22. 函数返回的地址什么时候仍然有效?【重点】
int *find(int *a, size_t n, int target)
{
for (size_t i = 0; i < n; i++) {
if (a[i] == target) {
return &a[i];
}
}
return NULL;
}
这里返回的是调用者数组中某个元素的地址。
int a[] = {10, 20, 30};
int *p = find(a, 3, 20);
if (p != NULL) {
*p = 99;
}
执行后,数组变成:
{10, 99, 30}
find 结束后,形参指针结束生命周期,但调用者的数组仍然存在,所以仍可通过返回的指针访问对应元素。
对比:
int *bad(void)
{
int x = 20;
return &x; // 错误用法
}
x 在函数返回后结束生命周期,不能再通过返回的指针访问它。
关键看所指对象的生命周期,而不是返回这个指针的函数是否已经结束。
更准确地说:对象仍存在只是必要条件,还要满足非空、边界、类型、初始化等访问要求。返回或复制地址,不会延长对象的生命周期。
23. 查找函数返回的指针能 free 吗?【重点】
如果搜索的是普通局部数组,不能释放返回的元素地址。
int a[] = {10, 20, 30};
int *p = find(a, 3, 20);
// free(p); // 不合法
如果搜索的是动态数组:
int *a = malloc(3 * sizeof *a); // 以下假设申请成功
a[0] = 10;
a[1] = 20;
a[2] = 30;
int *p = find(a, 3, 20);
p 指向第二个元素,是分配块内部地址,不能传给 free。
应使用首地址释放:
free(a);
也可以使用保存相同首地址的其他指针:
int *q = a;
free(q);
以上两种释放方式只能选择一种,不能连续执行。
判断依据是有效分配块的首地址,不是变量名,也不是谁最初接收申请结果。
如果查找到的恰好是动态数组的第一个元素,返回的指针确实保存分配块首地址;但实际是否应由调用者在这里释放,还要遵守程序对这块内存的管理约定。
24. 释放内存会影响其他指针和数据副本吗?
int *q = p;
int result = *p; // 假设 p 有效,所指整数已经初始化
free(p);
p = NULL;
释放后:
- q 不会自动变成 NULL。
- 不能再通过 q 访问原对象,也不能再次释放它。
- result 是独立的整数副本,不受原内存释放影响。
复制地址与复制数据,得到的是不同的东西。
更准确地说:释放后,不应继续读取、比较或使用这些失效的旧指针值;可以直接给相应指针变量重新赋值,例如 q = NULL。
25. 函数指针与函数调用有什么区别?
int (*p)(int, int) = add;
p = sub;
这里只是选择要指向的函数,没有执行加法或减法。
int result = p(3, 4);
才是调用函数。
对比:
p = sub;
与:
// p = sub(3, 4);
前者给 p 赋函数指针值。
后者先调用 sub,得到一个 int 返回值,再试图赋给函数指针,不符合这里的类型要求。
26. 回调为什么有用?类型要满足什么要求?
int calculate(int x, int y, int (*op)(int, int))
{
return op(x, y);
}
通过传入 add 或 sub,可以让同一段调用过程使用不同的计算规则。
函数必须与要求的类型兼容。这里要求:
- 两个 int 参数。
- 返回 int。
- 参数名字不影响函数类型。
回调还要遵守返回值的含义约定。例如,qsort 的比较函数约定:
| 小于 0 | 第一个元素应排在第二个元素前 |
| 等于 0 | 两个元素在当前比较规则下相等 |
| 大于 0 | 第一个元素应排在第二个元素后 |
下面按整数值从小到大排序:
#include <stdio.h>
#include <stdlib.h>
int compare_int(const void *left, const void *right)
{
const int *a = left;
const int *b = right;
return (*a > *b) – (*a < *b);
}
int main(void)
{
int a[] = {30, 10, 20};
size_t n = sizeof a / sizeof a[0];
qsort(a, n, sizeof a[0], compare_int);
for (size_t i = 0; i < n; i++) {
printf("%d ", a[i]);
}
putchar('\\n');
return 0;
}
输出:
10 20 30
qsort 负责排序过程,compare_int 负责比较规则。
这里没有直接写:
return *a – *b;
因为两个 int 相减可能发生有符号整数溢出;使用关系比较可以避免这个问题。
如果换成按绝对值从小到大排序,那么比较 -8 和 3 时应返回正数,因为 3 的绝对值更小,应排在前面。
回调让通用过程与具体规则分开:过程可以共用,规则可以替换。
27.指针使用中三个容易遗漏的问题
(1) 声明指针,不会自动创建它指向的对象
int *p;
这里只定义了一个指针变量。作为未初始化的普通局部变量,p没有可供使用的明确指针值,不能直接通过它读写:
*p = 10; // 错误
需要先让它指向有效对象:
int a = 0;
int *p = &a;
*p = 10; // a变成10
也可以先初始化为空指针:
int *p = NULL;
这表示p暂时不指向对象,并不是给它申请了存放整数的空间。取得有效对象的地址前,仍不能解引用。
(2) 下标访问与指针访问的关系
C中,a[i]定义为*(a + i)。也就是先找到偏移i个元素的位置,再访问对应对象。
int a[3] = {10, 20, 30};
int *p = a;
printf("%d\\n", a[1]); // 20
printf("%d\\n", *(a + 1)); // 20
printf("%d\\n", p[1]); // 20
printf("%d\\n", *(p + 1)); // 20
因此,下标运算不只用于数组名,也可以用于指针。但能写下标,不代表指针知道数组长度;程序仍需要保证访问没有越界。
(3) 字符数组与指向字符串字面量的指针
char a[] = "hello";
const char *p = "hello";
a是字符数组,包含h、e、l、l、o、\\0,共6个字符,可以修改其中的元素:
a[0] = 'H'; // a中的字符串变成"Hello"
p是指针变量,指向字符串字面量。不能修改字符串字面量的内容。
即使在C中写成char *p = "hello";,也不意味着字面量可修改。通过它写入属于未定义行为,因此使用const char *更清楚。
不过,可以改变p的指向:
p = "world"; // 合法,p改为指向另一个字符串字面量
这没有修改原来的"hello",只是改变了p保存的指针值。
以上就是本篇全部内容,考虑到文章篇幅,这里并没有加入练习示例,但C语言的学习看重实践,应当以终为始,更多练习将在之后更新。如果对你有一点点帮助,还请点赞关注,我会持续输出有质量的内容。
学习路上,我们常在。
网硕互联帮助中心




评论前必须登录!
注册