【C语言】深入理解指针

目录

1.字符指针

2.指针数组

3.数组指针

4.数组传参与指针传参

一维数组传参

二维数组传参

一级指针传参

二级指针传参

5.函数指针

6.函数指针数组

7.指向函数指针数组的指针(了解即可)

8.回调函数

回调函数的应用:库函数qsort

模拟实现库函数qsort


1.字符指针

允许用字符串来初始化字符指针

char*p="abcdef"这个语句是正确的,他表示把后面字符串首元素地址放到指针变量p里面去。其中abcdef是一个常量字符串

看下面这个代码

刚上来的两句是创建了两个字符数组,并用hello bit来初始化他们,这两个数组的创建必然要申请两块不同的内存空间,而打印的条件是str1==str2,而数组名是数组首元素地址,所以不满足if的条件,因此会打印str1 and str2 are not same。

然后是用常量字符串初始化字符指针的操作,对于常量字符串hello bit来讲,他存放在内存的代码段,他的地址是固定的,用他的地址来初始化str3和str4,因此str3和str4里面存放的都是常量字符串hello bit的首元素地址,因此打印的是str3 and str4 are same。

2.指针数组

指针数组顾名思义就是用来存放指针的数组,本质上还是一个数组,用来存放一组类型相同的指针。

比如这样一个代码

他们在内存中存放的形式如图

如果拿到了arr[0],也就是arr数组的第一个元素,里面存的是arr1,他指向了arr1数组,也就是说arr[0]就是arr1,arr1[0]表示arr1数组的首元素,又因为arr1就是arr[0]所以arr[0][0]就是arr1的首元素,这看起来像一个二维数组,同理arr[1][0]就是arr2数组的首元素。

虽然形式上跟二维数组一样,但是指针数组跟二维数组还是有很大区别的,二维数组在创建的时候在内存中被分配的空间是连续的,而这个指针数组再创建的时候就是存了三个指针,这个指针指向了三个数组,这三个数组并不一定连续。也就是说对于一个三行五列的二维数组(假设数组名为arr)来说,arr[0][4],arr[1][0]的地址是连续的,但是对于上面的指针数组来说arr[0][4]是arr1的第五个元素,而arr[1][0]是arr2的首元素,由于arr1和arr2在向内存申请空间的时候都是随机申请的,大概率不连续,因此这个指针数组arr的arr[0][4]和arr[1][0]也是大概率不连续的。

3.数组指针

类比一下我们熟悉的其他类型指针,比如int *p,*代表p是一个指针,int代表p指向的数据是int类型的,数组指针也类似,比如int *p[10],*代表p是一个指针,去掉*p之后剩下的int [10]是其指向数据的类型,这是一个数组类型。因此p就叫做数组指针变量,里面存放的是一个数组的地址。p的类型我们写作int(*)[10]。

在对数组指针变量进行初始化的时候,应该写成 int(*p)[10]=&arr;

可以这样理解,首先得有个指针变量p,然后他前面得有个*表示他是一个指针,此时如果没有(),p就会和[]结合,因此我们加上括号,让p和*结合代表他是一个指针。指向的元素是int [10]类型的,存放的内容是arr的地址。同时印证了这个数组指针的类型是int(*)[10],因为去掉指针变量的名字,就是他的类型。

数组指针的使用

先来看一个不合适的使用方法

说我们要打印这个数组的每个元素,这里代码使用了数组指针的方式,首先&arr代表取出整个数组的地址(arr在一般情况下表示数组首元素地址,但是有两个例外,一个是arr单独放在sizeof内部,另一个数&arr,这两种情况下arr代表的是整个数组),既然是一个数组的地址,我们就要放在数组指针里面,因此我们创建了一个int(*)[10]类型的指针变量p,用来存放arr的地址,然后我们要打印arr中的每个元素,肯定要对p进行解引用,*p拿到了arr(可以理解成&和*抵消了),要想打印arr中的每个元素,我们现在知道了arr的首元素地址(因为这里拿到arr是通过解引用,并不属于上面的两种特殊情况,因此这里arr就是数组首元素地址,是int*类型的),想要打印,直接就*(arr+i)即可。也就是说打印的时候应该*((*p)+i),非常的别扭。因为我们想要打印整个数组的内容,直接使用一个int*类型的指针即可,这里却使用了数组指针显然是不合理的,那么数组指针到底应该怎么用?

