前情提要
1. 数组与二维数组
数组是什么
数组是一种数据结构,用于存储相同类型的多个元素。每个元素都有一个唯一的索引,用于访问和操作该元素。数组可以是一维的,也可以是二维的。数组中的数据在内存中是连续存储的,每个元素占用相同大小的内存空间。
二维数组
二维数组是一种特殊的数组,它的每个元素都有两个索引(行和列),用于访问和操作该元素。二维数组可以看作是一个表格,每个元素都有一个行索引和一个列索引。二维数组中的数据在内存中也是连续存储的,每个元素占用相同大小的内存空间。
当然,也可以这样理解二维数组:二维数组是一个数组的数组,每个元素都是一个一维数组。每个一维数组都有自己的索引,用于访问和操作该数组中的元素。二维数组中的数据在内存中是连续存储的,每个元素占用相同大小的内存空间。
2. C语言函数的单个参数传递
C语言函数的单个参数传递是按值传递的,即函数接收的是参数的值(右值),而不是参数本身(左值)。在定义函数时,参数的类型可以是基本数据类型(如int、float等),也可以是指针类型,称为形参(formal parameter)。在调用函数时,需要传递与形参形式相同的参数,称为实参(actual parameter)。
基本语法示例如下:
int func(int a, int *b) // 函数的定义,包含两个形式参数a和b{ return a + *b; // 注意:b是一个指针,需要使用解引用运算符*来访问它指向的值}
int main(){ int a = 1, b = 2; int c = func(a, &b); // 调用函数func,将a和b的地址作为参数传递 // ······ return 0;}思考:那么数组作为数据的集合,在函数传参时,是否也按照按值传递的方式进行?
数组与指针
数组名与指针
数组名再部分场景可以看作是一个指向数组第一个元素的指针常量。假设我们定义了一个数组num[5], 那么num这个名字就可以看作是一个指向数组第一个元素的指针,即num == &num[0]。 这种在表达式中数组名表示数组首元素的情况被称为数组的退化(decay)。
当然在一些场景中并不会退化,例如:
- 作为sizeof的操作数:sizeof(num)计算的是整个数组的字节大小(如int num[5]的sizeof(num)是5*4=20),而如果是指针int *p = num,sizeof(p)只是指针本身的大小(32 位系统 4 字节,64 位系统 8 字节)。
- 作为&取地址符的操作数:&num的类型是指向整个数组的指针(如int (*)[5]),而非指向首元素的指针(int *)。虽然&num和num的数值地址相同,但类型和步长完全不同((&num)++会直接跳过整个数组)。
- 作为_Alignof(C11 起)的操作数:类似sizeof,针对整个数组的对齐属性。
- 字符串字面量初始化字符数组时:如char str[] = “hello”,这里的”hello”是数组初始化器,而非指针。
使用指针的方式读取数组元素
对于一个指针p而言,对p自增 p++,可以使指针指向下一个相同大小的内存空间,即指向数组的下一个元素。
前面提到,数组名相当于一个指向数组第一个元素的指针。因此,我们可以使用指针的方式来读取数组元素。
例如,假设我们定义了一个数组num[5], 那么我们可以使用指针p来读取数组元素,如下所示:
int num[5] = {1, 2, 3, 4, 5};int *p = num; // 指针p指向数组num的第一个元素printf("%d\n", *p); // 输出1p++; // 指针p指向数组num的第二个元素printf("%d\n", *p); // 输出2换句话说,num[i] 可以看作是 *(num + i),即指针num加上偏移量i,再解引用得到的结果。
但请注意,虽然C语言允许使用指针的方式来读取数组元素,但是不建议这样做,在实际编程中尽量使用数组下标来访问数组元素。因为使用指针来访问数组元素可能会导致指针越界访问,从而导致程序崩溃或安全问题。这里这样讲只是为了后文理解数组的函数传参问题。
二维数组与指针
前面提到,二维数组是一个数组的数组,每个元素都是一个一维数组。那么,对于二维数组来说,它的名字也可以看作是一个指向数组第一个元素(即第一个一维数组)的指针(行指针)。
以下内容提到的二维数组以 int num[3][4]为例。num 相当于 &num[0]。类型为 int (*)[4],即指向一个包含4个int元素的数组的指针。
注意,前面提到一维数组名可以看作是一个指向数组第一个元素的指针,可能有一下误解:二维数组名也可以看作是一个指向第一个一维数组的第一个元素指针的指针,即 int **。这种理解并不正确,二者是完全不同的类型。int (*)[4]一般用于表示二维数组的行指针,int **则可以用来表示指向一个指针数组中某元素的指针(对int类型取两次地址 int a; int *p = &a; int **dp = &p也可以得到 int **类型的二级指针,但对其进行逻运算会导致未定义行为)。
二者在使用上的确很相似,例如解两次指针解引用都可以访问到二维数组的元素,解一次引用都能得到int *类型的指针。但是,对 int (*)[4]解引用的理解应该是解一次引用首先得到的是一个一维数组,但在表达式中一维数组退化为指向首元素的指针,即 int *。但试图把二维数组行指针(int (*)[4])赋值给一个指向指针的指针(二级指针)(int **)是错误的。
int num[3][4] = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}};int (*p)[4] = num;int **q = p; // 错误:类型不兼容它们的最明显的不同之处在于指针进行逻辑运算时操作的步长不同。例如对 int (*)[4]类型的指针进行自增操作是,得到的是二维数组下一行的指针。int ** 可以用来指向一个一维指针数组的某个元素的地址,其自增操作是指向该指针数组的下一个元素的地址。可以结合以下代码来理解:
int num[3][4] = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}};int (*p)[4] = num; // 指针p指向二维数组num的第一个元素(即第一个一维数组)printf("%d\n", *(*p + 1)); // 输出2p++; // 指针p指向二维数组num的第二个元素(即第二个一维数组)printf("%d\n", *(*p + 2)); // 输出7
int num2[6] = {1, 1, 4, 5, 1, 4};int *ptr[6] = {num2, num2 + 1, num2 + 2, num2 + 3, num2 + 4, num2 + 5};int **q = ptr;printf("%d\n", *(*q + 1)); // 输出1q++printf("%d\n", *(*q + 2)); // 输出5- 前半部分
- 二维数组在内存里是连续的 “数组的数组”,行指针(
int (*p)[4])指向二维数组的某一行,步长是一整行(4 个 int)。 *(*p + 1):先解引用行指针得到第一行的起始地址,地址加 1 指向第一行第二个元素,解引用就得到值 2。- p++ 让行指针跳到第二行,
*(*p + 2)就是第二行起始地址加 2,指向第二行第三个元素,解引用得 7。
- 二维数组在内存里是连续的 “数组的数组”,行指针(
- 后半部分
- 指针数组(
int *ptr[6])的每个元素都是指向普通 int 数组的指针,二级指针(int **q)指向指针数组的元素,步长是一个指针的大小。 *(*q + 1):解引用二级指针得到指针数组第一个元素(指向普通数组第一个元素),地址加 1 指向普通数组第二个元素,解引用得 1。- q++ 让二级指针跳到指针数组第二个元素,
*(*q + 2)就是从普通数组第二个元素的地址再跳 2 步,指向普通数组第四个元素,解引用得 5。
- 指针数组(
小结
- 数组名不完全等价于指针,只是在多数表达式中退化为指向首元素的指针;
- 二维数组名退化后是行指针(
int (*)[n]),而非二级指针(int **); - 指针的步长由其类型决定。
数组与传参
一维数组的传参
前面已经提到,数组名在表达式中会退化为指向首元素的指针,因此一维数组名可以作为函数参数传递。其传递过去的实际上是指向首元素的指针。
void func(int *arr, int size) // 写法上*arr与arr[]是等价的,都表示指向int的指针而非一个数组{ for (int i = 0; i < size; i++) { printf("%d\n", arr[i]); }}
int main(){ int num[5] = {1, 2, 3, 4, 5}; func(num, 5); // 传递数组名num,实际传递的是指向num[0]的指针 return 0;}传参后的“数组”实际上是一个指向首元素的指针,只是在函数内部被当作数组来使用。因此,在函数内部可以使用数组下标来访问元素,相当于对指针进行了偏移。也就是说传参后,形参并不包含数组的长度信息,需要额外传递数组长度作为参数。sizeof(arr)实际打印的是一个int类型指针的大小。
二维数组的传参
二维数组名退化后是行指针(int (*)[n]),因此可以作为函数参数传递。其传递过去的实际上是指向首行的指针。
void func(int (*arr)[4], int row, int col) // (*arr)[4] == arr[][4]{ for (int i = 0; i < row; i++) { for (int j = 0; j < col; j++) { printf("%d\n", arr[i][j]); } }
//也可以使用指针法遍历 int *p = *arr; // p指向首行首元素 for (int i = 0; i < row * col; i++) { printf("%d\n", *p++); }}
int main(){ int num[3][4] = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}}; func(num, 3, 4); // 传递数组名num,实际传递的是指向num[0]的指针 return 0;}由于数组的每个元素大小都应该是确定的,而二维数组又可以看作是一维数组的数组,也就是说,直接传入二维数组的名字,在形参中列信息(每个一维数组长度)必须是确定的。那么假如我们要传入一个未知列数的二维数组,该怎么办呢?
例如下面一条代码同一个函数同时可以实现 2*2和 3*3的矩阵求和
int sum(int *num, int row, int col){ int s = 0; for(int i = 0; i < row * col; i++) { s += num[i]; // 对指针进行偏移,相当于访问二维数组的元素 // 等价于 s += (*(num + i)); } return s;}
int main(){ int m, n; int matrix1[2][2] = {{1, 2}, {3, 4}}; int matrix2[3][3] = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}}; printf("%d\n", sum(&matrix1[0][0], 2, 2)); printf("%d\n", sum(&matrix2[0][0], 3, 3)); return 0;}小结
从上文的描述不难看出,使用数组名字(或指针)传参时,函数内对数组元素的值的修改会直接影响原始数组,例如
void func(int *arr, int size){ for (int i = 0; i < size; i++) { arr[i] *= 2; // 对指针进行偏移,相当于访问数组的元素 }}
int main(){ int num[5] = {1, 2, 3, 4, 5}; func(num, 5); // 传递数组名num,实际传递的是指向num[0]的指针 for (int i = 0; i < 5; i++) { printf("%d\n", num[i]); // 输出2 4 6 8 10 } return 0;}总结
-
传参的本质
- 数组作为函数参数传递时,传递的不是整个数组,而是指向其首元素的指针。
- 函数内对数组元素的修改会直接影响原始数组(共享内存)。
-
一维数组传参
- 形参
int arr[]和int *arr完全等价,都是指针。 - 函数内无法用
sizeof(arr)获取数组长度,必须额外传递长度参数。
- 形参
-
二维数组传参
- 二维数组名是行指针,类型为
int (*)[列数],不是二级指针int **。 - 形参必须指定列数(如
int arr[][4]),因为编译器需要知道每行的步长。 - 处理动态行列的二维数组时,可传入首元素地址(
&arr[0][0])并按一维数组计算偏移。
- 二维数组名是行指针,类型为
-
关键注意事项
- 数组名的退化:在多数表达式中,数组名退化为指针,但作为 sizeof 或 & 的操作数时代表整个数组。
- 指针步长由类型决定:
int *步长是一个 int,int (*)[4]步长是一行(4个 int),int **步长是一个指针。 - 避免指针与数组的误用:理解类型差异,防止越界访问和类型不匹配的错误。