云计算百科
云计算领域专业知识百科平台

C语言指针与动态内存:从地址、数组到 malloc/realloc,万字讲透

万字详解: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。

声明能否改变 p能否通过 p 修改对象
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语言的学习看重实践,应当以终为始,更多练习将在之后更新。如果对你有一点点帮助,还请点赞关注,我会持续输出有质量的内容。

    学习路上,我们常在。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » C语言指针与动态内存:从地址、数组到 malloc/realloc,万字讲透
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!