C生万物 | 指针进阶 · 提升篇

学习完了指针初阶后,相信你对指针一定有了一个初步、清晰的认识。下面我们将进入【进阶】部分的学习,难度会逐渐上升↑ Are you ready?

一、字符指针

1、指针存放单字符

在初阶部分,我们有学习到了不同的指针类型,其中就包含一种叫做【字符指针】,我这里再重点拎出来说说

  • 所谓字符,也就是这个指针它指向一个字符
c 复制代码
char ch = 'w';
char* pc = &ch;
  • 那既然这指针指向了这个字符,即存放了这个字符的地址。我就可以通过*解引用去访问到这个地址中的内容,然后去进行一个修改
c 复制代码
*pc = 'x';

运行之后可以看到字符ch的内容确实发生了变化

  • 但若是我在初始化指针变量pc的时候在前面加上一个【const】作为修饰,此时还可以像上面这样去修改吗
c 复制代码
const char* pc = &ch;

通过运行结果可以看出是不可以的,加上【const】作为修饰后pc就为常量指针,其所指向的内容是不可以修改的,具体可以看看常量指针和指针常量的感性理解

2、指针存放字符串

对于字符来说,不仅接收单个字符,还可以一个字符串的首元素地址,我们来看看

c 复制代码
char* ps = "abcdef";
  • 可以看到通过*解引用访问到的是该字符串的首字符,因为指针里面指存放了它的地址,这就和一个整型指针里面存放了一个数组的首元素地址是同样的道理
  • 不过有很多通过就会将此理解为把整个字符串abcdef存放到字符指针ps中,这其实是不对的,我们通过画图的方式来理解一下

不过对于上面这种写法其实还有一种缺陷,因为字符串是一个常量,那对于常量而言是不可修改的,但是我们却将其地址给到了一个字符型指针,那此时就可以通过循环的方式解引用修改整个字符串,这就不合乎逻辑了,所以在初始化字符指针ps的时候应该在前面带上一个【const】

  • 其实就和上面存放单个字符的字符指针是一个意思
c 复制代码
const char* ps = "abcdef";

3、一道剑指offer的面试题

  • 下面是一道剑指offer中有关【字符指针】的面试题,放在这里作为讲解。接下去我想问:在两组字符串进行比较后输出的结果为多少
c 复制代码
int main()
{
	char str1[] = "hello bit.";
	char str2[] = "hello bit.";
	const char* str3 = "hello bit.";
	const char* str4 = "hello bit.";

	if (str1 == str2)
		printf("str1 and str2 are same\n");
	else
		printf("str1 and str2 are not same\n");

	if (str3 == str4)
		printf("str3 and str4 are same\n");
	else
		printf("str3 and str4 are not same\n");

	return 0;
}

结果如下:

那有的同学就很疑惑,str1str2明明就是一样的,为何输出打印的结果是【are not same】呢?

  • 我们在操作符章节有讲到==这个运算符,若只是两个普通变量之间的比较,用它就可以了,但是对于两个字符串之间的比较,可不能使用这个,而要用库函数中的strcmp具体的规则可以查看官方文档,后期会出专门的文章做讲解
  • 使用==运算符进行比较的时候并不是比较的两个字符串的内容,而是地址。那它们在定义的时候编译器分别为它们分配了各自的空间,所以它们的空间是独立的,内存地址也是不一样的。

💬那有同学问:那str3str4又怎么解释呢?

  • 还记得上面讲到过的【字符串是一个常量】这个概念吗,对于常量而言,是存放在内存中的只读数据区 ,也就是代码段 ,常量一般都存放在这个区域中,里面还存放有代码编译出来的一些指令,对于指令是不可以修改的,可以看看 C/C++内存分布
  • 那对于这些常量字符串来说在内存中只会保存一份,也就是说str3和str4都指向内存中的同一块空间,那它们的地址就是相同的,所以输出的结果就是【are same】

二、指针常量与常量指针

【引入】

  • 首先来看看下面这段代码,首先我定义了一个变量num为10,然后又对其进行了一个赋值修改,打印出来之后就是修改之后的值【相信这是最基本的认识】
c 复制代码
int main(void)
{
	int num = 10;
	num = 20;

	printf("num = %d\n", num);
	return 0;
}
  • 但若是我可以修改num值的话,别人也可以修改了,这就没有了安全性。所以我想给它加上一把锁🔒使得它无法被修改,这里介绍一种C语言中的关键字【const】,这个我在初识C语言也有说到过,若是我们在定义变量的时候在前面加上一个const做修饰,此时这个变量就会变成【常量】
  • 这个就和Java中的 final关键字 是一个道理,若是加上了这个关键字做修饰之后,就要在定义的时候对其进行一个初始化,并且后面不能去修改它的值
c 复制代码
const int num = 10;
  • 可以看到,在加上const常进行修饰之后,这个变量就无法被修改了,若是有人想要去修改的话编译器就会报出警告⚠

以上均为引言,接下去我们来说说有关【常量指针】和【指针常量】之间的区别

【常量指针】

1、介绍与分析

  • 上面看到,因为在定义num的时候前面加上了const常的修饰,就使得它变成了一个常量,无法被修改,在指针初阶章节,我有介绍过可以将一个指针进行解引用去修改这个指针所指向那块地址的值
c 复制代码
int* p = #
*p = 20;
  • 可以看到,确实可以对其进行一个修改
  • 那此时这个num的安全性就又降低了,所以我想再做制裁🗡,使得指针也无法对其解引用进行一个修改
  • 那么又需要使用上面所说的const修饰符,也是和修饰num一个道理,只需要在前面加上一个【const】作为修饰即可
c 复制代码
const int* p = #
  • 可以看到,此时我们通过指针解引用的方式也无法对其进行修改❌
  • 虽然是不可以通过指针解引用去修改这个指针所指向的值,但是可以去修改这个指针的指向,令其重新指向一个变量的地址,这是合法的
c 复制代码
const int num = 10;
//num = 20;
int num2 = 20;

const int* p = #
//*p = 20;		//err
p = &num2;
  • 不过原理还是一样的,我们无法通过这个指针进行解引用去修改它所指向的值

2、小结与记忆口诀

以上所描述的就是【常量指针】,一起来总结一下:book:

  • 总结:对于常量指针而言,是将【const】放在*的左边,表示的是指针所指向的内容不能通过指针来修改,但指针变量本身可修改
  • 口诀:常量指针所指向的是一个常量,不能修改;但是指针本身不是常量,可以修改

【指针常量】

知道了什么是【常量指针】,接下去让我们来看看什么是【指针常量】

1、介绍与分析

  • 刚才我们将const放在*的左边,现在我们换个地方,将它放在*的右边试试
c 复制代码
int* const p = #
  • 此时若再去做这两步操作的时候你就会发现和【常量指针】完全不同,可以通过指针解引同去修改指向的值,但是无法再次修改指针的指向
c 复制代码
*p = 20;		
p = &num2;		//err

2、小结与记忆口诀

以上所描述的就是【指针常量】,一起来总结一下:book:

  • 总结:对于指针常量而言,是将【const】放在*的右边,表示的是指针变量本身的指向不能修改,但是指针指向的内容可以通过指针来修改
  • 口诀:指针常量这个指针本身就是一个常量,不能修改;但是指针所指向的内容不是常量,可以修改

👉一份凉皮所引发的故事👈

