【C语言必学知识点五】指针

2023-12-15 17:38:09

指针

封面

导言

大家好,很高兴又和大家见面了!!!今天我们终于开始了指针内容的学习了。在开始介绍指针之前我们先回顾一下前面的知识点。
在前面的学习中,我们了解了内存以及地址的相关知识点:

  • 计算机硬件中的存储器分为主存储器和辅助存储器,主存储器就是我们所说的内存
  • 在主存储器中,主存储器被划分成了一个个小的存储单元,这就是内存单元
  • 内存单元的大小为1个字节
  • 每个内存单元都有自己的编号,这些编号就是内存单元的地址
  • 内存的工作方式是通过存储单元的地址进行存取的,这种存取方式被称为按地址存取
  • 地址是由电信号的低电位(0)与高电位(1)组成的,我们通过比特位来存放不同的电位
  • 一个比特位只能存放一个‘0’‘1’
  • 32位操作系统中,地址总共有32个比特位,在64位操作系统中,地址总共有64个比特位
  • 计算机的单位中除了bit、byte之间的转化为8外,其它单位之间的转化都是1024
  • 程序猿可以通过取地址操作符&将操作对象的地址取出来;
  • 程序猿可以通过解引用操作符*将地址中存放的值取出来;

PS:上述知识点在数组、函数栈帧的创建与销毁以及操作符篇章中都有详细介绍;

对这些内容感兴趣的朋友可以点击链接来了解一下相关知识点。

在回顾完这些知识点后,我们再来看看什么是指针;

一、指针与指针变量

在计算机科学中,指针(pointer)是编程语言中的一个对象,利用地址,它的值直接指向(points to)存在电脑存储器中另一个地方的值。由于通过地址能找到所需的变量单元,可以说地址指向该变量单元。因此将地址形象化的称为“指针”。意思就是通过它能找到以它为地址的内存单元。

我们知道字符变量是用来存放字符的、整型变量是用来存放整数的、浮点型变量是用来存放浮点数的,指针变量也是同样的作用,它是用来存放指针的,因为指针就是地址,我们也可以说指针变量是用来存放地址的

注意:我们在口语中说的指针一般指的是指针变量。

二、指针变量的创建和指针类型

我们知道对于变量的创建是通过数据类型+变量名这个格式来实现的,变量的初始化会根据变量数据类型的不同给变量赋予一个同类型的初始值,如:

//变量的创建及初始化
char ch = 'a';
short sh = 1;
int i = 2;
long l = 2;
long long ll = 4;
float f = 1.0f;
double lf = 2.0f;

现在我们知道指针变量存储的是指针,也就是存储的地址,我们可以通过取地址操作符&将操作对象的地址取出来赋值给指针变量来完成指针变量的初始化:

//指针变量的创建
int a = 4;
p = &a;

对于指针变量来说,它的数据类型与我们常见的数据类型区别,指针的数据类型是在数据类型的基础上加上一个*,如下所示:

//指针的数据类型
char*——字符型指针类型
short* ——短整型型指针类型
int* ——整型指针类型
long* ——长整型指针类型
long long* ——更长的整型指针类型
float* ——单精度浮点型指针类型
double* ——双精度浮点型指针类型
……

只要是数据类型再加上*,此时的数据类型就会变成指针的数据类型;

对于这颗*的理解,我是理解成钥匙孔,在介绍&*这两个操作符时,我有提到过,取地址操作符就相当于是取出门牌号,而解引用操作符就是门的钥匙,那现在我们从指针的数据类型就可以知道了为什么是*而不是#甚至是其它的符号,因为钥匙的形状要和钥匙孔对的上才行。

那现在问题来了这些类型与普通的数据类型有什么区别呢?这里我们通过sizeof来测试一下:

指针数据类型所占空间大小
从测试结果中我们可以看到,在32位操作系统下,不管是哪种类型的指针,此时所占空间大小都是4个字节,也就是32个比特位,下面我们来看一下在64位操作系统下又是什么情况:

指针数据类型所占空间大小
可以看到,此时的大小为8个字节,也就是64个比特位,这个数值有没有感觉很熟悉?
没错这个和地址在这两个操作系统下的大小是一致的,这一点可以直接证明指针就是地址

那既然不管什么类型的指针所占空间大小都是一样的,那是不是说我随意定义一个类型的指针就可以了呢?不同类型的指针有什么区别呢?

三、指针类型的意义

对于前面定义的整型变量a以及还未确定类型的指针p,为了探究不同类型指针的意义,我们分别用char类型、short类型、int类型以及long long类型的指针来接收变量a的地址,如下所示:

//指针类型的意义
int main()
{
	int a = 4;
	//通过取地址操作符将变量a的地址取出来存放在指针变量中
	char* p1 = &a;
	short* p2 = &a;
	int* p3 = &a;
	long long* p4 = &a;
	return 0;
}

此时我们已经完成了指针变量的创建,接下来我们分别通过对指针进行整数加减以及通过解引用来完成对变量a存储内容的修改,我们来看看不同类型的指针都会有哪些差异;

3.1 指针 ‘+’/‘-’ 整数

因为指针存储的是地址,所以指针加减整数实质上就是地址进行整数的加减,为了更加直观的看到其变化,我们通过打印格式%p——以地址的形式打印,我们现在对这四种指针类型分别进行+1和-1的操作,测试结果如下所示:

指针+-整数
从测试结果中我们可以看到:

对于char*类型的指针p1来说,它加减1的值刚好是1个字节的大小;
对于short*类型的指针p2来说,它加减1的值刚好是2个字节的大小;
对于int*类型的指针p3来说,它加减1的值刚好是4个字节的大小;
对于long long*类型的指针p4来说,它加减1的值刚好是8个字节的大小;

大家应该对char、short、int、long long这些数据类型所占空间大小应该还有印象吧,没印象也没关系,如下图所示:

数据类型所占空间大小
现在大家有什么发现吗?
没错,不同类型的指针在进行加1和减1操作后,指针变化的字节大小与对应的数据类型所占空间大小相同
那如果是+2,就相当于是+1之后再+1,那指针变化的字节大小应该是对应数据类型所占空间大小的2倍。同理,+3就是3倍,+4就是4倍……+n就是n倍;那具体是不是这样呢?我们继续测试:

指针+-整数2

从测试结果中可以看到,不管是+2/-2也好还是+10/-10也好,指针变化的大小确实是对应数据类型的整数倍。因此我们可以得到结论:
指针 + / ? 整数后变化的值 = 指针对应数据类型所占空间大小 ? 整数 指针+/-整数后变化的值=指针对应数据类型所占空间大小*整数 指针+/?整数后变化的值=指针对应数据类型所占空间大小?整数

3.2 指针解引用

接下来我们来看一下对于不同类型的指针进行解引用,又会是什么结果;

//指针解引用
int main()
{
	int a1 = 0x11223344;
	int a2 = 0x11223344;
	int a3 = 0x11223344;
	int a4 = 0x11223344;
	//通过取地址操作符将变量a的地址取出来存放在指针变量中
	//指针类型 = 数据类型*
	char* p1 = (char*)&a1;
	short* p2 =(short*)&a2;
	int* p3 = &a3;
	long long* p4 =(long long*)&a4;
	//通过解引用操作符对指针中存放的内容进行修改
	*p1 = 0;
	*p2 = 0;
	*p3 = 0;
	*p4 = 0;
	return 0;
}

这里我们通过四个变量来进行解引用,为了方便观察,我们通过调试内存窗口来观察不同类型的指针解引用的变化:

指针解引用
从内存窗口我们可以看到:

对于char*类型的指针p1,在通过解引用将地址中存储的值改为0时,p1改变了1个字节的内容;
对于short*类型的指针p2,在通过解引用将地址中存储的值改为0时,p2改变了2个字节的内容;
对于int*类型的指针p3,在通过解引用将地址中存储的值改为0时,p3改变了4个字节的内容;
对于long long*类型的指针p4,在通过解引用将地址中存储的值改为0时,p4改变了8个字节的内容;

可以看到这个改变内容的字节大小与指针对应的数据类型所占空间大小也是相同的,也就是说,不同类型的指针在进行解引用操作是可以操作的字节大小与对应类型所占空间大小相同。

经过这两次测试的结果,对于不同类型指针的意义,现在我们可以得到的结论:

  • 不同类型的指针在进行+/-整数时,指针变化的值为对应类型所占空间大小与整数的乘积;
  • 不同类型的指针在解引用时,对值修改可操作的字节大小为对应类型所占空间大小;

现在我们已经知道了什么是指针,也知道了指针类型的意义,现在我们来看一个新的概念——野指针;

四、野指针

我看到野指针的这个野时,联想到的是野猫、野狗、野猪……现在问题来了,它们为什么被称为野猫、野狗和野猪呢?
是因为它们和家养的小动物的区别是家养的小动物是有明确的主人喂养的,而这些野生的小动物都是流浪在野外的。
对于家养的小动物来说,我们只需要通过它们主人的住址就能找到它们,但是野生的小动物你即使知道它的活动区域也不一定能找到它,因为它们的位置是不可知的。

野指针也是一样,下面我们来看一下野指针的定义;

4.1 定义

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

4.2 野指针的成因

既然这些指针指向的位置是不可知的,那它们是怎么出现的呢?
对于野指针的出现主要是三个原因:

  • 指针未初始化;
  • 指针越界访问;
  • 指针指向的空间被释放了;

下面我们对这些原因进行一一的说明;

4.3 指针未初始化

指针变量实质上也是一个变量,只不过它存放的是地址而已,既然是变量,那我在创建时,如果未给变量进行初始化,那么就会导致此时的指针变量指向的是一个随机的地址,那如果我要对这个随机的地址进行解引用并对地址中的内容进行修改,那会出现什么情况呢?

野指针1
此时我们可以看到,在VS2019编译器下直接对这个错误进行了报错,报错的原因就是未初始化的局部变量p,也就是说此时都不需要你去思考如何操作了,编译器直接不给你过。
但是,在其它的编译器下可能会正常运行,但是此时*p对内存的访问其实是非法的。

我们在函数栈帧篇章有介绍过,局部变量的创建是在main函数的栈帧中实现的,即使是进行函数调用,也是得先将调用的函数的函数栈帧创建好了才能进行后续的操作,此时的*p 却是在内存中随意找寻的一块地址,那就会出现以下的情况:

野指针2

  • 当指针指向main函数的栈帧中的空间时,你并不能确定它指向的是哪一块空间,也就是说,指针p此时可能指向已经被使用的地址,那此时对这个空间的值进行修改,是不是有可能导致我写的代码不能正常的运行呢?
  • 当指针指向main函数的栈帧外的空间时,你就更不能确定它指向的是哪一块空间了,也就是说,指针p此时可能指向的是调用main函数的函数栈帧中的一块地址,那此时对这个空间的值进行修改,是不是有可能因为修改的这个值导致main函数的调用出现错误呢?

如果不好理解的话,那我们换一个角度来理解:

此时的指针p就好比一个旅行者张三,他跑到一个陌生的城市旅游,此时他是需要给自己找一个住处的。他寻找住处的方式就是通过酒店的房间地址来明确居住的房间。
给指针初始化的过程就是张三在酒店前台登记入住的过程,登记好了才能正常在酒店入住;
不给指针初始化就好比这个张三随意跑到一个房间居住,这个房间可能是酒店的房间,也可能是别人的住宅,不管是哪种情况,这种行为都是违法的。
所以,不给指针初始化,指针就会进行非法访问

4.4 指针越界访问

当我们正常的给指针初始化后,也可能出现野指针的情况,如下所示:

野指针3

在这个代码中,对于数组arr来说,它的空间内只有3个元素,我们通过数组名将数组的首元素地址赋值给变量p后,变量p在进行对地址内容修改时,修改了5个地址,此时系统就报错了,报错内容为变量arr周围的栈区被损坏,此时就是指针进行越界访问了。

这种情况就好比:

还是这个张三,他此时开了三间房,并且在酒店前台登记了,结果他在入住时,不仅将开好的三间房中放置了自己的行李,他还将自己的行李放在了另外两间房间内;
这种情况下,对于酒店来说,张三对未登记的两间房间进行了越界访问,这种行为也是不可取的;
所以,指针也是不能进行越界访问的

4.5 指针指向的空间被释放

当指针已经初始化了,也没有进行越界访问,还有可能会出现野指针的情况,如下所示:

