写在前面

这里我默认你看完了 C 语言指针详解(1),
现在继续对指针进行深度剖析

1. 数组名的理解

#include <stdio.h>
int main(){
int arr[5] = { 0 };
printf("%p\n", arr);
printf("%p\n", &arr[0]);
return 0;
}

输出结果如下图:
picture
可以看见, 数组名其实是一个地址, 而且和数组首元素的地址相同.
但是在这种情况下例外:

  • sizeof( 数组名 ), sizeof 里面单独放一个数组名, 表示的是整个数组, 计算的是整个数组的大小
  • &数组名, 得到的是整个数组的地址, 是数组指针, 这个地址和数组首元素的地址相同, 但意义不同.
#include <stdio.h>
int main(){
int arr[5] = { 0 };
printf("arr = %p\n", arr);
printf("&arr[0] = %p\n", &arr[0]);
printf("&arr = %p\n", &arr);
printf("arr+1 = %p\n", arr + 1);
printf("&arr[0]+1 = %p\n", &arr[0] + 1);
printf("&arr+1 = %p\n", &arr + 1);
return 0;
}

输出结果如下图:
picture
arrarr + 1相差4个字节, &arr[0]&arr[0] + 1相差4个字节, &arr&arr + 1相差20个字节.

小结: 数组名就是数组首元素的地址, 但除了以上两种特殊情况

2. 使用指针访问数组

有了以上知识的支撑, 结合数组的特点, 尝试使用指针访问数组.

#include <stdio.h>
int main() {
int arr[10] = { 0 };
int size = sizeof arr / sizeof arr[0];
int* p = arr;
for (int i = 0; i < size; i++) {
scanf("%d", p + i);
/*
scanf("%d", arr + i);//也可以写成这样, 完全等价
*/
}
for (int i = 0; i < size; i++) {
printf("%d ", *(p + i));
/*
printf("%d ", p[i]);//也可以写成这样, 完全等价
*/
}
return 0;
}

本质上, p[i]等价于*(p + i), 同理arr[i]等价于*(arr + i).
编译器在处理数组元素的访问的时候, 也是转换成首元素的地址 + 偏移量得到这个元素的地址, 再解引用访问.
看似[]是个操作符, 但编译器是按上面的逻辑实现的.
*(arr+i)==*(i+arr), 于是arr[i]也可以写成i[arr], p[i]也可以写成i[p].

3. 一维数组传参的本质

数组也是可以当作参数传递给函数的, 那函数内部能计算这个数组的元素个数吗?

#include <stdio.h>
void test(int arr[]) {
int size = sizeof arr / sizeof arr[0];
printf("size = %d\n", size);
}
int main() {
int arr[10] = { 0 };
test(arr);
return 0;
}

程序在 x86 和 x64 环境下的运行结果有所不同, 原因是 x86 的地址是 32 位, 占 4 字节, x64 的地址是 64 位, 占 8 字节.
picture
picture

可以看到, 在函数内部无法获取到这个数组的元素个数!
数组名是数组首元素的地址, 传参时传递的是数组名, 也就是传递的是数组首元素的地址.
函数的形参会使用一个指针变量来接收这个地址, 并不会创建这个数组.
那么在函数内sizeof(arr)计算的就是一个地址的大小而非数组的大小.
形参写成int arr[]数组的形式, []里的数组元素的个数可写可不写, 编译器不会识别. 数组传参本质上是传指针

4. 二级指针

指针变量也是变量, 是变量就有地址, 如果把指针的地址再保存起来放进另一个指针变量里, 这个指针就是二级指针

#include <stdio.h>
int main() {
int num = 10;
int* p = &num;
int** pp = &p;
return 0;
}

picture

可以看见指针p保存的是变量num的地址, 即认为p指向num, 同理pp指向p. 此时pp就是一个二级指针.
如何理解int** pp = &p;?
int*是指针指向元素的类型, 第二个*表示pp是个指针变量, 它保存的是指针p的地址.

  • *pp通过对pp保存的地址解引用, 找到p, 可以理解为*pp就是在访问p.
  • **pp先通过*pp找到p, 再对p解引用, 可以理解为**pp就是*p, 就是访问num.

5. 指针数组

类比字符数组和整型数组, 指针数组也就是存放指针的数组.
int* arr[10]就是定义一个指针数组, 数组的元素个数是 10, 数组的元素类型是int*.
指针数组的每个元素都是用来存放(地址)的, 每个元素又可以指向一块区域. 那我们用数组指针来模拟一下二维数组吧~

#include <stdio.h>
int main() {
int arr1[4] = { 1,2,3,4 };
int arr2[4] = { 2,3,4,5 };
int arr3[4] = { 3,4,5,6 };
int* arr[3] = { arr1,arr2,arr3 };
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 4; j++) {
printf("%d ", arr[i][j]);
// printf("%d ", i[arr][j]);
// printf("%d ", j[(i[arr])]);
// printf("%d ", *(*(arr + i) + j));
}
printf("\n");
}
return 0;
}