可能还是有同学对它们之间的关系不太理解。没关系,我们通过一个生活中的场景来介绍一下

  • 现在这里有三行代码,有一个常量num指针p里面保存了它的地址,还有一个常量num2
  • 我们假设这个指针p为一个女孩,num为一个男孩,他是这个女孩的男朋友。有一天男孩陪女孩去逛街,女孩看到路边有人在卖凉皮,所以就想要男孩给他买一份凉皮吃,可是呢男孩身上只有【10块钱】,若是给女朋友买了凉皮自己就没钱用了,于是说:"不行,不给你买,凉皮有什么好吃的😕"
  • 于是这个时候女孩就==生气了==,就对男孩说:"一份凉皮都不舍得给我买,还算是我男朋友吗?分手!",于是看另一个男孩还不错,就想去找另一个男孩【他身上有100块钱】
  • 于是这个时候男孩就不乐意了,好不容易追到的女朋友(不是靠钱),怎么能说分手就分手呢,不能分。此时它就做了一个动作:在这个操作符[*]的前面加上了const作为修饰符,我们来回顾一下前面的知识
  • 这里的*p = 0就相当于是指针通过解引同让num = 0,那指的就是让男孩变得身无分文;这里的p = &num2指的就是重新修改指针p的指向,使其指向另一个值的地址。👉这就是【常量指针】

  • 此时男孩意识到事情的严重性,那个男的身上这么有钱,万一被它拐走了。想了想还是去给她买吧,一份凉皮罢了,就和女孩说:"行行行,给你买,但是你不可以换男朋友"。此时他就又做了一个动作:在这个操作符[*]的后面加上了const作为修饰符,去掉了前面的const
  • 同理,这里的*p = 0就相当于是指针通过解引同让num = 0,那指的就是让男孩变得身无分文;这里的p = &num2指的就是重新修改指针p的指向,也就是换一个男朋友。👉这就是【指针常量】

建议广大女性读者选择第二种男朋友,若是想下面这样的,就直接分手吧

  • [*]的前后都加上了const修饰符,那么既无法通过指针去修改所指向的值,也无法修改指针的指向,虽然这使代码变得非常安全,但在还是没有这个必要╮(╯▽╰)╭
  • 要想一个男朋友连吃的都不给你买,而且还不准你换男朋友,强行霸占你🔨这种情况还是赶紧分手吧!

【总结一下】:

  • 对于【常量指针】而言,是将const放在[*]左边的,指针所指向的内容不能通过指针来修改,但指针变量本身可修改
  • 对于【指针常量】而言,是将const放在[*]右边的,指针变量本身的指向不能修改,但是指针指向的内容可以通过指针来修改

三、指针数组与数组指针

本模块我们来介绍指针数组与数组指针之间的区别

【指针数组】

首先我想问你一个问题:指针数组是一个指针还是一个数组呢?

1、概念明细

  • 好,解答一下上面的问题,对于【指针数组】来说,它是一个数组,而不是指针
c 复制代码
int arr1[5];	//整型数组 - 存放整数的数组

char arr2[5];	//字符数组 - 存放字符的数组

int* arr3[5];	//指针数组 - 存放指针的数组
  • 来看一下上面这三个数组的定义
    • 对于arr1,他是一个整型数组,它里面存放的都是整数
    • 对于arr2,他是一个字符数组,它里面存放的都是字符
    • 对于arr3,他是一个指针数组,它里面存放的都是指针
  • 通过这么的对比相信你对【指针数组】有了一初步的概念,它也是一个数组,里面放的都是指针

下面两个模块我将带你来回顾一下数组中的相关知识

2、数组地址偏移量与指针偏移量

  • 首先对于一个数组而言,我们如果可以得到它的首元素地址,然后通过这个地址就可以顺藤摸瓜🍈就可以获取到后面的所有元素
  • 但是光这么直接用arr[0]来访问太累了,不妨我们将数组的首元素地址给到一个指针变量,让它保存下这个地址,然后让它逐步地向后移动 。如果对指针还不是很了解的看看这篇文章------> 底层之美,莫过于C【1024,从0开始】先去了解一下什么是指针
c 复制代码
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
int* p = &arr[0];
  • 可以看到,指针变量里面存放着的是数组arr的首元素地址,那我们现在要通过这个指针变量去访问到后面的所有元素该怎么做呢?
  • 首先我们考虑先访问到第二个元素,要访问到一个元素首先考虑找到这个元素所在的地址,p指针第一个元素所在的地址,那么p + 1便是指向2所在元素的地址,那要访问到这个地址上所在的内容,那就要使用到*这个符号,对这块地址进行解引用*(p + 1),此时就可以访问到2这个元素了。那找3,找4也是一样的,只需要让这个指针向后偏移即可,所以我们可以通过循环去找,访问第i个元素便是*(p + i)
  • 可能有些同学还是不太理解,没关系,我们通过代码来验证一下
c 复制代码
for (int i = 0; i < 10; ++i)
{
	printf("%p == %p\n", p + i, &arr[i]);
}
printf("\n");
  • 可以看到,无论是对于p + i还是&arr[i],它们每次所访问的地址都是一样的,这其实也就意味着指针变量p在偏移的过程中相当于在代替数组首元素地址向后偏移

有了这些知识作为铺垫,我们就可以去尝试访问数组中的所有内容了

因为一维数组是一块连续的存储空间,所以我们只要得到这个数组的首元素地址。就可以通过p + i这样的方式找到它之后所有元素的地址,并且把他们地址进行解引用便能访问到数组中的所有元素

c 复制代码
int main(void)
{
	int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
	int* p = &arr[0];

	for (int i = 0; i < 10; ++i)
	{
		printf("%d ", *(p + i));
	}
	printf("\n");
	return 0;
}
  • 可以看到,通过将数组的首元素地址给到指针变量p,然后再使这个指针变量一位一位地向后偏移,每次偏移一个元素即4个字节,第i个元素的地址即为p + i,而当我们要去访问这个地址的内容时,直接对其进行解引用即可*(p + i),然后便可以看到数组中的十个元素都被打印出来了

3、指针变量与数组名的置换【✔】

  • 因为【数组名 = 首元素地址】,那不妨int* p = &arr[0]便可以写成int* p = arr,Ctrl + F5让代码走起来可以看到结果也是一样的
  • 那我这么做就相当于是把arr赋给了p,那此时arrp也就是一回事,那也可以说【arr <==> p】,所以我们在使用到arr的地方可以换成p,使用到p的地方可以换成arr
  • 那这个时候突然就想到一点我们上面在打印数组元素的时候都是使用arr[i],那此时是不是可以将arr[i]*(arr + i)做一个联系呢?当然是可以的:smile:

  • 因为arr为数组名,数组名表示这个数组的首元素地址。首元素地址向后偏移i个位置之后到达下标为i的那个元素所在的位置,再对其进行解引用就找到下标为i这个地址所对应的元素------这也就是对于【*(arr + i)】的一个解释
  • 那对于【arr[i]】又要怎么去解释呢?还记得我一开始讲一维数组的使用时说到[]是一个数组访问的操作符 ,那既然是操作符的话就会有操作数,操作数是谁呢?就是【arr】和【i】,那此时当我将arr[i]转换成*(arr + i)的时候,()里面的也就是这两个操作数,根据==加法的交换律==就可以将【arr】和【i】进行一个交换,那也就变成了*(i + arr)
  • 此时就可以去进行一个类推,因为*(arr +i)可以写成arr[i] <------ ⭐
  • 那么*(i + arr)是否可以写成i[arr]呢 <------⭐

此时我们通过代码来尝试一下,将推测转化为实际

  • 可以看到,依旧是可以的w(゚Д゚)==不过这种写法了解一下即可,不是很好理解,也不会用到==

  • 刚才有说到arrp其实是一回事,那可以写【arr[i]】,是不是也可以写成【p[i]】呢?答案是:当然可以!

看完上面的这些,相信你已经晕了(((φ(◎ロ◎;)φ))),不过没有关系,将知识点做个总结就可以很清晰了

arr[i] == *(arr + i) == *(p + i) == p[i]

4、实例讲解

回顾了数组的相关知识后,再来看【指针数组】相关内容,就变得易如反掌:hand:

① 指针数组存放地址

  • 好,首先来看到第一个案例,我定义了五个变量分别对它们进行了一个初始化,然后定义了一个指针数组,首先你要想到的就是[指针接受地址]这个概念
  • 所以我将这五个变量的地址都存放到了这个【指针数组】中,然后去遍历这个数组便可以访问到这五个变量的地址了