野指针4
在这个代码中,我们定义了一个整型指针类型的函数test并在函数内部创建了一个变量a,a的空间内部存放的值为1,此时我们将a的地址返回给函数,在主函数中整型指针p接收了这个返回值,并将地址中的值打印出来了。
可以看到,此时的程序是能正常运行的,但是系统会报警告,警告的内容为返回了局部变量或零时变量的地址a
通过函数栈帧的角度来分析的话,变量a的地址是创建在test函数栈帧内的,当调用结束时,test的函数栈帧就被销毁了,此时指针p指向的是main函数栈帧外的空间地址,虽然是一个明确的地址,但是此时的指针p是一个野指针,如下图所示:

野指针5

此时的指针p并不在函数栈帧内部,所以此时的p也是属于非法访问内存空间的。如果此时我在打印前再调用一个函数,我们来看一下会是什么结果;

野指针6

从这一次的测试结果中我们可以看到,此时不管是变量a也好、变量b也好还是指针p指向的地址也好,它们的地址都是同一个,所以此时我们想将a的值通过指针p来打印是做不到的,因为此时指针p指向的地址在test2中被使用了,所以打印出来的值是被使用后的值,也就是现在的100;

我们还是通过张三的例子来理解:

test函数中的变量a就是李四,李四现在在一家酒店登记了,并在自己登记的房间里睡了一觉,在李四离开酒店的时候,他将自己休息的房间号告诉了张三,并跟张三说,我在房间内给你留了一份礼物,于是张三就跑过去拿礼物了;
在第一个例子中,这时的房间还没有人使用,所以张三成功的取到了李四留下的礼物;
在第二个例子中,在张三过去前,房间被王五使用过了,结果王五在离开的时候将自己的行李落在了酒店,等张三到达酒店时,拿到的就是王五的行李;
以上这两种行为,对于酒店来说,都是不合法的,所以酒店会提出警告;

4.6 如何规避野指针

既然野指针会产生这些问题,那我们应该怎么做才能规避野指针呢?
方法很简单,只要将上述问题反着来就好了:

  1. 给指针进行初始化;
  2. 避免指针越界访问;
  3. 不要返回局部变量或者临时变量的地址;
  4. 当指针指向的地址不再使用时,将指针置为空(NULL);
  5. 在使用指针前,检查指针的有效性;

对于前面三点,我相信大家现在都是能理解的,但是对于第4点,可能就有朋友有疑问了,为什么当地址不再使用时要将指针置为空呢?

这是因为一个约定俗成的规则:

  • 当指针为空指针时,就不会对指针进行访问;
  • 所以就有了第5点,在使用指针前需要检查指针是否为空指针。

现在大家对指针以及野指针应该是比较熟悉了,下面我们来看一下指针是如何进行运算的;

五、指针运算

对于指针的运算,共有三种运算方式:

  • 指针±整数
  • 指针-指针
  • 指针的关系运算

5.1指针±整数

对于不同类型的指针加/减整数,实质上就是指针加减对应数据类型所占空间大小与整数的乘积。

如:

  • char*类型的指针进行整数的加减,就是指针加减char类型所占空间大小与整数的乘积;
  • int*类型的指针进行整数的加减,就是指针加减int类型所占空间大小与整数的乘积;
    即:
    p + N = p + s i z e o f ( t y p e p ) ? N p+N=p+sizeof(type_p)*N p+N=p+sizeof(typep?)?N
    p ? N = p ? s i z e o f ( t y p e p ) ? N p-N=p-sizeof(type_p)*N p?N=p?sizeof(typep?)?N

5.2 指针-指针

在数组篇章中我们介绍过,数组元素在内存中是从低地址到高地址进行连续存放的,下面我们来看一段代码:

//指针-指针
int main()
{
	int arr[10] = { 0 };
	int* p1 = &arr[0];//首元素地址
	int* p2 = &arr[9];//最后一个元素地址
	printf("%d\n", p2 - p1);
	return 0;
}

现在我们将数组首元素地址与最后一个元素的地址分别提取出来并存放在指针p1和指针p2中,下面我们看一下,p2-p1会得到什么结果:

指针-指针

这个结果似乎与数组元素下标之间的差值是一样的。如果真的是这样的话,那我们来测试一下下标为2的元素与下标为7的元素的指针的差值是不是5;

指针-指针2
从测试结果中可以看到,确实如此,在数组中数组元素地址之间的差值与下标的差值相等;

那问题来了,这个差值的含义是什么呢?

5.2.1 数组下标差值的意义

我们知道,数组的下标就代表数组的元素,数组下标是从0开始的,那我可不可以认为数组的下标就是代表数组元素前面的元素个数呢?

  • 比如首元素的下标为0,因为它前面的元素个数为0;
  • 第二个元素的下标为1,因为它前面有1个元素;
  • 第三个元素的下标为2,因为它前面有2个元素;
  • 以此类推,第十个元素的下标为9,因为它前面有9个元素;

根据这个逻辑,我们可以很容易得出,元素下标之间的差值就是两个元素之间的个数
比如这里的下标为7的元素与下标为2的元素下标之间的差值,就是从下标为2的元素到下标为7的元素之间的个数,它们之间总共有下标为:2/3/4/5/6的5个元素;
同理,首元素与最后一个元素的元素下标之间的差值,就是从首元素到最后一个元素之间的元素个数,它们之间总共有下标为:0/1/2/3/4/5/6/7/8的9个元素;

我应该有表述清楚这个逻辑吧!相信大家也都能够理解了。这个结论有什么用呢?下面我们继续来介绍;

5.2.2 strlen函数

在介绍数组时,我们有介绍过一个内容——字符串。
在介绍字符串时我们提到了一个计算字符串长度的库函数——strlen
下面我们就来通过MSDN来看一下这个库函数究竟是怎么使用的:

strlen函数

从资料卡中我们可以看到strlen函数需要传入一个字符指针类型的参数,并返回一个size_t的值,返回值为字符串中的字符数不包括\0。也就是说这个函数就是在自动帮我们计算字符串中\0前面的字符的个数。

5.2.3 字符串

对字符串在数组篇章中我们主要介绍了以下几点内容:

  1. 由双引号""引起的一个或多个字符叫做字符串;
  2. 双引号""中自带一个字符\0
  3. \0在字符串中是位于字符串的末尾,也就是字符串的最后一个字符;
  4. \0是字符串的终止符;

也就是说如果将字符串放在字符数组中,\0就是数组中的最后一个元素,\0的下标就代表着它前面的元素个数。如果我能够知道\0的下标,那我就能得出这个字符串的字符个数也就是字符串的长度,这样我是不是就能自己实现strlen这个函数了呢?

现在我们来整理一下我们已知的信息:

  • 首元素的下标为0;
  • 两个下标之间相差1;
  • 最后一个元素为\0

现在通过这三条信息,我们可以尝试着通过寻找\0的下标的方式来实现strlen函数。下面我们来一步一步的分析如何实现strlen这个函数;

5.2.4 strlen函数的模拟实现——寻找\0的数组下标

实现一个strlen函数,那我们就需要从函数的返回类型、函数的参数以及函数的实现这三步出发,下面我们一步一步的进行分析;

5.2.4.1 函数的返回类型

为了避免出现冲突,我们将模拟实现的strlen函数命名为my_strlen ,根据我们的需求,我们现在需要返回的是\0的下标,也就是一个整型值,也就是说我现在定义的函数返回类型应该是一个int型的函数,即:int my_strlen()

5.2.4.2 函数的参数

函数的参数我们现在需要思考的是我如何能找到\0的下标?
通过已知信息,我们可以大致想象一下,有两种方式——通过下标来寻找\0的下标、通过寻找\0来寻找\0的下标;

  • 如果我们通过下标来寻找的话,我们是通过给首元素的下标不断的进行+1的操作来寻找\0的下标。那现在问题来了我如何知道这个下标是不是\0的下标呢?好像这个方式无法实现,那我们再来看另一种方式;

  • 如果我们通过寻找\0来寻找元素下标的话,那我就需要通过判断此时的元素是否是\0,如果不是,再判断下一个元素,直到找到\0为止;像这样看的话好像可行性很高,那我们现在就需要知道如何找到数组中的每个元素了;

对于如何找到数组中的元素,这个问题我相信大家心里都是有一个比较明确的方式了——我们可以通过数组元素的地址来找到数组中的元素。

我们知道在内存中数组元素是从低地址到高地址进行连续存放的,相邻的两个元素的地址之间的相差的大小刚好为数组元素的类型所占空间的大小。

也就是在字符数组中,两个相邻的元素之间的地址之间相差的大小为1;
在整型数组中,两个相邻的元素之间的地址之间相差的大小为4;

也就是我只要对数组元素的地址加上一个元素的数据类型所占空间大小那就能得到下一个元素的地址了。

从指针±整数的结论我们可以得到:

  • 指针加上一个元素的数据类型所占空间大小就等价于指针+1

那现在首元素我是知道的,我们可以通过取地址操作符&将首元素的地址取出来,再传给函数my_stlen;这样我们是不是就可以通过指针+1的方式来找到后面的所有元素了呢?

在数组中我们也介绍过数组名就是数组首元素的地址,所以此时的实参我们需要传入的是数组名,那么形参就需要通过指针来进行接收也就是char* name

5.2.4.2函数的实现

现在我们以及确定了函数的返回类型以及函数的参数,我们现在只需要理清函数的实现思路就可以了;
在探讨函数参数时我们已经有了一个大致的思路:

  • 通过元素的地址+1寻找到下一个元素的地址;
  • 再通过判断元素是否为\0
  • 如果不是就继续进行地址+1的操作,直到找到\0为止;

从这个思路中,我们可以明确此时的函数是需要通过循环语句实现的,下面我们开始编写代码:

//strlen函数的模拟实现——寻找`\0`的数组下标
int my_strlen(char* ch)
{
	int i = 0;//数组下标
	while (*ch != '\0')//判断元素是否为\0
	{
		ch++;//地址+1,找到下一个元素
		i++;//下标+1,找到下一个元素的下标
	}
	//当结束循环时,说明已经找到了\0,此时我们只需要将下标返回给函数就行
	return i;
}

下面我们来测试一下:

strlen函数的模拟实现
从测试结果中我们可以看到,此时确实模拟实现了strlen函数。这里看似我们是通过找到\0的下标来实现的,实质上是因为首元素下标为0,最后一个元素的下标减去首元素的下标还是为最后一个元素的下标。因此这种实现方式我们实质上是通过最后一个元素下标减去首元素下标实现的

这时有朋友就会说了,既然指针-指针的结果与下标之间的差值相同,那我能不能通过\0的指针减去首元素的指针来实现strlen函数呢?
当然可以了,下面我们来通过指针-指针的方式实现;

5.2.5 strlen函数的模拟实现——指针-指针

我们想要通过指针-指针的方式实现的话,那我们就需要记录首元素的指针以及\0的指针才行,接下来我们就通过my_strlen2来实现strlen函数:

//strlen函数的模拟实现——指针-指针
int my_strlen2(char* ch)
{
	char* last = ch;
	//ch——首元素的指针
	//last——\0的指针
	while (*last != '\0')//判断元素是否为\0
		last++;//地址+1,找到下一个元素地址
	//当结束循环时,说明已经找到了\0,此时我们只需要将\0与首元素的指针的差值返回给函数就行
	return last - ch;
}

可以看到,相比于通过下标实现,此时的代码相对来说就简洁了一点,下面我们来测试一下:

strlen函数的模拟实现2
可以看到通过这种指针-指针的方式我们也很好的模拟实现了strlen函数。

可以看到,这两种实现方式都是通过迭代实现的,下面我们来拓展一下思维,通过函数递归来实现strlen函数

5.2.6 strlen函数的模拟实现——函数递归

此时如果通过递归实现的话,我们就需要思考如何进行递归。

我们现在的起点是首元素,终点是\0,从起点到终点我们需要做的事情就是两个:

  • 指针+1找到下一个元素地址;
  • 判断元素是否为\0

也就是说如果我要递归的话,那我递归一次就能找到下一个元素,如果我递归n次才找到\0,那就说明此时的字符串长度为n;
我如果能将递归的次数记录下来,是不是就可以得到字符串的长度了;

有了具体的思路,下面我们就开始实现,此时我们将函数命名为my_strlen3

