细谈C语言指针第三篇目——数组及数组和指针联系

C语言里面为用户存储一系列数据的需求,提供了一种数据结构,这种数据结构就是数组,那数组具体是怎么一回事,以及它和我们正在学习的指针有什么关系呢,我想这篇博客可以加深你对于这两者之间的理解和区分。

一、什么是数组:

首先我们先来给大家简单介绍一下C语言中数组的概念及其周边知识:

关于数组的定义,官方给出的解释是:一组相同类型元素的集合。“一组”说明了数组可以一次性定义和存储一系列数据元素,“相同类型”则揭示了数组元素在数据类型上的统一性原则。其定义的格式为:类型 数组名[数组大小];

如图所示:

int arr[10];  //这是一个int类型的数组,数组可以存放10个元素,每个元素是int类型。
float farr[5];//这是一个float类型的数组,数组可以存放5个元素,每个元素是float类型。

这里的数组的大小就是数组能存储数据元素的多少。当一个数组大小n给定时,它的有效检索下标范围(通过下标可以对数组中对应位置的元素进行访问)也就给定了,在[0,n-1]范围之间。作为一名合格的开发者,不能对数组下标小于0,大于n-1的内存空间进行访问,这块空间往往不属于我们用户,如果你去访问的话就会造成非法访问的问题。

但是值得注意的是我们的,如果你有一天真的去对一个数组越界访问了,编译器会提醒你吗?答案是:你不去进行写的操作,仅仅是进行读操作,它一般是不会给你报错的。

但是,直到有一天我在无意中接触到到这样一个代码:

#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>

int main()
{
	int i = 0;
	int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
	for (i = 0; i <= 12; i++)
	{
		arr[i] = 0;
		printf("hehe\n");
	}
	return 0;
}

在这个代码里面,作者对数组arr越界访问了三个元素,并且进行了写的操作,这没有问题,按照常理而言编译器给你报错就是了,但是当你实际去运行这个代码时,它运行出来的结果可能是我下面的这个,这足以颠覆在座各位关于数组的正常认知观:

它没有报错,而且还死循环地打印着hehe……

为什么会这样呢,实际上当你去调试地时候,你很快就会发现问题所在(如图所示):我们的arr[12]的地址和变量i的地址,在Visual Studio这款IDE上竟然是一模一样的。

调试信息:

换句话说,它们实际上是占用了内存中同一块空间,所以你在循环体中表面上对arr[12]做的一切操作,实际上是对变量i做的修改,所以也就不难理解死循环的问题了。

那为什么这时的越界访问,编译器没有报错呢,我想答案是:程序在忙着死循环呢,所以这时即便你去越界访问了,编译器也没有功夫给你报错。而这个时候,假如你是一个新手,一个程序小白,对于程序的调试手段还是懵懵懂懂的话,你可能根本就找不出问题出现在哪里。所以说,我们一定不要去试图越界访问一些不属于我们的空间,这是非常危险的行为!

另外关于数组的大小我们还需要额外注意以下两点:

  • C99(于1999年推出的C语言标准)之前数组大小只能使用常量来指定。这也是目前主流C编译器(如Visual Studio,Dev-c++等)所遵循的标准;
  • C99之后,引用了变长数组的概念,允许我们的用户在使用数组时使用变量来指定其大小。很多的在线OJ刷题平台——如牛客网,Letcode所遵循的标准。

上述两条标准,我们看碟下菜,同时从自己的实际需求出发遵循和选择不同的标准就可以了,这个不是很重要。

二、数组的初始化:

接下来,我们来和大家谈一谈数组初始化的问题,数组的初始化我们打算从以下三个维度给大家进行解读:分别是完全初始化和不完全初始化的角度,指定大小的初始化角度和不指定大小初始化的角度,还有字符数组的初始化:

I. 完全初始化和不完全初始化:

我们在定义一个数组的时候,就可以对一个数组初始化了:我们可以一次性对数组中所有的元素进行初始化,这种初始化的方式我们称之为完全初始化。反之,如果用户在初始化时只针对数组的一部分元素进行了初始化,那么我们把这种初始化的方式相对应地称之为不完全初始化。

