C语言笔记第8篇:深入理解指针 (2万字详解)

目录

深入理解指针(1)

1、内存和地址

1.1 内存

1.2 如何理解编址

2、指针变量和地址

2.1 取地址操作符(&)

2.2 指针变量和解引用操作符(*)

2.3 指针变量的大小

3、指针变量类型的意义

3.1 指针的解引用

3.2 指针+-整数

4、const修饰指针

4.1 const修饰变量

5、指针运算

5.1 指针+-整数

5.2 指针-指针

5.3 指针的关系运算

6、野指针

6.1 野指针成因

6.2 如何规避野指针

6.2.1 指针初始化

6.2.2 小心指针越界访问

6.2.3 指针变量不再使用时,及时置为NULL,指针使用之前检查有效性

6.2.4 避免返回局部变量的地址

7、assert断言

8、指针的使用和传址调用

8.1 传址调用

8.2 strlen的模拟实现

深入理解指针(2)

1、数组名的理解

2、数组传参的本质

3、冒泡排序

4、二级指针

5、指针数组

6、指针数组模拟二维数组

深入理解指针(3)

1、字符指针

2、数组指针变量

3、二维数组传参的本质

4、函数指针变量

4.1 函数指针变量的创建

4.2 函数指针变量的使用

4.3 两端有趣的代码

4.3.1 typedef关键字

5、函数指针数组

6、转移表

7、回调函数


本篇博客因为有2万字的笔记,所以内容比较多。大致分为了3个章节。可以在目录里看到。如果想看后面的知识点就可以点开目录跳转到知识点所在位置观看。

目录在右下角

那么就开始学习吧!

深入理解指针(1)

1、内存和地址

1.1 内存

在讲内存和地址之前需要知道它们之间有什么关系。

举个例子:在生活中,你住在一个公寓,这个公寓很高,有几十层的高度。每一层有二十多个房间。如果你的朋友想来找你那该怎么找?一个一个的找效率太低了,你就给它一个这个房间的门牌号,比如:101、310、402... 你的朋友可以通过这个房间号直接锁定了第几楼第几个房间的位置并找到你。

通过以上例子大概就能知道了内存和指针的关系。比如:你的朋友要找你玩,你可以把门牌号(地址)给你的朋友,然后你的朋友通过地址,找到这个房间(内存单元),。

如果把上面的例子对照到计算机中,又是怎样?

我们知道计算机上CPU(中央处理器)在处理数据的时候,需要的数据是在内存中读取的,处理后的数据也会放回内存中,电脑上的内存是8GB/16GB/32GB等,那这些内存高效的管理呢?

其实也是把内存划分为一个个的内存单元,每个内存单元的大小为1字节,每个字节的内存单元都有一个地址编号

就像一个高楼大厦,那怎么合理分配这么大的空间,就是划分为多个小的房间,每个房间都有门牌号。

关于计算机单位:

计算机常见单位:bit(比特)、byte(字节)、KB、MB、GB、TB、PB

计算机单位之间的换算:

1bit --x8--> 1byte(字节) --x1024--> 1KB --x1024--> 1MB --x1024--> 1GB --x1024-->1TB --x1024--> 1PB

1个bit位可以存放1个二进制位(1 / 0),1个byte(字节)是8个bit位也就是说可以存储8个二进制位。这8个二进制位至少可以表示一个char类型的数据,一个内存单元正好可以存储一个char类型的数据。

也可以将每个内存单元简单理解为一个宿舍,有8个学生,每个学生就是1个bit位。

总结:数据在内存中是以二进制的形式存储,方便CPU拿取内存中的二进制指令进行运算,因为计算机只能识别二进制指令。

生活中我们把门牌号叫地址,在计算机中我们把内存单元编号也称为地址。C语言中给地址起了新的名字叫:指针。所以我们可以理解为:内存单元编号==地址==指针

总结:

  1. 在计算机中为了方便管理内存,内存会被划分为以字节为单位的内存空间,也就是说一个内存单元的大小是一个字节
  2. 为了方便找到这个内存单元,会给每个内存单元一个编号,就像生活中每个房间的门牌号
  3. 有了内存单元的编号,就可以快速的找到内存单元
1.2 如何理解编址

        

CPU访问内存中某个字节空间,必须知道这个字节空间在内存中的什么位置,而因为内存中的字节很多,所以需要给内存进行编址(就如同宿舍很多,需要给宿舍编号一样) 

 计算机中的编址,并不是把每个字节的地址记录下来,而是通过硬件设计完成的。

钢琴、吉他上面没有写 “都瑞咪发嗦啦” 这样的信息,但是演奏者照样能够准确的找到每个琴弦上音调的位置,这是为何?因为制造商已经在乐器硬件层面设计好了,并且所有的演奏者都知道。本质是一种约定出来的共识!

总结:简单理解就是每个内存单元都有一个地址编号,但是内存单元编号本身并不是也开辟一块内存空间存储起来的,内存单元编号它本身就是某块内存空间的地址,是绑定的,约定好的,所以并不需要额外的内存单元来存储另一个单元的地址信息。

注:内存和CPU之间有三种联系方式,分别是:地址总线、数据总线和控制总线。

首先,必须理解,计算机内是有很多硬件单元,而硬件单元是要互相协同工作的。所谓的协同,至少相互之间要能够进行数据传递。

但是硬件和硬件之间是相互独立的,那么如何通信呢?答案很简单,用 “线” 连起来。

而CPU和内存之间也是有大量的数据交互的,所以,两者必须用线连接起来,我们现在需要了解一种线,叫地址总线

我们简单理解,32位机器有32根地址总线,每根线只有两种状态,表示0,1【电脉冲有无】,那么一根线就能表示2种含义,2根线能表示4种含义,依次类推。32根地址总线就能表示2^32种含义,每一种含义都代表一个地址

地址信息被下达给内存,在内存上,就可以找到该地址对应的数据,将数据通过数据总线传入CPU内寄存器

总结:CPU通过地址总线传输的一个地址信息给内存,在内存上,找到该地址对应的数据。再通过数据总线将内存单元里的数据传输给CPU中的寄存器,该寄存器保存数据,所以相反,CPU也可以通过地址总线传输地址让计算机在内存中找到这个地址并将数据通过数据总线写入这个地址。

控制总线:就是控制CPU是从内存中读取数据还是将数据写入内存。

以上就是CPU和内存之间怎样联系的具体步骤。

通过以上知识点,我们需要知道每个地址也是有单位的,虽然内存单元地址刚开始不用内存单元来存储,但如果我们想要获取这个地址,通过这个地址访问对应的内存单元时就需要创建指针变量(后面会讲)。这个指针变量就是在内存中开辟了一块4个字节的空间来存储这个地址。所以可以得知内存单元的编号(地址)是4个字节的。但是也不一定是固定4个字节的,如果是64位机器地址大小就是8字节,但是我们平常用的都是32位机器,所以地址是4字节

总结:每个地址单位是4个字节,每个地址所关联的内存单元是1个字节。

2、指针变量和地址

2.1 取地址操作符(&)

理解了内存和地址的关系,我们再回到C语言,在C语言中创建变量其实就是向内存申请空间,比如:

看上图,变量a是int类型需要向内存申请4个字节空间的地址来存储数据10,数据10的被拆分为4个字节存储到内存中,变量a的地址是从4个字节地址中选择较小的那个字节的地址来表示变量a的地址,拿数据时CPU可以通过这个地址向后再访问3个字节的内存单元就可以取出数据10。