//strlen函数的模拟实现——函数递归
int my_strlen3(char* ch)
{
	if (*ch == '\0')
		//当找到\0时,就要开始回归了,此时的递进次数为0
		return 0;
	//当没找到\0时,需要先将指针自增,再进行递进
	return 1 + my_strlen3(++ch);
}

接下来咱们来验证一下:

strlen的模拟实现3
可以看到,此时我们也很好的实现了strlen函数。

经过这个模拟strlen函数的实战练习,我相信大家对指针±整数以及指针-指针的运算方式都已经熟悉了。在开始介绍下一种运算之前我们需要注意:

  • 在数组中指针-指针的意义才是两个指针之间的元素个数;
  • 当任意两个指针进行相减时得到的是两个指针之间的空间个数;

为了更好的说明第二点,下面我们来看这两个例子:

两个变量的指针进行相减
可以看到,此时变量a和变量b的指针相减的结果并不是说a到b之间有三个元素,大家如果理解了函数栈帧的话,那就应该知道,此时的结果是代表变量a与变量b之间有3个空间;

两个变量的指针进行相减2
在这个情况下,它们两个的差值为61,这是说明此时变量a与变量b的地址之间有61个空间;

对于获取两个变量之间的空间个数意义不大,所以指针-指针的应用主要是在数组中进行使用;

接下来我们来看一下指针的关系运算;

5.3 指针的关系运算

既然指针与指针可以进行相减,那么指针之间也是能够比较大小的,下面我们来看一个例子:

指针的关系运算
可以看到,此时指针pa与指针pb之间正常的进行了大小的比较,从比较结果中我们得知pa的值大于等于pb的值。

通过这个例子,我相信大家很容易就能理解这个运算。这种运算方式可以让我们在进行条件判断时又多了一种新的思路:

  • 通过指针来进行条件判断;

下面我们来做一道题:

编写函数,通过指针来完成对数组的初始化

如果这里没有说通过指针来完成的话,那我们按照之前的思路就是通过数组下标来访问数组的每一个元素,从而实现对数组的初始化,代码如下:

//编写函数,完成对数组的初始化
void init_arr(int arr[], int sz)
{
	for (int i = 0; i < sz; i++)
		arr[i] = 0;
}

现在我们可以通过指针的方式来进行,代码如下:

//编写函数,通过指针来完成对数组的初始化
void init_arr2(int* arr, int sz)
{
	for (int* p = arr + sz; p > arr;)
		*(--p) = 0;
}

在这个代码中,我们通过将数组最后一个元素的下一个地址赋值给指针p,此时的p的地址肯定是比首元素的地址大的,所以此时我们通过先将p进行自减1,再来解引用完成初始化。当p的地址自减为首元素地址并完成初始化之后再进行判断时,此时条件不成立,结束循环;

这一题就是一个简单的使用指针的关系运算的例子,大家只需要通过这个例子知道指针的这种运算方式,并在之后的解题过程中对解题方式有一个新的解题思路那就可以了。

现在咱们对指针的运算已经全部介绍完了,下面我们来思考一个问题——在数组中数组名代表的是首元素的地址,如果我将首元素地址存放在一个指针中,如下所示:

int main()
{
	int arr[5] = { 1,2,3,4,5 };
	int* pa = &arr[0];
	return 0;
}

此时的指针pa存放的也是首元素地址,那这个数组名arr与指针pa之间有什么关系呢?下面就让我们来探讨一下数组与指针;

六、指针和数组

对于数组名,我们在数组的篇章中有介绍过以下的知识点:

  1. 数组名存放的是数组首元素的地址;
  2. 当使用sizeof(arr_name)来计算数组大小时,此时的数组名代表的是整个数组;
  3. 当使用&arr_name来取地址或者是传参时,此时的数组名代表的是整个数组;
  4. 我们可以使用arr_name[num]来通过下标num访问数组的各个元素;

有细心的小伙伴就会发现,在数组篇章中,我曾给出过自己从指针的角度来看待数组名时,对数组名的理解:

  • 数组名此时就相当于是存放了首元素地址的一个指针,所以我们可以通过数组名来访问元素的地址。

那究竟是不是这样呢?下面我们来做一个测试——我们来通过指针+下标来访问数组的元素:

指针与数组1

可以看到,此时我们是可以通过指针+数组下标来访问数组元素的;

之所以能通过指针访问数组元素,就是因为数组元素在内存中是从低地址到高地址进行连续存放的,相邻两个地址之间的差值刚好是一个数据元素对应的数据类型所占空间大小。在前面进行模拟实现strlen的时候咱们也证实过了。

通过这个例子,我们就能得到结论: 数组名 [ 下标 ] < = = > ? ( 指针 + 下标 ) 数组名[下标]<==>*(指针+下标) 数组名[下标]<==>?(指针+下标)

下面我们来进行第二个测试,通过sizeof来分别计算数组名与指针:

指针与数组2

可以看到,在sizeof中数组名此时代表的是整个数组,但是,在sizeof中指针代表的依旧是数组首元素的地址;

那如果我们将整个数组的地址存入指针又会如何呢?

指针与数组3

可以看到,此时就算是将整个数组的地址存放进指针,通过sizeof计算的指针大小依旧是一个元素所占空间的大小;

这是为什么呢?如果数组名就是指针的话,那我们此时应该是计算的整个数组大小所占空间大小才对呀?下面为了解开这个疑惑,我们通过图像来进行理解:

指针与数组4
在反汇编界面我们找到了指针、变量以及数组的位置,接下来我们通过这里提供的信息来分析一下:

  • 对于指针来说,它们指向的只能是一个地址;
  • 但是对于数组名来说,我们通过取地址操作符取出来的是整个数组的地址,只不过这个地址是数组的起始地址;
  • 在sizeof中我们可以看到 s z 1 sz_1 sz1?存储的是14h,转换成十进制就是20;
  • s z 2 sz_2 sz2? s z 3 sz_3 sz3?存储的都是4,也就是一个元素所占空间大小;

从前面的知识中我们已经知道的是:

  • sizeof在计算空间大小时,计算的是操作对象所占空间的大小;
  • 指针此时指向的是数组首元素的地址;

也就是说数组首元素的地址并不能代表整个数组,但是数组名也是数组首元素地址,用sizeof计算数组名时却是得到的整个数组所占空间大小,这个又怎么解释呢?

对于这个问题,我们可以理解为用sizeof计算数组名时,数组名表示的是数组所占空间的起始地址
指针 p a 2 pa_2 pa2?指向的是整个数组的起始地址,而我们通过sizeof计算 p a 2 pa_2 pa2?时,计算的是起始地址所占空间大小

指针与数组5

如上图所示,sizeof在计算数组名时,计算的实质上是数组中5个元素所占空间的大小总和,在计算指针时,计算的实质上是数组首元素所占空间大小;

因此对于指针与数组的关系我们就有了以下结论:

  • 数组名[下标]<==>*(指针+下标)
  • 在代表数组首元素地址时,数组名<==>指针
  • 指针指向的永远是一个地址,而数组既可以表示一个地址,也可以表示整个空间的起始地址
    • 计算空间大小时数组名代表的是数组所占空间大小的起始地址,是从起始地址开始计算,计算的是整个数组所占空间大小
    • 计算空间大小时指针指向的是数组所占空间大小的起始地址计算的是起始地址所占空间大小

现在我们已经介绍完了指针与数组之间的关系,不知道大家有没有注意,在上面的例子中的反汇编界面的截图中,对于指针 p a pa pa p a 2 pa_2 pa2?来说,它们自己所在的空间也是有一个地址,并且地址并不相同,唯一相同的是地址里存放的内容,都是数组首元素的地址。

对于变量也好、数组也好,它们的地址我们可以存放在指针中,那如果我想存放指针的地址,又应该怎么处理呢?

下面我们就来介绍一下存放指针地址的指针——二级指针;

七、二级指针

指针变量实质上也是一个变量,既然是变量,那如果存放地址,也是需要存放在指针中,只不过唯一不同的是,存放指针变量的指针我们将其称为——二级指针;

7.1 二级指针的创建

二级指针与指针一样,都是通过type* name的格式进行创建的,只不过有区别的是此时的name是指针,所以它本身带有一颗*,所以二级指针的创建格式为:

//二级指针的创建格式
type** name;
//type——指针对应的数据类型
//type*——指针类型,这里的*代表此时的变量类型是指针类型
//*name——指针变量名,这里的*代表此时的变量是一个指针
//name——变量名

在知道创建的格式后,我们就来尝试着创建一个二级指针变量;

//二级指针的创建
int main()
{
	int a = 10;
	//将a的地址存放进一级指针中
	int* pa = &a;
	//将一级指针pa的地址存放进二级指针中
	int** ppa = &pa;
	return 0;
}

现在我们就创建好了一个二级指针。此时可能就有朋友的思维开始发散了——既然存放一级指针的指针为二级指针,那存放二级指针的指针是不是就是三级指针,以此类推,就能得到四级指针、五级指针……

这个想法非常正确,对于三级指针、四级指针以及多级指针的创建就是如二级指针一样,如下所示:

//二级指针的创建
int main()
{
	int a = 10;
	//将a的地址存放进一级指针中
	int* pa = &a;
	//将一级指针pa的地址存放进二级指针中
	int** ppa = &pa;
	//将二级指针ppa的地址存放进三级指针中
	int*** pppa = &ppa;
	//将三级指针pppa的地址存放进四级指针中
	int**** ppppa = &pppa;
	//……
	return 0;
}

这时我们都是可以通过解引用操作得到变量a中存放的内容:

二级指针的创建
多级指针与一级指针相比,只是多了解引用的次数,本质上都是一样的——存放地址的变量。但是我们几乎很少使用到二级指针以上的指针,所以对于多级指针,大家只需要理解其实质以及知道如何创建就行。

7.2 二级指针的工作原理

既然二级指针存放的是指针的地址,那我们又应该怎么使用它呢?为了搞清楚这个问题,我们首先要了解二级指针的工作原理:

二级指针工作原理
二级指针存放的是一级指针的地址,所以我们对二级指针进行第一次解引用时找到的是一级指针存放的内容——变量a的地址;
对二级指针进行第二次解引用时,就相当于对一级指针进行解引用,所以找到的是变量a存放的内容;
因此我们可以得到结论:

  • ? p p a < = = > p a *ppa<==>pa ?ppa<==>pa
  • ? ? p p a < = = > ? p a < = = > a **ppa<==>*pa<==>a ??ppa<==>?pa<==>a

既然存放指针地址的指针被称为一级指针,那么如果有一个数组它的数组元素为指针,那这样的数组又是什么呢?

八、指针数组

数组的定义是一组相同数据类型元素的集合,那如果元素的数据类型为指针类型,这样的集合就被称为——指针数组

指针数组,本质上是一个数组,只不过此时的数组元素为指针

那对于这个数组元素为指针的数组,我们应该如何创建呢?

8.1 指针数组的创建

一个数组的创建格式为:

//数组的创建格式
type arr_name[size];
//type——数组元素的数据类型
//type[size]——数组的数据类型
//arr_name——数组名
//size——数组大小

现在我们已经知道了数组元素的数据类型为指针类型,也就是type*,也就是说对于指针数组的创建格式应该是:

//指针数组的创建格式
type* arr_name[size];
//type*——数组元素的数据类型
//type*[size]——数组的数据类型
//arr_name——数组名
//size——数组大小

如果我需要创建一个数组名为arr,数组大小为3的整型指针数组,那我就可以根据格式创建这个指针数组:

//指针数组的创建
int main()
{
	int* arr[3];
	//int*——数组元素类型为整型指针类型
	//int*[3]——数组类型为大小为3的整型指针类型
	//arr——数组名
	//[3]——数组大小/数组元素个数
	return 0;
}

现在我们已经创建好了一个整型指针数组,接下来我们就需要对这个数组进行初始化了。

8.2 指针数组的初始化

对于指针数组的初始化我们有三种方式:

  • 通过指针变量进行初始化;
  • 通过取地址操作符进行初始化;
  • 通过空指针NULL进行初始化;

如下所示:

//指针数组的初始化
int main()
{
	int a = 1;
	int b = 2;
	int* pa = &a;//变量a的指针
	int* arr[3] = { pa,&b,NULL };//指针数组初始化
	//NULL——空指针
	return 0;
}

