探究C语言数组的奥秘:大小可省略的定义、内存存储、数组名、传参、指针遍历、数组指针和指针数组、柔性数组等

探究C语言数组的奥秘:大小可省略的定义、内存存储、数组名、传参、指针遍历、数组指针和指针数组、柔性数组等_第1张图片
也许你认为,C语言中的数组非常好理解,就是把一组相同类型的元素存储在同一块空间里。但是你可能并没有真正理解数组的本质,不信的话请回答一下下面的几个小问题,如果你能非常清晰的回答这些问题,那么你对C语言中的数组的理解就入门了。

  1. 一维数组和二维数组在定义时,哪些大小可以省略,哪些不可以省略?如果可以省略,在什么时候是可以省略的呢?
  2. 一维数组和二维数组在内存中是如何存储的?
  3. 一维数组和二维数组的数组名分别表示什么意思?
  4. sizeof(数组名) 和 &数组名 分别表示什么?
  5. 一维数组和二维数组传参时,形参应该如何写?
  6. 数组和指针有什么联系?有什么区别?
  7. 如何使用指针来遍历一维数组和二维数组?
  8. 如何理解数组指针和指针数组?
  9. 什么是柔性数组?

如果你想了解这些内容,那就花点时间阅读下本篇博客吧,希望你能有所收获。

什么是数组

数组,指的是相同类型的元素的集合,比如,想要存储10个整数,除了定义10个int类型的变量,也可以定义一个数组来存储。

数组分为一维数组和二维数组,三维以上的数组就不常见了,所以本篇博客重点讨论一维数组和二维数组。

所谓一维数组,就是很正常的把一些元素放到一起。比如存储1~10,就这么存储:[1 2 3 4 5 6 7 8 9 10]。

而二维数组是有行和列的,比如,用一个3行5列的二维数组来存储1~15,可以这么理解:

探究C语言数组的奥秘:大小可省略的定义、内存存储、数组名、传参、指针遍历、数组指针和指针数组、柔性数组等_第2张图片

数组的定义和初始化

数组的定义和初始化是要按照指定的个数来的。

对于一维数组,只需要表示出数组名、数组容量和数组元素类型即可。比如,一个能存储10个int类型的数组就应该这么定义:int arr[10];

这样的数组如果是一个局部的数组,也就是在大括号内定义的话,默认会被初始化为随机值。如果你想要手动初始化,比如把1~10放进去,可以这么写:

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

如果手动进行初始化,可以省略数组的元素个数,编译器会根据你初始化的元素个数来给数组开辟空间。

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

以上代码中,虽然我把表示数组大小的方括号里的10给省略了,编译器也会开辟一个能够存储10个int数据的数组,因为我手动给这个数组初始化了。

当然,我也可以只给数组的部分元素初始化,比如:

int arr[10] = {1, 2, 3};

如果像上面这么写,这个数组就只会初始化前3个元素为1~3,剩下的7个元素会被默认初始化成0,所以数组实际存储的是:[1 2 3 0 0 0 0 0 0 0]。

一个很常见的写法是,把整个数组都初始化成全0。

int arr[10] = {0};

根据我们对一位数组不完全初始化的理解,如果像上面这么写,就只把第一个元素初始化为0,其余9个元素会被默认初始化为0,其实效果就是把整个数组的所有元素都初始化为0。

讲完了一维数组,那二维数组呢?

二维数组的定义需要指定行数和列数,比如一个3行5列的数组应该这么定义:int arr[3][5];

如果要对其进行初始化,比如初始化为:第一行1~5,第二行6~10,第三行11~15,就这么写:

int arr[3][5] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15};

效果可以理解成:
探究C语言数组的奥秘:大小可省略的定义、内存存储、数组名、传参、指针遍历、数组指针和指针数组、柔性数组等_第3张图片
也可以把每一行也用大括号括起来,这样更加清晰:

int arr[3][5] = { {1, 2, 3, 4, 5}, {6, 7, 8, 9, 10}, {11, 12, 13, 14, 15} };

建议再换个行:

int arr[3][5] = { {1, 2, 3, 4, 5}, 
                  {6, 7, 8, 9, 10}, 
                  {11, 12, 13, 14, 15} };

那能不能像一维数组那样省略大小呢?答案是:行可以省略,列不能省略!也就是说,可以省略行,写成这样:

int arr[][5] = { {1, 2, 3, 4, 5}, 
                 {6, 7, 8, 9, 10}, 
                 {11, 12, 13, 14, 15} };

编译器看到后,就会理解成,由于你放了3行,所以行数就是3。

同样,也可以进行不完全初始化,比如:

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

由于只放了2行,第三行就会被默认初始化为0,也就是这样的布局:
探究C语言数组的奥秘:大小可省略的定义、内存存储、数组名、传参、指针遍历、数组指针和指针数组、柔性数组等_第4张图片
当然每一行也可以不完全初始化,比如:

int arr[3][5] = { {1, 2, 3}, 
                  {6, 7} };

效果就是,第一行前3个元素初始化为1~3,后2个元素初始化为0,第二行的前2个元素初始化为6和7,后3个元素初始化为0,第三行全部初始化为0。
探究C语言数组的奥秘:大小可省略的定义、内存存储、数组名、传参、指针遍历、数组指针和指针数组、柔性数组等_第5张图片
当然,如果内层的大括号省略掉,也有类似的效果,不过会一行一行放,放满一行再放下一行,比如:

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

会先把第一行放满,第一行就是1~5,第二行前2个元素就是6和7,后3个元素初始化为0,第三行所有元素都是0。
探究C语言数组的奥秘:大小可省略的定义、内存存储、数组名、传参、指针遍历、数组指针和指针数组、柔性数组等_第6张图片
和一维数组一样,如果想把所有元素都初始化为0,就这么写:

int arr[3][5] = {0};

这样就只放了一个0,其余元素都被初始化为0,就相当于把整个数组的所有元素都初始化为0。

数组在内存中的存储

数组在内存中的存储满足以下2点:

  1. 数组在内存中是连续存储的。
  2. 随着数组下标的增长,地址是由低到高变化的。

先举一个一维数组的例子。写一个程序,把一维数组的每一项的地址都打印出来:

#include 

int main()
{
	int arr[5];
	for (int i = 0; i < 5; ++i)
	{
		printf("&arr[%d] = %p\n", i, &arr[i]);
	}

	return 0;
}

输出结果如下:

探究C语言数组的奥秘:大小可省略的定义、内存存储、数组名、传参、指针遍历、数组指针和指针数组、柔性数组等_第7张图片
观察到,数组的相邻2项之间的地址都相差了4个字节。为啥是4个字节呢?因为每个int是4个字节,由于数组在内存中是连续存储的,相邻两项的地址自然隔了4个字节。除此之外,随着数组下标从0增长到4,地址也是从低到高变化的。

再举一个二维数组的例子:

#include 

int main()
{
	int arr[3][5];
	for (int i = 0; i < 3; ++i)
	{
		for (int j = 0; j < 5; ++j)
		{
			printf("&arr[%d][%d] = %p\n", i, j, &arr[i][j]);
		}
	}

	return 0;
}

输出结果如下:
探究C语言数组的奥秘:大小可省略的定义、内存存储、数组名、传参、指针遍历、数组指针和指针数组、柔性数组等_第8张图片
相邻2项的地址仍然间隔4个字节,且随着下标的增长,地址也在增长,这说明数组的内存分布规律同样适用于二维数组。

神奇的数组名