比如,在上述代码就是创建了整型变量a,内存中申请4个字节,用于存放整数10,其中每个字节都有地址,上图4个字节的地址从低到高分别是

1  0x0093F80C  
2  0x0093F80D  
3  0x0093F80E 
4  0x0093F80F  

表示变量a的地址就是最低的地址:0x0093F80C

看到这里可能有人疑问了,就是内存中每个内存单元存储的不是二进制形式的数据吗?为什么上图的内存中存储的是16进制。在这里声明一下,在内存中数据是以2进制的形式存储的,但是显示时是16进制显示的,方便观察。

总结当一个变量需要开辟的内存单元多于一个字节时,就取这些开辟好的内存单元的地址中的低地址来表示变量的地址,也就是属于这个变量内存空间的最低地址

2.2 指针变量和解引用操作符(*)

注:指针变量才是学习指针最重要的核心

通过上面的代码中打印&a取出的地址可以发现地址也是一个值,如果地址是一个值那是不是就可以创建一个变量来存储这个地址呢?答案是可以的,我们可以通过指针变量来存储这个地址,指针变量就可以通过这个地址找到这个地址的内存单元并读取或修改这块空间里的值,比如:

通过上图代码可以得知创建一个指针变量pa来接收&a取出的地址,pa和&a是等价的,这就是指针变量。认真的来讲:pa的类型是int*int说明这个指针变量所指向对象是int类型的,*说明这个pa是指针变量

这个就是最基础的指针变量,整型指针变量,可以简称为整型指针。pa因为是存放指针的变量,所以叫做指针变量。

重点:我们看到的地址都是int类型的值表示的,当你对一个变量a取地址时编译器会根据变量a的类型来决定&a地址的指针类型。在指针空间中就是将这个int*类型的地址拆分开放在每个字节的内存空间。但是当你拥有一个int类型的地址时,比如:0x0012ff40时,你想直接访问这个地址指向的内存空间。就可以将它强制类型转换为指针类型。就可以根据指针的类型访问多大的空间。

假如此时我有一个变量:

1  char ch = 'w';
2  //接收&ch的指针变量是什么?

接收&ch的指针变量是什么,看上面的指针变量有定义:int说明这个指针变量所指向对象是int类型的,*说明这个pa是指针变量。*可以证明这个变量是指针变量,所以*必不可少,那就剩类型需要更改了,这个&ch地址所指向的对象是char类型的,所以对应的指针变量就是:

1  char ch = 'w';
2  char* pc = &ch;//指针变量

这个是指向字符的指针变量,简称字符指针。

看到这里是不是就明白当面对不同类型的变量时,该用什么指针类型变量来接收这个地址了吧。

比如遇到double类型的变量时,就用double*类型的指针来接收double类型变量的地址。

例如:

1  double d;
2  double* pd = &d;

总结:指针变量就是用来存放地址的,存放在指针变量中的值,都会被当成地址使用。

但是用指针变量拿到地址有什么用呢?比如我在一个宿舍,我将宿舍的门牌号告诉好兄弟,我的好兄弟可以通过这个门牌号找到我给我送点东西,或是来找我玩。相同的,指针也是这个道理,如果想改变这个空间的值或访问这个空间的值,就给指针变量这个空间的地址,指针就可以通过这个地址找到这个空间并修改这个空间所存储的值。

这里的*是解引用操作符或者叫间接访问操作符,*pa可以直接通过pa中的地址找到地址指向的变量a的内存空间,给*pa赋值20变量等价于变量a赋值了20,所以说*pa等价于变量a。*pa是直接通过地址找到的变量a的内存空间。

1  *pa == a;
2  (*pa = 20) == (a = 20);

但是可能就有人会想,解引用*pa改变a那不是多此一举吗?其实指针访问变量空间的应用场景并不是这里,而是函数传参,想一想,函数传参形参是实参的一份临时拷贝,改变形参不会影响实参。如果我想写一个函数,交换两个变量的值,怎么办?答案是传地址,通过地址可以直接访问到变量的空间并修改:

#include <stdio.h>
void swap(int* x, int* y)
{
	int s = *x;
	*x = *y;
	*y = s;
}
int main()
{
	int x = 0;
	int y = 0;
	scanf("%d%d", &x, &y);
	printf("交换前:x=%d y=%d\n", x, y);
	swap(&x, &y);
	printf("交换后:x=%d y=%d\n", x, y);
	return 0;
}

运行结果:

可以看到确实通过函数交换了两个变量的值,函数调用时实参传递地址,形参由指针接收这个地址,指针形参通过这个地址可以访问到变量的空间,相当于让实参和形参有了连接,而不是拷贝。这就是传址调用。

2.3 指针变量的大小

指针变量并不会因为类型而决定它的大小,比如int*类型的指针变量是4个字节,那char*类型的指针变量是1个字节吗?double* 类型的指针变量是8个字节吗?当然不是,指针变量说白了就是开辟一块空间存储地址,地址固定大小就是4/8个字节,是根据环境来指定的,地址一般是由32个或64个0/1组成的二进制序列组成的地址。指针变量就是开辟地址大小的空间来存放地址,所以指针变量要么是4个字节,要么就是8个字节。32位机器(x64)就是4字节,64位机器(x86)就是8字节:

注:一个指针变量存放的地址就是CPU通过地址线将某变量的地址存放在指针变量所在内存空间不同环境地址总线数量不同,所以地址大小也就不同。

32位机器(x86)环境运行:

64位机器(x64)环境运行:

32位机器(32位平台)下的地址总线是32根,地址线上传输过来的电信号转换成数字信号后,得到32个0/1组成的二进制序列就是地址(64位机器就是64根地址线,地址线数量不同,表示地址二进制序列的大小也就不同)。

有句俗话就是:不要在门缝里看人,把人看扁了。这句话在当前场景就是不要在门缝里看指针,把指针看扁了。什么意思?就是不要看一个指针变量是int*,大小是4个字节。就以为另一个char*指针变量的大小就是1个字节。不管指针变量的类型一不一样,指针大小就是取决于地址的大小,和类型无关。

总结:

  • 32位平台下地址是32个bit位,指针变量大小是4个字节。
  • 64位平台下地址是64个bit位,指针变量大小是8个字节。
  • 注意指针变量的大小和类型是无关的,只要指针类型的变量,在相同的平台下,大小都是相同的。

3、指针变量类型的意义

指针变量的大小是取决于地址在当前平台的大小,而不是取决于指针类型的。那指针类型真的只是简单的表示指针变量所指向的数据是什么什么类型的吗?有没有其他特殊的意义呢?答案是有的。

3.1 指针的解引用

先看下面两段代码在内存中调试的结果:

可以看到指针类型的不同导致了解引用时访问的内存单元大小不同,int*指针变量解引用时访问了4个字节的内存空间并将4个空间存储的值都修改为0。而char*指针变量解引用时只访问了低地址的那一个字节,解引用赋值时也只改动了一个字节。可以得知指针类型决定指针变量解引用时访问几个内存单元。

指针类型的里的类型int、char、double本来是表示指针变量指向的空间存储的什么类型的数据。所以解引用时访问多大内存空间也是类型决定的。类型的大小就决定了解引用访问空间的大小。

指针类型存储的是一个类型的地址,地址始终是指向一个字节的内存单元,如果是int*的指针变量,解引用时的访问权限是4个字节也就是4个内存单元,是会从当前的地址再向后访问几个地址的空间拿到4个内存单元大小的空间。