指针数组在进行初始化时,有以下三种情况:

  1. 如果明确数组的各个元素,可以通过指针变量或者取地址的方式进行完全初始化;
  2. 当只知道数组的部分元素,并进行不完全初始化时,未被初始化的元素会自动初始化为空指针(NULL);
  3. 当对数组元素不明确时,我们可以通过空指针(NULL)进行初始化;

下面我们通过监视窗口来看一下这三种情况下的初始化:

指针数组的初始化
从监视窗口中我们可以看到:

对于完全初始化的指针数组arr1来说,数组内的元素都是明确的;
对于不完全初始化的arr2和arr3来说,不管是通过指针变量、取地址还是空指针对其进行不完全初始化,未被初始化的元素会自动初始化为空指针;
对于未初始化的arr4来说,指针内存放的值为0xcccccccc这样的随机值,并且这个随机值的类型为int*,也就是说,此时的地址为一个随机的地址,前面我们提到过,当地址为随机值时,此时它时野指针;

我们现在也知道野指针会引发的问题,所以为了避免指针数组内存放的为野指针,我们就必须要对指针数组进行初始化。

九、二级指针与指针数组

一维数组的数组名在除了计算数组所占空间大小以及进行取地址操作这两种情况外,数组名就等价于数组首元素的指针。
大家现在思考一下,如果我们将一维数组的元素替换成指针类型,那与这个数组名等价的数组首元素的指针又应该是什么样的呢?

为了搞清楚这个问题,下面我们来分析一下;

指针存放的是数组首元素的地址,此时的数组首元素的类型不是指针类型;
当数组首元素的类型为指针类型时,此时存放首元素地址的指针存放的应该是一个指针的地址;

在前面的介绍中我们有提到过,存放指针地址的指针我们将其称为二级指针。也就是说指针数组首元素的指针是一个二级指针,根据指针与数组的关系我们可以得到:

  • 在除了计算指针数组所占内存空间大小以及进行取地址操作这两种情况外,指针数组的数组名等价于一个二级指针

有了这个结论后,下面我们来探讨一下对于二级指针与指针数组我们应该如何使用;

9.1 二级指针±整数

对于一级指针来说,指针±整数得到的值是指针±对应数据类型所占空间大小*整数。那二级指针进行加减整数时又会是什么情况呢?下面我们就来测试一下:

二级指针+-整数
从测试结果中我们可以看到,对于不同类型的二级指针来说,在进行加1或者减1时,变化的大小都是4个字节。既然这样,那我们就用char**的指针ppa为例子来继续测试加减整数:

二级指针+-整数
可以看到二级指针ppa在进行加减整数时,指针变化的值为4的整数倍。

通过这两次的测试结果,我们就能得到以下结论:

  • 不同类型的二级指针进行加1和减1后,指针变化的值都为4;
  • 同一类型的二级指针进行加减整数后,指针变化的值为4的整数倍;

此时有朋友可能就会好奇了,为什么是4这个值呢?我们先来回顾一下指针加减整数的规则:
指针 + ? N = 指针 + ? s i z e o f ( 指针对应数据类型 ) ? N 指针+-N=指针+-sizeof(指针对应数据类型)*N 指针+?N=指针+?sizeof(指针对应数据类型)?N

下面我们来看一下一级指针的创建格式:

//一级指针的创建格式
type* p = &name;
//type——指针对应的数据类型
//*——表示此时的变量为指针类型
//p——指针变量名
//name——被指针指向的变量

从这个格式中,我们可以看到,在type*中,type表示的就是指针对应的数据类型;前面我们在介绍二级指针时,我们是将其中一颗*给了指针变量也就是type* *p = &p;的格式。下面我来给大家介绍一个新的思路,我们将二级指针看做一个一级指针,然后我们就能得到二级指针的格式:

//二级指针的创建格式
(type*)* pp = &p;
//type*——指针对应的数据类型
//*——表示此时的变量为指针类型
//pp——指针变量名
//p——被指针指向的变量

可以看到,在这种格式下,我们就可以很清楚的看到,二级指针对应的数据类型为指针类型。在前面我们也介绍过,对于不同的指针类型在32位操作系统下所占内存空间大小都是4个字节,在64位操作系统下所占内存空间大小都是8个字节。而前面我们是在32为操作系统下进行测试的,这就是为什么对于二级指针进行±整数时变化的值为4的整数倍。

9.2 二级指针-二级指针

对于一级指针来说,在数组中,指针-指针的值为指针之间的元素个数。二级指针也满足这个运算规则,如下所示:

二级指针-二级指针
我们如果将二级指针看做一级指针的话,那这里就相当于是一个指针对应的数据类型为整型指针类型的两个一级指针在进行相减,所得的结果也是两个一级指针之间的元素个数。

9.3 二级指针的关系运算

同理,对于指针指向的目标为指针类型的一级指针,它也是可以进行关系运算的。如下所示:

二级指针的关系运算
现在大家应该都能理解二级指针和指针数组之间的关系了。与其说是二级指针与指针数组之间的关系,倒不如说是一级指针与移位数组之间的关系,只不过此时的指针所指向的元素的数据类型为指针类型,数组元素的数据类型也为指针类型;

现在咱们已经将二级指针与指针数组之间的关系介绍完了。不知道有没有朋友会有疑惑,我们这里一直在强调除了用sizeof计算数组所占空间大小和通过取地址操作符取出数组的地址这两种情况外,其他的时候指针与数组名是等价的,那它们之间可不可以进行相互转换呢?下面我们就来探讨一下;

9.4 指针与数组名的相互转换

为了探讨指针与数组名能否进行相互转换,下面我们先进行第一个测试:

  1. 使用*(数组名+下标)来访问数组元素;

通过数组名+下标访问数组元素
可以看到,此时对数组名进行解引用操作也是可以像指针一样找到数组各个元素的,不管是整型数组也好还是整型指针数组也好,都是能够正常访问的;

  1. 使用指针[下标]来访问数组元素;

通过指针[下标]访问数组元素
可以看到,此时对指针使用下标引用操作符也是能够正常访问数组的各个元素的,不管是整型一级指针还是整型二级指针,都是可以正常访问的;

  1. 进行数组传参时,通过指针接收;

指针接收数组传参
通过指针来接收数组的传参,是没有任何问题的,这里我们可以看到指针接收完后,还能通过下标引用操作符来找到数组的各个元素;

  1. 进行指针传参时,通过数组接收

数组接收指针传参
通过数组来接收指针的传参也是没有任何问题的,这里我们看到数组在接收完后,还能通过解引用操作符来访问指针指向的各个对象;

经过上面的例子,我相信大家对指针与数组名之间的相互转换已经非常熟悉了,下面我给大家介绍一些关于指针与数组之间的有趣的变形;

9.5 有趣的变形

  1. 下标[数组名]

有趣的变形1
可以看到对于下标引用操作符,下标与数组名的位置是可以进行互换的;

  1. 下标[指针]

有趣的变形2
既然数组名就是指针,那么对于指针来说,在使用下标引用操作符时也是同样可以将指针与下标的位置进行互换的;

  1. &指针[下标]

有趣的变形3

当我们对指针使用下标引用操作符时,就等价于对指针进行解引用,再进行解引用后再对其取地址,还能得到指针;

  1. *数组名

*数组名
我们可以通过对数组名进行解引用来访问数组的各个元素,但是,数组名并不能像指针一样进行自增操作:

数组名自增
这里是因为对于数组名来说,数组名是整个数组在内存中的起始位置,此时如果对数组名进行自增,就相当与是把整个数组的起始位置进行移动,所以此时的数组名表示的也是整个数组,并不是数组的首元素,这个一定要牢记;

9.6 总结

介绍到这里,关于指针与数组的关系我们就全部介绍完了,下面我们来对这些内容做个总结:

  1. 有三种情况数组名表示的是整个数组:
  • 通过sizeof计算数组所占空间大小
  • 使用取地址操作符&对数组名进行取地址操作
  • 对数组名进行自增自减操作等赋值操作
  1. 在其它情况下,数组的本质就是指针: 数组名 < = = > 指针 数组名<==>指针 数组名<==>指针
  2. 指针 < = = > & 数组名 [ 下标 ] < = = > & 下标 [ 数组名 ] < = = > & 指针 [ 下标 ] < = = > & 下标 [ 指针 ] 指针<==>\&数组名[下标]<==>\&下标[数组名]<==>\&指针[下标]<==>\&下标[指针] 指针<==>&数组名[下标]<==>&下标[数组名]<==>&指针[下标]<==>&下标[指针]
  3. 数组 [ 下标 ] < = = > 指针 [ 下标 ] < = = > ? ( 指针 + 下标 ) < = = > ? ( 数组名 + 下标 ) < = = > 下标 [ 数组名 ] < = = > 下标 [ 指针 ] 数组[下标]<==>指针[下标]<==>*(指针+下标)<==>*(数组名+下标)<==>下标[数组名]<==>下标[指针] 数组[下标]<==>指针[下标]<==>?(指针+下标)<==>?(数组名+下标)<==>下标[数组名]<==>下标[指针]
  4. 我们可以修改指针指向的内容,但不能修改数组名指向的内容;

    即:我们可以对指针进行自增/自减等赋值操作,但不能对数组名进行自增自减等赋值操作

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

在搞清楚了指针与数组的关系后,我们再来探讨一下指针数组。
既然数组的本质就是指针,而对指针数组来说,数组元素为指针,也就是说我可以认为指针数组的元素可以是数组
在前面介绍二维数组时,我们有介绍过一种理解——二维数组可以看做是一维数组的集合
既然指针数组的元素可以是数组,而二维数组可以看做是一维数组的集合,那是不是说指针数组与二维数组等价呢?

很遗憾的告诉各位,指针数组与二维数组并不等价,同理二级指针与二维数组也是不等价的。之所以不等价,是因为对于指针数组来说,数组元素存放的地址可以是不连续的地址,但是对于二维数组来说,数组元素的地址是连续存放的,如下所示:

指针数组模拟二维数组

从结果中我们可以看到:

  • 对于二维数组的元素地址间的差值刚好是8,也就是说,二维数组可以看做是三个地址连续存放的一维数组的集合
  • 而对于指针数组来说,我们可以看到指针数组的元素的地址之间并不相邻

因此指针数组与二维数组并不等价,又因为指针数组与二级指针等价的,所以二级指针与二维数组并不等价

既然我们从地址连续存放的一维数组的集合的角度来看待二维数组的话,那我们就可以通过指针数组来模拟实现二维数组。如下所示:
指针数组模拟二维数组2
在前面对指针与数组的探讨中我们得到的结论是在对数组元素进行访问时解引用操作符*<==>下标引用操作符[]。因此我们可以通过第二个下标引用操作符来访问指针数组中各元素的数组元素。

指针与数组的关系到这里我们就已经深入探讨完了,相信大家对于这些内容都应该有了更加清晰的认识了,下面为了更好的介绍之后的内容,我们需要先给大家补充一些知识点。

十一、void*指针

在函数中我们有介绍过void表示的是无类型,函数中的void表示无返回类型。在指针中同样也有void*这种类型的指针,这类指针我们可以称它为无具体类型的指针,也可以称为泛型指针。

在前面指针类型的意义中我们提到过,指针的类型决定了指针对数据进行一次修改时的可操作空间大小;

  • 对于char*的指针来说,它修改一次数据可以操作的空间为1个字节。我们让char*的指针接收char类型的对象的地址是比较合适的,这样我们在修改内容时可以对char类型的地址中存放的内容进行一个字节一个字节的修改;
  • 对于int*的指针来说,它修改一次数据可以操作的空间为4个字节。我们让int*的指针接收int类型的对象的地址是比较合适的,这样我们在修改内容时可以对int类型的地址中存放的内容进行四个字节四个字节的修改;
  • 对于void*类型的指针来说,它可以接收所有类型的对象的地址,并不能对其进行解引用以及进行指针的运算;

下面我们来通过实例验证一下:

void*指针

从报错中我们可以看到,void*类型的指针在接收不管是char类型还是int类型的对象的地址时都是没有问题的,但是我们在对其进行解引用、加减整数、以及进行指针-指针时都有出现报错,报错的内容总结下来就是——对象具有void类型,并且void*的大小是未知的;

那对于void*类型的指针来说他能做什么呢?下面我们来测试一下:

void*指针