这里直接给出答案:数组指针在访问二维数组的时候比较方便。

假如有这样一个二维数组arr

当然这只是为了方便理解才画成这样,实际上因为二维数组中每个元素在内存中是连续存放的,他们应该排成一条线。

二维数组可以理解成里面有三个一维数组,也就是说二维数组是一维数组的数组。而二维数组的数组名也代表首元素的地址,首元素又是一个一维数组,因此二维数组的数组名是一个一维数组的地址,我们如果想要把他存起来,应该使用一个数组指针。那么下面这个代码就可以实现二维数组元素的打印

因为arr包含三个一维数组,每个一维数组是四个元素,因此arr的数组名也就是首元素地址是一个int [4]类型的数组的地址,类型是int(*)[4],因此在形参接收的时候应该写int(*p)[4]。

首先p是int(*)[4]类型的数组指针,+1就会跳过一个int [4]类型的数组,p里面存放的是arr首元素的地址,也就是第一个一维数组的地址,因此*p就拿到了第一个数组,相当于拿到了第一个数组的数组名,也就是说*p是int*类型的,+1就会跳过一个int类型。

*p+i用来访问所有行,*((*p+i)+j)就能访问所有元素了,或者说(*p+i)[j]也可以,他们是等效的。

当然也可以直接形参直接使用二维数组来接收

实际上形参写成数组形式只是语法允许,让我们能够直观的知道接收的是一个数组,本质上地址就是要用指针来接收,即使写成第二种写法,编译器在编译的时候也会编译成我们第一种写法,在为形参开辟空间的时候也是为数组指针开辟一块空间。

4.数组传参与指针传参

一维数组传参

下面哪些传参方式是正确的?

先看test函数的传参,实参是arr,也就是一个int类型数组的数组名,表示首元素的地址,应该用一个int*类型的指针来接收,因此形参写成int*arr是对的,又因为C语言语法允许数组传参的时候用数组接收,因此int arr[]和int arr[10]作为形参也是可以的,因为实参传的只是一个地址,形参部分是不会真正去创建一个数组的,但是语法规定可以用数组来作为形参接收一个数组名,我们甚至可以把形参写成int arr[1000],也是对的,编译器都会按照形参是int *类型的来理解。

再来看test2的传参,arr2是一个指针数组,他的数组名是这个指针数组首元素的地址,也就是一个指针的地址,即二级指针,实参传了二级指针,形参要拿一个二级指针变量来接收,因此形参应该是int **类型的,当然语法允许用和实参代表的数组同类型的数组作为形参来接收,但是本质上形参还是一个指针。

因此上面所有传参均正确。

二维数组传参

test函数实参是一个二维数组的数组名,他代表二维数组arr首元素的地址,又因为二维数组arr是由3个一维数组构成的,每个一维数组又有五个int类型的元素,因此arr的首元素是第一个一维数组的地址,存放他应该用一个数组指针,因此实参应该写成int(*arr)[5],表示arr是一个指针,指向的数组类型是int[5]类型的数组,也就是我们所谓的二维数组里面的一维数组,当然数组名作为实参传递,语法上允许用与arr代表的数组类型相同的数组来作为形参接收,这是为了方便理解,但是系统是不会为形参这个数组去开辟内存空间的,编译的时候仍然会按照数组指针的形式开辟空间。

二维数组在创建的时候可以省略行但是不能省略列,因此正确的形参创建形式有int arr[3][5],int arr[][5],int(*arr)[5]。

