深入理解 C 语言指针(二):数组与指针的深度绑定
在上一篇《深入理解C指针(一)》中,我们理解了内存与地址的关系,掌握了指针变量的声明与使用,学会了通过解引用操作内存,并最终用传址调用解决了函数间数据交换的核心难题。
然而,指针的运用远不止于此。在实际编程中,我们更需要处理的是数据集合而非单一变量。数组作为最基本的数据集合,与指针有着千丝万缕、密不可分的关系。你是否曾对以下问题感到困惑:
- 为什么
arr
和&arr[0]
的值相同,但sizeof(arr)
却不像一个指针? - 当把数组传递给函数时,为什么在函数内部无法用
sizeof
计算出数组的真实长度? - 除了指向普通变量,指针能否指向另一个指针?这种“套娃”式的指针又有什么用?
- 如果说指针数组是“存放指针的数组”,那它又能为我们解决什么实际问题?
在本篇《深入理解指针(二)》中,我们将探讨这些实用的话题。我们将从数组名的本质出发,揭示其与指针的深厚渊源;学习如何使用指针高效地访问和操作数组;剖析一维数组传参的底层真相;并最终迈向二级指针与指针数组的广阔世界,甚至用它们来模拟二维数组。
文章目录
- 1. 数组名的理解
- 2. 使用指针访问数组
- 2.1 指针访问数组的基本方法
- 2.2 两种方式的对比与选择
- 3. 一维数组传参的本质
- 4. 冒泡排序
- 4.1 概念和思想
- 4.2 基础实现:双循环结构
- 4.3 优化冒泡排序
- 4.4 总结
- 5. 二级指针
- 5.1 为什么需要二级指针?
- 5.2 二级指针的内存模型
- 5.3 二级指针的运算:解引用的层次
- 6. 指针数组
- 7. 指针数组模拟二维数组
- 总结
1. 数组名的理解
在我们之前的指针学习中,要获取数组第一个元素的地址,我们会这样写:
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
int *p = &arr[0];
但是如果我们直接打印 &arr[0]
和 arr
的值,会发现这样的情况:
#include <stdio.h>
int main() {int arr[10] = {1,2,3,4,5,6,7,8,9,10};printf("&arr[0] = %p\n", &arr[0]);printf("arr = %p\n", arr);return 0;
}
输出结果:
&arr[0]
和arr
的值一模一样。这就可以证明我们的第一个核心结论:
数组名在绝大多数情况下,代表的是数组首元素的地址。
既然数组名是地址,那么下面的代码为什么输出结果是 40
(假设int
为4字节,10个元素总大小为40字节),而不是 4
或 8
(一个指针变量的大小)呢?
#include <stdio.h>
int main()
{int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };printf("%d\n", sizeof(arr));return 0;
}
输出结果:
其实数组名就是数组首元素(第⼀个元素)的地址是对的,但是有两个例外:
sizeof(数组名)
:- 当数组名单独放在
sizeof
操作符内部时,它代表的不是首元素的地址,而是整个数组。 - 此时,
sizeof(arr)
计算的是整个数组所占用的总字节大小。
- 当数组名单独放在
&数组名
:- 当对数组名使用取地址符
&
时,它代表的是整个数组的地址。 - 这里必须区分“首元素的地址”和“数组的地址”。尽管它们的值相同,但含义和操作后的结果截然不同。
- 当对数组名使用取地址符
除此之外,任何地方使用数组名,数组名都表示首元素的地址。
理解这两个例外后,我们可以看下面的代码:
#include <stdio.h>
int main()
{int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };printf("&arr[0] = %p\n", &arr[0]);printf("arr = %p\n", arr);printf("&arr = %p\n", &arr);return 0;
}
输出结果:
三个打印结果⼀模⼀样,这时候就有点疑惑了,那arr和&arr有啥区别?
虽然他们的值相同,但意义不同。让我们通过指针运算来揭示它们的本质区别:
#include <stdio.h>
int main() {int arr[10] = {0};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); printf("&arr = %p\n", &arr);printf("&arr+1 = %p\n", &arr+1); return 0;
}
输出结果:
&arr[0] + 1
和arr + 1
:它们的操作对象是“一个int
元素”,因此+1
会跳过 一个int
的大小(4字节),指向下一个元素。&arr + 1
:它的操作对象是“一个拥有10个int
的数组”,因此+1
会跳过 整个数组的大小(40字节),指向下一个逻辑数组的起始位置。
到这里大家应该搞清楚数组名的意义了吧。
数组名是数组首元素的地址,但是有2个例外。
2. 使用指针访问数组
在理解了“数组名即是首元素地址”这一概念后,我们可能会想到:能否可以直接使用指针来操作数组呢?
2.1 指针访问数组的基本方法
既然数组名 arr
是一个地址,我们可以将其赋值给一个指针变量 p
,然后通过指针运算来访问数组的每一个元素。
#include <stdio.h>
int main() {int arr[10] = {0};int sz = sizeof(arr) / sizeof(arr[0]); int *p = arr;for (int i = 0; i < sz; i++) {scanf("%d", p + i); // 等价于 &arr[i]}for (int i = 0; i < sz; i++) {printf("%d ", *(p + i)); // 等价于 arr[i]}return 0;
}
在上述代码理解之后,我们再分析一下:数组名arr
是数组首元素的地址,可以赋值给指针变量p
,其实数组名arr
和p
在这里是等价的。那我们可以使用arr[i]
可以访问数组的元素,那p[i]
是否也可以直接访问数组呢?
#include <stdio.h>
int main()
{int arr[10] = { 0 };//输⼊int i = 0;int sz = sizeof(arr) / sizeof(arr[0]);//输⼊int* p = arr;for (i = 0; i < sz; i++){scanf("%d", p + i);//scanf("%d", arr+i);//也可以这样写}//输出for (i = 0; i < sz; i++){printf("%d ", p[i]);}return 0;
}
结论:
p[i]
这种写法是完全合法且等价于*(p + i)
的。
这就意味着,一旦一个指针 p
指向了一个数组的起始位置,你就可以像使用数组名一样使用这个指针!p[i]
会被编译器自动翻译为 *(p + i)
来执行。
现在,我们可以从一个全新的、统一的角度来理解数组访问了:
访问方式 | 编译器实际处理为 | 本质 |
---|---|---|
arr[i] | *(arr + i) | 数组访问是指针运算的语法糖 |
p[i] | *(p + i) | 指针也可以使用数组语法 |
这个规则是对称的。无论是数组名还是指针变量,后面跟了下标 []
,编译器都会将其转换为 首地址 + 偏移量,再解引用 的形式。
2.2 两种方式的对比与选择
特性 | 指针运算 (*(p+i) ) | 下标语法 (p[i] ) |
---|---|---|
可读性 | 较低,需要理解指针运算 | 更高,更直观,更像操作数组 |
灵活性 | 极高,可以进行复杂的指针算术 | 一般,仅限于固定偏移 |
常用场景 | 底层操作、灵活遍历(如*p++ ) | 常规的、顺序的数组访问 |
建议:在大多数需要明确访问数组第 i
个元素的场景下,使用 p[i]
这种形式,因为它意图更清晰,可读性更好。
3. 一维数组传参的本质
在C语言中,我们经常将数组传递给函数进行操作,但其中隐藏着一个至关重要的本质,理解它对于避免常见的编程错误至关重要。这个本质就是:在C语言中,不存在真正的“数组传参”,只有“地址传参”。
我们先看一个典型的问题代码:
#include <stdio.h>void test(int arr[]) {int sz2 = sizeof(arr) / sizeof(arr[0]); printf("sz2 = %d\n", sz2);
}int main() {int arr[10] = {1,2,3,4,5,6,7,8,9,10};int sz1 = sizeof(arr) / sizeof(arr[0]); printf("sz1 = %d\n", sz1); test(arr);return 0;
}
输出结果:
为什么在 main
函数中能正确计算出大小,在 test
函数中就不行?我们先来调试看一下再说结论:
这就是数组传参的本质了,回顾上一节的结论:数组名是首元素的地址(除了两个例外)。那么,当我们写下 test(arr);
时,实际上发生了什么呢?
真相是:
我们所传递的并非整个数组,而仅仅是数组首元素的地址。 这个过程,本质上是一种 “值传递”——只不过这个“值”是一个 地址值。
因此,上面的函数调用 test(arr);
实际上完全等价于:
test(&arr[0]);
所以函数形参的部分理论上应该使用指针变量来接收首元素的地址。那么在函数内部我们写sizeof(arr)
计算的是⼀个地址的大小,而不是数组的大小。正是因为函数的参数部分本质是指针,所以在函数内部是没办法求得数组元素个数的。
void test(int arr[]) // 参数写成数组形式,本质上还是指针
{printf("%d\n", sizeof(arr));
}
void test(int* arr) // 参数写成指针形式
{printf("%d\n", sizeof(arr)); // 计算⼀个指针变量的⼤⼩
}
int main()
{int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };test(arr);return 0;
}
**总结:**一维数组传参,形参的部分可以写成数组的形式,也可以写成指针的形式。
4. 冒泡排序
在理解如何使用指针和数组协同工作后,我们可以浅浅的实践一下,而冒泡排序就是一个完美的案例
4.1 概念和思想
冒泡排序的核心思想非常直观,就像它的名字一样:越小的元素会经由交换慢慢“浮”到数列的顶端(升序排序时)。
算法步骤:
- 比较相邻元素:从数组的第一个元素开始,依次比较相邻的两个元素。
- 交换逆序对:如果第一个比第二个大(对于升序排序而言),就交换它们的位置。
- 一轮冒泡:对每一对相邻元素重复步骤1和2,从开始第一对到结尾最后一对。一轮结束后,最大的元素会“沉”到数组末尾,就像冒泡一样。
- 重复执行:针对所有未排序的元素重复上述步骤,直到整个数组有序。
4.2 基础实现:双循环结构
void bubble_sort(int arr[], int sz) { // arr就是数组首元素的地址 sz就是数组长度int i = 0;for (i = 0; i < sz - 1; i++) { // 外层循环:控制排序的轮数int j = 0;for (j = 0; j < sz - i - 1; j++) { // 内层循环:进行一轮中的两两比较if (arr[j] > arr[j + 1]) { // 比较相邻元素// 交换 arr[j] 和 arr[j+1]int tmp = arr[j];arr[j] = arr[j + 1];arr[j + 1] = tmp;}}}
}
代码分析:
- 外层循环 (
i
):控制总共需要进行多少轮排序。n
个元素最多需要n-1
轮。 - 内层循环 (
j
):负责在每一轮中进行实际的比较和交换。sz - i - 1
是关键,因为每完成一轮,数组末尾就已经有一个元素就位了,无需再参与比较。 if
判断与交换:这是排序逻辑的核心,通过比较和交换来消除“逆序对”。
4.3 优化冒泡排序
基础版本有一个明显的缺点:即使数组已经提前有序,它仍然会机械地完成所有轮次的循环。我们可以通过一个变量 flag
来优化它。
void bubble_sort(int arr[], int sz) {int i = 0;for (i = 0; i < sz - 1; i++) {int flag = 1; // 假设这一趟已经有序int j = 0;for (j = 0; j < sz - i - 1; j++) {if (arr[j] > arr[j + 1]) {flag = 0; // 如果发生了交换,说明数组尚未有序int tmp = arr[j];arr[j] = arr[j + 1];arr[j + 1] = tmp;}}if (flag == 1) { // 如果这一趟没发生任何交换,说明数组已完全有序break; // 提前终止排序}}
}
优化点分析:
- 我们定义了一个
flag
变量,在每一轮开始前,假设这一轮已经有序 (flag = 1
)。 - 在内层循环中,只要发生了一次交换,就表明此刻数组是无序的,我们就要将
flag
置为0。 - 在一轮结束后检查
flag
的值。如果它仍然是1
,说明这一轮没有进行任何交换,数组已经完全有序,可以立即break
跳出循环,无需再进行后续无用的比较。
这个优化对于近乎有序的数组来说,性能提升是巨大的。
4.4 总结
- 算法思想:冒泡排序通过不断比较相邻元素并交换逆序对,使最大(或最小)元素逐渐移动到其正确位置。
- 核心实现:双循环结构是骨架,比较和交换是血肉。
- 重要优化:使用
flag
标志位实现提前终止,可以显著提升对有序或近乎有序数据的排序效率。这是编写高效算法必须具备的思维。 - 与指针/数组的关系:
- 冒泡排序是对一维数组进行原地操作的典型例子,函数内部直接通过指针(数组名)修改了主函数中的数组内容。
- 它完美诠释了数组传参的本质是传地址,以及如何通过指针/数组语法来操作内存。
虽然冒泡排序的时间复杂度为 O(n²),在实际应用中并不高效,但它作为入门算法,对于理解循环、条件判断、数组操作和基础算法思想有着重要的价值。
5. 二级指针
在掌握了指针与数组的密切关系后,我们开始进行下一步的学习:二级指针。
5.1 为什么需要二级指针?
指针变量也是变量,是变量就有地址,那指针变量的地址存放在哪里?这就是二级指针 。
pa
的类型是int*
,它存储的是a
的地址,通过*pa
可以访问或修改a
。ppa
的类型是int**
,它存储的是pa
的地址,通过*ppa
可以访问或修改pa
,通过**ppa
可以访问或修改a
。
5.2 二级指针的内存模型
理解二级指针最有效的方式是通过内存图(可以参考上述图片)。假设变量 a
、指针 pa
和 ppa
在内存中的地址如下:
变量 | 地址 | 存储的值 | 说明 |
---|---|---|---|
a | 0x0012FF50 | 10 | 普通的整型变量 |
pa | 0x0012FF48 | 0x0012FF50 | 一级指针,值为a的地址 |
ppa | 0x0012FF40 | 0x0012FF48 | 二级指针,值为pa的地址 |
这个模型清晰地展示了“指向”的关系:
ppa
--> pa
--> a
5.3 二级指针的运算:解引用的层次
对二级指针进行解引用操作,需要理解其层次性:
*ppa
(第一层解引用):- 含义:取出
ppa
中存储的地址(即pa
的地址0x0012FF48
),然后找到这个地址所对应的内存空间。 - 结果:找到的就是
pa
这个变量本身。 - 因此,
*ppa
等价于pa
。
- 含义:取出
**ppa
(第二层解引用):- 含义:先通过
*ppa
找到pa
,再对pa
进行解引用(即*(*ppa)
)。 - 结果:找到的就是
pa
所指向的变量,也就是a
。 - 因此,
**ppa
等价于*pa
,也等价于a
。
- 含义:先通过
6. 指针数组
指针数组是指针还是数组?
我们可以通过类比来理解:
int arr[5]
:这是一个整型数组。它是一个包含5个元素的数组,其中每个元素都是一个int
类型的整数。char arr[5]
:这是一个字符数组。它是一个包含5个元素的数组,其中每个元素都是一个char
类型的字符。int* arr[5]
:这是一个指针数组。它是一个包含5个元素的数组,其中每个元素都是一个int*
类型的指针(即地址)。
指针数组的每个元素都是用来存放地址(指针)的。
如下图:
指针数组的每个元素是地址,又可以指向一块区域。
7. 指针数组模拟二维数组
指针数组一个强大而经典的应用是模拟二维数组。这与我们之前提到的“数组名是地址”的概念紧密结合。
#include <stdio.h>
int main() {int arr1[] = {1, 2, 3, 4, 5};int arr2[] = {2, 3, 4, 5, 6};int arr3[] = {3, 4, 5, 6, 7};// 定义一个指针数组,其元素分别指向这三个一维数组的首元素int* parr[3] = {arr1, arr2, arr3}; // 数组名就是首元素地址for (int i = 0; i < 3; i++) { for (int j = 0; j < 5; j++) { printf("%d ", parr[i][j]);}printf("\n");}return 0;
}
输出结果:
为什么 parr[i][j]
能 work?
这需要运用我们之前学到的所有知识:
parr[i]
:这是访问指针数组的第i
个元素。这个元素是一个指针,它指向第i
行一维数组的首元素(例如arr1
的首地址)。parr[i][j]
:根据“数组访问是指针运算的语法糖”规则,它被编译器转换为*(parr[i] + j)
。parr[i] + j
:计算第i
行一维数组中第j
个元素的地址。*(parr[i] + j)
:对该地址解引用,获取到元素的值。
注意:与真二维数组的区别
虽然语法相似,但这种模拟的二维数组与真正的 int arr[3][5]
有本质区别:
- 真正二维数组:内存是连续分配的的一大块区域。
- 模拟二维数组:由指针数组和多个一维数组组成。每一行(即每个一维数组)在内存中是连续的,但行与行之间不保证连续。
parr
数组本身是连续的,但它里面存放的各个地址是分散的。
总结
至此,我们完成了对C指针第二阶段的深入探索。从数组名的本质出发,我们揭开了其“地址”的真实身份与两个关键例外;通过使用指针访问数组,我们掌握了高效遍历内存的两种等价语法;在剖析一维数组传参的本质时,我们看透了“地址传递”的真相,理解了函数内外sizeof
结果差异的根源。
我们不仅用冒泡排序演练了指针与数组的经典协作,还引入了“提前终止”的优化思想,展现了编写高效算法的重要性。进而,我们的视野从管理数据的指针,提升到了管理指针的二级指针,理解了其在函数内修改外部指针的关键作用。最后,我们探索了指针数组这一强大工具,学会了如何用它来模拟二维数组,构建灵活的非连续数据结构。
回顾本系列,指针不再是那个令人畏惧的抽象概念,而是你手中一把精准控制内存的利器。你已经看到了它如何与数组紧密结合,从单一变量操作上升到对复杂数据集合的管理。
但这远非终点,而是一个新的起点。指针的旅程仍在继续:如何利用指针进行动态内存管理(malloc、free)、如何构建链表、树等更复杂的动态数据结构、如何理解函数指针等更高级的主题,都将是我们未来探索的方向。
希望本篇内容能帮助你彻底理解指针与数组的共生关系,打破对它们的恐惧。实践是掌握指针的唯一途径,大胆地去编码,去调试,去感受直接操作内存带来的力量与控制感。
外sizeof
结果差异的根源。
我们不仅用冒泡排序演练了指针与数组的经典协作,还引入了“提前终止”的优化思想,展现了编写高效算法的重要性。进而,我们的视野从管理数据的指针,提升到了管理指针的二级指针,理解了其在函数内修改外部指针的关键作用。最后,我们探索了指针数组这一强大工具,学会了如何用它来模拟二维数组,构建灵活的非连续数据结构。
回顾本系列,指针不再是那个令人畏惧的抽象概念,而是你手中一把精准控制内存的利器。你已经看到了它如何与数组紧密结合,从单一变量操作上升到对复杂数据集合的管理。
但这远非终点,而是一个新的起点。指针的旅程仍在继续:如何利用指针进行动态内存管理(malloc、free)、如何构建链表、树等更复杂的动态数据结构、如何理解函数指针等更高级的主题,都将是我们未来探索的方向。
希望本篇内容能帮助你彻底理解指针与数组的共生关系,打破对它们的恐惧。实践是掌握指针的唯一途径,大胆地去编码,去调试,去感受直接操作内存带来的力量与控制感。
欢迎在评论区留下你的思考、疑问或实践心得。你对指针还有哪些困惑?又希望接下来深入探讨哪个主题?让我们共同学习,继续在C语言的深邃世界中探索前行。
往期回顾:
深入理解C语言指针(一)| 从内存到传址调用,掌握指针的核心本质