c 复制代码
int main(void)
{
	int a = 1;
	int b = 2;
	int c = 3;
	int d = 4;
	int e = 5;

	int* arr[5] = { &a, &b, &c, &d, &e };
	for (int i = 0; i < 5; ++i)
	{
		printf("%d ", *(arr[i]));
	}
	printf("\n");
	return 0;
}
  • 接下去你要想到的就是[解引用]这个知识点,我说到指针其实就是地址,那对地址进行一个解引用其实可以将[*][&]进行一个抵消,这也就取到了五个变量的地址,通过下标i控制就遍历到了这五个变量

② 指针数组存放数组

  • 好,再来看下面这段代码,我定义了三个整型数组,数组的个数都是5,然后又定义了一个指针数组,将三个整型数组的数组名都存放进去,我们知道数组名即为首元素地址,所以这是合法的
  • 接下去我就要通过这个指针数组访问到这三个整型数组中的所有元素
c 复制代码
int arr1[5] = { 1, 1, 1, 1, 1 };
int arr2[5] = { 2, 2, 2, 2, 2 };
int arr3[5] = { 3, 3, 3, 3, 3 };

int* parr[3] = { arr1, arr2, arr3 };

for (int i = 0; i < 3; ++i)
{
	for (int j = 0; j < 5; ++j)
	{
		printf("%d ", *(parr[i] + j));
	}
	printf("\n");
}
  • 通过算法图示来看看,外层的遍历,可以访问到这个三个数组的首元素地址,此时我们若还要去访问到每个数组中的元素的话,就要再通过一个内部的循环去遍历每一个数组,这个操作的话相信你看过我的数组文章一定是没问题的
  • 这里的parr[i] + j也就是位于每个数组的首地址向后偏移j个位置,所以访问到的就是下标为j这个位置的地址,但是我们要访问值的话就要加上一个解引用的操作。当然,通过【*】和【()】的规则我们也可以将*(parr[i] + j)转换为*(*(parr + i) + j)或者是parr[i][j]
  • 来看一下运行结果

在学习了【指针数组】后,来辨析一下三个数组吧

c 复制代码
int* arr1[10];	

char* arr2[4];	

char** arr3[5];	
  • 首先第一个arr1,数组大小为10,数组里面存放的都是int*的整型指针
  • 然后第二个arr2,数组大小为4,数组里面存放的都是char*的字符指针
  • 最后第三个arr3,数组大小为5,数组里面存放的都是cahr**的二级字符指针

【数组指针】

讲完指针数组后,我们就来讲讲它的双胞胎兄弟 ------ 【数组指针】

💬首先还是这个问题,数组指针是指针?还是数组?

1、数组指针的定义

  • 我们通过指针初阶中所学习的整型指针和字符指针来做一个对比
c 复制代码
int a = 10;
char ch = 'x';
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };

int* pa = &a;			------ 整型指针 - 存放整型地址的指针
char* pc = &ch;			------ 字符指针 - 存放字符地址的指针
int(*parr)[10] = &arr;	------ 数组指针 - 存放数组地址的指针
  • 也是一样来分析一下这三个指针
    • 对于pa,它是一个整型指针,里面存放的是一个整型的地址
    • 对于pc,它是一个字符型指针,里面存放的是一个字符的地址
    • 对于parr,它是一个数组指针,里面存放的是一个数组的地址
  • 通过这么的对比相信你对【数组指针】有了一初步的概念,它也是一个指针,它所指向的是一个数组的地址

然后就来仔细介绍一下数组指针

  • 下面有一个arr数组,数组里面有5个元素,每个元素都是一个int类型。那现在我要将这个数组的地址存起来,那肯定需要一个指针来接收,那既然是一个指针的话我们肯定会想要用*做修饰,不过这还不够,因为接收的是一个数组的地址,所以我们还会想要再加上[10],而且这个10还不能像我们定义数组的可以省略调用,一定要加上
  • 但是像下面这样真的可以吗?或许你应该去了解一下运算符优先级,因为[]的优先级是最高的,所以这个【pa】会首先和[]结合,而不是先和*,那么它就是一个数组,而不是指针了!
c 复制代码
int arr[5] = { 1,2,3,4,5 };
int* pa[10] = &arr;
  • 若是想要【pa】和这个*先结合的话,在它们的外面加上一个()即可,如下所示👇
c 复制代码
int (*pa)[10] = &arr;

==这才是一个完整又正确的【数组指针】==

2、&数组名VS数组名

对于数组名是首元素地址这个说法我们已经是耳熟于心了,不过上面看到了一个新的写法&数组名,这和数组名存在着什么关联呢?本模块我们就来探讨一下这个

  • 可以看到,在下面我分别打印了三种情形,那可以预测第一种和第二种是一样的,而第三种可能就不一样
c 复制代码
int arr[5] = { 1,2,3,4,5 };

printf("%p\n", arr);
printf("%p\n", &arr[0]);
printf("%p\n", &arr);

但是从运行结果可以看到它们都是一样的,这是为什么呢?

  • 数组章节我就有讲到过&数组名值得是取出整个数组的地址,而&arr[0]则是数组首元素的地址。不过从下图可以看,它们的位置是一样的,所以打印出来的地址就是一样的

💬那有同学说:难道它们就完全相同吗,那&数组名还有什么意义呢?

  • 但此时我将当前取到的地址再去 + 1的话,会有什么变化呢?
c 复制代码
printf("%p\n", arr);
printf("%p\n", arr + 1);
puts("---------------");

printf("%p\n", &arr[0]);
printf("%p\n", &arr[0] + 1);
puts("---------------");

printf("%p\n", &arr);
printf("%p\n", &arr + 1);
puts("---------------");

可以看到,最后一个&数组名和上面两个的结果不同

  • 对于arr&arr[0]都一样,取到的是首元素的地址,这是一个整型数组,首元素是一个int类型的数据,那么其地址就是int*类型,那在【指针初阶部分】我有讲到过一个int*的指针一次可以访问4个字节的大小,那在这数组中每个元素都占4个字节,所以 + 1就会跳过一个元素也就是4个字节
  • 对于&arr来说,取出的是整个数组的大小,虽然它的位置和首元素地址是一样的,但是它 + 1跳过的确是整个数组的大小,上面说到过一个数组的地址给到【数组指针】来接收int (*parr)[5] = &arr;,此时去掉它的变量名后这个指针的类型就是int(*)[10],上面我们也有讲过一个指针一次可以访问的字节取决于它的类型

具体可以看看这张图👇

💬在知晓了这一点后许多同学就明白了这个地址的偏移为何是这样,但是仔细一算好像也不对呀,整个数组所占的字节数不是20吗,这里是14呀?

  • 要知道,编译器对于一块地址的表示形式是以十六进制的形式 ,所以我们计算出的差值应该再转换为十进制 才对,那么14转换为十进制后刚好就是20,不清楚规则的同学可以去了解一下十六进制转十进制

3、数组指针的使用【⭐】

讲了这么多后,这个数组指针到底有什么用呢?

1.数组指针在一维数组的使用场景

  • 之前我们在使用函数封装一个打印数组时有着下面两种写法,一个就是使用数组做接收,一个则是使用指针做接收。因为外界所传入的都是数组名,数组名就是首元素地址
c 复制代码
void print1(int arr[], int n)
{
	int i = 0;
	for (i = 0; i < n; ++i)
	{
		printf("%d ", arr[i]);
	}
	printf("\n");
}

void print2(int* arr, int n)
{
	int i = 0;
	for (i = 0; i < n; ++i)
	{
		printf("%d ", arr[i]);
	}
	printf("\n");
}

print1(a, sz);
print2(a, sz);
  • 那在学习了【数组指针】后,我们还可以把形参写成下面这种样子
c 复制代码
void print3(int (*p)[5], int n)
{
	int i = 0;
	for (i = 0; i < n; ++i)
	{
		printf("%d ", (*p)[i]);	//a[i]
	}
}
  • 实参就要以下面这种形式进行传递,那此时形参p接收到的就是整个数组的地址,那么此时*p也就取到了这个一维数组的数组名,那我们平常用数组名来访问数组中的每个元素时,都是用的arr[i]这样的形式,那么用解引用后的数组指针来访问就可以写成(*p)[i]