注:除去数组名单独放在sizeof括号里还有&数组名这两种情况,二维数组的数组名一定是一个数组指针。

一级指针传参

对于指针传参来说,如果实参是一个一级指针,形参就用同类型的指针来接收即可,二级指针同理,这非常的简单,因此对于指针传参,我们来讨论一下如果我们知道了形参是一个一级指针,实参可能传递的是什么。

可以接收

1.char a=0; test2(&a) 一个char类型变量的地址

2.char ch="abc"; test2(ch);一个char[]类型数组的数组名

3.char a=0;char* p=&a; test2(p);一个char*类型的指针变量

二级指针传参

如果形参已经确定是char**p,可能接收的是什么实参?

可以接收

1.char a=0;char *p=&a;test(&p);一个char*类型指针的地址

2.char a=0;char *p=&a;int**pp=&p;test(pp);一个二级指针

3.char* arr[10];test(arr);一个char*[10]类型数组的数组名

注:数组传参,形参可以用数组形式接收,也可以用指针形式接收,但是指针传参,形参指针写成指针形式。

5.函数指针

函数指针顾名思义就是指向函数的指针,里面存放的是一个函数的地址

来看一段代码

居然是一样的,这说明函数名和&函数名代表的意思都是函数的地址

既然函数有地址,我们就可以把他存到指针变量里面去,这个指针变量就是函数指针,在初始化的时候我们写作void (*p)(),也就是函数的返回值类型以及形参类型决定了他的函数指针是什么样的类型。比如有一个函数

int add(int x,int y),用来存放他的函数指针在初始化的时候就写作

int(*p)(int,int),去掉指针的名字,就是他的类型,因此这个函数指针的类型就是int(*)(int,int)

再比如有一个函数void test(charpc,int arr[10]),初始化他的函数指针就写作void (*p)(char*,int [10]),p的类型就是void(*)(char*,int [10]),10可以省略,也可以写成int *

有了函数指针,我们就可以对他进行解引用来调用函数。如果pf里存放了函数add的地址,那么add(3,5)和(*p)(3,5)就是等效的。实际上*都多余了,因为我们函数名和&函数名其实是一回事,我们可以测试一下

因此我们既然把&add赋给了pf,那就相当于把add赋给了pf,也就是说pf就是add,因此我们直接pf(3,5)也是可以正常调用add函数的

来看两个有趣的代码

首先我们看到最后有个小括号,这肯定代表函数调用,然后左边最外层的括号应该就是就是为了分割开,这样我们就从内部的0开始入手,0前面有一个括号,括号里面是一个类型,这表示把0强制类型转换成函数指针的类型,那么0作为函数指针就代表了一个地址,再对他进行解引用就能调用0地址处的函数。之所以最右边调用的小括号里面啥也没有,是因为这个函数不需要任何参数。这个代码应用场景可能就是我们知道了0地址处有一个函数,想要调用他。

这个代码看起来无从下手,因为他好像也并没有函数调用,也没有强制类型转换,如果我提前告诉你,这是一个函数名为signal函数的声明,你有没有头绪了呢?

解释一下,我们就从signal开始入手,因为这肯定是一个函数名,那么他后面的括号就是他的参数类型,分别是int和void(*)(int),也即一个int类型和一个函数指针类型,那么知道了函数名,函数的参数类型,把他们都去掉,不就是他的返回类型吗?故signal的返回类型是void(*)(int),他的返回值也是函数指针类型。因此上面这段代码是signal的声明。

这个代码之所以难以理解,就是因为这个函数指针的类型写起来非常具有迷惑性,如果把这个函数指针类型进行类型重定义成一个简单的类型,这个代码阅读起来就会非常的轻松。那首先来介绍一下typedef的使用方法,如果是把unsigned int类型重命名为uint,就写作typedef unsigned int uint,如果是把int 类型重命名为ptr_t,就写作typedef int ptr_t

