【C语言-指针-指针与数组】2. 指针与数组——数组名其实是个“隐藏的指针”

开发技术 作者: 精灵星空 SKY315.COM 更新于: 浏览: 5
码字发文不易,转载请注明出处【精灵星空 SKY315.COM】

上节课咱们把指针是啥搞明白了。简单回顾一下:指针就是存地址的变量,&取地址,*解引用。你定义一个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语言里最核心的知识点之一。你把这层关系吃透了,后面学字符串操作、动态内存分配、链表,全都能串起来。回去多写几个例子,把指针在数组上移来移去,打印每个位置的地址和值,观察规律。动手跑一遍,比看十遍都管用。

下节课咱们讲指针和字符串的关系,那是指针和数组关系的延伸,也是字符串处理的核心。