目录1.引言2.数组名的理解3.使用指针访问数组4.一维数组传参的本质5.冒泡排序6.二级指针7.指针数组8.字符指针变量9.数组指针变量10.二维数组传参的本质11.函数指针变量12.函数指针数组13.转移表14.回调函数15.qsort 函数16.结语1.引言上篇博客我们已经讲解了指针的部分知识本篇博客就继续深入讲解指针不过这里的指针更多会与数组与函数相结合。2.数组名的理解我们在上篇博客结尾说过数组名就是数组的首地址通过下图代码可以看出这一点。但有两个特例其中一个就是 sizeof因为我们可以通过 sizeof(arr) / sizeof(arr[0]) 计算得到数组的元素个数这里的 arr 就不能理解为是数组的首地址他表示的是整一个数组另一个就是 arr此时得到的是整个数组的地址不同于 arr 所得到的是数组的首地址所以这里的 arr 同样表示的是整一个数组。但是 arr 的结果与 arr 相同这是因为对于一个数组而言arr 获取的是数组的首地址也就是第一个元素的地址但整一个数组的地址同样是从第一个元素的地址开始的如下图那样。所以两者最终指向的地址相同但 arr 中的 arr 要理解为是整一个数组。3.使用指针访问数组使用指针访问数组其实我们前面已经讲过了这里讲一点拓展知识不感兴趣的小萌新可以直接跳过。在我们最开始学习循环语句与数组时想要打印输出一个数组的元素我们会采用下图的方式。而当我们学过指针之后除了使用下标引用操作符外还可以使用指针与指针的解引用操作来打印输出数组元素。那当我们知道了数组名可以表示数组的首地址后我们可以进一步修改代码去掉原本用于获取 arr 数组首地址的指针变量 p直接使用 arr 加偏移量的形式进行解引用操作那代码就成了下面这样。通过上面一步步的演变我们可以推出下图这个关系这实际上也就是编译器的处理逻辑。编译器在处理数组元素访问时会先转化为数组首地址加偏移量的形式找到该元素的地址然后通过解引用操作获取到值。那我们又知道加法是支持交换律的所以可以顺势推导出下图这个新的关系。当我们尝试使用新的方式来打印输出数组时发现其实也是可行的。但不建议各位平时这样书写代码容易让别人看不懂这里仅仅是用作演示。4.一维数组传参的本质在正式讲解之前我们先来看下面这个例子。那从上图的结果以及划红线部分的提示我们应该猜出来了一维数组传参的本质就是传递了数组的首地址。虽然我们在 test 函数的参数部分写了 int arr[5]看似传的是整个一维数组那在求解数组元素个数时函数内应该和函数外一样都输出 5。但实际上编译器是进行了数组降级将 int arr[5] 转变为 int* arr以指针变量的形式接收了一维数组的首地址所以函数内的 sizeof (arr) 求的并不是整个一维数组的大小而是指针变量的类型大小。那根据运行环境的不同在 x86 的 32位环境下指针变量类型大小为 4最后结果就为 1而在 x64 的 64位环境下指针变量类型大小为 8最后结果就为 2。那从这里我们就知道了我们是不能通过参数部分的数组来求数组元素个数的。同时我们也清楚了为什么在使用一维数组传参时可以省略数组大小写成 int arr[ ] 这样的形式。那我们将代码改一下把数组大小以参数的形式随数组的首地址一起传入 test 函数就可以正常遍历打印输出数组的元素了。5.冒泡排序我们前面在学习数组时其实已经尝试设计一个函数为数组排序了这里就再讲解一个冒泡排序。冒泡排序的核心逻辑就是对数组的相邻元素两两进行比较。基于上述文字描述我们对冒泡排序的运行逻辑有了一个大概的了解接下来就是书写代码实现冒泡排序具体代码如下。当然我们也可以在每一次循环结束后都打印一遍数组这样就能知道每次循环的排序结果了。但是从上面的排序结果来看我们的数组有时并不需要经过 n-1次循环就能实现升序排序此时再往后循环就是浪费时间那有没有办法让他在完成升序排序后就直接退出办法是有的我们只要放置一个用于检验数组是否完成排序的 flag 变量当数组内元素不再发生交换时我们就修改 flag 变量的值并让程序提前结束。6.二级指针我们前面学习的指针都是一级指针也就是那些用来存放变量地址的指针变量。那什么是二级指针我们知道地址本质上也是一种数值所以地址就是指针变量所存放的值那与变量相同指针变量也是有自己的地址的如果我们要获取一个指针变量的地址那就要用到二级指针。大概的关系图与使用语法如下。那关于二级指针的解引用操作与一级指针相同只是二级指针解引用一次所得的是一级指针的值也就是变量的地址解引用两次才是变量的值。7.指针数组我们前面学习数组时知道不同类型的数组可以存放不同类型的元素那与此类似的指针数组也是一种类型的数组他的数组元素就是一个个的指针。既然指针数组的每个元素都是指针而每个指针又都可以指向一片空间那我们就可以用它来模拟二维数组。像下图这样这是孤立的三个一维数组。现在我们创建一个指针数组利用指针数组的元素是三个一维数组的首地址就可以将他们串联起来就像下图演示的这样。关于上面打印输出时为什么指针的解引用情况是那样可以看下图的推导这里是以 arr 数组的第一个元素为例做的推导。然后再结合我们前面关于一维数组的推导就可以得到下面图二的推导。这里同样是以 arr 数组的第一个元素为例做的推导。那么除了用指针的形式外我们还可以用数组的形式两种方法最后的结果都是一样的。8.字符指针变量根据前面我们对指针变量的理解字符指针变量应该如下图这样使用这当然是没问题的。但字符指针变量相比于其他指针变量又有自己的特性接下来我们深入了解一下。我们除了可以让其正常接收一个字符的地址外还可以让其接收一个字符串的地址不过和数组类似此时接收的就是这个字符串的首字符的地址。当我们对字符指针变量 ps 做解引用操作时发现获取到的只有字符 a这也证明了 ps 获取的就是字符 a 的地址。那如果我们想要正常打印整个字符串就使用 %s 的打印格式。不过要注意此时直接使用 ps 即可不需要再对其做额外的操作。不过值得注意的是我们赋给字符指针变量的字符串是常量字符串是无法做内容的修改操作的。像下图这样如果直接使用 *ps 做修改操作虽然没有语法错误但在程序运行时就会报错。那为了避免上述问题我们可以使用 const 修饰字符指针变量此时如果使用 *ps 做修改操作就会直接报语法错误。从最后的结果来看我们知道 str1 与 str2 不相同而 str3 与 str4 相同那是什么原因造成的这是因为 str3 与 str4 其实指向的是同一个地址在 C或C 中会把常量字符串储存到单独的一个空间中换成其他常量也是如此此时如果有多个字符指针变量指向同一个常量字符串他们实际上会指向同一个地址但用同一个常量字符串去初始化不同的数组时数组开辟的空间各不相同而 str1 与 str2 实际上就是各自数组的首地址所以并不相同。那如果我们想要比较两个字符数组的内容是否相同就要用到 strcmp 函数这个会在之后的博客里讲到这里就先不展开讲解了。9.数组指针变量我们前面学习了指针数组那是数组元素为指针变量的数组而数组指针则是可以指向数组的指针变量其存放的值就是数组的地址。那关于数组指针变量的写法如果我们单单模仿普通指针变量的写法就会得到下图这样的代码形式。但根据前面学过的指针数组我们知道那本该是指针数组的语法形式那数组指针到底该怎么表示其实我们只要用 ( ) 将 * 与指针变量名包裹起来得到的就是数组指针。对于数组指针的理解可以看下图。至于为什么要使用 ( )是因为 [ ] 的优先级大于 *原本表示的是一个数组这个数组是指针数组利用 ( ) 改变优先级保证这是一个指针变量且是数组指针变量。我们通过调试在监视窗口比较 arr 与数组指针变量 p 的类型就能发现两者类型其实相同。接下来就是 p 的解引用操作不过单从打印结果来看p 与 *p 的作用相同其实不然我们前面在讲解数组名时说过arr 中的 arr 要理解为是整个数组但对于整个数组而言其地址是从第一个元素地址开始的而 *p 解引用操作后获取的是 arrarr 数组名也可以表示地址表示的是数组的首地址也就是第一个元素的地址。所以从值上来看两者是一样的但各自所表示的意义却不相同。此时我们对 p 与 *p 做偏移操作就能进一步观察到两者的区别。由于 arr 中 arr 表示的是整一个数组所以在偏移时一次就会跨过一个数组的长度此时的偏移量就是 40而对于 arr他表示的是数组的首地址那在偏移时一次只会跨过一个数据类型的长度此时的偏移量就是 4。10.二维数组传参的本质二维数组传参与一维数组传参一样传递的也是指针变量。但与一维数组又有所不同虽然两者传递的都是数组的首地址但一维数组的首地址就是普通指针变量int arr[ ] int* arr那二维数组的首地址就是我们刚刚所讲的数组指针变量int arr[3][5] int (*arr)[5]。至于为什么是这样的我们前面在讲解数组时说过二维数组与一维数组一样在内存中也是连续存放的。那对于二维数组而言它的首元素就是 arr[0]指的是这整个一维数组而数组指针恰好是指向数组的指针变量所以在实际传递时编译器进行数组降级将 int arr[3][5] 转变为 int (*arr)[5]以数组指针变量的形式接收了二维数组的首地址。那同样的我们也不能通过参数部分的数组来求二维数组的行数与列数也顺带解释了为什么在使用二维数组传参时可以省略数组的行数写成 int arr[ ][5] 这样的形式。11.函数指针变量那函数指针变量与数组指针变量类似函数指针变量就是可以指向函数的指针变量其存放的值就是函数的地址。在学会函数指针变量后我们除了正常使用函数名调用函数外还可以使用函数指针变量来调用函数。函数指针变量的写法也与数组指针变量类似我们不能写成 int* p( ) test 这样的形式而是要写成 int (*p)( ) test 这样的形式。关于函数指针变量的理解可以看下图。那从下图结果来看void (*p1)() test 与 void (*p2)() test 的结果是相同的那是不是和数组指针变量一样test 为整个函数的地址test 为函数的首地址我们同样可以通过函数指针变量的偏移结果来分析那从后面的新结果比较就能发现函数指针变量不同于其他类型的指针变量在获取函数地址时 是可加可不加的。而且我们从 void 类型在这里也能正常偏移就能看出函数指针变量的第二个特点他不同于其他指针变量在使用 void 类型时不能进行解引用操作与指针加减整数操作因为函数的返回值类型可以是 void 所以 void 类型的函数指针变量可以正常进行解引用操作与指针加减整数操作。接下来就是函数指针变量的解引用操作。但从结果来看函数指针变量的解引用操作似乎没什么作用这是因为我们在使用函数指针变量获取函数地址时 是可加可不加的。如果是使用 void (*p) test在解引用操作后就是 *p test但也可以使用 void (*p) test那此时 p test。所以对于函数指针变量而言在解引用操作时 * 也是可加可不加的。p testp test*p test 这三者的作用是完全相同的都是用来获取函数的地址。那知道了函数指针变量的特性之后我们就可以尝试使用函数指针变量的方式来调用函数。下图这个代码相信各位小萌新已经相当熟悉了我们除了正常用 add 函数名调用函数外也可以先用函数指针变量 p 存放 add 函数的地址然后用 *p 或 p 来调用函数。然后这里再补充一个 typedef 关键字typedef 的作用是对数据类型重命名可以让一些复杂的数据类型在使用时变得更为简单。不过值得注意的是typedef 只能重命名数据类型并不能重命名变量名等其他内容。那如果要为数组指针变量或函数指针变量的数据类型重新命名就要像下面这样因为无论是 int (*)[5] 还是 void (*)( )我们都是将变量名写在 ( ) 里的那相应的在重命名数据类型时也要命名在 ( ) 里。除此之外还要注意我们前面在创建变量时有时会直接在一行创建多个就如下图这样。但当我们用相同的方式去创建多个指针变量时结果并不是我们想的那样a,b 都创建为指针变量而是 a 为指针变量b 为 int 整型。至于为什么会这样我们要知道 * 表示该变量是一个指针变量而一个 * 只能修饰一个变量。所以当我们要创建多个指针变量时应在每一个变量前都加上 *就像下图代码这样这样创建出来的 a,b 才都是指针变量。12.函数指针数组那函数指针数组也与指针数组类似函数指针数组是另一种相似类型的数组他的数组元素就是一个个的函数指针。像下图这样我们设计了四个自定义函数并使用函数指针变量获得了每个函数的地址。但是我们仔细观察发现这四个函数的参数类型与返回值类型都是相同的而函数名又可以表示函数的地址此时我们就可以创建一个函数指针数组将这四个函数作为数组元素存起来。13.转移表根据我们曾经制作扫雷游戏时使用的 do-while 语句嵌套 switch 语句再结合上面的四个自定义函数我们就能复原出一个简单的计算器。但我们仔细观察下面这段代码就会发现switch 语句里的大部分 case 语句框架相同如果将来我们要进一步拓展计算器的运算功能后面的代码只会越来越冗长那有没有什么方法可以优化代码那就要用到我们前面所讲的函数指针数组。将四个函数地址作为数组元素存入函数指针数组后再在数组最前面加上 NULL 指针这样就能使数组的下标与我们的选择数对应上。然后修改原本的 switch 语句为 if - else if - else 语句此时所有的函数都可以通过函数指针数组来调用即使后续拓展了计算器的功能也只要补充对应功能的函数然后在数组中继续添加新的数组元素并修改 if 语句对应的取值范围即可。我们创建一个函数指针数组存放函数地址在使用时直接通过下标查表跳转就是模拟了转移表的运行逻辑。14.回调函数回调函数就是通过函数指针调用的函数。当我们把一个函数的地址作为参数传给另一个函数时这个指针被用来调用其所指向的函数此时被调用的函数就是回调函数。单是解释可能不太容易理解我们依旧刚刚的计算器代码为例。我们之前讲过为了提高代码的复用性以及方便后期维护我们会将实现某项功能的大段代码进行封装。但仅仅是进行简单的封装下图的四段代码就会对应变成四个函数这样并不能使程序变得简洁。如果我们仔细观察就会发现每段代码的框架其实相同核心区别就是调用的函数不同而函数名可以表示函数地址。如果我们将函数地址作为参数进行传递其余部分封装进一个函数里就实现了回调函数的使用。像下图这样我们再设计一个 cal 函数用于功能封装add,sub 等四个函数地址作为参数传入然后在需要使用时直接利用函数指针进行调用这就是所谓的回调函数。回调函数的使用过程大概如下图所示那样。15.qsort 函数我们前面写过冒泡排序的代码也使用了冒泡排序对数组进行排序但冒泡排序的效率太低了。我们可以思考一下如果现在要使用冒泡排序对下图这个完全倒序的数组进行排序程序需要比较几次显然根据我们所学计算一下就是 10*990 次。那如果数组再大一点从 10 个数变为100 个数呢此时所需的比较次数就是 100*999900 次。显然数组越大冒泡排序完成排序所需的时间就越多所以我们对于数组的排序通常会直接使用 stdlib.h 头文件提供的库函数 qsort。qsort 函数是基于快排思想实现的排序算法但由于初学者不太容易理解快排思想我们这里也就先不展开讲解了关于快排的具体实现过程会在后续的博客讲到感兴趣的小萌新可以先自行了解一下。在使用 qsort 函数前我们先要知道 qsort 函数有哪些参数。我们通过对 qsort 函数右击鼠标选择转到定义就能看到其所包含的四个参数。现在我们来分析一下这四个参数各自的作用第一个参数 _Base其数据类型是 void*可用于接收不同类型的数组。因为不同于我们自己所写的冒泡排序只能对 int 整型数组排序qsort 函数可对任意合法数组排序那为了使代码更具通用性就需要使用 void* 指针来实现泛型编程。第二个参数与第三个参数的数据类型均为 size_t说明此时传入的两个参数与数据大小相关。如果我们翻译一下两个变量名就能知道_NumOfElements 变量传递的是数组的大小。因为我们前面讲过数组在实际传递时是会被编译器降级为指针这个指针指向的是数组的首地址如果想让编译器知道数组的大小就需要通过参数形式传入。而 _SizeOfElements 变量传递的是数组中每个元素的字节长度。因为对于不同类型的数组其数组元素大小必然是不同的为了让编译器在排序时能准确比较并交换两个数组元素就要把数组元素的长度也通过参数形式传入。最后一个参数是 _CompareFunction单从名字来看我们知道他与数组的比较方法有关。因为除了常用的整型数组外还有字符数组与结构体数组等其他类型的数组不同数组的比较方法也有差异。那为了让比较方法也更具通用性我们就使用函数指针的方式来调用对应的比较函数。如果我们对前面那一长串在 VS 内部重命名的数据类型 _CoreCrtNonSecureSearchSortCompareFunction 也进行转定义查看就能发现这个函数指针的返回值类型是 int两个参数类型是 void*。那这里还要再补充一下关于 qsort 函数的比较方式qsort 函数会根据返回结果的正负来判断元素的大小关系0 表示前者大于后者0 表示两者一样大0 表示前者小于后者。所以我们在自定义比较函数时可以直接返回两个元素的差值。在清楚 qsort 函数的参数之后我们就可以尝试使用 qsort 函数来排序我们的数组了。如下图所示我们先要包含头文件 stdlib.h 确保程序可以正常使用 qsort 函数其次我们对应传入各个参数确保 qsort 函数可以按照我们想要的方式对数组进行排序。那最终结果也证明我们通过 qsort 函数实现了对数组的升序排序。虽然我们不会快排但我们可以利用之前所学的冒泡排序模拟 qsort 的运行逻辑具体代码如下。可以看到在 qsort 函数中由于最开始是使用 void* 指针接收数组的首地址所以在使用前先要强制类型转换但考虑到不同类型的数组需要转换成不同类型的指针为了提高代码的通用性我们直接统一转换为 char* 指针因为 char 类型是一字节大小此时每次偏移都只会跨过一个字节长度那结合我们传入的参数 w就可以调节偏移距离为各自数组的数据类型因为参数 w 对应的就是数组中一个元素的字节长度。同样的我们在进行元素值交换时也利用参数 w 通过循环的方式对两个元素一字节一字节的交换。16.结语本篇博客到这里就结束了希望能够帮到各位刚接触C语言的小萌新们下篇博客将继续讲解C语言的相关知识。