上节课咱们把指针是啥搞明白了。简单回顾一下:指针就是存地址的变量,&取地址,*解引用。你定义一个int *p,它就能存一个int变量的地址,通过*p就能操作那个变量。
今天咱们要讲指针和数组的关系。我先把结论放这儿:数组名本身就是一个地址,而且它就指向数组的第一个元素。 你一旦把这个搞懂了,指针和数组之间的那层窗户纸就捅破了。
先回忆一下数组名是啥
咱们前面学数组的时候说过一句话:数组名就是首元素的地址。 当时你可能没太在意,现在学了指针,咱们得把这句话拿出来好好品一品。
int a[5] = {10, 20, 30, 40, 50};
你说a是什么?是数组名。但它的值是什么?是a[0]的地址。
咱们验证一下:
#include <stdio.h> int main() { int a[5] = {10, 20, 30, 40, 50}; printf("a的值:%p\n", a); // 输出一个地址 printf("a[0]的地址:%p\n", &a[0]); // 输出同一个地址 printf("a[1]的地址:%p\n", &a[1]); // 比上面大4(一个int的大小) return 0; }
你跑一下就会发现,a和&a[0]打印出来是一模一样的地址。这说明啥?数组名a就是a[0]的地址。 它俩是一个东西,只是写法不同。
那a[1]的地址呢?它比a[0]的地址大4个字节(在大多数系统上int占4字节)。因为数组在内存里是连续存放的,第二个元素紧挨着第一个元素。
用指针指向数组
既然数组名就是地址,那咱们就可以把它赋给一个指针:
int a[5] = {10, 20, 30, 40, 50}; int *p = a; // p指向数组的第一个元素
注意这里写的是int *p = a;,不是&a。因为a本身就已经是地址了,所以不用再加&。你也可以写成int *p = &a[0];,效果完全一样。
现在p指向了a[0]。那怎么通过p来访问数组里的其他元素呢?
指针的算术运算——指针会“走路”
这是指针最好玩的地方。指针加上一个整数,不是简单地加几个字节,而是加“几个元素”。
int *p = a; // p指向a[0] printf("%d\n", *p); // 输出10,就是a[0] printf("%d\n", *(p + 1)); // 输出20,就是a[1] printf("%d\n", *(p + 2)); // 输出30,就是a[2]
p + 1是什么意思?它不是把地址加1个字节,而是加1个int的大小(4个字节)。也就是说,指针往后移动了一个“身位”,指向了下一个元素。
为啥要这样设计?因为指针是有类型的。你声明的是int *p,编译器就知道你指向的是int,那p + 1就得跳过一整个int,才能到下一个int的位置。如果指针是char *,那p + 1就只跳1个字节。
咱们用代码验证一下:
#include <stdio.h> int main() { int a[5] = {10, 20, 30, 40, 50}; int *p = a; printf("p指向的值:%d\n", *p); // 10 printf("p+1指向的值:%d\n", *(p + 1)); // 20 printf("p+2指向的值:%d\n", *(p + 2)); // 30 printf("p的地址:%p\n", p); // 比如0x1000 printf("p+1的地址:%p\n", p + 1); // 0x1004,加了4个字节 return 0; }
你看,p + 1的地址比p大了4。这就是指针的算术运算——它以指向的类型大小为单位移动。
两种访问方式的等价关系
因为数组名本身就是地址,指针也能当数组名用,所以下面这几种写法效果完全一样:
int a[5] = {10, 20, 30, 40, 50}; int *p = a; // 下面这些都是等价的 a[2] // 数组下标访问 *(a + 2) // 数组名当指针用,加偏移量再解引用 p[2] // 指针当数组名用,下标访问 *(p + 2) // 指针加偏移量再解引用
这四种写法,打印出来的都是30。你可能会问:a[2]和*(a + 2)等价我理解,那p[2]又是啥?
其实p[2]在C语言里会被编译器自动翻译成*(p + 2)。下标运算符[]本质上就是“指针加偏移量再解引用”的语法糖。 你写a[2],编译器看到的是*(a + 2)。你写p[2],编译器看到的是*(p + 2)。
所以你看,数组和指针在这件事上是完全互通的。数组名可以当指针用,指针也可以当数组名用。
用指针遍历数组——写起来更灵活
既然指针能移动,那用它遍历数组就特别顺手:
#include <stdio.h> int main() { int a[5] = {10, 20, 30, 40, 50}; int *p; // 方式一:用下标遍历(你以前熟悉的) for (int i = 0; i < 5; i++) { printf("%d ", a[i]); } printf("\n"); // 方式二:用指针加偏移量遍历 for (int i = 0; i < 5; i++) { printf("%d ", *(a + i)); } printf("\n"); // 方式三:用指针自己往后走 for (p = a; p < a + 5; p++) { printf("%d ", *p); } printf("\n"); return 0; }
第三种写法特别能体现指针的精髓:p一开始指向a[0],每次循环p++让它往后移动一个元素,直到p走到a + 5(也就是数组末尾之后的位置)就停。这个“指针往后走”的动作,比用下标访问多了一层直观——你能看到指针在内存里一步一步往前挪。
数组名和指针的一个关键区别
虽然数组名和指针在大多数情况下可以互换使用,但它们有一个本质区别:数组名是常量地址,不能改;指针是变量,可以改。
int a[5] = {10, 20, 30, 40, 50}; int *p = a; p++; // 合法,p可以移动 // a++; // 错误!数组名是常量,不能移动
a是数组名,它代表的是数组首元素的地址,这个地址是固定的,你不能让它指向别的地方。而p是一个指针变量,它里面存的地址可以改,你想让它指向哪儿就指向哪儿。
p = &a[2]; // p现在指向a[2],合法 printf("%d\n", *p); // 输出30
这就是数组名和指针最根本的区别:数组名是“死”的,指针是“活”的。
再看一个例子:用指针反转数组
咱们前面用下标写过数组反转,现在用指针再写一遍,你感受一下两种写法的区别:
#include <stdio.h> void reverse(int *arr, int n) { int *left = arr; // 指向第一个元素 int *right = arr + n - 1; // 指向最后一个元素 int temp; while (left < right) { temp = *left; *left = *right; *right = temp; left++; // 左指针往后走 right--; // 右指针往前走 } } int main() { int a[6] = {1, 2, 3, 4, 5, 6}; reverse(a, 6); for (int i = 0; i < 6; i++) { printf("%d ", a[i]); } printf("\n"); // 输出 6 5 4 3 2 1 return 0; }
你看,left和right两个指针从两头往中间走,交换它们指向的值,直到相遇。这个写法和用下标写的逻辑完全一样,但用指针写出来,那种“在内存里移动”的感觉更直观。
一个容易晕的点:*p++是啥意思?
咱们来拆一个稍微绕一点的表达式:
int a[5] = {10, 20, 30, 40, 50}; int *p = a; printf("%d\n", *p++);
这个*p++到底先干啥后干啥?是(*p)++还是*(p++)?
答案是:先取*p,然后p往后走一步。
因为++的优先级比*高,但是++在变量后面的时候(后置++),是“先用旧值,再加1”。所以*p++等价于*(p++)——先解引用p得到当前值,然后把p加1。
如果你写(*p)++,那就是先取*p,然后把*p这个值加1。这改的是数组元素的值,而不是指针本身。
int a[5] = {10, 20, 30, 40, 50}; int *p = a; printf("%d\n", *p++); // 输出10,然后p指向a[1] printf("%d\n", *p); // 输出20 p = a; // 重新指向a[0] printf("%d\n", (*p)++); // 输出10,然后a[0]变成11 printf("%d\n", a[0]); // 输出11
这个点你刚开始不用死磕,知道*p++是“先取值再移动指针”就行了。以后写代码多了自然就熟了。
几个常见的坑
坑1:指针越界
int a[5] = {1, 2, 3, 4, 5}; int *p = a; p += 10; // p指向了数组外的位置 printf("%d\n", *p); // 危险!访问了不该访问的内存
指针越界比数组下标越界更隐蔽,因为编译器不会检查你的指针指向哪里。每次移动指针的时候,脑子里要清楚它现在指向数组的哪个位置。
坑2:混淆a和&a
int a[5]; printf("%p\n", a); // a[0]的地址 printf("%p\n", &a); // 整个数组的地址
这两个打印出来的值是一样的(都是同一个地址),但它们的类型不一样。a是int*类型,&a是int(*)[5]类型——指向整个数组的指针。a + 1移动4个字节,&a + 1移动20个字节(整个数组的大小)。平时你基本用不到&a,知道有这么回事就行。
坑3:指针没初始化就使用
int *p; // 没初始化,里面是垃圾地址 *p = 10; // 往一个未知地址写数据,程序崩溃
定义指针的时候,要么让它指向一个已有的变量,要么初始化为NULL。
int *p = NULL; // 初始化为空指针,安全
总结一下
今天这节课的核心就几句话:
数组名就是首元素的地址。 它跟指针在大多数情况下可以互换使用。
指针加1,不是加1个字节,而是加一个元素的大小。 因为指针有类型,编译器知道该跳多远。
a[i]、*(a+i)、p[i]、*(p+i)这四种写法完全等价。 下标运算符本质就是指针加偏移再解引用。
数组名是常量地址,不能改;指针是变量,可以移动。
用指针遍历数组,就是让指针从数组头走到数组尾。
指针和数组的关系是C语言里最核心的知识点之一。你把这层关系吃透了,后面学字符串操作、动态内存分配、链表,全都能串起来。回去多写几个例子,把指针在数组上移来移去,打印每个位置的地址和值,观察规律。动手跑一遍,比看十遍都管用。
下节课咱们讲指针和字符串的关系,那是指针和数组关系的延伸,也是字符串处理的核心。