c 复制代码
print3(&a, sz);

💬但这样不是很别扭吗?传进来数组的地址,然后再解引用获取到数组名,还不如直接传递数组名呢🤨

  • 是的,一般数组指针我们不会用在一维数组的情况下,但是我们一般直接会用数组名或者指针来接收。但数组指针在二维数组中使用的还是比较的多的

2.数组指针在二维数组的使用场景

  • 下面是我们之前在使用函数封装二维数组打印的时候所需要的传参
c 复制代码
void print4(int arr[3][5], int row, int col)
{
	int i = 0;
	for (i = 0; i < row; i++)
	{
		int j = 0;
		for (j = 0; j < col; j++)
		{
			printf("%d ", arr[i][j]);
		}
		printf("\n");
	}
}

int a[3][5] = { {1,2,3,4,5}, {2,3,4,5,6}, {3,4,5,6,7} };
print4(a, 3, 5);
  • 那采用【数组指针】的写法也是像上面这样,但是有同学却疑惑说:传进来的不是一个二维数组吗?
c 复制代码
void print5(int (*p)[5], int row, int col)
{
	int i = 0;
	for (i = 0; i < row; i++)
	{
		int j = 0;
		for (j = 0; j < col; j++)
		{
			printf("%d ", *(*(p + i) + j));
		}
		printf("\n");
	}
}
  • 这一块的话我就来重点分析一下 了:首先你要知道知道对于一维数组而言,它的首元素地址即为数组中第一个元素的地址,那么二维数组的首元素地址相当于什么呢?如果你仔细看过数组章节的话就可以知道为第一行的地址 ,此时形参p接收到的即为第一行的地址。对于二维数组把每一行看做是一个元素,那么对于这个数组来说三行就有三个元素,那么要如何访问到每一行呢?那就是使用p + i,随着【i】的不断变化就可以取到每一行的地址
  • 但是我们要访问的是二维数组中的每一个元素,那取到这一行的地址后还不够,因为我们访问数组中元素时使用的都是数组名,此时*(p + i)也就拿到了当前的这一行的数组名,假设现在要访问第一行,那它的数组名那就是a[0],或者是*(a + 0),以此类推后面的几行数组名就是a[1]、a[2]。那数组名我们知道,意味着首元素地址,现在先访问第一行中的每个元素,那么首先拿到的就是【1】的地址,那要访问到后面的每一个元素首先要对地址进行一个偏移,*(p + i) + j就可以拿到每个元素的地址,那此时就简单了,再解引用*(*(p + i) + j)也就取到了当前行中的每个元素,根据数组名和指针的转换规则,即为p[i][j]

来看一下运行结果


在学习了【指针数组】和【数组指针】后,来看一下这四个指针 or 数组?

c 复制代码
int arr[5];
int *parr1[10];
int (*parr2)[10];
int (*parr3[10])[5];
  1. 第一个【arr】首先和[]结合,表明它是是一个数组,数组有五个元素,每个元素都是int类型的,说明这是一个一维数组
  2. 第二个【parr】首先和[]结合,表明它是一个数组,数组的每个元素都是一个int类型的指针,说明这是一个指针数组
  3. 第三个【parr2】首先和*结合,表明它是一个指针,然后往后一看,它指向一个数组,该数组有10个元素,每个元素都是int类型,说明这是一个数组指针
  4. 第四个【parr3】首先和[]结合,表明它是一个数组 ,数组有十个元素,把parr3[10]去掉后就可以看出它的类型,是int(*)[5],说明数组中存放着的都是数组指针,每个数组指针都指向一个存有5个元素,每个元素都是int类型的数组。最后我们判定其为==数组指针数组==

第四个的图示如下:

【数组传参与指针传参】

相信有很多同学对于数组传参、指针传参都是搞的稀里糊涂的

1、 一维数组传参

代码:

c 复制代码
/*一维数组传参*/
void test(int arr[]) //ok?
{}
void test(int arr[10]) //ok?
{}
void test(int* arr) //ok?
{}

int main()
{
	int arr[10] = { 0 };
	test(arr);
}

解析:

  • 首先来看一维数组的传参,test传进来一个arr数组名,那第一个利用arr[]接收这是我们最常见的,没有问题✔
  • 第二个和第一个类似,只是在[]里加上了一个10,不过我们知道对于一维数组里面的数组大小声明是可以省略的,所以没有关系
  • 第三个是采用*arr的方式进行接收,那传递进来的arr为数组名,数组名是首元素地址,那给到一个指针作为接收也没什么问题

代码:

c 复制代码
void test2(int* arr[20]) //ok?
{}
void test2(int** arr) //ok?
{}

int main()
{
	int* arr2[20] = { 0 };
	
	test2(arr2);
}

解析:

  • 接下去看到我向test2传递了一个指针数组,那使用* arr[20]合情合理 ✔
  • 那么第二个** arr是都可以呢?这点我们可以通过画图来分析,因为arr2是一个指针数组,而且里面存放的每个元素都是int类型的, 那我们传递【指针数组】的数组名过去的话,那其实就是首元素地址,即这个一级指针int*的地址,那么形参部分使用二级指针来接收也是正确的 ✔

总结:

最后总结一下一维数组传参形参可以是哪些内容

  1. 形参可以是数组
  2. 形参可以是指针
  3. 形参可以是一个二级指针,指针数组的地址可以给到二级指针做接收,==因为指针数组里面存放的都是一级指针==

2、 二维数组传参

代码:

c 复制代码
/*二维数组传参*/
void test(int arr[3][5])//ok?
{}
void test(int arr[][])//ok?
{}
void test(int arr[][5])//ok?
{}

int main()
{
	int arr[3][5] = { 0 };
	test(arr);
}

解析:

  • 接下去我们再来看看二维数组的传参,第一个无需多说。第二个的话形参这种写法是不可以的,因为二维数组必须确定它的列,也就是每行有多少个元素,但是有多少行可以不用知道❌
  • 那对于第三个来说就是正确的,虽然省略了第一个[]的数组,但是指明了列的个数,就没有关系 ✔

代码:

c 复制代码
void test2(int* arr)//ok?
{}
void test2(int* arr[5])//ok?
{}
void test2(int(*arr)[5])//ok?
{}
void test2(int** arr)//ok?
{}

int main()
{
	int arr[3][5] = { 0 };
	test2(arr);
}

解析:

  • 上面的代码是采取形参部分指针进行接收,上面我们有分析到,二维数组的数组名是首行的地址,那可以使用一个一级指针来接收吗?很显然是不可以的❌
  • 第二个int* arr[5]可以吗?首先你要分析看它是个什么,我们传递过来的是一个地址,那地址就要使用指针来进行接收,但是可以看到这很明显是一个指针数组,因为arr和[]先结合了,所以也是错误的❌
  • 那么第三个呢?通过观察可以判断出它是一个数组指针, 接收一个二维数组第一行的地址,那肯定是不会有问题的 ✔
  • 最后是一个二级指针,但是二级指针只能接收一个一级指针的地址,不过我们传递过来的是一个二维数组中某一行的地址,根本牛头不对马嘴❌

总结:

最后总结一下二维数组传参形参可以是哪些内容

  1. 直接用二维数组做接收
  2. 二维数组的数组名是首行的地址,是一个一维数组的地址,要使用数组指针来接收

3、 一级指针传参

代码:

c 复制代码
void print(int* p, int sz)
{
	int i = 0;
	for (i = 0; i < sz; i++)
	{
		printf("%d\n", *(p + i));
	}
}
int main()
{
	int arr[10] = { 1,2,3,4,5,6,7,8,9 };
	int* p = arr;
	int sz = sizeof(arr) / sizeof(arr[0]);
	//一级指针p,传给函数
	print(p, sz);
	return 0;
}

解析:

  • 接下去我们来看看一级指针的传参,那其实这很明确,在main函数中指针指向arr数组的首元素地址,传递过去后形参部分的p也指向这个地址,那么通过解引用就访问到了数组中的每一个元素