注意:指针的访问权限还是我们自己给的,如果把一个整型变量的地址给一个char*类型的指针变量,这个指针变量解引用只能访问到一个字节。所以我们在写程序时应该尽量使用对应类型的指针变量来接收该类型的地址。

3.2 指针+-整数

先看下面代码:

   #include <stdio.h>
1  int main()
2  {
3	  int n = 0x11223344;
4	  int* p = &n;
5	  char* pc = &n;
6	  //指针p和p+1的地址
7	  printf("p = %p\n", p);
8	  printf("p+1 = %p\n", p + 1);
9	  //指针pc和pc+1的地址
10	  printf("pc = %p\n", pc);
11	  printf("pc+1 = %p\n", pc + 1);
12	  return 0;
13  }

运行结果:

可以看到int*指针类型的p+1后地址+4,也就是跳过了4个内存单元的地址,char*指针pc+1后地址就+1,地址只跳过了1个内存单元的地址。指针类型变量指向的数据类型多大+1或-1跳过的空间大小就有多大。

指针类型除了决定解引用时访问内存单元大小,还可以决定指针变量+1跳过几个字节的空间。比如char*类型的指针变量+1拿到一个字节后的地址。int*类型的指针变量+1拿到跳过4个字节空间的地址。因为不同类型的指针变量需要跳过当前类型指针指向数据所占的空间去到下一个存储数据的地址。

指针类型的设计:

为什么这样设计指针类型?就是根据数据的类型大小,取出存储数据空间的地址用对应的数据类型解引用或+1、-1的操作能够刚好访问到这个大小的空间,或跳过这个数据所占内存的空间,如果当前指针指向的是double类型的数据,所占内存8个字节,那指针+1只能跳过一个字节需要+8次,不是很麻烦吗?为了方便+1能够刚好跳过这个指针指向数据的内存大小来到下一个元素的地址访问下一个元素,就给指针类型设计了指针类型+1或-1跳过内存空间的大小正好是指针指向数据类型的大小,double*类型的指针变量只需要+1就可以跳过double类型大小的8个字节的空间。

结论:

  • 指针类型是有意义的。
  • 指针类型决定了指针在解引用操作时的访问权限,也就是一次解引用访问几个字节的内存单元空间。
  • 比如:char*类型的指针解引用时访问1个字节,int*类型的指针解引用时访问4个字节
  • 指针类型决定了指针在+1/-1操作的时候,一次跳过几个字节(指针的步长)

可以发现指针类型决定的解引用正好拿取指针所

指向的数据类型大小,不多拿也不少拿。只要访问到指针指向的那个数据所占内存大小就可以了。+1/-1操作也能刚好跳过类型大小的字节空间的地址。

还需要注意的是,地址的访问权限不一定都是创建指针变量时给的。

比如有一个int类型的变量a,&a的地址本身就是int*类型的,&a+1也是跳过4个字节的,既然你取的是int变量的地址,那地址的类型自然就是int*的类型,不需要再额外定义int*的指针变量去给它int*类型的访问权限。

学到了上面的指针,知道了指针类型的作用,那怎么使用呢?

如果有一个整型数组arr,你想访问它里面的元素,该怎么访问呢?

方法一数组下标的访问,例如:

int arr[] = {1,2,3,4,5,6,7,8,9,10};
arr[6]、arr[3]、arr[9]

方法二指针访问,例如:

int arr[] = {1,2,3,4,5,6,7,8,9,10};
int* parr = arr;//使用int*指针来接收
*(parr+6)、*(parr+3)、*(parr+9)

*(parr+6)等价于arr[6]的,所以指针可以通过指针类型的特性去访问数组中的每个元素,在函数调用时传数组名时形参可以创建一个指针变量来接收数组名。

因为数组名是首元素的地址,本身就是地址,所以可以直接使用指针变量来接收该地址。

这里就需要给大家讲一下数组名本身就是首元素的地址,数组名是地址,所以是不能直接给数组名赋值的,只能改变这个地址所指向的空间的元素。

int arr1[] = {1,2,3,4,5,6,7,8,9,10};
int arr2[] = {1,2,3,4,5};
arr1 = arr2;//错误的,地址不能被赋值
arr1[0] = arr2[0];//正确的,可以通过解引用该地址访问空间并赋值
arr1==&arr1[0];//数组名是等价于首元素地址的

4、const修饰指针

4.1 const修饰变量

const是C语言中的一个关键字,也叫保留字。const的作用是将const修饰的变量改为常量属性,下次给这个变量赋值但是因为是常量属性所以不能改,改了就会报错。

给一个代码:

#include <stdio.h>
int main()
{
	const int n = 10;
	n = 20;
	printf("%d\n", n);
	return 0;
}

运行后:

确实将变量n改变成了常量属性,无法直接赋值。

但是当你把这个const修饰过的变量的地址给一个指针,通过指针改变它可以发现真的能够改变:

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

运行结果:

我已经把n修饰为常量属性了,n不能改了,但是指针还可以改,相当于饶了一圈又将变量改了,指针并不在const的修饰范围。

举个例子:前一年很火的电视剧狂飙,里面的高启强心狠手辣,是个黑恶势力,经常人,并且不是他亲自动手。比如变量n就是高启强,他想一个对他不利的人,const就看做公安局。公安局一直盯着高启强,高启强不敢有大动作。所以就告诉老默,想吃鱼了。老默明白了。指针变量p就是老默。高启强不方便搞定这个人,但是老默可以,老默并不在公安局的监视范围,所以可以轻松完成

如果不想让限制这个变量不想被任何方法修改,怎么办?可以把指针也用const修饰。让老默也受到公安局的监视不就可以了

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

运行后:

将指针也修饰const后,指针也不能修改这个变量了,只能访问,不能修改。

const修饰指针其实有两种修饰方法,一种是const放在*左边,另一种是const放在*右边。

假设有两个变量和一个指针变量:

int n = 10;
int m = 20;
int* p = &n;

1.const放在*左边:

const int* p = &n;
*p = 30;//会报错
p = &m;//不会报错

如果const放在*左边修饰的就是*p,指针指向的内容不能被修改了,但是指针变量本身是可以修改的。

int const *p = &n;等价于 const int* p = &n;

2.const放在*右边:

int* const p = &n;
*p = 30;//不会报错
p = &m;//会报错

const放在*右边直接修饰的是变量p,限制着指针变量本身。所以改变指针变量p地址指向是会报错的,但是可以修改指针指向的内容。

如果既不想让指针变量p改变地址指向,也不想让指针变量p改变p所指向的空间里存储的值,就左右各修饰一个const:

const int* const p = &n;
*p = 30;//会报错
p = &m;//会报错

5、指针运算

指针的基本运算有三种,分别是:

  • 指针+-整数
  • 指针-指针
  • 指针的关系运算
5.1 指针+-整数

因为数组在内存中是连续存放的,只要知道第一个元素的地址,顺藤摸瓜就能找到后面的所有元素

1  int arr[10] = {1,2,3,4,5,6,7,8,9,10};

可以看到数组在内存中是连续存放的,是由低到高依次存储,大家观察一下,每个元素的地址与下一个元素的地址相差4个字节,这是因为数组的每个元素需要4个字节的内存单元来存储元素,所以每个元素的地址相差4个字节。

从这里我们得知了数组在内存中确实是连续存放的,我们是不是可以用指针访问整个数组的所有元素呢?答案是可以的:

#include <stdio.h>
int main()
{
	int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
	int sz = sizeof(arr) / sizeof(arr[0]);//求出数组元素个数
	int* p = arr;//等价于int* p = &arr[0];
	int i = 0;
	for (i = 0; i < sz; i++)
	{
		printf("%d ", *(p + i));//遍历访问数组的每个元素
	}
	return 0;
}

运行结果:

注意:使用指针遍历的前提是元素必须是连续存放的。

5.2 指针-指针

指针减去指针就是两个地址相减,得到的就是两个地址之间的元素的个数,如果是int*的指针,是以4个字节为一个元素单位计算的,如果是char*类型的指针是以1个字节为一个元素单位计算的。

指针-指针 (地址-地址) 的前提是两个指针指向同一块开辟好的数组空间,这就是语法规则。

所以不能这样:

运行结果是错误的,所以一定要遵循语法规则。

可以用指针减去指针做什么呢?

练习:指针-指针来模拟strlen库函数,求出字符串的长度:

#include <stdio.h>
int my_strlen(char* str)
{
	char* str1 = str;//创建一个新的指针来接收这个地址
	while (*str1 != '\0')//用新指针不停的遍历找到'\0'
	{
		str1++;
	}
	return str1 - str;//新指针('\0'的地址)减去形参指针(第一个字符的地址)
}
int main()
{
	char str[] = "hello world";
	int len = my_strlen(str);
	printf("%d\n", len);
	return 0;
}

运行结果:

总结:

  • 指针-指针必须指向同一块空间,可以相互运算。因为如果是&arr[0]+9就是&arr[9],arr[9]-arr[0]就是9了,指针减指针也是看两个指针的类型求出它们之间的元素个数。

  • 准确来说指针-指针求出的是以元素大小为单位的绝对值

  • 指针-指针不能是两个不同变量空间的地址相减,1.如果类型不同不确定是用哪个类型来表示元素个数的元素。2.就算类型一样两个地址相减也没有什么意义,答案也不对,因为两块不同的空间中间会有未开辟的内存空间隔开,谁知道未开辟的内存空间里有多少元素个数。
5.3 指针的关系运算

所谓的指针关系运算,就是指针和指针(地址和地址)之间比较大小。高地址比低地址大,低地址比高地址小。

可以使用指针关系运算,判断一个指针是否小于另一个指针,如果小于则打印这个指针对应的数组元素。前提是要找到数组最后一个元素地址的下一个地址,再不停的进行比较,如果小于这个地址就访问地址指向的空间打印空间里的数据。

#include <stdio.h>
int main()
{
	int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
	int sz = sizeof(arr) / sizeof(arr[0]);//求出元素个数
	int* p = arr;//获取首元素地址
	while (p < arr + sz)
	{
		printf("&数组元素:%d==", *p);
		printf("%p < %p\n", p, arr + sz);
		p++;
	}
	printf("%p == %p", p, arr + sz);
	return 0;
}

运行结果:

6、野指针

概念:野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)

6.1 野指针成因

1.指针未初始化

int main()
{
    int* p;//局部变量,在创建的时候内存中存储的是随机值
    *p = 20;//这时候给p随机值当做地址访问就是非法访问
    return 0;
}

在内存中的一块空间,你需要申请才能使用。像上面的未初始化的野指针,局部变量自动赋值为随机数,把随机数当成地址,这个地址指向的这块空间还未申请开辟,不属于当前的程序的内存空间。通过这个随机数地址访问指向的空间并赋值就是非法访问。

2.越界访问

int main()
{
    int arr[10] = {0};
    int* p = &arr[0];
    int i = 0;
    for(i=0;i<=11;i++)//判断表达式的判断已经超出了数组元素个数
    {
       *(p++) = i;
    }
    return 0;
}

运行后:

编译器报错,因为越界访问了。

3. 指针指向的空间被释放了

int* test()
{
	int n = 100;
	return &n;
}
int main()
{
	int* p = test();
	*p = 20;
	return 0;
}

出了局部范围局部变量就会销毁,但是在出函数结束之前返回了一个局部变量n的地址给指针p,因为局部变量n的空间已经返还给操作系统了,所以p就是野指针了,再解引用访问就是非法访问。

6.2 如何规避野指针
6.2.1 指针初始化