从测试结果中我们可以看到,我们可以正常的对指针进行关系运算、打印存储的地址以及赋值的操作;

也就是说对于void*指针来说,它是无法实现指针的±整数运算、解引用以及指针-指针运算这些运算的,但是我们可以对指针变量进行基本的操作。

对于void*指针的使用我们会在后面的内容进行介绍,大家不要心急,耐心往下继续阅读;

十二、关键字const

对于const这个关键字,它的中文翻译为常数、恒量;恒定的,不变的;它在C语言中的作用正如它的意思一样,将操作对象变成不变的,这个关键字我们前面几乎没有遇到过,它具体有什么作用呢?下面我们就来一起探讨一下;

12.1 变量

12.1.1 变量的分类

对于C语言来说,变量可分为全局变量和局部变量,下面我们来看一下什么是局部变量,什么是全局变量:

//变量的分类
int a = 10;//全局变量
test()
{
	int d = a;//局部变量
}
int main()
{
	int b = 20;//局部变量
	if (a < b)
	{
		int c = a + b;//局部变量
	}
	return 0;
}

在这个例子中,我们分别定义了四个变量,根据代码的注释我们可以看到变量a为全局变量,变量b和变量c为main函数内部的局部变量,变量d为main函数外部test函数内部的局部变量;

在C语言中,我们将花括号{}称为代码块,因为我们所有的代码都是需要再{}内部编写的。对于变量来说,在{}外面定义的变量称为全局变量,在{}内部定义的变量称为局部变量;

12.1.2 变量的生命周期和作用域

变量的生命周期我们可以简单的理解为就是变量的创建与销毁的周期
变量的作用域我们可以简单的理解为就是变量可以使用的区域

对于全局变量与局部变量来说,它们的生命周期与作用域是有区别的:

  • 全局变量的生命周期是跟随整个工程的,全局变量在创建后,除非关闭这个工程,否则它会一直存在,它的作用域也是作用于整个工程的
  • 局部变量的生命周期是跟随创建变量的{},在{}内部创建好局部变量后,一旦出了{},局部变量就被销毁了,它的作用域也是对应的{}

下面我们通过代码来对全局变量以及局部变量的生命周期和作用域进行说明:

变量的生命周期和作用域
在这个代码以及测试结果中,我们可以得到以下信息:

  1. 对于全局变量a来说,不管是在test函数内部还是在main函数的内部以及if语句的代码块内部都是可以正常使用的,所以此时我们可以说全局变量a此时的使用范围是从它创建后的任何地方都可以进行使用
  2. 对于局部变量c来说,它能在if语句的代码块内部使用,也可以在if语句外,main函数的代码块内进行使用,所以此时我们可以说局部变量c的使用范围是在main函数的代码块内部
  3. 对于局部变量b和局部变量d来说,它们都是可以在自己对应的代码块内部进行使用的,所以此时我们可以说局部变量b和局部变量d的使用范围是在它们对应的代码块内部

下面我们继续看下面的代码:

变量的生命周期和作用域
可以看到,此时代码出现了6处报错,报错内容都是未声明的标识符,也就是说在报错的这些地方是不存在这些变量的。

现在有朋友可能就有疑问了,局部变量出现这种情况我都能理解,此时它是因为出了自己的作用域就被销毁了嘛,但是你都说了全局变量是跟随整个工程的,你这现在不是自己打自己的脸吗?

别着急,下面我们下面我们继续介绍一个新的关键字——extern——引入外部符号(可以引用其它源文件内部定义的全局变量),现在我们再来看一下下面的代码:

变量的生命周期和作用域3
从这次的结果中我们可以看到此时通过关键字extern对全局变量a进行声明后现在再到test函数中使用变量a是没有任何问题的。

但是我们通过对b、c、d进行extern的声明后,此时报错了,错误内容为无法解析的外部符号,这也就是说,extern只能对全局变量使用

因此这个例子再一次证明了局部变量的生命周期与作用域都是自己对应的代码块内部,而对全局变量来说,我们可以通过关键字extern对变量进行声明,所以全局变量的生命周期和作用域是在整个工程内部的

12.1.3 变量的优先级

现在我们设想一下,全局变量和局部变量可不可以同名呢?如果可以同名,那应该是全局变量优先使用还是局部变量优先使用呢?下面针对这两个问题,我们来通过代码测试一下:

变量的优先级

从测试结果中我们可以看到,在局部变量a的代码块内部打印的是局部变量a的值,而当局部变量被销毁后打印的则是全局变量a的值,也就是说当局部变量与全局变量的变量名相同时,程序优先执行的是局部变量。因此,我们在今后写代码时,尽量避免局部变量与全局变量同名的情况

12.2 const修饰局部变量

const这个关键字,它是可以对变量进行修饰的,当他修饰变量时,会给变量赋予一个常属性,使变量不可被改变,如下所示:

const修饰局部变量
可以看到此时程序的报错内容为表达式必须是可修改的左值,也就是说,此时被const修饰后的局部变量b是不可像局部变量a一样被修改的;

但是为什么我们说它const修饰的局部变量只是拥有了常属性呢?这是因为我们此时可以通过指针对其进行修改,如下所示:

const修饰局部变量2
此时我们可以看到程序是正常运行的,而且b的值此时也被修改为了20,所以被const修饰的局部变量只是拥有了常属性,不能直接对其进行更改,但是它的本质还是一个变量,所以我们可以通过指针来对它的值进行修改。

这种通过地址来修改变量的值的方式是绕过了C语言的语法规则,打破了const的规则限制,这显然是不合理的,那我们应该怎么做才能保证即使拿到了变量的地址也无法对变量进行修改呢?

12.3 const修饰指针变量

为了能够在拿到变量地址后也无法修改变量的值,我们可以通过const对指针进行修饰。但是应该如何修饰呢?下面我们来看一段代码:

const修饰指针变量
在这个代码中,我们通过将const放在指针变量的不同位置来对const进行修饰,可以看到,此时的程序报错内容是指针pa和pa2这两个const在*左边修饰的指针,而对于const在*右边修饰的指针系统并未报错,那是不是代表我们可以通过指针来修改变量的值呢?下面我们继续测试:

const修饰指针变量

从测试结果中我们可以看到,此时变量的值确实通过指针pa被修改了,也就是说如果我们想限制指针无法通过解引用修改指向的对象中存储的内容,那我们就需要将const放在*左边对指针进行修饰才行。

根据const的语法规则,如果我们将const放在*右边时,此时const限制了什么内容呢?下面我们继续测试:

const修饰指针变量3
在这个代码中,我们想通过指针变量p完成对变量a以及变量b的修改,可是我们可以看到,在完成对变量a的修改后,我们将指针p指向b时,此时系统报错了,报错内容为此时的变量p是无法被修改的。
那如果此时const放在*的左边能不能对指针指向的对象进行修改呢?我们继续测试:

const修饰指针变量4
可以看到,此时的指针p是可以对指向的对象进行修改的。通过上面的测试我们可以得到结论:

  • 当const在指针*左边修饰指针变量时,限定的是*p,即无法对*p的值进行修改,但是可以对指针p指向的对象进行修改
  • 当const在指针*右边修饰指针变量时,限定的是p,即无法对指针p指向的对象进行修改,但是可以对*p的存储内容进行修改

在前面我们在介绍野指针时有说过,我们可以通过下面五点来规避野指针:

  1. 给指针进行初始化;
  2. 避免指针越界访问;
  3. 不要返回局部变量或者临时变量的地址;
  4. 当指针指向的地址不再使用时,将指针置为空(NULL);
  5. 在使用指针前,检查指针的有效性;

既然我们需要再使用指针前检查指针的有效性,那我们应该怎么做呢?这就是我们现在要介绍的一个新的知识点——assert断言;

十三、assert断言

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

13.1 assert工作原理

assert()这个宏可以接收一个表达式作为参数。如果表达式为真(返回值非零),assert()不会产生任何作用,程序继续运行。如果表达式为假(返回值为零),assert()就会报错,在标准错误流stderr中写入一条错误信息,显示没有通过表达式,以及包含这个表达式的文件名和行号。

借助这个宏,我们就可以在使用指针前来检查指针的有效性,如下所示:
assert断言1
可以看到,当assert的括号内的条件不满足时,此时系统就会报错,在报错中会显示文件的路径以及报错的具体位置,同时系统也会弹出调试错误的窗口。

13.2 NDEBUG

当我们在确保程序没问题后,不需要进行断言时,我们可以在头文件语句前定义一个宏NDEBUG。此时在重写编译程序时,编译器就会禁用文件中的所有assert()语句。当遇到新问题时,我们只需要将这个宏注释掉,就能继续启用assert()语句来检测程序的问题了。
assert断言2
可以看到,此时虽然指针是空指针,但是因为NDEBUG的加入,assert()并未启用,所以正常打印了hehe,如果我们将它注释掉,它就又会正常启用assert(),如下所示:

assert断言3

13.3 assert的优缺点

对于程序猿来说,assert()还是非常友好的:

  1. 它能识别并自动表示文件和出现问题的行号;
  2. 通过与NDEBUG来配合使用,就能实现开启或关闭assert()机制;

但是因为引入了额外的检查,所以在使用assert()时会增加程序的运行时间。

对于断言功能,一般我们是在Debug版本中使用,这样能够帮助我们来排查程序中存在的问题;为了不影响用户使用程序的效率,我们在Release版本中禁用assert就可以了。对于VS这样的集成开发环境中,在Release版本中,编译器会直接帮我们将assert给优化掉。

十四、字符指针变量

char*类型的变量被我们称为字符指针变量,我们在使用时有以下几种使用方式:

//char*指针变量
int main()
{
	char a = 'a';
	char ch[5] = "abcd";
	char* pa = &a;//指针指向字符变量
	char* pc = ch;//指针指向字符数组首元素地址
	char* p = "abcd";//指针指向字符串地址
	return 0;
}

对于指向字符变量和指向字符数组这两种用法大家应该都是比较熟悉的,现在我们要探讨的就是第三种用法,指向字符串的地址,这个字符串的地址是什么?下面我们来看一下:

char*指针变量

在监视窗口中我们可以看到,此时字符串中存放了5个元素,字符指针p指向的是字符串的首字符a,大家此时有没有一种熟悉的感觉?字符串好像和字符数组有点相似,那字符串与字符数组到底是不是一样的呢?下面我们就来探讨一下;

14.1 字符串和字符数组

为了更直观的看到字符串与字符数组的相关信息,我们还是借助监视窗口来观察:
char*指针变量2
此时我们可以看到,从内容的存储上来对比的话,字符串与字符数组是没有区别的,元素都有对应的下标,并且下标都是从0开始依次递增。
唯一有区别的就是数组名表示的是数组的起始地址,也就是数组首元素的地址,而字符串的值就是字符串,但是当我们用字符指针变量来指向字符串时,指针指向的是字符串的首字符地址。

既然它们的区别不大,那我是不是可以通过字符指针对字符串进行像字符数组一样的操作呢?

char*指针变量3

从结果中我们可以看到此时我们正常对字符数组的元素进行了修改,并成功进行了输出,但是通过字符指针变量对字符串进行修改后并未进行输出,为什么会这样呢?我们还是通过监视窗口来进一步观察:

char*指针变量
此时我们可以看到程序在运行到通过指针进行对字符串元素修改时,程序进行了报错,报错内容为写入访问权限冲突,也就是此时是不可以进行写入的。为什么会这样呢?

这里我们就需要引入一个新的概念——常量字符串。在介绍常量字符串之前,我们先要弄清楚什么是常量。

14.2 常量

常量顾名思义就是不变的量,在C语言中常量有四种分类:

  1. 字面常量
  2. const修饰的常变量
  3. 枚举常量
  4. #define定义的标识符常量

const修饰的常变量

在前面我们已经介绍了const修饰的局部变量,当局部变量被const修饰后,我们经过测试发现并不能对局部变量直接进行修改,这就表示此时的局部变量拥有了常量属性,即不能被更改的属性
但是我们可以借助指针来对局部变量的值进行修改,这就表示此时的局部变量还是一个变量可被修改。所以被const修饰的局部变量我们就将其称为常变量

字面常量

字面常量是我们现在要重点介绍的内容。所谓的字面常量,我们可以简单的理解为我可看到的1/2/3/4……这些数字、a/b/c/d……这些字符、以及由这些字符组成的字符串等这些已经被定义好的值。