先说结论。在C语言中,数组名表示数组首元素地址,但是有2个例外:

  1. sizeof(数组名),数组名直接放在sizeof()内部,此时数组名不表示首元素地址,而是表示整个数组,求的是整个数组的大小,单位是字节。
  2. &数组名,在&符号后面的数组名,此时数组名不表示首元素地址,而是表示整个数组,取出的是整个数组的地址。详见后面讲解的“数组指针”。

关于sizeof,大家应该很熟悉了,可以计算类型的大小。如果想要计算数组的大小,就sizeof(数组名)即可。比如:

int arr[10];
printf("%d\n", sizeof(arr)); // 40

以上代码中,由于数组有10个元素,每个元素是int,数组总大小是40个字节,所以会输出40。

下面来对比一下3个代码:

int arr[10];

printf("arr = %p\n", arr);
printf("&arr[0] = %p\n", &arr[0]);
printf("&arr = %p\n", &arr);

其实3个玩意打印出来结果是一样的。但是我们要理解它们的本质:

  1. arr是数组名,数组名表示数组首元素地址,也就是arr[0]的地址。
  2. &arr[0],就是数组首元素的地址。
  3. &arr,数组名表示整个数组,取出的是整个数组的地址。

“整个数组的地址”和“数组首元素地址”在值上是一样的。但是如果+1,或者解引用,效果是不一样的,这就要牵扯对指针的理解了。指针类型决定了指针+1时跳过的步长。比如一个int*的指针+1会跳过4个字节,也就是跳过一个int,而一个char*的指针+1会跳过1个字节,也就是跳过了一个char。

在上面的例子中,arr和&arr[0]都表示数组首元素的地址,也就是一个int的地址,类型是int*,+1后会跳过一个int,也就是跳过4个字节。而&arr表示整个数组的地址,如果+1,会跳过整个数组。观察一下以下程序:

#include 

int main()
{
	int arr[10];

	printf("        arr = %p\n", arr);
	printf("    arr + 1 = %p\n", arr + 1);
	printf("    &arr[0] = %p\n", &arr[0]);
	printf("&arr[0] + 1 = %p\n", &arr[0] + 1);
	printf("       &arr = %p\n", &arr);
	printf("   &arr + 1 = %p\n", &arr + 1);

	return 0;
}

输出结果如下:
探究C语言数组的奥秘:大小可省略的定义、内存存储、数组名、传参、指针遍历、数组指针和指针数组、柔性数组等_第9张图片
观察到确实是这样的。arr+1和&arr[0]+1都跳过4个字节,而&arr+1跳过了16进制的0x28,也就是10进制的40个字节。

那二维数组的数组名表示什么呢?实际上,二维数组的数组名也表示首元素的地址,而二维数组的首元素是第一行,也就是说,二维数组的数组名表示第一行的地址。比如一个二维数组是int arr[3][5];,数组名arr表示的是第一行的地址,也就是一个容量是5个int的一维数组的地址,类型就是int (*)[5]。

数组和指针的区别和联系

数组和指针有什么区别呢?这个问题其实很奇怪,因为这2个玩意完全就不是一个东西。数组是一组相同类型的元素的集合,而指针是用来存储地址的,它们八竿子打不着。但是确实存在着一个关联,那就是前面讲解到的:数组名表示数组首元素的地址。根据这一点,就可以拿到一个指向数组首元素的指针。又由于数组在内存中是连续存放的,就可以通过这个指针来遍历这个数组。比如:

#include 

int main()
{
	int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
	int* p = arr;
	while (p < arr + 10)
	{
		printf("%d ", *p++);
	}

	return 0;
}

输出结果如下:
探究C语言数组的奥秘:大小可省略的定义、内存存储、数组名、传参、指针遍历、数组指针和指针数组、柔性数组等_第10张图片

数组传参

有些时候,我们要把数组作为实参,传递给函数,函数的形参应该如何写呢?

先看一维数组传参。假设有这样的场景:

int arr[10];
test(arr);