如图所示:

//arr1数组的初始化就是一种完全的初始化:
int arr1[10]={1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

//arr2数组的初始化就是一种不完全的初始化:
int arr2[10]={1, 2, 3, 4, 5};

完全初始化没问题,数组中元素地值就是我们所指定地那个,那不完全初始化呢,没有被用户指定值的数组元素得到了什么值,是随机值吗?其实吧:绝大多数的编译器对于用户没有指定值的数组元素都会默认初始化为0的。大家也可以在自己的编译器上去试一试哦!

II. 指定大小的初始化和不指定大小的初始化:

上面例子里面arr1和arr2都是指定了大小的初始化,这里的指定大小指的是指定数组的大小。但是其实如果你在定义数组的时候就进行初始化的话,C语言是允许你不指定数组大小的,这个时候数组的大小系统会根据你初始化元素的个数来确定数组元素的大小。

如图所示:

//这种定义数组和初始化的方式在C语言中是被允许的:
int arr[]={1, 2, 3, 4, 5, 6, 7, 8}//这个时候数组的大小就是8。

所以,不指定大小的初始化一定是一种完全初始化。

最后一个问题,如果我在定义一个数组的时候既不去指定数组的大小,也不去对数组中的元素进行初始化会怎么样?答案是:这种做法是错误,是C所不允许的。因为我们在创建一个变量或一个数组时,这种做法的本质是在向内存申请空间。这是我们学习计算机语言中一条很重要的认知概念,只要你有这点认识,你也就不难理解为什么上述那种做法是错误的原因了:即你既没有给我一个数组的大小,也没有给出这个数组元素有哪些,那么我怎么去向内存申请空间啊,申请多少空间啊,这是不确定的,所以不被允许这种行为。

III. 字符数组的初始化:

字符数组是数组大家族里面一个很特殊的成员,对于它的初始化,常见的有以下两种:

//字符数组的初始化:为了定义的方便,字符数组在初始化时往往采用不指定大小的初始化
//方式一:
char str1[]={'h', 'e', 'l', 'l', 'o', ',', 'w', 'o', 'r', 'l', 'd'};
//方式二:
char str2[]="hello,world";

欸,你会发现,对于一个字符数组,你既可以一个一个元素的初始化,也可以直接一点,拿一个字符串整体给到字符数组进行保存。

现在我的问题,上面定义出来的str1和str2的内容是一样的吗?——答案是不一样的。

这里你如果用printf函数去分别输出两个字符数组的内容到我们的终端上,你会发现str2它就端端正正的帮你把"hello,world"的内容,但是我们str1后面还打印了一些我们看不懂的玩意,什么“烫烫烫烫”的东西……

实际上问题出现在str2比str1还多保存了一个转义字符——'\0'。这个'\0’是我们C语言中字符串的结束标志,当你用双引号圈起一个字符串的时候,编译器它也就自动地帮你把这个'\0'给带上了。所以在当你在打印一个字符数组的时候,它会一直帮你把'\0'之前的内容全部给你打印出来。于是对于str2来说这些内容就是"hello world"了,但是对于str1的那种定义方式而言,它是不会给你自动加'\0'的,所以'\0'到底在哪呢,那也就只有计算机它自己知道了,所以你也就看到了它竟然给了你一些乱码的东西。

另外还有什么方式可以来验证这个想法呢?其实大家还可以去分别去计算一下str1和str2的数组长度,使用语句公式:sizeof(数组名)/sizeof(数组中任意的一个元素或其类型)。通过这种方式你去看一下str1和str2各自的长度,你会发现str1比str2小1,少的这个元素毫无疑问就是'\0'(一个转义字符占据一个数组大小的空间)。

三、数组元素的存储特点:

关于数组中元素在内存中是如何存储的,C语言标准给出的答案是:数组是一种相同类型元素的集合,它的元素在内存中是连续存储的。这句话是什么意思呢?我们不妨来看一下下面这段代码:

我们这里定义了是个元素的数组,并且printf打印了数组中每个元素它的地址(其中的%p是以十六进制形式打印数据)。仔细观察你不难发现,这些个地址编号从低到高呈现出有规律的递增变化,而且通过计算不难得知每次递增的数字是4,十六进制的4也就是十进制的4,所以地址编号随着数组下标的增大在逐渐增大,且数组下标增加1地址编号增大4。

在内存中每一个字节其所占据的内存空间,都有它唯一的地址编号作为标识。据此上面的现象和结论还可以抽象成另一层含义进行理解:即相邻两个数组元素之间相差4个字节的空间,而这恰好等于数组一个元素的大小,即一个int数据类型变量的大小。

于是我们可以用下图来形象生动地表示出数组元素在内存中的存储特点:

图中特别指明了一维数组在内存中的存储,平时我们直接int arr[10]都是在定义一个一维数组,显然上图揭示和说明一维数组在内存中是线性存储的,它的内存布局是连续的

但是其实吧C语言还给出了二维数组的定义,即int arr[3][4]就是在定义一个int类型的二维数组,它给人的感觉就是一种3行4列的平面二维结构,但是可以告诉大家的是二维数组它在内存中存储却不呈现平面结构,也是一种连续的线性结构,如图所示:

于是对于二维数组arr[3][4]我们正确的描述应该是这样的:数组有3个元素,每个元素是大小为4的一维数组,二维数组的存储也是一种连续的线性存储。据此,你能不能也用上述图画的方式形象地表述出二维数组在内存中的存储方式呢?哈哈,试试吧😘!

好的,既然你现在已经对数组在内存中的存储有了一些认知,博主现在提个问题,你能不能从计算机内存的角度来给我替《第一节:什么是数组》里面的那个死循环代码做个解释,实际上啊,那个故事深究起来是这么一回事:

这其中除了涉及了数组存储的知识,还要涉及到内存使用习惯的问题。不知道大家有没有印象,博主在《数据在计算机中的存储方式(二)》中,有提到一个概念说:我们的内存在为局部变量申请和开辟空间时,是习惯优先使用高地址的,即:先创建的变量它的地址编号比后创建的地址编号要高。而数组是随着下标的增大,地址编号在不断增大的。所以当你在数组越界之后,继续去增大它的下标去访问它后面的地址,就难免会访问到我们在这之前创建的变量的地址,并无意之中对它造成人为的修改,而产生一些难以预料的结果。

四、数组是指针,指针是数组?

作为初学者很容易把数组和指针这两个概念搞混,以为一个数组的数组名是一个指针,亦或者以为定义了一个指针就是在定义一个数组。但是实际上上述两种观点都是对数组和指针的错误理解。数组是数组,指针是指针,这是两个完全不同的概念,我们通过一些现象来验证我们的观点的:

现象一:两个的大小不同:

你看虽然指针pa里面放着数组arr,但是arr的大小有(4x10=40)Byte,作为指针的pa却只有4个字节的大小。两个内存大小都不一样的东西你能够等同起来吗?

现象二:其次如果你说数组名是指针,它能不能像指针变量那样来自由地存储一个变量的地址呢,答案是否定的:(下面这种写法是错误的)

种种现象都无不在揭示着数组和指针是不能等同而论的,于是有小伙伴可能就要疑惑了,说那数组名到底是个什么玩意,怎样去理解才算是正确的理解方式呢。其实啊,关于数组名是什么?我想先带大家来看一段代码:

通过上图你会发现:&arr[0]也就是取出数组首元素的地址,它的结果,和我们直接去打印数组名arr的结果是一样的。难道数组名就是数组首元素的地址?我想为了说明这个事实,这个代码还不具有说服力,我们接着再来看下面这段代码:

在这段代码里面,我们先来看前面四组数据,你会发现我们的数组名加一和取出首元素的地址再加一,它们两个的结果是一样的。嗯,那好吧我们懂了:数组名就是首元素的地址。

而其实我想说,到这里其实我们的理论体系还不够完善:如果说我们的数组名就是数组首元素的地址,那为什么我之前sizeof(arr)拿到的结果是40,是地址就应该是4/8个字节啊。而且后面还有一组测试&arr,这又是个什么东西,为什么对它加一和前面两个的结果有那么大的差别。

好,首先我们先来解决sizeof(arr)结果为什么是40的问题:实际上是这样的,第一我们的数组是int,意味着数组中每个元素需要4个字节的空间;第二我们的数组大小是10,而要把10个元素的int类型的数组存储起来我们需要4x10=40个字节的空间,所以sizeof(数组名)得到的结果是40,显然这里的数组名表示的是整个数组。

其次,我们再来结果&arr的问题:虽然&arr和arr,&arr[0]在初始值上一样的,但是加一之后后者,也就是后面两组的数据:它们均由原先的007CFDAC变成了我们的007CFDB0,这中间跨过了四个地址编号,也就是跨过了四个字节的空间,这显然这是我们数组中一个元素的大小是一致的。

然后我们再来看一看前者的变化:前者由最开始的007CFDAC变成了地址编号为007CFDD4,你计算一下两个地址编号之间的差距:你会发现结果是28,十六进制的28,也就是十进制的40,所以两个地址编号之间相差了整整一个数组的大小。显然这里的数组名,不是数组首元素的地址,而是表示整个数组。

于是关于C语言中的数组名,我们可以有以下关于它的论述:

  • 绝大多数情况下,数组名是数组首元素的地址;
  • 但有两个例外,一个是&数组名和sizeof(数组名),这两个场景中的数组名表示的是整个数组。

我想这才是关于数组名的正确认识。

五、数组的传参:

由前面的论述,我们已然清楚数组名本质上是数组首元素的地址,数组传参传过去的是数组名,也就是数组首元素的地址。所以数组传参的本质是指针。

也正是因为这里的数组名是数组首元素的地址,所以数组传参时形参部分可以写成指针的形式,用一个指针去接受一个首元素的地址,如图所示:

void test(int *arr)
{
    //函数体
}

好的,这没有问题,天经地义是吧。但是,我们在平时学习中,也会看到数组传参形参的部分写成数组的形式,OK,那这也是没有问题的,如图所示:

void test(int arr[])
{
    //函数体
}

但是,请记住:即便你把形参部分写成数组形式也好,写成指针的形式也罢,都可以,但是它们的本质都是指针!

正是因为如此,所以下面有个小明同学,他写了一个冒泡排序,让你来评价一下,这个冒泡排序有没有问题呢,如果有,那问题出现在哪里呢:

void bubble_sort(int arr[])
{
	int i = 0;
	int j = 0;
	int len = sizeof(arr) / sizeof(int);
	for (i = 0; i < len - 1; i++)
	{
		for (j = 0; j < len - 1 - i; j++)
		{
			if (arr[j] > arr[j + 1])
			{
				int tmp = arr[j];
				arr[j] = arr[j + 1];
				arr[j + 1] = tmp;
			}
		}
	}
}

显然这个程序在代码的第五行是存在问题的:通过我们前面的论述,无论你形参怎么写都好,只要是数组传参,我们的传过去的就是数组首元素的地址,本质还是指针,所以,是指针,通过sizeof计算的结果就只能是4/8个字节,你能正确反映出数组本身的大小吗?是不可以的。既然不可以,也就无法正确计算出数组中元素的个数。后面也就无法正常地排序。

所以对于这个冒泡排序,正确的形参设置方式,应该是下面这种:

void bubble(int arr[], int len)
{
    //函数体
}

六、数组下标访问的本质:

最后我们再来探讨最后一个问题:数组通过下标来定位和访问它其中的元素,其本质是什么?

实际上,数组通过下标来检索元素的本质:是让数组名往其后遍历下标i个长度,找到对应元素在数组内存中的地址编号,而后对它进行解引用操作。即下面两行代码在本质上是一模一样的,如图所示:

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

最后,有些同学会想我加法是满足交换律的啊,所以*(arr+1)=*(i+arr),*(arr+1)它可以写成arr[1],那*(i+arr)可不可以写成i[arr]呢,答案是可以的!如图所示:

这是个非常有趣的现象是把哈哈😂,你如果不了解数组下标访问的本质,你能理解这样的代码吗?答案显然是困难的。

好的这期关于指针的知识我们分享到这,我们下期再见!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值