这段代码只是模拟二维数组的效果, 实际上并非完全是二维数组, 因为它每一行并非连续.
arr[i]是在访问 arr 数组的第 i 个元素, 也就是第 i 个一维数组,
arr[i][j]就是在访问第 i 个一维数组中第 j 个元素.
补充: arr[i], 相当于*(arr+i), 那arr[i][j]就相当于*(*(arr + i) + j).

6. 字符指针变量

字符指针的类型是char*, 有两种使用方式.

  • 第一种:
char ch = 'H';
char* p = &ch;
*p = 'M';
  • 第二种:
const char* s = "Hello"; 

第二种并不是把Hello字符串放进字符指针s里,而是把字符串的首地址放入了s里.
Hello是字符串常量(含\0), 常量的本质是不占据任何存储空间, 属于指令的一部分, 编译后不再更改.
字符串中的字符依次存储在内存中一块连续的区域内,并且把空字符\0自动附加到字符串的尾部作为字符串的结束标志.
故字符个数为n的字符串在内存中应占(n+1)个字节.
字符串常量与指针的关系:
在程序中,字符串常量会生成一个"指向字符的常量指针", 当一个字符串常量出现于一个表达式中时,表达式所使用的值就是这些字符所存储的地址,而不是这些字符本身.
例如:char a[5];a[0] = “a”;就是错误的,报错结果:invalid conversion from ‘const char*’ to ‘char’.
不能把字符串常量赋值给一个字符数组,因为字符串常量的直接值是一个指针,而不是这些字符本身。
例如:char a[10]=“love”,的意思就是用字符串“love”来初始化字符数组a的内存空间,而数组的首地址也就是“love”字符串的地址。

7. 数组指针变量

数组指针, 顾名思义就是指向数组的指针, 它存放的是数组的地址.

#include <stdio.h>
int main() {
int arr[10] = { 0 };
int (*p)[10] = &arr;
printf("p = %p\n", p);
printf("&arr = %p\n", &arr);
return 0;
}

picture

int (*p)[10]是一个数组指针, p先和*结合, 说明p是一个指针变量,
[10]说明p指向数组的元素个数, int说明p指向的数组的元素类型.
这个指针指向一个元素个数为 10 的整型数组. 称p数组指针.
[]的优先级高于*, 必须加括号来保证p先和*结合,
否则就成了int* p[10], 这是一个指针数组.

写到这里,小结一下吧~

  • int arr[10], 这是定义了一个整型一维数组, 数组名是arr, 它的类型是int[10].
  • int arr[3][4], 这是定义了一个整型二维数组, 数组名是arr, 它的类型是int[3][4].
  • int* arr[10], 这是定义了一个整型指针一维数组, 数组名是arr, 它的类型是int*[10].
  • int* arr[3][4], 这是定义了一个整型指针二维数组, 数组名是arr, 它的类型是int*[3][4].
  • int (*p)[10], 这是定义了一个指向元素个数为 10 的整型数组的数组指针p, 它的类型是int[10]*.
  • int (*p)[3][4], 这是定义了一个指向元素个数为 3 行 4 列 的整型数组的数组指针p, 它的类型是int[3][4]*.
  • 上强度了, 来看看数组指针数组指针数组指针
  • int (*arr[4])[3];这是定义了一个数组指针数组, 写成int (*)[3] arr[4]这样是不是好理解?但语法不支持!
    首先, 数组指针数组是一个数组, 它的元素个数是 4, 元素类型是一个指向元素个数为 3 的整型数组的数组指针.
    int (*arr[4])[3]这个数组指针数组的类型是int[3]*[4].
  • int* (*p)[10];这是定义了一个指针数组指针, p是指针变量名, 它指向一个元素个数为 10 的整型指针数组, 它的类型是int*[10]*.

8. 二维数组传参的本质

二维数组本质上也是一个一维数组, 只不过每个元素是一个一维数组罢了. 可以理解为二维数组的首元素就是第一行, 是一维数组.
int arr[3][4] = { 0 };这是一个二维数组, arr[0]就是第一行的数组名, 也就是第一行数组首元素a[0][0]的地址,
arr[0][0]就是第一行数组的第一个元素. 可以理解为:*(arr[0]+0)
&arr[0]是给第一行的数组名取地址, 得到的是第一行数组的地址.
又知道数组名是数组首元素的地址, 那么二维数组的数组名就是第一行的地址, 也就是一维数组的地址(指针).
二维数组传参本质上也是传递地址, 传递的是第一行一维数组的地址(数组指针)
理解了二维数组传参的本质后, 当二维数组传参给函数时, 形参可以写成数组, 也可以写成指针的形式.

9. 函数指针变量

类比数组指针, 不难看出: 函数指针是保存函数的地址的, 可以通过这个地址调用函数.