此时函数test的形参应该如何写呢?

  1. 我们传过去了一个数组,自然可以用一个数组来接收:void test(int arr[10]);
  2. 数组的大小可以省略,而且建议省略:void test(int arr[]);
  3. 由于数组名表示首元素地址,我们本质上是传过去了一个地址,所以应该用一个指针来接收:void test(int* arr);
  4. 既然传过去的是地址,那前2种写法中,数组的大小重要吗?一点都不重要,所以你哪怕乱写其实也不会出问题,比如:void test(int arr[1000]);

接着看二维数组传参:

int arr[3][5];
test(arr);

此时test的形参应该如何写呢?

  1. 我们传过去了一个数组,自然可以用一个数组来接收:void test(int arr[3][5]);
  2. 注意:二维数组的行可以省略,但是列不能省略!比如:void test(int arr[][5]);
  3. 数组名表示首元素地址,二维数组的数组名表示第一行的地址,也就是一个容量为5个int的数组的地址,类型是int (*)[5],形参可以这么写:void test(int (*arr)[5]);
  4. 既然传过去的是地址,那前2种写法中,数组的大小重要吗?一点都不重要,所以你哪怕乱写其实也不会出问题,但是在二维数组中,列是很重要的,必须写对,但是行随便。比如:void test(int arr[1000][5]);

指针数组和数组指针

指针数组,本质是数组,只不过存放的是指针。比如:int* arr[10];就是一个指针数组,能存储10个int*类型的指针。

数组指针,本质是指针,只不过是指向数组的指针,存放的是数组的地址。详见我之前写过的一篇博客,戳这里跳转。

柔性数组

柔性数组是结构体内的大小可以变化的数组,这个知识点和动态内存管理也有联系,这里展开讲解就太长了。还好我之前写了一篇博客来讲解这个知识点,戳这里跳转。

总结

现在我们可以回答开头的问题了。

  1. 一维数组和二维数组在定义时,哪些大小可以省略,哪些不可以省略?如果可以省略,在什么时候是可以省略的呢?一维数组如果初始化,可以不指定大小,编译器会根据初始化的情况来给数组开辟空间。二维数组如果初始化,行可以省略,但是列不能省略,编译器会根据初始化的情况来决定有几行。
  2. 一维数组和二维数组在内存中是如何存储的?数组在内存中是连续存放的,随着数组下标的增长,地址是由低到高变化的,这点对一维数组和二维数组都适用。
  3. 一维数组和二维数组的数组名分别表示什么意思?数组名表示首元素地址,但是有2个例外:sizeof(数组名),&数组名,数组名都表示整个数组。除此之外,数组名都表示首元素地址,其中二维数组的“首元素”指的是第一行。
  4. sizeof(数组名) 和 &数组名 分别表示什么?sizeof(数组名)求的是整个数组的大小,&数组名取出来的是整个数组的地址。
  5. 一维数组和二维数组传参时,形参应该如何写?可以用数组接收,一维数组的大小可以省略,二维数组的行可以省略,列不能省略。对于省略的大小,其实乱写也是符合语法的,但是不建议。除此之外,根据“数组名表示数组首元素地址”这个知识点,也可以用指针接收。
  6. 数组和指针有什么联系?有什么区别?数组是一组相同类型元素的集合,指针是存储地址的。它们之间被“数组名”这个桥梁关联起来,因为数组名表示数组首元素地址。
  7. 如何使用指针来遍历一维数组和二维数组?根据数组在内存中是连续存储的,只要拿到首元素地址,就能遍历整个数组。
  8. 如何理解数组指针和指针数组?数组指针是一个指针,存储的是数组的地址。指针数组是一个数组,存储的元素类型是指针。
  9. 什么是柔性数组?柔性数组是结构体内的最后一个成员数组,且大小可以变化(大小不确定)的。管理柔性数组,要使用动态内存管理的方式。

感谢大家的阅读!

你可能感兴趣的:(C语言,c语言,算法,数据结构,数组,柔性数组)