思考:

当一个函数的参数部分为一级指针的时候,函数能接收什么参数?

  1. 可以直接是一个变量的地址
  2. 可以是一级指针
  3. 一维数组的数组名(数组名是首元素地址,数组中的每一个元素都是一个变量)

4、 二级指针传参

代码:

c 复制代码
void test(int** ptr)
{
	printf("num = %d\n", **ptr);
}

int main()
{
	int n = 10;
	int* p = &n;
	int** pp = &p;
	test(pp);
	test(&p);
	return 0;
}

解析:

  • 接下去我们来看看一级指针的传参,那其实这很明确,在main函数中指针指向arr数组的首元素地址,传递过去后形参部分的p也指向这个地址,那么通过解引用就访问到了数组中的每一个元素

思考:

当一个函数的参数部分为二级指针的时候,函数能接收什么参数?

  1. 可以直接是一个一级指针的地址
  2. 可以是二级指针
  3. 指针数组的数组名(数组名是首元素地址,数组中的每一个元素都是一个一级指针)

四、指针函数与函数指针

【指针函数】

1、定义

指针函数,简单的来说,就是一个返回指针的函数 ,其本质是一个函数,而该函数的返回值是一个指针

格式 】:返回类型* 函数名(参数表)

  • 指针函数还是很好理解的,通过基本的函数来做个对比
c 复制代码
int func(int x, int y)
c 复制代码
int* func(int x, int y)
  • 很清楚地可以看出,【指针函数】就是普通的一个函数,只是它的返回值类型为一个指针罢了

2、示例

下面展示一个指针函数的相关案例

  • Open()函数从外界接收一个值,用于在函数内部开辟出一块大小为n的空间,然后return返回,返回类型为int*,此时外界使用int*来进行接收,就获取到了函数内部开辟出这个数组的首元素地址,然后通过循环为数组中n个元素初始化
  • 这里无需担心在函数内部开辟的这块空间的地址,因为它存放在堆上,而不是在栈上,所以不会随着函数栈帧的销毁而消亡,所以这里在举例的时候我专门去堆上面申请空间然后返回,若是返回函数中局部变量的地址,就会有很大的风险!
c 复制代码
int* Open(int n)
{
	int* a = (int*)malloc(sizeof(int) * n);
	if (NULL == a)
	{
		perror("fail malloc");
		exit(-1);
	}
	return a;
}

int main(void)
{
	int n = 10;
	int* arr = Open(n);

	memset(arr, 0, sizeof(int) * n);

	for (int i = 0; i < n; ++i)
	{
		*(arr + i) = i + 1;
	}
	printf("Initialized Successfully\n");
	return 0;
}

通过运行结果可以看出确实可以起到初始化数组的效果

【函数指针】

讲完指针函数,我们也来说说它的双胞胎兄弟 ------ 函数指针

1、概念理清

经过上面所讲的字符指针、数组指针,相信你马上就能类比出函数指针:没错,它就是一个指针,所指向的就是一个函数

  • 在【数组指针】中我有讲到过数组名&数组名的区别,虽然它们都指向数组的首元素地址,但是在它们往后偏移时,访问的字节数却不同;既然一个数组可以取出它的地址,那么函数是否可以取出它的地址呢?一起来看看
  • 从打印结果可以看出无论是函数名还是&函数名,它们的地址都是相同的,这是为什么呢?这就是语法规定的,一个函数名取不取地址都是这个函数的地址 ,因为对于函数来说也没有什么首函数的地址,是吧

对于数组的地址,我们可以用数组指针保存起来,那函数可以吗?当然可以,使用到的就是【函数指针】

  • 那我现在想问,下面那种形式可以将函数的地址存放起来呢
c 复制代码
//下面pfun1和pfun2哪个有能力存放test函数的地址?
void (*pfun1)();
void *pfun2();

💡答案揭晓,就是第二个,解析如下

  • 回忆我们数组指针的写法,为了不让指针变量和[]先结合,所以在*和指针变量外加了一(),其实对于函数指针也是一样的, 若是不加这个括号的话,就会变成* pf(),pf就会优先和后面的()结合,那么这会被编译器当成是一个函数的声明
  • 加上括号后,(*pf)就会是一个指针,向外一看有个(),说明它指向一个函数,这个函数的参数就是Add形参部分两个参数的类型
  • 最后是它的返回类型,也就是这个函数的返回类型int

所以Add函数的函数指针应该写成下面这种形式

c 复制代码
int (*pf)(int, int) = &Add;

2、如何调用函数指针?

清楚了函数该如何去声明后,那既然有了这个指针,而且它指向一个函数,是否可以通过这个指针去调用这个函数呢?

  • 调用函数肯定得传参,那我们为刚才声明的形参部分传入两个参数试试,然后再拿返回值接收一下
  • 可以看到确实可以调用Add函数进行求和计算
  • 不过这个编译器到底是怎么根据这个函数指针来判断去调用的Add函数,我们来对比一下
c 复制代码
int ret = (*pf)(3, 4);
printf("ret = %d\n", ret);

int ret2 = Add(1, 2);
printf("ret = %d\n", ret2);

通过调试来观察可以发现,编译器很智能,确实是通过函数指针的指向去找到函数的地址

也可以通过汇编来看,很清晰地看出它们都去call了这个函数的地址


  • 上面说到无论是函数名还是&函数名,它们所取到的地址都是一样的,所以我们可以将函数指针的声明写成下面这种形式,读者可以自己去试一下,效果也是一样的
c 复制代码
int (*pf)(int, int) = Add;
  • 那观察上面这样的声明形式,把指针变量单独抽离出来其实就是把Add赋给了pf,然后调用的时候在前面加上一个*作为解引用,取到这个函数,那其实Add和pf就是一样的,所以我们可以像pf(1, 2)这样去调用函数,具体如下
c 复制代码
//int ret = (*pf)(3, 4);
int ret = pf(3, 4);

int ret2 = Add(1, 2);

通过运行可以发现效果也是一样的,所以前面的*其实是可以省略的,甚至你多加几个像(****pf)(3, 4)都是可以的

3、两道"有趣"的代码题O(∩_∩)O

通过函数指针的学习,我们来看看下面两道很有趣的代码

下面两题均来自《C陷阱与缺陷》

< 第一题 >

代码:

c 复制代码
(*(void (*)())0)();

解析:

💬如果你是头一次看上面这段代码的话,心里一定是一个大大的问号???现在我就来解释一下

  • 本题的突破口在于这个0,仔细观察可以发现,0前面有一个括号(),括号里面的这种形式若是你自己去看的话就是一个函数指针,那相当于就是对0进行一个强制类型转换,把它变成一个函数地址,然后前面的*我们刚才讲过,就是对这个函数进行解引用,获取到这个函数。那么最后一步便是去调用这个函数

具体的分解可以看看下图👇

分步细说:

  1. void (*)() ------ 》一个没有形参,返回类型为void的函数指针

  1. (void (*)())0 ------》 对0进行强制类型转换,使其被解释成为一个函数的地址

  1. *(void (*)())0 ------》对0地址处的函数进行解引用,获取到这个函数

  1. (*(void (*)())0)() ------》调用0地址处的函数

原文现身:

< 第二题 >

代码:

c 复制代码
void (*signal(int, void(*)(int)))(int);

解析:

💬同理,若是第一次见一定会被它绕晕了😵了

  • 本题真的可以说是在套娃了,首先你看到的一定是signal,它呢是C语言中的一个信号函数,有兴趣可以去了解一下,我们知道()的优先级高于*,所以signal会和后面的内容先结合,那其实已经可以看出这是一个函数声明了。进到里面再来看看这个函数有两个参数,一个是int,一个是函数指针,那么外层的又是什么呢?
  • 仔细看下图,我将内部的signal()函数声明抽离了出来,只剩下了头和尾,你可以做一个视觉上的合并,那其实又是一个void (*)(int)的函数指针,其实这就是signal函数的返回类型,是一个函数指针