#include <stdio.h>
void func() {
}
int main(){
printf("%p\n", func);
printf("%p\n", &func);
return 0;
}

picture

函数名就是函数的地址, & 函数名也可以获得函数的地址

要把函数的地址保存起来, 就得使用函数指针变量, 其实函数指针和数组指针类似
比如这个func函数, void (*p)() = func;就是用函数指针p来保存这个func函数的地址.
p的类型是void(*)()
void是函数的返回值类型, *表示p是个指针, ()是函数的参数, 只需要给出参数的类型即可.

#include <stdio.h>
int add(int x, int y) {
return x + y;
}
int main() {
int (*p)(int, int) = add;
printf("%d\n", p(1, 2));
printf("%d\n", (*p)(2, 3));
return 0;
}

picture
可以看到, 不论是否对函数指针解引用, 都可以访问这个函数.
函数指针数组怎么写呢? int (*parr[3])();就是这样啦,
parr先和[3]结合, 说明parr是个数组, 数组的元素类型是int (*)()类型的函数指针.

函数指针数组的用途: 转移表

一、实现菜单功能
在创建菜单系统时,函数指针数组非常有用。例如,一个简单的命令行菜单可能有多个选项,如“新建文件”“打开文件”“保存文件”等操作。每个选项对应一个特定的功能函数。
我们可以定义一个函数指针数组,数组中的每个元素指向一个不同的菜单功能函数。这样,当用户选择一个菜单选项时,我们可以根据选项的索引来调用对应的函数。
假设我们有以下函数声明:
 void newFile(); 
 void openFile(); 
 void saveFile(); 
可以定义函数指针数组如下:
 void (*menuFunctions[])(void)={newFile, openFile, saveFile}; 
当用户选择菜单选项 i (其中 i 是对应功能在数组中的索引)时,就可以通过 menuFunctionsi; 来调用相应的函数。
这种方式使得菜单的扩展非常容易。如果要添加一个新的菜单选项,只需要编写新的功能函数,并将其函数指针添加到函数指针数组中即可,不需要对菜单的调用逻辑进行大规模的修改。
二、事件处理机制
在图形用户界面(GUI)编程或者事件驱动编程中,函数指针数组也有广泛的应用。
当有多个不同类型的事件发生时,例如鼠标点击、键盘按键按下、窗口大小改变等,每个事件都需要有相应的处理函数。
我们可以创建一个函数指针数组,其中每个元素指向一个特定事件的处理函数。例如,在一个简单的GUI库中:
假设我们有事件类型 EVENT_MOUSE_CLICK 、 EVENT_KEY_PRESS 、 EVENT_WINDOW_RESIZE 等,以及对应的处理函数 void handleMouseClick() 、 void handleKeyPress() 、 void handleWindowResize() 。
可以定义函数指针数组 void (*eventHandlers[])(void)={handleMouseClick, handleKeyPress, handleWindowResize}; 
当一个事件发生时,根据事件的类型确定索引(假设事件类型有对应的整数值),然后调用 eventHandlersindex; 来处理该事件。
这种事件处理机制使得程序结构更加清晰,不同事件的处理逻辑相互独立,易于维护和扩展。如果要添加新的事件类型及其处理函数,只需要将新的函数指针添加到数组中即可。
三、算法选择
在一些需要根据不同情况选择不同算法的场景中,函数指针数组很方便。
比如在一个数值计算程序中,可能有多种排序算法可供选择,如冒泡排序、快速排序、插入排序等。
我们可以定义每个排序算法的函数,如 void bubbleSort(int arr[], int n); 、 void quickSort(int arr[], int n); 、 void insertionSort(int arr[], int n); 。
然后创建一个函数指针数组 void (*sortAlgorithms[])(int arr[], int n)={bubbleSort, quickSort, insertionSort}; 
根据用户的输入或者程序运行时的某些条件(例如数据规模的大小,小数据规模可能适合插入排序,大数据规模适合快速排序),选择函数指针数组中的某个函数指针来调用相应的排序算法,如 sortAlgorithms[index](array, size); ,其中 index 是根据条件确定的算法在数组中的索引。
这样的设计模式提高了程序的灵活性,可以方便地切换不同的算法而不需要大量修改程序的主要逻辑。

10. 回调函数

回调函数是一个通过函数指针调用的函数, 如果你把函数的指针(地址)作为参数传递给另一个函数, 当这个指针被用来调用其所指向的函数时, 被调用的函数就是回调函数.
回调函数不是由该函数的实现方直接调用, 而是在特定的事件或条件发生时由另外的一方调用, 用于对该事件或田间进行响应.

#include <stdio.h>
void func() {
printf("Hello!\n");
}
void test(void(*p)()) {
p();
}
int main() {
if (1) {
test(func);
}
return 0;
}

func就是一个简易的回调函数, 当某个条件发生时, 由函数指针调用这个函数, 被调用的就是回调函数.

写在最后

本章干货较多, 建议小伙伴莫心急, 慢慢啃~