但是如果要把函数指针 int(*)(int)重命名为pint,就不能写tepedef int(*)(int) pint,而应该写成typedef int(*pint)(int)

要把函数指针void(*)(int)重命名为pf_t,就写作typedef void(*pf_t)(int),这样就可以把上面的代码简化为pf_t signal(int,pf_t),这样读起来就舒服多了,一眼就看出这是一个函数的声明。

6.函数指针数组

顾名思义就是这个数组中的每个元素都是函数指针类型。

假如我们要写一个计算器,于是我们就写了下面四个函数

我们发现这些函数的参数和返回值都已相同类型的,那我们就可以把这些函数放到一个数组里面去,怎么放函数?只能放他的函数名吧,那我们又说函数名其实是函数的地址,因此我们放的其实是一些函数的地址,也就是函数指针,当然要用一个函数指针数组来存放,那么如何初始化这个数组?类比int arr[4]={0};这表示arr数组里面能放4个int类型的元素

那么我们想要一个能够放四个int(*)(int,int)类型变量的数组,就初始化成int(*)(int,int) arr[4]是不是?这样理解可以,但是语法规定我们要把arr[4]写到*的旁边,也就是说我们应该这样初始化函数指针数组

注:指针变量在创建的时候,指针变量一定是在*的旁边的。

int(*p)(int ,int),*表示p是个指针,去掉*和p就是指针指向的元素类型是

int(int,int),说明指向的元素是函数类型,单独去掉变量名就是p的类型,也就是说p是int(*)(int,int)类型。

int(*arr[4])(int,int),那么对于简单的计算器这个代码的实现,就可以这样写(计算器只针对整数)

7.指向函数指针数组的指针(了解即可)

假如arr是一个函数指针数组,我们现在&arr,放到指针p里面,则p就是指向函数指针数组的指针,他的类型是什么呢?

首先来看函数指针数组的类型应该怎么写。int(*[4])(int,int),是函数指针数组的类型,现在p要指向他,应该写成int(*(*p)[4])(int,int)=&arr,其中p旁边的那个*表示p是一个指针,去掉p,就是p的类型,所以p的类型是int(**arr[4])(int,int),类型去掉p和他旁边的*,就是p指向的元素的类型,因此p指向的元素类型是int(*[4])(int,int),也即一个函数指针类型。

8.回调函数

函数指针有一个非常重要的用途就是实现回调函数。回调函数就是通过函数指针调用的函数。比如前面的计算器,除了使用函数指针数组来实现,还可以使用函数指针来实现。

其中calc函数就通过函数指针回调了加减乘除的函数,此时的add,sub,mul,div就是回调函数,通过函数指针调用谁,谁就是回调函数

回调函数的应用:库函数qsort

qsort的头文件是stdlib.h

他的四个参数分别是要进行排序的数组base的首地址,base数组的元素个数,每个元素的大小,以及一个函数指针,这个函数指针指向了一个函数,这个函数的参数是两个void*类型的指针,返回类型是int,要求这个函数能够比较参数(这个函数的参数是两个指针)指向的两个元素的大小,规定如果elem1指向的元素比elem2指向的元素大,那这个函数就返回一个大于零的数,反之就返回一个小于零的数,如果elem1和elem2指向的元素一样大,就返回0。

void*类型的指针可以接收任何类型的地址,但是不能直接解引用,由于我们不知道未来要比较的两个函数是什么类型的,那就只能写成void*类型,就可以接收任何类型了,在解引用之前强制类型转换即可。

qsort默认是以升序排列的(如果要降序排列,只需要比较大小的函数返回值上用相反的逻辑即可,比如elem1指向的元素如果比elem2大,就返回一个小于零的数),并且可以排列任何类型的数组。来看一个例子

模拟实现库函数qsort

下面我们将使用回调函数模拟实现qsort,由于目前没有学习快速排序,因此使用冒泡排序代替。