对于这些常量来说它们都有一个共同点——值是确定的不可被修改的。如下所示:

常量1
从程序报错中我们可以看到,此时的报错内容都是表达式必须是可修改的左值,这就是常量的属性——不可被修改

14.3 常量字符串

对于一个明确的字符串来说,它本身是一个常量,当我们将字符指针指向常量字符串时,此时的字符串可以通过指针进行访问:

常量2
但是我们不能通过指针对字符串中的元素进行修改。

常量3
常量字符串与字符数组类似,字符串中的元素也是有对应的元素下标,并且下标是从0开始逐渐递增。当我们通过字符指针指向常量字符串时,指针指向的实质上是常量字符串的首元素地址。

为了能够避免出现使用字符指针来修改常量字符串的内容,所以我们在定义字符指针时,最好是通过const对指针变量进行修饰:

//const修饰字符指针变量
int main()
{
	const char* p = "abcd";
	return 0;
}

当我们将常量字符串放在数组中时,实质上是在函数栈帧上开辟了一块新的空间,在空间内存放了对应的字符,我们通过指针或者是数组名[下标]对数组元素进行更改时,实质上是在对新开辟的这块空间存储的内容进行更改,并不是对这些常量字符进行更改:

常量4

在计算机内存中,常量都是有自己的地址的。
我们将常量值存放在数组中时,计算机就会通过常量的地址找到对应常量的值,并将该值存放在数组中对应的元素地址下,所以此时我们是可以修改数组元素存放的值;
但是对于常量字符串来说,我们将其用字符指针指向时,是指向的常量字符串自己本身的地址,如果我们通过字符指针对其进行修改,就好比我们要强行的实现1=2这样的等式,这显然是不合理的;

下面我们再来看一个代码:

//常量字符串
int main()
{
	const char* p1 = "abcd";
	const char* p2 = "aefg";
	const char* p3 = "abcd";
	printf("p1 = %p\n", p1);
	printf("p2 = %p\n", p2);
	printf("p3 = %p\n", p3);
	return 0;
}

大家觉得在这个代码中,对于这三个字符指针存储的地址是相同的还是不相同的?

常量5
从测试结果中我们可以看到:

  • 指针p1和p3因为都是指向的常量字符串"abcd",所以它们此时存储的地址是相同的;
  • 指针p2指向的是另一个常量字符串"aefg",这个字符串的起始地址与"abcd"的起始地址肯定是不一样的。
  • 在这个例子中,这三个指针指向的常量字符串虽然它们首元素存储的值都是字符a,但是此时它们就相当于是两个字符数组,只是首元素存储的值一样,但是数组在内存中申请的空间却不是同一块;
  • 指针p1和p3指向的是同一个字符数组,所以它们指向的地址是同一个地址;
  • 指针p2指向的是不同的字符数组,所以它指向的地址是不同的地址;

下面我们再来看一个代码:

//常量字符串
int main()
{
	char ch1[5] = "abcd";
	char ch2[5] = "abcd";
	char* p1 = ch1;
	char* p2 = ch2;
	printf("p1 = %p\n", p1);
	printf("p2 = %p\n", p2);
	return 0;
}

在这个例子中,大家觉得此时这两个指针所指向的地址是否相同呢?

常量6
从测试结果中我们可以看到,此时两个指针存储的地址并不相同。这是因为:

  • 此时这两个指针指向的是两个字符数组,虽然两个字符数组中存储的元素是相同的,但是数组在内存上申请的空间地址并不是同一块;

14.4 总结

相信大家此时对字符指针以及常量字符串与字符数组的区别已经理解了,下面我们来对这一块内容做个总结:

  1. 字符指针在指向常量字符串时需要使用const进行修饰;
  2. 常量字符串相当于一个不可被修改的字符数组,字符串的元素下标是从0开始依次递增;
  3. 我们可以通过下标引用操作符对常量字符串中的元素进行访问,但不可对其进行修改;
  4. 同一个常量字符串的地址是相同的;
  5. 不同的常量字符串即使首元素相同,首元素的地址也不相同;

十五、数组指针

指针类型我们前面介绍的有字符型指针、整型指针、浮点型指针等等,我们现在要介绍的是另一种类型的指针——数组型指针。

15.1 数组类型

这时有朋友就会说了,啊!怎么还有个数组型?数组型是什么数据类型?别着急,我们先回顾一下数组:

//数组的创建格式
type arr_name[size];
//type——数组元素数据类型
//type[size]——数组数据类型
//arr_name——数组名
//size——数组大小

之前我们一直关注的是数组元素的数据类型、数组名以及数组大小,我们一直忽略了一个信息,那就是数组数据类型type[size]。我们根据这里的创建格式可以看到,所谓的数组数据类型,实质上就是数组元素数据类型+数组大小

现在我们知道了什么是数组类型,接下来我要给大家介绍一种新的观点来看待数组——数组类型的变量;

15.2 数组类型的变量

对于数组来说,其实我们可以用变量的角度来看待数组,如下所示:

//数组类型的变量
type variate_name[size];
//type[size]——数据类型
//variate_name——变量名
//size——数据类型向内存申请的空间个数

通过数组类型创建的变量,我们将其称之为数组变量,简称数组。对于数组变量来说,它具有以下的性质:

  1. 变量在内存中会申请size个连续的空间
  2. 变量中能存放的数据个数与空间个数相同,并且这些数据会从低地址到高地址依次连续存放
  3. 变量名存放的是这块空间的起始地址,也是第一块空间的地址,即首元素地址
  4. 这个连续的空间都有对应的编号,这些编号是从0开始依次递增,我们将其称之为下标
  5. 通过变量名+下标我们可以找到对应空间的地址
  6. 当我们向变量中存放数据时,这些数据会从0下标开始依次存放进对应的空间中
  7. 对空间地址进行解引用我们可以找到空间中存放的数据,这些数据我们称之为数组元素
  8. 我们还可以通过下标引用操作符和对应的下标来访问下标对应的数组元素
  9. 通过下标访问数组元素的形式有:
    • 变量名[下标]
    • [变量名]下标
    • [下标]变量名
    • 下标[变量名]
    • *(变量名+下标)
    • *(下标+变量名)
  10. 当我们对数组元素进行初始化时,未被初始化的数组元素会自动初始化为0

15.3 数组变量的创建和初始化

我们以数组变量的观点来对数组创建并初始化的话,我们就可以写成以下的形式:

//数组变量的创建与初始化
int main()
{
	int arr[3] = { 1,2,3 };
	//int[3]——数组类型
	//3——数组类型在内存空间中申请了3个连续的空间
	//int——申请的每个空间所占内存大小为int类型所占空间大小
	//arr——数组变量名,简称数组名
	//{1,2,3}——申请的空间中存放的数据
	return 0;
}

现在大家通过这个数组变量的观点,对数组应该有了新的认识了。

下面大家可以根据这个观点回答以下两个问题吗:

  1. 为什么数组名与指针等价?
  2. 为什么解引用操作符与下标引用操作符等价?
  1. 为什么数组名与指针等价?

因为数组名存放的是地址,而存放地址的变量,我们将其称之为指针。

  1. 为什么解引用操作符与下标引用操作符等价?

因为下标引用操作符的实质是通过下标找到对应的空间地址,再对其进行解引用操作;

在了解了数组类型以及数组变量后,我们再来看一下数组指针。

15.4 数组指针变量的创建与初始化

指针我们前面说过他的本质就是一个变量。前面我们介绍的指针的创建格式如下:

//指针的创建格式
point_type* variate_name;
//point_type*——数据类型为指针类型;
//variate_name——变量名

现在我们可以用一个新的角度来看待指针的创建——数据类型+指针变量名,如下所示:

//指针的创建格式
type *point_variate_name;
//type——数据类型;
//*point_variate_name——指针变量名

可以看到不管是第一种角度还是第二种角度,对于决定是指针类型还是指针变量的关键在于*,所以对于指针来说*才是指针定义的关键。

当我们以第二种角度来看待指针变量的创建的话,我们此时如果将数据类型变成数组类型时,我们就可以得到数据类型为数组类型的指针变量,即数组指针变量,简称数组指针:

//数组指针的创建格式
type(*point_variate_name)[size];
//type[size]——数组类型
//*point_variate_name——指针变量名

注:对于*[]这两个操作符来说,[]的优先级高于*,所以我们需要使用()使*与变量名先结合。

根据指针和指针变量的定义,指针就是地址,指针变量就是存储指针的变量。我们可以很容易的得到结论——数组指针存放的是指向对象的地址;

//数组指针的创建与初始化
int main()
{
	int a = 10;
	int(*p)[1] = &a;
	return 0;
}

这里我们通过数组指针p存储了变量a的地址,因为此时数组指向的对象存储的数据只有一个,所以我们只需要像内存申请一块空间就可以进行存放了。

在这个代码中此时数组指针p的元素下标为0,我们可以通过下标引用操作符找到对应空间中存放的信息——变量a的地址:

数组指针变量的创建和初始化
在找到变量a的地址后,我们可以通过对其解引用来找到a中存放的数据:

数组指针变量的创建和初始化2
因为解引用操作符和下标引用操作符是等价的,所以对于数组指针变量我们可以写成以下形式:

数组指针变量的创建和初始化3

  • 当我们通过两次解引用操作来访问变量a中存放的数据时,此时的数组指针就和二级指针类似;
  • 当我们通过两次下标引用操作符来访问变量a中存放的数据时,此时的数组指针就和二维数组类似;

15.5 数组指针与指针数组

前面我们介绍了指针类型的数组——指针数组,它与数组指针的创建格式如下所示:

//指针数组的创建格式
point_type* arr_name[size];
//point_type*[size]——指针数组类型
//arr_name——数组名
//size——数组大小

//数组指针的创建格式
type(*point_variate_name)[size];
//type[size]——数组类型
//*point_variate_name——指针变量名
//size——数组类型向内存申请的连续空间的数量

通过对比这两种创建格式,我们不难发现它们之间的区别就是指针标志*的结合对象不同:

  • *与数据类型结合就是指针数组
  • *与变量名结合就是数组指针

现在可能有部分朋友感觉更迷糊了,难道它们就只是指针标志移动了一下位置,其实它们是同一个东西?
这个答案是否定的,指针数组与数组指针它们是两个东西,它们有着本质的区别。下面听我慢慢道来。

15.5.1 数组与指针区别

在前面介绍时我们有说过,在某些特定的情况下数组名代表的是数组在内存空间中的起始位置,在大部分情况下数组名就是指针

所以我们对数组名和指针的处理也是采取的模糊化的方式,将数组名就是看成指针,指针看做的就是数组名,但是我们要清楚的是数组并不等于指针

数组与指针的区别主要由以下几点:

  1. 数组和指针在内存中的空间不相同
  • 数组在内存中申请空间时,申请的是一片连续的空间;
  • 指针指向的空间是内存空间中的一块空间;

数组指针与指针数组

  1. 数组和指针的存储数据个数不同
  • 数组在内存中会根据自己的大小申请对应的空间个数,每个空间中都能存放一个数据,所以数组能存放与数组大小数量相同的数据;
  • 指针在内存空间中只申请了一块空间,所以指针也只能存储一个数据;

数组指针与指针数组2

  1. 数组和指针的工作原理不同
  • 数组是先通过数组名找到数组的空间起始位置,再通过对应编号找到要访问的空间,最后对空间地址进行解引用找到空间中存储的数据;
  • 指针则是通过存储的数组首元素地址找到数组的首元素,再通过首元素的地址加上各个空间的编号找到各个空间的地址,最后通过地址进行解引用找到空间中存储的数据;

数组指针与指针数组3

15.5.2 数组指针与指针数组的区别

在理解了数组与指针的区别后,我们再来看一下指针数组与数组指针的区别:

  1. 指针数组与数组指针的内存空间不同

数组指针与指针数组4

从反汇编界面我们可以看到:

  • 指针数组在申请空间时,会根据数组大小来申请对应数量的空间;
  • 数组指针则只在内存空间中申请了一块空间用来存放地址;
  1. 指针数组与数组指针的存储数据个数不同
  • 对于指针数组来说,它能存储的数据个数与数组的空间大小是一致的,就如上图所示,对于指针数组arr来说,它能存储3个数据;
  • 对数组指针来说,因为它在内存中只申请了一块空间,所以,它能存储的数据也只有一个;