同样地,我们再来捋一遍

分步细说:

  1. void (*)(int) ------ 》是一个函数指针,为signal函数的形参

  1. signal(int, void(*)(int)) ------》 是一个函数声明,signal与右侧的()率先结合,内部有两个形参

  1. void (*)(int) ------》也是一个函数指针,不过是作为signal函数返回类型

优化:

对于上面的这种写法你是否觉得很冗余,其实可以再度进行一个优化,那么你可能很快就看得懂了

  • 因为 void (*)(int) 是出现了两次,之前我们在C语言中有学习过typedef这个关键字,可以用来对一个很长的数据类型或者变量进行重命名,那么在这里我们也可以这样做
  • 不过呢,你要把重命名后的名字放在(*)里面,因为语法这么规定了,去掉变量名后就是它的类型
c 复制代码
typedef void(*ptr_t)(int);
  • 于是这句代码就可以简化为下面这种形式👇注意解引用那个*不要了,函数指针这里是可以省略的
c 复制代码
//void (*signal(int, void(*)(int)))(int);
ptr_t signal(int, ptr_t);

原文现身:

4、函数指针数组

指针可以存放在一个数组中,那函数指针可以吗?来看看【函数指针数组】吧

概念明细

  • 还记得我们学习完【数组指针】后的这道练习题吗,最后我们判定它的类型为数组指针数组,它是一个数组,里面存放的都是数组指针
c 复制代码
int (*parr3[10])[5];
  • 那对于函数指针来说,和这个其实存在异曲同工之妙,只需要把后面的[]改为()即可,当然你也可以改个名字