模拟实现,我们就把参数设置成和库函数qsort一样,最后一个函数指针指向的是一个能比较两个元素大小的函数,需要使用者自己编写。

我们排序使用的是冒泡排序的思想,就是比较相邻的两个元素,如果前面比后面大,就交换,那现在的问题是当时冒泡排序是针对整形数组的,比较就是直接base[j]和base[j+1]进行比较即可,但是现在我们想要这个my_sort类型能够对任意类型的数据进行排序,就不能简单的写成base[j]和base[j+1],因为我们连base的类型都不知道,当然也不知道+1会跳过几个字节,也就不知道+1是不是拿到了和base[j]相邻的元素。那么我们干脆一个字节一个字节的操作,因为不管什么类型,大小至少也是一个字节,那如何操作一个字节?很简单,使用char类型的指针即可。因此不管base代表什么类型的数组,我们都先把base强制类型转换成char*类型,那么base[j]其实就是(char*)base+j*size,与他相邻的元素就是(char*)base+(j+1)*size。

这样我们就拿到了要比较的两个元素,但是这两个元素如何比较?直接使用大于号显然是不合理的,这时候就用到了我们的第四个参数,cmp函数,我们需要自己写一个能够比较两个元素大小的函数,并且规定如果前者大,就返回一个大于0的数,反之就返回一个小于0的数,如果二者相等,就返回0。

通过判断cmp函数的返回值,我们就知道了需不需要交换这两个元素。那问题又来了,如何交换?直接创建临时变量吗?那临时变量是什么类型的呢?但是要交换的数据是什么类型,他们最小也有一个字节,那么我们直接一个字节一个字节的交换就行,什么时候才算交换结束呢?当然是交换完size个字节后,就结束了。如图是swap函数的交换

这样我们的my_sort函数就可以正常使用了,比如我们要排序的是一个整形数组,我们就要去写一个能够比较两个整形数据的函数,比如这样写

如果我们要比较两个结构体类型的数据呢?

假如结构体类型是这样的

如何比较两个struct stu类型变量的大小?

由于上面的结构体类型含有两个成员变量,那么要比较struct sut类型变量的大小,就可以按照name的大小来进行比较,也可以按照age的大小进行比较,当然如果按照name的大小来进行比较,就不能直接相减了,因为字符串的比较是用库函数strcmp,这个库函数的返回值逻辑与我们的cmp函数一致,也是前者大就返回大于零的数,后者大就返回小于零的数,一样大就返回0,因此我们如果按照name的大小来比较的话,直接返回strcmp的值就可以,如图

注:由于空指针是无法进行解引用操作的,因此我们在写cmp函数的时候都需要进行强制类型转换,要比较的是什么类型的,就强制类型转换成对应的指针。

相关推荐
tangliang_cn8 分钟前
java入门 自定义springboot starter
java·开发语言·spring boot
莫叫石榴姐8 分钟前
数据科学与SQL:组距分组分析 | 区间分布问题
大数据·人工智能·sql·深度学习·算法·机器学习·数据挖掘
程序猿阿伟8 分钟前
《智能指针频繁创建销毁:程序性能的“隐形杀手”》
java·开发语言·前端
新知图书19 分钟前
Rust编程与项目实战-模块std::thread(之一)
开发语言·后端·rust
威威猫的栗子22 分钟前
Python Turtle召唤童年:喜羊羊与灰太狼之懒羊羊绘画
开发语言·python
力透键背22 分钟前
display: none和visibility: hidden的区别
开发语言·前端·javascript
bluefox197923 分钟前
使用 Oracle.DataAccess.Client 驱动 和 OleDB 调用Oracle 函数的区别
开发语言·c#
ö Constancy1 小时前
c++ 笔记
开发语言·c++
fengbizhe1 小时前
笔试-笔记2
c++·笔记
墨染风华不染尘1 小时前
python之开发笔记
开发语言·笔记·python