数组指针与指针数组5
此时我们可以看到,数组指针在存放3个元素时系统会报错——初始值设定项值太多。

  1. 指针数组与数组指针的工作原理不同
  • 指针数组在读取数据时,是先通过数组名找到数组空间的起始位置,然后通过空间编号找到对应的空间,再对空间中存储的地址找到该地址对应的空间,最后通过对该地址进行解引用找到空间中存放的数据;
  • 数组指针在读取数据时,是先通过存储指向空间的起始地址找到对应的空间的起始位置,再通过空间编号找到对应的空间,最后通过对该空间的地址进行解引用找到空间中存储的数据;

数组指针与指针数组6

现在我们知道了指针数组与数组指针是两个东西了,对指针和数组的详细剖析,我们知道了指针和数组还是有区别的。前面我们通过两次解引用找到了数组指针指向的对象存储的数据,这种工作方式与二级指针是一样的,那是不是说明其实数组指针与二级指针是同一个内容的不同形式呢?接下来我们就来探讨一下数组指针与二级指针的异同点;

15.5.3 数组指针与二级指针

对于同为指针的数组指针和二级指针来说,它们有很多相同的地方:

  1. 内存中申请的空间相同

对于指针来说,指针指向的是对象的地址,指针在内存中申请空间只会申请一块空间来存储指向对象的地址。

数组指针与二级指针

不管是一级指针、二级指针还是数组指针也好,只要是指针它们都只会在内存空间中申请一块空间来存放数据;

  1. 工作原理相同
  • 二级指针是先通过存储的一级指针的地址找到一级指针,再对一级指针进行解引用找到一级指针中存储的变量的空间地址,然后通过变量的空间地址找到变量,最后再对变量的空间地址进行解引用找到变量中存放的数据;
  • 数组指针是先通过存储的对象在内存空间中的起始地址找到对应空间的起始位置,在通过空间编号找到对应的空间地址,最后通过对空间地址进行解引用找到地址中存放的数据;

数组指针与二级指针2

二级指针是通过两次解引用来找到对应的数据,而数组指针通过空间编号找到对应空间的这个过程就是一次解引用的过程,所以两种指针在寻找数据的工作原理上是相同的都是通过两次解引用来找到对象中存储的数据;

但是二者又有很多不同的地方:

  1. 指向的空间不同
  • 二级指针指向的是一块空间;
  • 数组指针指向的是一块连续的空间;
    数组指针与二级指针3
  1. 指向的对象不同
  • 二级指针存放的是一级指针的地址,指向的是一个一级指针;
  • 数组指针存放的是一块连续的空间的起始地址,能够在内存空间中申请一块连续空间的对象,目前我们所学的就是数组,所以数组指针指向的其实是一个数组;

数组指针与指针数组4

从监视窗口我们可以看到,二级指针存储的对象的数据类型为int*即指针类型,所以二级指针指向的是一个指针;
数组指针存储的对象的数据类型为int型,并且指向的对象有三个元素,所以数组指针指向的是整个数组,并且这个数组是一个一维数组;

综合上面的异同点,我们可以得出结论:

  • 数组指针和二级指针没有关系,它们是两种类型的指针
  • 数组指针指向的是一个一维数组

在前面的探讨中我们发现数组指针在访问数据时的方式和二维数组也很相似,那数组指针和二维数组又是什么关系呢?

15.5.4 数组指针和二维数组

在探讨数组指针和二维数组的关系前,我们先来回顾一下对应的知识点:

  • 二维数组是同一类型的一维数组的集合,二维数组的数组名存放的是二维数组在内存空间中的起始位置,同时也是二维数组第一个元素的地址,即一个一维数组的地址;
  • 数组指针是一个数组类型的指针,数组指针指向的是一个连续空间的起始地址,即一个一维数组的地址;

既然二维数组名表示的是一个一维数组的地址,而数组指针指向的也是一个一维数组的地址,那是不是说明二维数组与数组指针等价呢?下面跟着我的思路咱们一步一步的来探索;

  • 首先我们通过一维数组的角度来看待二维数组:
//用一维数组的角度看二维数组
type arr_name[size][num];
//type[num]——数据类型
//type[size][num]——数组类型
//arr_name——数组名
//num——数据类型在内存空间中申请的空间个数
//size——数组大小

此时如果我们通过数组下标来访问二维数组的元素时,我们是以arr_name[下标]的形式进行访问,如果写成指针的形式则是*(arr_name+下标)

  • 然后当我们访问首元素时,此时的下标为0,也就是说我们可以写成*arr_name这种形式;
  • 最后当我们继续通过数组下标对首元素的数组元素进行访问时,此时的数组名就是指向首元素的指针,也就是说,我们可以通过指针来代替数组名,即*point_name[下标]

下面我们再来数组指针:

//数组指针
type(*point_name)[size];
//type[size]——数据类型
//*point_name——指针名
//size——数据类型在内存空间中申请的空间个数
  • 当我们通过数组指针访问指向的数组时,我们需要通过数组的下标找到数组元素的地址,即point_name[下标]
  • 当我们找到数组元素的地址后,我们可以对其进行解引用来访问下标对应的数组元素,即*point_name[下标]

经过对比,可以看到,数组指针和二维数组不能说是一模一样吧,只能说是没有区别。那是不是这样呢?下面我们通过代码来验证一下:

数组指针与二维数组
从监视窗口中我们可以看到,指针存储p存储的内容为数组arr首元素的地址,指针p在加1后指向的地址是数组第二个元素的地址。

也就是说数组指针p可以通过+数组元素下标来访问二维数组的各个元素,此时的指针p是与二维数组的数组名等价的。为了进一步验证这个结论,我们来进行以下的测试;

  1. 通过数组下标访问数组的各个元素

数组指针与二维数组2
此时我们通过两次解引用不管是使用数组名还是指针名都成功的访问到了数组各每个元素;

  1. 通过解引用操作访问二维数组的各个元素

数组指针与二维数组3
通过解引用操作,我们也成功的访问到了二维数组的各个元素;

  1. 通过数组指针接收二维数组

数组指针与二维数组4
我们在对二维数组进行传参时,数组指针很好的接收二维数组并成功通过下标对数组元素进行了访问;

4.通过二维数组接收数组指针

 数组指针与二维数组5
我们在对数组指针进行传参时,二维数组很好的接收了数组指针并成功通过数组下标对指针指向的数组的数组元素进行了访问;

经过咱们对数组指针深入且细致的探讨,我相信大家应该已经完全理解了数组指针,下面我们就来对数组指针做一个总结:

15.5.5 总结

  1. 数组指针是一个数组类型的指针,指向的是一个一维数组
  2. 当二维数组的数组名指向首元素时,数组指针与二维数组的数组名等价,数组名可以与数组指针相互转换
  3. 数组不等于指针
  4. 数组指针与指针数组是两个不同的概念,数组指针是一个一级指针,而指针数组相当于一个二级指针

十六、函数指针变量

C语言学习到现在,不知道大家有没有发现一个有趣的事情,那就是C语言的命名特别的简单粗暴:

  • 对不同类型的数组命名是字符数组、整型数组、浮点型数组、指针数组……这些数组的前半部分说明了数组元素的数据类型;
  • 对不同类型的指针命名是字符指针、整型指针、浮点型指针、数组指针……这些指针的前半部分就说明了指针指向的对象;

根据这个命名特点,我们不难得出函数指针变量即函数指针,它指向的对象应该是一个函数。那它指向的是函数名还是&函数名呢?

下面我们就来探讨第一个问题——函数名是什么?

16.1 函数名

【函数栈帧的创建和销毁】篇章中有提到过,我们在调用函数时,函数会先通过 ebpesp 这两个指针在内存空间中创建一块空间供函数使用,这块空间我们称之为函数栈帧。

  • 对于变量来说,变量所在空间的地址就是变量的地址,我们通过&变量名将变量的地址取出来后存放进指针,此时指针指向的就是变量的所在空间;
  • 对于数组来说,数组的起始点的地址就是数组的地址,同时也是数组首元素所在空间的地址:
    • 我们通过数组名将数组首元素所在空间的地址直接存放进指针,此时指针指向的就是数组的首元素的所在空间;
    • 我们通过&数组名将数组在内存空间中的地址的起始地址取出来后放进指针中,此时指针指向的就是数组的起始点;

那对于函数来说,函数名又代表的是什么呢?下面我们就来做个测试:

函数名
从这次测试结果的报错中,我们可以得到以下信息:

  • 函数名和&函数名都是地址;
  • 函数名和&函数名都不能进行± 整数;

既然函数名和&函数名都是地址,它们的地址又会有什么区别呢?下面我们继续测试:

函数名2
从反汇编窗口中我们可以看到我们在调用函数的时候得到的函数地址与函数名存放的地址以及&函数名得到的地址是一致的,也就是说函数名和&函数名代表的都是函数的地址;
函数名3
在进入函数后,我们从内存窗口和监视窗口可以得到以下的信息:

  • 函数的地址并不是函数的栈顶;
  • 通过指针-指针我们可以发现函数的地址与函数栈顶之间相距1,095,684个空间;

通过以上信息,我们可以得到结论:

  • 函数名和&函数名代表的都是函数的地址,但是这个地址不是函数的栈顶地址;
  • 函数名和&函数名不能进行±整数;

既然函数名和&函数名都代表的是函数的地址,那既然是地址,就能存入指针中,此时指向函数的指针我们将其称之为函数指针变量,简称函数指针

我们应该如何创建一个函数指针呢?下面我们来看一下函数指针是如何创建的;

16.2 函数指针变量的创建和初始化

我们在创建函数指针时,需要声明函数的返回类型、函数参数的类型以及函数指针变量名:

//函数指针的创建格式
return_type (*point_name)(parameter_type,……)
//return_type——函数指针指向的函数返回类型
//*point_name——函数指针变量名
//parameter_type——函数参数类型
//return_type (*)(parameter_type,……)——函数指针类型

从函数指针的创建格式中,有几点需要我们注意一下:

  1. 函数参数类型的数量与参数的数量要一致,参数变量名可以省略;
  2. 当只有返回类型、指针标志以及参数类型时,这代表的是函数指针的数据类型;

根据函数指针的创建格式,我们就可以来创建一下函数指针了:

//函数指针的创建
int main()
{
	//无返回类型函数指针
	void(*p1)();
	//字符型函数指针
	char(*p2)(char);
	//指针型函数指针
	int* (*p3)(int*, int);
	return 0;
}

这里我们创建了三种类型的函数指针——无返回类型的函数指针、字符型的函数指针以及指针型的函数指针。从函数指针的创建信息中我们就可以获得以下信息:

  1. 函数指针p1为无返回类型的指针,那p1就不能进行解引用以及指针±整数等操作;
  2. p1指针指向的函数是一个无返回类型的函数,函数没有参数;
  3. p2指针指向的函数是一个返回类型为char的函数,函数的参数也为char
  4. p3指针指向的函数是一个返回类型为int*的函数,函数的参数有两个,分别为int*int

现在我们函数指针创建好了,我们应该如何对其进行初始化呢?

对于指针而言,初识化的方式有两种:明确的指向对象和空指针。函数指针的初始化也是一样,当我们有明确的对象时,我们可以直接将对象的地址赋值给指针进行初始化,没有明确的对象时,就可以将指针先置空:

//函数指针的创建和初始化
//无返回类型
void test1()
{
	printf("1 + 2 = %d\n", 1 + 2);
}
int main()
{
	//函数指针初始化——置空
	void(*p)() = NULL;
	//对指针赋值
	p = test1;
	//函数指针初始化——指向明确对象
	void(*p1)() = test1;
	void(*p2)() = &test1;
	return 0;
}
  • 我们在对函数指针置空后,在有明确的指向对象时,可以在后期进行赋值;
  • 当有明确的指向对象时,因为函数名和&函数名代表的都是函数的地址,所以我们初始化的方式既可以通过函数名进行初始化,也可以通过&函数名进行初始化;

16.3 函数指针的使用

当指针指向函数时,此时我们可以认为指针就代表着函数,从而对函数进行直接的调用:

函数指针的使用
对于无返回类型的指针来说,我们是不能对指针进行解引用的,所以要使用无返回类型的指针调用函数,只有这一种方式。但是对于有返回类型的指针,我们还可以通过解引用的方式来进行函数调用:

函数指针的使用2
相信大家有了前面的知识储备,对这一块的内容应该能很容易的理解了。下面我们要介绍一个新的知识点——关键字typedef——数据类型重命名;

16.4 关键字typedef

关键字typedef的作用是给数据类型重新命名,如下所示:

关键字typedef
我们现在通过将int重命名为N,并用N创建了一个变量a,通过输出结果我们可以看到,变量可以正常创建,并且我们也能通过sizeof计算N所占空间的大小。

看到这里有朋友可能就会说了,你这不是多此一举吗?用得着将int重命名吗?

这里我想说的是,如果我们遇到一个比较复杂的数据类型,如前面介绍的函数指针的类型int*(*)(int*,int)当我们要通过这个类型创建多个函数指针时,每一次都要写一大串的代码是不是就比较麻烦,这时我们就可以通过typedef来将此类型重命名为一个很简单的名字,如下所示:

关键字typedef2
可以看到我们此时将int*(*)(int*,int)这个类型重命名为了p_int并通过新的类型名创建了两个函数指针,这两个函数指针都是可以正常进行函数掉用的。

  • 这里要注意的是,对函数指针类型重命名时,我们需要将新的名字放在指针标志的括号内才能完成重命名

16.5 有趣的代码

下面我们来看两个有趣的代码:

//代码1
(*(void(*)())0)();

这个代码是在干啥呢?我们能看到的就是两个解引用操作符、一个void、一个0和一堆的括号,这个代码我们应该如何理解呢?下面听我慢慢道来;

要理解这个代码我们首先要找到我们熟悉的部分,比如void(*)*()

这一串我们不熟悉,但是void我们熟悉呀,它表示的是无类型。

什么东西是无类型的呢?我们看一下它的后面是什么——(*)()这个是在干什么?

好像还是看不懂,但是如果我们将void与它们连起来看我们就得到了void(*)(),这个有没有很熟悉的感觉?

没错这个就是一个返回类型为void的函数指针类型,如果此时我们将这个类型重命名,我们就能得到新的表达式:

(*(void(*)())0)();
//void(*)()——函数指针类型
typedef void(*point)();
//将void(*)()这个数据类型重命名为point
(*(point)0)();
//point——函数指针类型

新得到的这个东西又是啥呢?这里我们需要补充一个知识点——数字0为整型。

我们现在在整型的前面放置了一个(数据类型),这是在干嘛?
有朋友很快就想到了——强制类型转换。

所以这里其实是将0强制类型转换成了函数指针类型,之后我们再对函数指针类型的0进行解引用,并在后面加了一个函数调用操作符。

最终我们就能得到结论,这里实质上是在进行函数指针的调用;

//代码2
void (*signal(int, void(*)(int)))(int);

有了前面代码的经验,现在我们继续来看这个代码。在这个代码中,好多字母啊,感觉又看不过来了,别着急,我们还是先找到熟悉的身影——void(*)(int),这个是一个函数指针类型,所以为了更好的观察,我们先将他重命名:

void (*signal(int, void(*)(int)))(int);
//void(*)(int)——函数指针类型
typedef void(*P)(int);
//将void(*)(int)重命名为P
void(*signal(int, P))(int);
//P——函数指针类型

现在我们继续观察,有没有什么新的发现?

如果我们将signal(int,P)拿掉,我们会发现,这句代码的最外层还有一个void(*)(int)。此时我们将最外面这个数据类型替换成重命名后的数据类型我们就能得到新的代码:P signal(int, P)

这句代码的结构为:数据类型 标识符(int, P),此时我们但看标识符部分的内容,一个括号加两个数据类型,这里很明显是在进行函数传参。signal这个函数它有两个参数——一个是int类型,一个是函数指针类型;

那它实际上是数据类型 标识符(参数类型,参数类型),这个格式是函数声明的格式。

所以这里其实是在进行函数声明,声明的是signal这个函数,它的数据类型为函数指针类型,它有两个参数,参数类型分别为整型和函数指针类型。

十七、函数指针数组

在学习了指针和数组后,我们会发现,数组真的是个好东西,只要是相同类型的元素,我们都可以将它们放进数组中。比如我们熟知的常见的数据类型的元素——字符型、整型、浮点型……

  • 字符型的元素我们可以放入字符数组中;
  • 整型的元素我们可以放进整型数组中;
  • 浮点型的元素我们可以放进浮点型的数组中;

在前面我们还介绍了两种新的类型——指针型、数组型;

  • 当我们将指针型的元素放入数组时,数组被称为指针数组
  • 当我们将数组型的元素放入数组时,数组被称为多维数组,如二维数组;

对于上一篇介绍的函数指针来说,它属于指针类型的元素,只不过它的具体类型时函数类型的指针,就像字符指针、整型指针一样;

  • 当我们将字符指针类型的元素放入数组时,数组被称为字符指针数组
  • 当我们将整型指针类型的元素放入数组时,数组被称为整型指针数组
  • 同理,当我们将函数指针类型的元素放入数组时,数组就被称为函数指针数组

既然函数指针数组的数组元素为函数指针类型,那我们应该如何创建这么一个数组呢?

17.1 函数指针数组的创建

我们在介绍函数指针数组的创建之前我们先来复习一下函数指针的创建:

//函数指针的创建格式
return_type(*point_name)((parameter_type, ……);
//return_type——函数的返回类型
//point_name——指针名
//*——指针标志
//parameter_type——函数参数类型

在创建函数指针时我们之所以要将指针标志与指针名用括号括起来,这是因为我们需要确保指针是与指针名结合起来,当指针名与返回类型结合起来时表示的是函数的返回类型为指针类型:

//指针函数的创建格式
return_type* point_name((parameter_type, ……);
//return_type*——函数的返回类型为指针型
//point_name——指针名
//*——指针标志
//parameter_type——函数参数类型

指针标志与数据类型结合时,就是指针型的函数,指针标志与变量名结合时,就是函数型的指针,我这样应该描述清楚了吧。下面我们再来看一下函数指针数组的创建格式:

//函数指针数组的创建格式
return_type (*point_arr_name[size])(parameter_type,……)
//return_type——函数返回类型
//*——指针标志
//point_arr_name——指针数组名
//size——数组大小
//parameter_type——参数类型

这个创建格式我们可以理解为这是一个函数指针型的数组,既然是函数指针,那指针标志肯定是要与变量名结合,所以需要用()*变量名结合起来;

下面我们在来回顾一下指针数组的创建格式:

//指针数组的创建格式
type* arr_name[size]
//type*——数据类型
//type*[size]——数组类型
//arr_name——数组名
//size——数组大小

可以看到我们如果要创建一个指针型的数组的话,变量名需要先于[]结合才行,所以理论上我们应该写成(*(point_arr_name[size]))的形式,但是因为[]的有限级要高于*,所以里面的括号可以省略不写,于是我们就得到了(*point_arr_name[size])

我们现在知道了函数指针数组的创建格式,那它又应该如何初始化呢?

17.2 函数指针数组的初始化

经过前面的分析,我们确定了函数指针数组它是一个函数型的指针数组,所以实际上我们是在给指针数组进行初始化。既然是指针数组,那当我们没有明确的指向对象时,我们需要使用NULL对指针数组进行初始化:
函数指针数组的初始化
当有明确的指向对象时,我们可以直接进行初始化:

函数指针数组的初始化2
现在已经知道了函数指针数组的创建和初始化了,那函数指针数组我们又应该如何使用呢?

17.3 函数指针数组的使用

函数指针数组的使用是函数和数组的一个结合体,我们可以通过数组下标找到对应的数组元素,因为数组的元素是函数指针类型,所以我们在找到元素后可以通过函数调用操作符来调用函数,如下所示:

函数指针数组的使用
看到这里,可能会有朋友觉得这个函数指针数组很鸡肋,这个东西可以用来干什么呢?这样我们就不得不提到函数指针数组的实际运用——转移表;

十八、转移表

什么是转移表?

这里我们可以简单的理解为函数的中转中,我们在调用函数前需要通过一个中转站来进行函数的调用。

这个中转站其实就是函数指针数组。所以转移表的实质就是通过函数指针数组来将复杂的函数调用简单化。如下所示:

转移表
当然,我们在实际使用时调用函数的数量是根据实际情况而定的,只要被调用的函数满足以下三个条件即可通过函数指针数组来进行调用:

  1. 函数具有相同的返回类型
  2. 函数具有相同的参数数量
  3. 函数具有相同的参数类型

下面我们通过模拟实现计算器的例子来说明转移表的使用;

18.1 计算器的模拟实现

  • 功能需求

我们现在打算实现一个进行两个整型运算的计算器,这个计算器具有+、-、*、/、&、|、^的功能。

  • 函数封装

有了需求,现在我们就可以对这些功能通过函数进行封装,功能比较简单,所以我们直接展示封装代码:

//加法
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 Bit_And(int x, int y)
{
	return x & y;
}
//按位或
int Bit_Or(int x, int y)
{
	return x | y;
}
//按位异或
int Bit_Xor(int x, int y)
{
	return x ^ y;
}
  • 用户界面

做好了计算器的核心内容,下面我们就要进行面向用户的程序编写了,首先肯定是用户界面,用户需要知道他能在这个程序里做什么内容,如下所示:

//用户界面
void menu()
{
	printf("   欢迎使用简易版计算器  \n");
	printf("##########################\n");
	printf("####  0.退出计算器    ####\n");
	printf("####  1.加 法 运 算   ####\n");
	printf("####  2.减 法 运 算   ####\n");
	printf("####  3.乘 法 运 算   ####\n");
	printf("####  4.除 法 运 算   ####\n");
	printf("####  5.按位与运算    ####\n");
	printf("####  6.按位或运算    ####\n");
	printf("####  7.按位异或运算  ####\n");
	printf("##########################\n");
}
  • 用户功能编写

对于用户来说,一个计算器只需要能够输入数据和输出数据以及能够重复进行运算就可以了,因此我们可以通过循环语句和输入输出来完成:

//用户功能编写
int main()
{
	int input = 0;
	int x = 0;
	int y = 0;
	int ret = 0;
	int (*p[])(int, int) = { NULL,Add,Sub,Mul,Div,Bit_And,Bit_Or,Bit_Xor };
	do
	{
		menu();
		printf("请输入您想进行的运算序号>:");
		scanf("%d", &input);
		if (0 == input)
		{
			printf("正在退出,请稍后\n");
			Sleep(1000);//停留1秒
			system("cls");//清空屏幕
		}
		else if (1 <= input && input <= 7)
		{
			printf("请输入两个整数>:");
			scanf("%d%d", &x, &y);
			ret = p[input](x, y);
			printf("%d\n", ret);
		}
		else
		{
			printf("功能暂未开发,请重新输入\n");
		}
	} while (input);
	return 0;
}

在定义函数指针数组时,我们通过给首元素赋值空指针,达到一个占位的效果,这样我们在调用函数时就能够根据输入的数值来选择对应的函数进行调用了。

  • 效果演示
  1. 运算功能演示:

运算功能
可以看到,我们很好的对各个函数进行了调用;

  1. 报错功能演示:

报错功能
当我们输入0-7以外的数字时,系统会进行报错并让用户重新进行选择;

  1. 退出功能演示

退出功能
在输入0后,系统会先进行提示;

退出功能2
在等待1s后,系统会先清空窗口的内容,然后再退出程序;

现在咱们简易的计算器程序的编写就完成了,可以看到,与以往的对函数调用相比,我们通过函数指针数组进行中转调用函数时只需要一句代码通过输入值来确定需要调用的函数,这样就提高了代码的编写效率;

结语

【C语言必学知识点五】指针篇章的内容到这里咱们就全部介绍完了,这个篇章从23.12.06开始编写,耗时9天的时间,终于完成了全部内容的介绍:

结语
4w+的总字数,感觉都快写一本书了。这篇文章基本上是深度剖析了指针与数组的关系,我自己也在编写的过程中对这些知识点有了新的认知和理解,同时也希望这篇文章对大家在学习和复习指针的相关知识点时能够提供一点帮助和一些新的理解。

大家可以通过点赞、评论、关注、转发、收藏等方式来支持一下博主,最后感谢各位的翻阅,咱们下一篇文章再见!!!

文章来源:https://blog.csdn.net/2301_79458548/article/details/134210438
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。