c 复制代码
int (*pfArr[10]();
  • 再来对比我们前面学习过的【函数指针】,你有发现区别在哪吗?没错,就是多了个[10],因为[]的优先级较高,所以pArr会和它先结合,那其实就可以肯定它为一个数组了
c 复制代码
int (*pfArr)();

声明知道了,那具体怎么使用呢?怎么去接收多个函数的地址呢?再来看看

c 复制代码
int Add(int x, int y)
{
	return x + y;
}

int Sub(int x, int y)
{
	return x - y;
}

int main(void)
{
	int (* pfArr[2])(int, int) = {Add, Sub};
	
	int ret1 = pfArr[0](5, 3);
	int ret2 = pfArr[1](5, 3);

	printf("ret1 = %d\n", ret1);
	printf("ret2 = %d\n", ret2);
	return 0;
}
  • 很简单,上面有Add和Sub两个加与减的函数,那将它们存放到一个数组中,首先用花括号把它们括起来{Add, Sub},然后还是和函数指针一样的声明,只需要在指针变量后加上一个[2]即可,那么这就是一个【函数指针数组】
  • 接着去调用的话其实和要结合函数和数组的调用形式,既要控制数组的下标,还要考虑调用函数时传入相应的参数,如下所示👇

对于【函数指针数组】,我想你应该感受到了它的强大,竟然可以存放多个数组的地址然后根据不同的下标索引找到不同的函数进行调用,如果使用得当,那一定可以事半而功倍

具体引用:转移表✔

对于函数指针数组而言,有一个很经典的应用就是转移表,简单来说就是计算器

  • 首先我使用分支循环实现了简易的功能计算,代码如下
c 复制代码
void menu()
{
	printf("**************************\n");
	printf("***** 1.Add    2.Sub *****\n");
	printf("***** 3.Mul    4.Div *****\n");
	printf("***** 5.Cls    0.Exit*****\n");
	printf("**************************\n");
}

int main(void)
{
	int input = 0;
	int x = 0, y = 0;
	int ret = 0;

	do {
		menu();
		printf("请输入你的选择:>");
		scanf("%d", &input);
		switch (input)
		{
		case 1:
			printf("请输入两个运算数:>");
			scanf("%d %d", &x, &y);
			ret = Add(x, y);
			printf("结果为:%d\n", ret);
			break;
		case 2:
			printf("请输入两个运算数:>");
			scanf("%d %d", &x, &y);		
			ret = Sub(x, y);
			printf("结果为:%d\n", ret);
			break;
		case 3:
			printf("请输入两个运算数:>");
			scanf("%d %d", &x, &y);
			ret = Mul(x, y);
			printf("结果为:%d\n", ret);
			break;
		case 4:
			printf("请输入两个运算数:>");
			scanf("%d %d", &x, &y);
			ret = Div(x, y);
			printf("结果为:%d\n", ret);
			break;
		case 5:
			system("cls");
			break;
		case 0:
			break;
		default:
			printf("请输入正确的内容:");
			break;
		}

	} while (input);
	return 0;
}

但是仔细观察可以发现,每一条case语句中,都有重复的工作,就显得很冗余,为什么每个case里都要放一个输入呢,这是我后来发现的问题,若是把这个输入放在外面的话,就会造成按下0想要退出的时候还会出现输入运算数的情况,因为这是处于一个do...while的循环之中

但是此处我若是利用函数指针数组的话,就会很方便了

  • 函数声明如下,将这四个加、减、乘、除的函数的地址放到数组中存起来,通过下标的方式来进行访问
c 复制代码
int (*pfArr[5])(int, int) = {0, Add, Sub, Mul, Div};
  • 于是内部的逻辑就可以写成下面这样,通过去判断输入的input来实现不同的功能,只有当input >= 1 && input <= 4时,才进行运算,此时把输入操作符的逻辑放在这里即可,便不会影响其他功能了
c 复制代码
do {
	menu();
	printf("请输入你的选择:>");
	scanf("%d", &input);
	
	int (*pfArr[5])(int, int) = {0, Add, Sub, Mul, Div};
	
	if (input == 0)
	{
		break;
	}
	
	if (input >= 1 && input <= 4){
		printf("请输入两个运算数:>");
		scanf("%d %d", &x, &y);
		int ret = pfArr[input](x, y);
		printf("结果为:%d\n", ret);
	}
	else if (input == 5) {
		system("cls");
	}
	else {
		printf("输入有误,请重新输入\n");
	}
} while (input);

5、指向函数指针数组的指针

学习了函数指针数组后,你是否有联想到取出这个数组的地址再存放到指针里去呢?这不,它来了

  • 仿照前面的写法,若现在要是一个指针的话,那你应该想要又需要*()了,因为存在优先级的问题,指针变量会和[]相结合,所以我们可以取出函数指针数组的地址,给到一个指针作为接收,这个指针即为ptr
  • 分解着来细说一下,首先说明一下,有些同学直接拿函数指针的*作为指针符,这是不对的, 那是用来对函数指针所指向函数的地址进行解引用的 ,可不能混淆,所以我们要另外再加一个*,与ptr进行结合
  • 那么此时ptr就一定是一个指针,然后朝外一看有一个数组,那它便指向一个数组,这个数组的有5个元素,每个元素的类型我们只需要拿到数组名即(*ptr)[5]即可,便发现里面存放的都是函数指针。这么分析下来这个【ptr】确实是一个==指向函数指针数组的指针==

再来看一组练习巩固一下

  • 【pfun】是一个指针 ,它指向一个形参类型为const char*,返回类型为void的函数
  • 【pfunArr】是一个数组 ,数组大小为5,里面存放的均是指向指向一个形参类型为const char*,返回类型为void的函数指针
  • 【ppfunArr】是一个指针,它指向一个数组,数组里面的都是函数指针。。。同上
c 复制代码
void test(const char* str)
{
	printf("%s\n", str);
}
int main()
{
	//函数指针pfun
	void (*pfun)(const char*) = test;
	//函数指针的数组pfunArr
	void (*pfunArr[5])(const char* str);
	pfunArr[0] = test;
	//指向函数指针数组pfunArr的指针ppfunArr
	void (*(*ppfunArr)[5])(const char*) = &pfunArr;
	return 0;
}

研究到这块就可以了,如果上面的这些你全搞懂了的话,那么指针这一块相当于学得还可以了,不过缺乏实战, 【炼狱篇】会有大量的实战,虽然题量很多而言很难,但这是提升自己最好的机会!

五、回调函数

1、回调函数的概念

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

2、为什么要使用回调函数?

👉最大的一个目的,就是为了实现:解耦!

  1. 在主入口程序中,把回调函数像参数一样传入库函数。这样一来,只要我们改变传进库函数的参数,就可以实现不同的功能,且不需要修改库函数的实现,变的很灵活,这就是解耦

  2. 主函数和回调函数是在同一层的,而库函数在另外一层。如果库函数对我们不可见,我们修改不了库函数的实现,也就是说不能通过修改库函数让库函数调用普通函数那样实现,那我们就只能通过传入不同的回调函数了,这也就是在日常工作中常见的情况


注:使用回调函数会有间接调用,因此,会有一些额外的传参与访存开销,对于MCU代码中对时间要求较高的代码要慎用

3、回调函数使用场景

场景一:模拟计算器的加减乘除

  • 在函数指针章节,我有介绍了如何使用【函数指针数组】去模拟计算器的加减乘除,现在我们使用回调函数来试试

==功能与菜单==

c 复制代码
int Add(int x, int y)
{
	return x + y;
}

int Sub(int x, int y)
{
	return x - y;
}

int Mul(int x, int y)
{
	return x * y;
}

int Div(int x, int y)
{
	return x / y;
}

void menu()
{
	printf("**************************\n");
	printf("***** 1.Add    2.Sub *****\n");
	printf("***** 3.Mul    4.Div *****\n");
	printf("***** 5.Cls    0.Exit*****\n");
	printf("**************************\n");
}

==主程序与回调函数==

c 复制代码
void calc(int (*p)(int, int))
{
	int x = 0, y = 0;
	printf("请输入两个运算数:>");
	scanf("%d %d", &x, &y);
	int ret = p(x, y);
	printf("结果为:%d\n", ret);
}

int main(void)
{
	int input = 0;
	do {
		menu();
		printf("请输入你的选择:>");
		scanf("%d", &input);
		switch (input)
		{
		case 1:
			calc(Add);
			break;
		case 2:
			calc(Sub);
			break;
		case 3:
			calc(Mul);
			break;
		case 4:
			calc(Div);
			break;
		case 5:
			system("cls");
			break;
		case 0:
			break;
		default:
			printf("请输入正确的内容:\n");
			break;
		}

	} while (input);
	return 0;
}

通过画图来看一下是如何通过函数指针来实现的回调

  • 可以看出,回调函数它不会自己调用,而是将自己的函数名传递给到另一个函数(此处的Add和Sub即为回调函数),然后在这个函数内部通过函数指针去调用这个函数。就是这样函数指针会接收来自不同函数的地址,继而实现计算器的加、减、乘、除各种功能

场景二:模拟qsort函数【⭐】

学习过数据结构的同学一定接触过【快速排序】,即QuickSort。不了解的可以看看 数据结构 | 十大排序超硬核八万字详解

1、qsort函数解读

  • 在C语言中,也有一个关于快速排序的库函数,叫做qsort,来看一下官方文档是怎么说的
  • 清楚了这个函数的基本作用后,那最想知道的就是它如何使用,既然是函数的话就需要传递参数,给个特写📷
    • base ------ 待排序元素的起始地址,类型为【void】表示可以传递任何类型的数组
    • num ------ 表示待排序数据的元素个数
    • size ------ 表示数组中每个元素所占的字节数
    • int (*compar)(const void*, const void*) ------ 函数指针,用于接收回调函数

2、用用qsort

💬首先我们用它来排下整型数组试试

c 复制代码
cmp_int(const void* e1, const void* e2)
{
	return *(int*)e1 - *(int*)e2;
}
c 复制代码
void test1()
{
	int arr[10] = { 2,3,6,7,5,1,4,9,10,8 };
	int sz = sizeof(arr) / sizeof(arr[0]);

	printarray(arr, sz);
	
	qsort(arr, sz, sizeof(arr[0]), cmp_int);
	
	printarray(arr, sz);
}

运行结果:

解析:

c 复制代码
cmp_int(const void* e1, const void* e2)
{
	return *(int*)e1 - *(int*)e2;
}
  • 主要来讲一下这个函数,这就是本文要讲解的回调函数,为什么它的形参是一个void*的指针呢?这种类型的指针一般被我们称作为【垃圾桶】,那垃圾桶我们平常都在用,不考虑垃圾分类的话,可以接收任何种类的垃圾,那么在这里就是==可以接收任何类型的数据==,即整型、字符型、浮点型,甚至是自定义类型它都可以接受
  • 但是呢我们在使用的时候还是要去进行一个转换,此处就要使用到【强制类型转换 】,将其转换为int *的指针,那么它就指向了我们要待排序的数组。但是要怎么比较和交换两个数据呢,这就要看qsort()函数内部的实现了,它是基于快速排序的思想,如果你懂快速排序的话,脑海里立马就能浮现出它们的比较的场景
  • 还是来看一下官方文档,其实下面的这种比较思路很常见,像字符串函数[strcmp]也是这样的:
    • 前一个比后一个小,返回-1
    • 前一个和后一个相等返回,返回0
    • 前一个比后一个大,返回1

当然,除了上面这种内置类型外,自定义类型的数据也是可以比较的,接下去我们来比较一下两个学生的信息

  • 下面是结构体的初始化和定义,以及qsort函数的调用
c 复制代码
typedef struct stu {
	char name[20];
	int age;
}stu;
c 复制代码
void test2()
{
	stu ss[3] = { {"zhangsan", 22}, {"lisi", 55}, {"wangwu", 33} };

	qsort(ss, 3, sizeof(ss[0]), cmp_byname);
	//qsort(ss, 3, sizeof(ss[0]), cmp_byage);
}
  • 下面是两个回调函数的实现,在看了第一个后相信你已经很熟悉了,形参还是void*类型的指针,但是在比较的时候要转换为结构体指针 ,否则就无法访问到成员了。对于【姓名】的比较是按照首字母的ASCLL码值来的,这里我们直接使用库函数strcmp即可,比较的规则和qsort()是一致的
c 复制代码
Cmp_ByName(const void* e1, const void* e2)
{
	return strcmp(((stu*)e1)->name, ((stu*)e2)->name);
}

Cmp_ByAge(const void* e1, const void* e2)
{
	return ((stu*)e1)->age - ((stu*)e2)->age;
}

首先来看按照名字排序的结果

然后是按照年龄排序的结果

3、使用冒泡排序模拟qsort

  • 普通的冒泡排序的话相信是个大学生应该都会写,这里就不解释了,如果不会的话看看我的排序文章
c 复制代码
void BubbleSort(int* a, int n)
{
	for (int i = 0; i < n - 1; ++i)
	{
		for (int j = 0; j < n - 1 - i; ++j)
		{
			if (a[j] > a[j + 1])
			{
				int t = a[j];
				a[j] = a[j + 1];
				a[j + 1] = t;
			}
		}
	}
}

但此时我若是要用这个冒泡排序去排任意类型的数据呢?该如何进行修改

  • 此时就需要使用到刚才所学习的qsort()函数了。我们可以仿照着它的参数来写写看
c 复制代码
void bubblesort(void* base, int num, int sz, int(*cmp)(const void* e1, const void* e2))
  • 既然参数做了,那么函数体内部我们也需要做一个大改动。例如对数组中的两个数据进行比较的时候,就不能单纯地使用关系运算符>>==了,此处函数指针就派上了用场,我们可是使用函数指针去接收不同的回调函数 ,继而去实现不同的类型数据的比较,也就是上面所写的Cmp_intCmp_ByNameCmp_ByAge
  • 而且对于内部的交换逻辑我们也要单独去实现,不同数据的交换方式是不一样的

那现在,我们就来实现一下上面说到的这两块内部逻辑

  • 首先就是jj + 1这两个位置上的值要如何进行比较的问题,那既然base指向首元素地址,那有同学说不妨让它进行偏移,但是它的类型是void*,虽然这种类型的指针可以接收各种各样的数据地址, 但是却无法进行偏移,因为它也不知道要偏移多少字节,所以我上面在回调函数内部对两个形参进行了强转才可以进行比较
  • 我们知道,对于char类型的字符,在内存中只占有1个字节的大小,那么char*的指针每次后移便会偏移一个字节,那既然在形参我们传入了数组中每个元素在内存中所占字节数的话,就可以使用起来了,和char*的指针去做一个配合
  • 所以两数比较的逻辑就可以写成下面这样
c 复制代码
//判断两数是否需要交换
if (cmp((char*)base + j * sz, (char*)base + (j + 1) * sz) > 0)
{
	//两数据交换的逻辑
}

接下去就来实现两数交换的逻辑

  • 因为我们是使用的char*指针一个字节一个字节去访问数据的,所以交换的时候也需要按照字节来交换。单独封装一个Swap()函数,把要交换两个数的地址和单个数据所占的字节数传入

声明:

c 复制代码
void Swap(char* buf1, char* buf2, int sz)

调用:

c 复制代码
Swap((char*)base + j * sz, (char*)base + (j + 1) * sz, sz);

内部逻辑就是单个数据的交换【记住,这只是单个数据,所以循环sz次】

c 复制代码
void Swap(char* buf1, char* buf2, int sz)
{
	//两个数据按照字节一一交换
	for (int i = 0; i < sz; ++i)
	{
		int t = *buf1;
		*buf1 = *buf2;
		*buf2 = t;

		buf1++;
		buf2++;
	}
}

具体交换细节可以看下图 测试一下:

  • 可以看到,整数类型的数据排序成功了
  • 再看看内置类型

4、原理分析

仔细看一下这张图,你就清楚整个调用过程了

场景三:模拟任务下载进度

本代码来自我的Linux基础入门篇之进度条小程序,也很好地展现了回调函数的魅力之所在:rose:

==processBar.h==

c 复制代码
  1 #include <stdio.h>
  2 #include <unistd.h>
  3 #include <string.h>
  4 
  5 #define TOP 100
  6 #define BODY '='
  7 #define RIGHT '>'
  8 
  9 extern void processBar(int rate);
 10 extern void initBar(); 

==processBar.c==

c 复制代码
  1 #include "processBar.h"
  2 
  3 const char* label = "|/-\\";    
  4 char bar[TOP];
  5                                                                                                                                      
  6 void initBar()
  7 {             
  8     memset(bar, '\0', sizeof(bar));
  9 }                                  
 10  
 11 // 单次的进度推进
 12 void processBar(int rate)
 13 {                        
 14     if(rate < 0 || rate > 100)  return;
 15                                        
 16     int len = strlen(label);
 17     printf("[%-100s][%d%%][%c]\r", bar, rate, label[rate % len]);
 18     fflush(stdout);     // 刷新缓冲区                            
 19     bar[rate++] = BODY;              
 20     if(rate < 100)     
 21         bar[rate] = RIGHT;
 22     else                  
 23         initBar();
 24 }  

==main.c==

c 复制代码
  1 #include "processBar.h"
  2 
  3 // 函数指针类型
  4 typedef void (callback_t)(int rate);
  5 
  6 // 模拟一种安装或下载的场景(回调函数)

  7 void downLoad(callback_t cb)
  8 {
  9     int total = 1000;   // 1000B
 10     int curr = 0;       // 0B
 11 
 12     while(curr <= total)
 13     {
 14         /* 进行某种下载任务 */
 15         usleep(50000);     // 模拟下载时间
 16      
 17         // 计算下载速率       
 18         int rate = curr * 100 / total;    
 19         cb(rate);   // 通过函数指针去调用对应的函数
 20                        
 21         // 循环下载了一部分           
 22         curr += 10;                                
 23     }
 24     printf("\n");          
 25 }                                                                                                                                    
 26      
 27 int main(void)   
 28 {
 29     // 将所需要的调用的函数地址传递给回调函数
 30     printf("download 1:\n");
 31     downLoad(processBar);
 32                                              
 33     printf("download 2:\n");
 34     downLoad(processBar);
 35 
 36     printf("download 3:\n");
 37     downLoad(processBar);
 38 }

场景四:模拟文件下载模块

我们为什么要用回调函数呢?

记得在一次C++开发面试的时候被被一位主面官问到过这个问题,现在再回答一遍。

  • 我们对回调函数的使用无非是对函数指针的应用,函数指针的概念本身很简单,但是把函数指针应用于回调函数就体现了一种解决问题的策略,一种设计系统的思想。

  • 在解释这种思想前我想先说明一下,回调函数固然能解决一部分系统架构问题但是绝不能再系统内到处都是,如果你发现你的系统内到处都是回调函数,那么你一定要重构你的系统。回调函数本身是一种破坏系统结构的设计思路,回调函数会绝对的变化系统的运行轨迹,执行顺序,调用顺序。回调函数的出现会让读到你的代码的人非常的懵头转向。

  • 那么什么是回调函数呢,那是不得以而为之的设计策略,想象一种系统实现:在一个下载系统中有一个文件下载模块和一个下载文件当前进度显示模块,系统要求实时的显示文件的下载进度,想想很简单在面向对象的世界里无非是实现两个类而已。但是问题恰恰出在这里,显示模块如何驱动下载进度条?显示模块不知道也不应该知道下载模块所知道的文件下载进度(面向对象设计的封装性,模块间要解耦,模块内要内聚),文件下载进度是只有下载模块才知道的事情,解决方案很简单给下载模块传递一个函数指针作为回调函数驱动显示模块的显示进度。

下面是模拟实现这个文件下载模块的代码,仅供参考【C++实现】

cpp 复制代码
#include <iostream>
#include <random>
#include <ctime>

typedef void(*on_process_callback)(std::string data);

//处理完成的回调
void on_process_result(std::string data)
{
   //根据返回消息进行处理
   std::cout << data.c_str() << std::endl;
};

class TaskProcessing
{
public:
   TaskProcessing(on_process_callback callback) : _callback(callback)
   {};

   void set_callback(on_process_callback callback)
   {
   	_callback = callback;
   };

   void do_task()
   {
   	//当文件传输完成
   	if (_callback)
   	{
   		srand((int)time(NULL));
   		if (rand() & 1)
   		{
   			(*_callback)(std::string("ftp succeed"));
   		}
   		else
   		{
   			(*_callback)(std::string("ftp failed"));
   		}
   	}
   };
private:
   on_process_callback _callback;
};

int main()
{
   TaskProcessing* process = new TaskProcessing(on_process_result);
   process->do_task();
   system("pause");
}
相关推荐
荒古前29 分钟前
龟兔赛跑 PTA
c语言·算法
嵌入式科普1 小时前
十三、从0开始卷出一个新项目之瑞萨RZN2L串口DMA接收不定长
c语言·stm32·瑞萨·e2studio·rzn2l
小林熬夜学编程3 小时前
【Linux网络编程】第十四弹---构建功能丰富的HTTP服务器:从状态码处理到服务函数扩展
linux·运维·服务器·c语言·网络·c++·http
Jackey_Song_Odd3 小时前
C语言 单向链表反转问题
c语言·数据结构·算法·链表
A懿轩A4 小时前
C/C++ 数据结构与算法【数组】 数组详细解析【日常学习,考研必备】带图+详细代码
c语言·数据结构·c++·学习·考研·算法·数组
半盏茶香4 小时前
在21世纪的我用C语言探寻世界本质 ——编译和链接(编译环境和运行环境)
c语言·开发语言·c++·算法
字节高级特工5 小时前
【C++】深入剖析默认成员函数3:拷贝构造函数
c语言·c++
计算机学长大白6 小时前
C中设计不允许继承的类的实现方法是什么?
c语言·开发语言
XH华11 小时前
初识C语言之二维数组(下)
c语言·算法
Uu_05kkq15 小时前
【C语言1】C语言常见概念(总结复习篇)——库函数、ASCII码、转义字符
c语言·数据结构·算法