如果创建了一个指针已经明确要让这个指针指向哪里就直接初始化那个地址。如果创建指针时还不知道指针明确要指向哪里时就先初始化为NULL,让这个指针指向一个NULL,也就是空指针。NULL是C语言中的一个标识符常量,值是0, 0也是地址,这个地址是无法使用的,读写该地址会报错。(使用时需要包含头文件#include <stdio.h>

NULL标识符定义:

#ifdef __cplusplus
    #define NULL 0
#else
    #define NULL ((void*)0)
#endif

以上代码可以看到NULL的本质就是0,在cplusplus也就是C++上NULL是0,其他语言的NULL是把0强制类型转换成一个地址,但是也是一个空指针。所以NULL本质就是0。NULL本质是0那可不可以给指针直接初始化为0呢?

int* p = 0;

当然可以直接初始化为0, 0和NULL是一样的。但是你给一个0就还要看一下变量是否是整型的变量。但是初始化NULL就可以知道我是给指针初始化为空指针的。就知道了是为指针初始化的。整型初始化可以用0,指针初始化尽量不用0,用NULL。这样代码可读性更高。

int* p = 0; 等价于 int* p = NULL;
指针初始化建议用int* p = NULL;
6.2.2 小心指针越界访问

一个程序向内存申请了多大空间,通过指针就只能访问这个申请过的空间,不能超出范围访问,超出了就是越界访问。

6.2.3 指针变量不再使用时,及时置为NULL,指针使用之前检查有效性

在创建指针时暂时不想使用时就初始化为空指针。接下来在使用这个指针之前先判断这个指针是否为NULL,不为NULL就可以解引用访问。

int main()
{
    int arr[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    int* ptr1 = arr;
    int* ptr2 = NULL;
    if(ptr1!=NULL)//使用之前进行判断
    {
         //使用ptr1
    }
    if(ptr2!=NULL)
    {
         //使用ptr2
    }
    return 0;
}
6.2.4 避免返回局部变量的地址

不要返回局部变量的地址,因为出了局部变量的局部范围局部变量的空间就会自动销毁并返还给操作系统,再对这块空间访问就是非法访问。

7、assert断言

assert.h头文件定义了宏assert(),用于在运行时确保程序符合指定条件,如果不符合,就会报错终止运行。这个宏常常被称为 "断言"。

assert(p != NULL);

上面代码在程序运行到这一行语句是,验证变量p是否等于NULL。如果确实不等于NULL,程序继续执行,否则就会终止运行,并且报错误信息提示。

assert和if一样是可以进行判断的,如果为真返回非0,如果为假则返回0。虽然都可以判断,但是它们有一点还是不一样的。就是如果判断为假后的区别反应。

assert和if的判断区别:

如果是if判断为假就走else或者继续执行下一条语句,只是不进入if语句内执行。

如果是assert判断为假会终止程序的运行并在标准错误流stderr中写入一条错误信息,显示没有通过的表达式,以及包含这个表达式的文件名和行号。

以上就是两种判断的区别,如果需要调用的指针不能为空指针时就可以使用assert。

assert()的使用对程序是非常友好的,使用assert有几个好处:它不仅能自动标识文件和出问题的行号,还有一种无需更改代码就能开启或关闭assert()的机制。如果已经确认程序没有问题,不需要再做断言,就在#include <assert.h>语句的前面,定义一个宏NDEBUG

#define NDEBUG
#include <assert.h>

然后重新编译程序,编译器就会禁用文件中所有的assert()语句。如果程序又出现问题,可以移除这条#define NDEBUG 指令,再次编译,这样就重新启用了assert()语句。

缺点:assert()的缺点是,因为引入了额外的检查,增加了程序的运行时间。

8、指针的使用和传址调用

学习了指针的知识,那指针该怎么使用呢?

8.1 传址调用

我们平常使用指针时一般在同一个局部范围创建指针接收变量的地址修改变量,我们最常使用指针的地方就是函数传参,因为直接将变量作为实参传递给函数,函数的形参接收到的只是实参的一份临时拷贝,形参的修改并不会影响到实参。但是如果我们需要一个函数来交换两个变量的值该怎么办?我们最先想到的方法是:

void Swap(int x,int y)
{
    int z = 0;
    z = x;
    x = y;
    y = z;
}
int main()
{
    int a = 10;
    int b = 20;
    printf("交换前:a=%d b=%d\n",a,b);
    Swap(a,b);
    printf("交换后:a=%d b=%d\n",a,b);
}

运行结果:

直接传参由形参接收,但是因为形参是实参的一份临时拷贝,形参里的修改不会影响到实参。实参不会改变,怎么办?我们可以使用传址调用。就是将变量的地址作为实参传递给函数,函数的形参为指针,用指针来接收这个地址。在函数中可以使用形参访问这块地址并修改,相当于有了远程连接:

void Swap(int* x,int* y)
{
    int z = 0;
    z = *x;
    *x = *y;
    *y = z;
}
int main()
{
    int a = 10;
    int b = 20;
    printf("交换前:a=%d b=%d\n",a,b);
    Swap(&a,&b);
    printf("交换后:a=%d b=%d\n",a,b);
}

运行结果:

函数传参有两种:传址调用、传值调用

传值调用:就是直接将变量传递给函数,函数接收它的临时拷贝,就叫传值调用。

传址调用:就是将地址作为参数传递给函数,函数接收它的地址,可以通过这个地址直接访问它,就叫传址调用。

8.2 strlen的模拟实现
#include <stdio.h>
size_t my_strlen(const char* str)
{
	assert(str != NULL);//确保了指针的有效性
	size_t count = 0;
	while (*str != '\0')
	{
		count++;
		str++;
	}
	return count;
}
int main()
{
	char arr[] = "hello world";
	size_t len = my_strlen(arr);
	printf("%zd\n", len);
	return 0;
}

size_t 是无符号整型,是专门为sizeof发明的类型。因为sizeof计算一个变量或类型的空间不可能返回一个负数大小的空间,所以返回类型为size_t。但是strlen和sizeof一样,计算字符串长度是不可能返回负数,最少也是0,所以strlen的返回值也是无符号数,用size_t来作为strlen的返回类型。

深入理解指针(2)

1、数组名的理解

int arr[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int* p = &arr[0];

这里我们使用的&arr[0]的方式拿到了数组第一个元素的地址,但是数组名本来就是地址,而且是数组首元素的地址,给一段代码:

可以看到数组名就是首元素的地址。但是有2个例外:

  1. sizeof(arr)这里的数组名表示的是整个数组,所以sizeof(数组名)计算的是整个数组的大小,单位是4个字节。
  2. &arr这里的数组名表示的是整个数组,取出的是整个数组的地址,+1或-1可以跳过整个数组

除此之外遇到所有的数组名都是首元素地址。

来看三个数组名的地址和+1后跳过的多大一块空间:

可以看到虽然&arr是整个数组的地址,但是不代表整个数组需要的空间有独立地址。所以整个数组的地址依然是首元素的地址,只不过+1跳过多大空间的权限为整个数组的大小。&arr[0]和arr都是int*类型的地址,+1跳过4个字节。但是&arr是什么类型的地址?&arr是数组指针类型的地址,int(*)[10]就是&arr的指针类型。(数组指针后期会讲解)

为了让大家更加深刻的理解数组名,下面给一段代码:

#include <stdio.h>
int main()
{
	int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
	int sz = sizeof(arr) / sizeof(arr[0]);
	int i = 0;
	for (i = 0; i < sz; i++)
	{
		printf("%d ", arr[i]);
	}
	return 0;
}

我们除了使用arr[i]遍历访问整个数组元素,我们还可以使用什么方法访问数组呢?

printf("%d ",*(arr+i));

这样也可以访问每个元素,arr本身是数组首元素地址,地址+i再解引用就访问到了对应的元素。所以:

arr[i]==等价于==*(arr+i)

arr[i]只是一种形式,在编译阶段arr[i]会被编译为*(arr+i),所以可以证明[ ]只是操作符。

既然arr[i]等价于*(arr+i),arr[i]的原型就是*(arr+i),加法又是支持交换律的。那我可以将*(arr+i)写成*(i+arr),那是不是也可以写成i[arr]格式呢?

printf("%d ",*(i+arr));
printf("%d ",i[arr]);

答案是可以的:

这更加说明了arr[i]或i[arr]只是一种形式,并不是固定的格式必须arr[i]。arr[i]只是一种形式,真正的运算还要转换成*(arr+i)进行运算。但是这里讲i[arr]只是让大家对数组名有更深刻的理解,只是不让大家的思维局限于arr[i],但是写代码时最好不要写成i[arr]这种形式,虽然可以访问,但是很难理解,可读性差。

2、数组传参的本质

先看下面的代码:

#include <stdio.h>
void print(int arr[])
{
	int sz = sizeof(arr) / sizeof(arr[0]);
	int i = 0;
	for(i=0;i<sz;i++)
	{
		printf("%d ", arr[i]);
	}
}
int main()
{
	int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
	print(arr);
	return 0;
}

运行结果:

不是应该从1-10依次打印吗?怎么会只打印了1。这就要关系到数组传参的本质了。数组名传参就是将数组名首元素的地址传给函数。既然是地址,sizeof(arr)就是求地址的大小/sizeof(arr[0])元素大小,因为是x86环境所以是4/4,sz=1。所以只打印了一次。

但是有人觉得奇怪了,为什么在main函数里创建的数组的数组名也是首元素地址,但是sizeof(数组名)里的数组是整个数组。为什么传参后就不是了。这是因为在传参之前的数组名不仅仅是作为数组首元素地址而存在的,此时的数组名身上可是还有多种buff加身的。但是传参时传的仅仅只是首元素地址,而不是数组名本身。可以理解为实参数组名拷贝了一份首元素地址信息传给函数。所以函数拿到的只是一个地址。能代表整个数组的是数组名,而不是首元素地址。

所以不要被上面代码中传递实参用数组接收就以为是还是个数组,这里数组名传参传的既不是整个数组又不是数组名,本质上数组传参传递的是组首元素的地址

#include <stdio.h>
void print(int* arr)
{
	int sz = sizeof(arr) / sizeof(arr[0]);
	int i = 0;
	for(i=0;i<sz;i++)
	{
		printf("%d ", arr[i]);
	}
}
int main()
{
	int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
	print(arr);
	return 0;
}

如果将形参的类型改变为指针就可以看懂了吧,其实就是用指针接收首元素地址。sizeof(地址)得到的就是地址的大小,上面之所以可以用int arr[ ]数组的形式接收是因为传的本来就是数组地址,所以可以使用这种格式来表示,但是不代表这里的arr就是数组。

所以就不要在函数内部求形参数组的大小了,函数形参的数组只是一个首元素地址。也就是指针。

那有什么解决方法,在函数内部遍历整个数组呢?

#include <stdio.h>
void print(int* arr, int sz)
{
	int i = 0;
	for(i=0;i<sz;i++)
	{
		printf("%d ", arr[i]);
	}
}
int main()
{
	int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
    int sz = sizeof(arr) / sizeof(arr[0]);
	print(arr, sz);
	return 0;
}

运行结果:

在传参之前算出数组元素个数,然后将算出的元素个数也通过传参传过去。

总结:一维数组传参,形参的部分可以写成数组的形式,也可以写成指针的形式。

3、冒泡排序

冒泡排序是一种数组排序的算法,这种排序就像汽水里的气泡一样不停的从下往上面冒泡,所以名为冒泡排序(bubble sort)。

冒泡排序的算法思想就是需要排序n-1趟,每一趟排出最大的数在位置最后。因为这种方法最多n-1趟就可以将数组排序完毕。因为每次筛选最大值排在最后,有n个数,n-1个数筛选完后最后一个数必定是在第一个,也就是最小值。经过第一趟排序需要n-1次判断两个相邻的数,如果前面大于后面的就调换。算上排最大值本身,与其他的值经过筛选判断也只需要n-1次判断排出最大值。每一趟排出最大数下一趟排序的n-1需要再减去前面已经排过的趟数。因为每一趟都排出最大值下一趟就不需要对最大数也进行判断,只需要判断已排序最大值前面的那些值就可以了。

既然知道了冒泡排序算法的思想,那接下来就实现冒泡排序算法:

int main()
{
	int arr[] = { 10,9,8,7,6,5,4,3,2,1 };
	int sz = sizeof(arr) / sizeof(arr[0]);
	int i, j;
	for (i = 0; i < sz - 1; i++)//循环排序n-1趟
	{
		int flag = 1;//假设顺序是正确的
		for (j = 0; j < sz - 1 - i; j++)//循环n-1-i次判断并调换找出最大值
		{
			if (arr[j] > arr[j + 1])
			{
				int s = arr[j];
				arr[j] = arr[j + 1];
				arr[j + 1] = s;
				flag = 0;//设置为需要排序
			}
		}
		if (flag == 1)//假设一趟下来没有任何排序的值,说明已经不再需要排序,跳出循环排序
		{
			break;
		}
	}
    for (i = 0; i < sz; i++)
    {
	    printf("%d ", arr[i]);
    }
	return 0;
}

运行结果:

4、二级指针

什么是二级指针?它的作用是什么?

int a = 10;
int* p = &a;

a是一个int类型的变量,它有4个字节的空间,这块空间也有地址。取出a的地址初识化给一个指针变量p,p需要创建4/8个字节的空间来存放这块地址。但是这块空间有没有地址呢?答案是有的。我们将指针变量p空间的地址取出来(注意,不是这块空间里存储的变量a的空间,而是存储这个地址的空间的地址),指针变量p的地址需要创建二级指针来接收。那什么是二级指针呢?

int a = 10;
int* p = &a;
int** pp = &p;

int**是二级指针,那int*就是一级指针,指针的级数是通过地址的层级来决定的。

看上面这段代码pp就是二级指针,它是用来接收一级指针地址的,将int**拆分开来看是这样的:

int a = 10; int说明a存储的整型变量的值
int * p = &a; 这里的*说明p是指针变量,int说明p指向的是int类型
int* * pp = &p; 这里的*说明pp是指针变量,int*说明pp指向的是int*类型

其实一级指针p和二级指针pp都是指针变量,都是开辟了4/8个字节存储的地址,不同的是指向的类型不同。一级指针是指向类型变量的,存储的是普通类型变量的地址。二级指针是指向一级指针的,存储的是一级指针的地址。

所以有二级指针就有更高级别的指针,例如三级指针就是存储二级指针空间的地址:

int a = 10;
int* p = &a;
int** pp = &p;
int** * ppp = &pp; *说明ppp是指针变量,int**说明ppp指向的是二级指针

既然二级指针指向一级指针,一级指针又指向变量。那是不是可以用二级指针直接访问变量的空间?当然是可以的:

二级指针pp解引用两次访问到了变量a的空间,*pp第一次解引用通过一级指针空间的地址访问到一级指针的空间,然后再**pp解引用一次通过一级指针空间里存储的地址访问到变量的空间。

简单理解多级指针之间的关系:

5、指针数组

什么是指针数组呢?

我们可以类比一下:

  • 整型数组 - 存放整型的数组 int arr[10];
  • 字符数组 - 存放字符的数组 char str[10];

那指针数组就是存放指针的数组,指针数组的元素类型可就多了:int* char* double*的都有。

比如:int* parr[5];就是指针数组的创建,这是一个数组,是存放多个整型指针的数组。

数组的每个元素是数组的类型:

指针数组的每个元素都是用来存放地址(指针)的。

数组指针的每个元素是地址,又可以指向一块区域。

那有人会问既然数组指针是存放指针的,那是不是就可以这样使用:

能用是能用,但是很少会这样去使用指针数组的,如果只是为了打印12345直接创建个数组遍历不就好了吗?

看上面的代码,parr通过下标访问到元素时元素还是一个地址,需要再一次解引用才能访问到变量。这里的下标访问也是一次解引用,那两次解引用就可以证明这里的数组名的类型是一个二级指针,我也可以使用二级指针来接收parr首元素地址。就比如int类型的数组名arr,它是首元素的地址,指向int类型。它是一个指向int类型的地址那这个地址的类型就是int*,arr首元素的类型就是int*指针。

int* p = arr;
int** pparr = parr;

如果指针数组不是这样使用的,那该怎么使用呢?

6、指针数组模拟二维数组

指针数组一般使用方式就是类似模拟二维数组,就是有多个数组,指针数组可以将每个数组的数组名(首元素地址)作为指针数组的元素,每次解引用访问到该首元素地址就可以继续锁定这个数组其他元素的地址找到元素地址并访问:

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

运行结果:

深入理解指针(3)

1、字符指针

字符指针的使用方法是什么?

char ch = 'a';
char* pc = &ch;

一般是使用字符指针接收一个字符型的变量的地址。其实字符指针还可以这样使用:

char* p = "hello world";

这个字符串有11个字节的大小,这么大的字符串怎么能初识化给字符指针呢?如果好好想一想其实字符串也是有首元素地址的。将 "hello world\0" 初始化给字符指针p,并不是表面上把一整个字符串存放在指针p的空间中,而是将字符串的首元素地址初始化给指针p,p拿到的就是首元素地址。p可以通过这个地址继续访问后面的元素。

其实表达式都是有2个属性的:值属性、类型属性

比如b = 2+3;

2+3 值是5(值属性)

2+3类型是int(类型属性)

那上面的字符串 "hello world\0"也是有值属性和类型属性的,它的值属性就是首字符 'h' 的地址,它的类型属性就是char*。所以上面代码中的字符串只是将首元素地址传递给了字符指针p。

注意:直接给字符指针初始化的字符串是常量字符串,是不能被修改的。

就好像你对一个常量3修改为常量5,3=5这样是不行的,常量是不能够被修改的。

如果修改了程序就会崩掉:

解决方法:在p的左边加一个const,不能对地址指向的空间进行修改。

给一道经典的笔试题,让大家更深刻的了解字符指针初始化字符串:

#include <stdio.h>
int main()
{
	char str1[] = "hello world";
	char str2[] = "hello world";
	char* str3 = "hello world";
	char* str4 = "hello world";
	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;
}

这段代码给了两个字符数组和两个字符指针,并且都是初始化为 "hello world",所以都是首字符地址。然后判断两个字符数组的地址是否相同。再判断两个字符指针的地址是否相同。来猜一猜结果

答案是:

两个字符数组地址各不相同,两个字符指针地址相同。为什么?

首先两个字符数组地址肯定是不想同的,虽然字符串相同,但是两个数组是各自开辟了一块空间来存放字符串,所以地址不相同。两个指针存放的是常量字符串的首元素地址,为什么相同呢?因为在给指针初始化字符串时是常量字符串,常量字符串是不能修改的,所以没有必要保存两份。所以两个指针所指向的常量字符串是共用一个,可以使用,但是都不能修改。

所以像这种常量字符串,在内存中只保留一份。

2、数组指针变量

首先要认识到,之前的指针数组是数组,是存放指针的数组

接下来学习的:数组指针

类比:

字符指针 - 指向字符的指针,存放的是字符的地址  char ch = 'w';   char* pc = &ch;

整型指针 - 指向整型的指针,存放的是整型的地址  int n = 100;   int* p = &n;

数组指针 - 指向数组的指针。存放的是数组的地址  int arr[10];   int(*p)[10] = &arr;

int arr[10];
int (*p)[10] = &arr;

注:这里说指向数组的指针不是存储数组首元素地址的指针,而是存储指向整个数组的地址的指针。

int arr[6];
int* p = arr; 数组首元素的地址
int (*p)[6] = &arr; 数组的地址

(*p)两边的括号是不能省略的:

int (*p)[10];//数组指针
int *p[10];//指针数组

如果是指针数组的话,p[10]说明p是个数组,元素类型是int*。

但如果是数组指针的话用(*p)将p和[10]分开,*表示p是指针变量,指向的是int[10]整型数组,数组有10个元素。

注意:数组指针的 [10] 里面的10也是不能省略的,因为数组指针需要明确知道它指向的数组有几个元素的大小,才能给数组指针变量p多大的访问权限。比如:p+1就能跳过40个字节的空间。这就是[10]的作用,[10]就是整个数组的大小。可以理解为数组指针p能够跳过arr数组大小的空间。所以[ ]坚决不能为空,[10]就是要明确指针数组p所指向的arr数组的大小,也决定了这个指针数组的权限

这里数组指针p的类型是:

int (*)[10]

如果创建了一个char*类型的指针数组,该怎么用数组指针接收地址:

char* ch[8];
char* (*ch)[8] = &ch;

*说明ch是指针,指向的是char*[8]类型的数组。

之前提到过&arr的的指针类型:

int arr[10] = {0};
arr; 数组首元素的地址 - int*
&arr; 数组的地址 - int (*)[10]

这里的arr指针类型是int*,权限为4个字节,arr+1就跳过4个字节。&arr的指针类型是int(*)[10],权限为40个字节,因为10个元素,每个元素是int类型也就是4个字节。所以&arr+1就跳过40个字节。

知道了数组指针是不是就可以这样写代码:

虽然可以这样写,但是大家会不会感到别扭。就是为什么要取出整个数组的地址在解引用得到arr再遍历访问,这样不是多此一举吗?

那数组指针有没有适用场景呢?答案是:有的。

3、二维数组传参的本质

如果清楚了二维数组传参的本质,那数组指针的使用场景也就清楚了。

二维数组传参:

#include <stdio.h>
void print(int parr[3][5], int r, int c)
{
	int i, j;
	for (i = 0; i < r; i++)
	{
		for (j = 0; j < c; j++)
		{
			printf("%d ", parr[i][j]);
		}
		printf("\n");
	}
}
int main()
{
	int arr[3][5] = { {1,2,3,4,5},{2,3,4,5,6},{3,4,5,6,7} };
	print(arr, 3, 5);
	return 0;
}

用int arr[3][5]作为形参的传参方式,但是除了这个,还有哪些二维数组传参方式呢?

还可以使用数组指针作为形参来接收二维数组arr的首元素地址。

例如:

#include <stdio.h>
void print(int(*parr)[5], int r, int c)
{
	int i, j;
	for (i = 0; i < r; i++)
	{
		for (j = 0; j < c; j++)
		{
			printf("%d ", *(*(parr+i)+j));
		}
		printf("\n");
	}
}
int main()
{
	int arr[3][5] = { {1,2,3,4,5},{2,3,4,5,6},{3,4,5,6,7} };
	print(arr, 3, 5);
	return 0;
}
*(parr+i)等价于parr[i] 访问行(一维数组)
*(*(parr+i)+j)等价于parr[i][j] 访问列(一维数组元素)

因为二维数组的每一行是一个一维数组。这一个一维数组可以看做是二维数组的一个元素。所以二维数组是一维数组的数组。二维数组数组名也表示首元素的地址,也就是一维数组的地址。一维数组的地址是数组指针类型的,所以传参时可以使用数组指针来接收。

简单概括:二维数组的每个元素就是一维数组,二维数组的数组名也表示数组首元素的地址,就是第一行(一维数组)整个数组的地址。

总结:一维数组int arr[5]的每个元素是int类型的值,&每个元素所在的地址是int*类型的。二维数组int arr[3][5]的每个元素都是一个一维数组。&每个元素取出整行(一维数组) 的地址是int(*)[5]类型的。[5]里的5是列的个数。

arr是二维数组的数组名,*arr就是第一行(一维数组)的数组名。

值得了解的是,二维数组数组名本身就是元素的地址,可以通过+-整数拿到每个元素的地址。当解引用时拿到的就是一维数组的数组名,也就是一维数组的首元素地址。再经过+-整数可以访问一维数组也就是一行里所有的元素。这就是二维数组需要两次解引用。所以二维数组数组名+-整数的地址就是一行(一维数组)的地址也就是int(*)[5]数组指针类型。但是需要知道二维数组并不是上面的指针数组模拟二维数组。将多个不同也就是不相连的一块一维数组空间的地址作为指针数组的元素,+1来到下一个元素的地址但是这个地址和上一个元素的地址不相连。但是二维数组不同,它里面所存储的每一个元素都是由低到高依次存储的,是一块完整相连的二维数组空间。那为什么二维数组数组名+1能跳过一行(一维数组)里5列元素。那是因为它的地址访问权限就是这个大小。解引用时访问权限就是一维数组的元素类型的大小。

不管是二维数组名首元素地址还是解引用后访问到二维数组的元素一维数组的首元素地址,都是同一块空间的地址,并不是把每一行放在不同的空间。访问权限不同只不过是当前地址类型不同罢了。有时候二维数组名arr是首元素的地址0x12ff40,解引用数组名*arr的一维数组首元素地址也是0x12ff40。虽然是同一个地址,但是+-整数的访问权限不同。二维数组数组名是为了访问每个元素一维数组的,访问权限是一个一维数组的大小。*arr是一维数组,+-整数是访问一维数组里每个int类型的元素,所以访问权限就是4个字节。

4、函数指针变量

数组指针 - 是指向数组的指针 - 是存放数组地址的指针

函数指针 - 是指向函数的指针 - 是存放函数地址的指针

那函数的地址是不是对函数名&呢?

4.1 函数指针变量的创建

可以看到&Add的地址和Add的地址一模一样,说明Add函数名本身就是函数的地址。

知道了函数的地址,那函数指针又是什么格式的呢?

函数指针的写法和数组指针十分有九分的相似:

int Add(int x,int y);
int (*pf)(int x ,int y) = &Add;

*说明p是指针,(int x,int y)是指向函数的参数,int是指向函数的返回类型。整体就是函数指针。

但是因为Add本身就是函数的地址,所以不用再额外的&地址,而且这个函数指针还可以改造:

int Add(int x,int y);
int (*pf)(int ,int) = Add

其实函数指针()里只需要填指针指向函数形参的类型,只需知道指针指向函数的参数是什么类型的就可以,变量名字不用填写。

4.2 函数指针变量的使用

通过函数指针调用指针指向的函数:

#include <stdio.h>
int Add(int x, int y)
{
	return x + y;
}
int main()
{
	int (*pf)(int, int) = Add;
	int ret = (*pf)(10, 20);
	printf("%d\n", ret);
	return 0;
}

运行结果:

可以看到通过函数指针变量可以找到函数并调用。但是前面我们知道Add函数名它本身就是地址,我们每次调用函数时是函数名(实参)的方式调用,这里说明不用解引用地址就可以直接通过地址调用函数。既然函数指针pf已经被赋值了Add函数的地址,那我使用pf时是不是就不用解引用再调用,可以直接拿着这个地址调用:

int (*pf)(int, int) = Add;
int ret = pf(10, 20);

运行结果:

这里可以证明每次调用函数时都是直接通过函数名也就是函数地址调用函数,不用解引用。

4.3 两端有趣的代码

代码1:

(*(void (*)()) 0 )();

首先需要知道地址本身就是一个int类型的值,假设我有一个int类型的变量a,我&这个变量a的地址时会将它的地址自动转换成对应的指针类型。但是如果我直接拥有一个int类型的地址:0x0012ff40,我想调用它指向的int类型的空间,但是它是int类型无法直接解引用。那我可以将它强制类型转换成int*类型(int*),0x0012ff40此时就是有4个字节的访问权限的地址。解引用就会找到这个地址指向的那4个字节的空间。

注:这里可以说明我们是可以将一个整型的值强转成指针类型的地址并访问。

所以上面的代码里的0就可以看作int类型的0x00000000,就是将0x00000000强制转换成函数指针类型地址,变为地址就可以调用这个地址处的函数。

代码2:

void (* signal(int , void (*)(int) ) )(int);

先把看signal(int , void (*)(int))可以看出是一个函数,参数是整型int和函数指针void(*)(int),但是函数参数肯定不可能只有类型没有变量名,所以可以看出这是一次函数声明,就是声明signal这个函数,函数声明时可以不用填写变量名,明确有什么类型就可以了。但是函数声明得有类型,那这次函数声明的函数的返回类型是什么?如果将signal(int , void (*)(int))拿出来剩下的就是函数指针void(*)(int),说明函数的返回类型是函数指针。最后再看上面代码是返回值类型为函数指针类型的函数的声明。

4.3.1 typedef关键字

typedef 是用来类型重命名的,可以将复杂的类型,简单化。

比如,你觉得unsigned int 写起来不方便,如果能写出uint 就方便多了,那么我们可以使用:

typedef unsigned int uint;
//将unsigned int 重命名为 uint

我也可以将指针变量重命名,比如:

typedef int* ptr_t;
//将int* 重命名为 ptr_t

既然指针能够重命名,那我是不是也可以将上面的代码中函数指针类型也改的简短一点:

typedef void(*pf_t)(int);
//将void(*)(int) 重命名为 pf_t

然后就可以来看有没有更加方便:

typedef void(*pf_t)(int);
void (* signal(int , void (*)(int) ) )(int);
pf_t signal(int , void (*)(int));

5、函数指针数组

整型指针数组 - 存储整型指针的数组

函数指针数组 - 存储函数指针的数组

如果我实现了4个函数,需要函数指针来调用,难道需要连续创建4个函数指针来接收4个函数吗?

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;
}
int main()
{
	int (*pf1)(int, int) = Add;
	int (*pf2)(int, int) = Sub;
	int (*pf3)(int, int) = Mul;
	int (*pf4)(int, int) = Div;
	return 0;
}

这样会不会太麻烦了,如果有多个函数就要有多个函数指针来接收吗?有没有什么办法可以将函数集成起来吗?我们可以使用函数指针数组来接收:


int main()
{
	int (*arr[4])(int ,int) = {Add,Sub,Mul,Div};
	return 0;
}

这就是函数指针数组,还可以理解为存储函数指针的数组。arr[4]说明arr是个数组,数组的元素类型是int(*)(int,int)的函数指针。

注意:使用函数指针数组的前提是函数指针数组的每个元素返回类型、参数个数和参数类型都必须形同,才能在集成到一个数组中。

6、转移表

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

用函数指针模拟计算器:

#include <stdio.h>
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("**** 0. exit ***********\n");
	printf("************************\n");
}
int main()
{
	int input = 0;
	int x, y;
   //根据输入的值作为数组下标访问到的函数中间就像转移一样,所以函数指针数组就叫转移表
   //转移表
	int (*pfArr[])(int, int) = { 0,Add,Sub,Mul,Div };
	do
	{
		menu();
		printf("请选择:>");
		scanf("%d", &input);
		if (input >= 1 && input <= 4)
		{
			printf("请输入两个操作数:>");
			scanf("%d%d", &x, &y);
			int ret = pfArr[input](x, y);
			printf("%d\n", ret);
		}
		else if (input == 0)
		{
			printf("退出计算器\n");
		}
		else
		{
			printf("选择错误,请重新选择\n");
		}
	} while (input);
	return 0;
}

根据输入的值作为函数指针数组下标访问到的函数中间就像转移一样,所以函数指针数组就叫转移表

转移表虽然精妙,但是还是有一定的局限性存在的,就是转移表的方法需要函数指针数组,而函数指针数组里的元素的返回类型和参数的类型必须相同。比如函数指针数组(int,int)里的int,int,就必须要整型的才可以。但是如果我想进行float类型的运算呢?这个转移表就明显解决不了,需要知道就算是这么巧妙的代码也有局限性的。

还有一种方法,可以完成计算器的计算:

#include <stdio.h>
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("**** 0. exit ***********\n");
	printf("************************\n");
}
void calc(int(*pf)(int, int))//接收使用函数指针函数地址
{
	int x, y;
	printf("请输入两个操作数:>");
	scanf("%d%d", &x, &y);
	int ret = pf(x, y);//通过函数指针来调用传过来的函数
	printf("%d\n", ret);
}
int main()
{
	int input = 0;
	do
	{
		menu();
		printf("请选择:>");
		scanf("%d", &input);
		switch (input)
		{
		case 1:
			calc(Add);//给自定义函数calc传递函数地址
			break;
		case 2:
			calc(Sub);
			break;
		case 3:
			calc(Mul);
			break;
		case 4:
			calc(Div);
			break;
		case 0:
			printf("退出计算器\n");
			break;
		default:
			printf("选择错误,请重新选择\n");
		}
	} while (input);
	return 0;
}

上面代码只是个引子,就是为了让大家更方便理解回调函数

7、回调函数

通过函数指针调用的函数就是回调函数。就像上面的代码,通过函数指针pf调用的Add、Sub、Mul、Div这些函数都被称为回调函数

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

C语言第8篇:深入理解指针到这里也就结束了,如果有什么问题可以在评论区留言,再见。

  • 27
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值