Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第十一章 指针和数组的关系

本章要点

在第十章中,我们用 malloc 动态分配了一段内存,返回的是一个 int * 类型的指针,却可以像数组一样用 scores[i] 来访问它。这个现象自然而然引出一个问题:指针和数组之间,到底有什么关系? 再加上此前我们就注意到数组名在多数场合表现得像一个地址——这两条线索指向同一个方向:指针和数组在 C 语言中有着深层的关联。

在此之前,我们分别学习了数组(第三章)和指针(第七章)。数组是一片连续内存,用下标访问;指针存地址,用 * 间接访问。本章把这两个概念放在一起,彻底梳理清楚它们的关系。

本章把指针与数组的关系彻底梳理清楚。具体涵盖以下内容:

  • 数组名和指针的关系——数组名是不是指针?退化机制、[ ] 的本质、sizeof 与取地址的差异
  • 数组和 malloc 出的指针的关系——为什么堆内存也能当数组用?栈数组与堆内存的对比

理解了这些,你便能在两种表示之间自如切换,也为后续章节理解生命周期和动态内存打下基础。


一、数组名和指针的关系

1. 数组名——多数场景会”退化”为指针

当你写下 int arr[5]; 时,arr 这个名字代表的是整个数组——5 个 int 连在一起的一整块空间。然而,在绝大多数表达式中(sizeof& 是少数例外),编译器会自动将 arr 转换为数组首元素的地址。这个行为通常被称为数组名到指针的”退化”(decay)。

int arr[5] = {10, 20, 30, 40, 50};

// arr 在表达式中退化为首元素地址,所以这两行打印的值相同
printf("arr     = %p\n", (void *)arr);
printf("&arr[0] = %p\n", (void *)&arr[0]);

要注意的是,arr 退化后得到的是一个地址值,但 arr 本身并不是一个指针变量——它没有自己独立的存储空间来存这个地址。它只是数组的名字,在需要参与运算时被编译器”翻译”成了首元素地址。因此数组名不能像指针变量那样被重新赋值:

int arr[5];
int *p = arr;     // 正确:把数组首地址赋给指针 p
arr = p;          // 错误!数组名不能放在等号左边被赋值

一个有效的心理模型:你可以把 arr 想象成一个固定不变的首元素地址——它的值永远是数组开头的位置,你不能修改它,但你可以读它、用它参与运算(arr + 1arr[2] 等等)。这个简化模型足够应付绝大多数初学场景。更精确的理解(数组名标识数组对象、在表达式中发生退化)会随着使用经验的积累自然建立起来,目前不必强求。


2. 数组名和指针的本质区别

上面我们看到,数组名退化后在表达式中表现得”像”一个指针。那么,数组名到底是不是指针?

答案很明确:不是。 arr 是数组的名字,p 是指针变量,两者在本质上完全不同:

  • 数组是一块数据的容器——int arr[5] 在栈上分配了 5 个 int 连在一起的一整片空间,arr 是这片空间的名字。
  • 指针是一个寻址的工具——int *p 是一个独立的 8 字节变量,里面存着一张写着”你要找的数据在某某位置”的纸条。

正因为数组名不是指针变量,它们在具体行为上产生了实际的差异。下面逐一来看。

差异一:sizeof 的结果不同

int arr[5] = {1, 2, 3, 4, 5};
int *p = arr;

printf("sizeof(arr) = %d\n", (int)sizeof(arr));  // 20(整个数组的字节数)
printf("sizeof(p)   = %d\n", (int)sizeof(p));    // 8(指针本身的字节数,64位系统)
  • sizeof(arr):得到整个数组占用的内存大小——5 * sizeof(int),大多数平台上为 20 字节。sizeof 是少数数组名不退化的例外之一,此时 arr 代表的是整片数组空间。
  • sizeof(p):得到指针变量本身的大小,64 位系统上通常是 8 字节,与它指向什么数据毫无关系。

这个差异在将数组作为函数参数传递时尤为关键,后续学习函数与数组配合时自然会遇到。

差异二:取地址的行为不同

int arr[5];

printf("arr    = %p\n", (void *)arr);    // 首元素地址
printf("&arr   = %p\n", (void *)&arr);   // 整个数组的地址
// 这两个数值相同,但类型不同!

虽然 arr&arr 打印出的地址值相同,但它们的类型截然不同:arr 在表达式中退化为 int *(指向首元素的指针),而 &arr 的类型是 int (*)[5](指向整个数组的指针)。这种类型差异会影响指针算术运算——对 arr 加 1 会前进 1 个 int 的距离,而对 &arr 加 1 则会前进整个数组的距离(即 5 * sizeof(int) 字节)。

对比指针变量:

int *p = arr;
printf("p   = %p\n", (void *)p);   // p 的值:arr 的首地址
printf("&p  = %p\n", (void *)&p);  // 指针变量 p 自己的地址(和 p 的值完全不同!)

p 是一个独立的指针变量,它有自己的存储空间。p 的值是数组的首地址,而 &pp 这个变量自身在内存中的地址——两者毫不相干。

差异三:数组名不可修改

int arr[5];
int *p = arr;

p++;    // 正确:p 是变量,可以改变指向
arr++;  // 错误!数组名不能自增

数组名代表整块数组空间本身,它不是一个单独存放地址的变量——这才是它不能放在赋值号左边、也不能执行 ++ 的根本原因。指针变量则不同,它是一个实实在在的变量,里面存了一个地址,可以随时改写成另一个地址,指向不同的数据。


二、数组和 malloc 出的指针的关系

第一部分回答了”数组名是不是指针”——不是。现在来看另一个同样重要的问题:malloc 返回的明明是一个指针,为什么能把它当数组用?

回忆一下第十章中这个程序片段——我们让用户输入学生人数 n,然后 malloc 了一块内存来存放成绩,紧接着就用 scores[i] 访问了它:

int n;
printf("请输入学生人数:");
scanf("%d", &n);

int *scores = malloc((size_t)n * sizeof(int));
if (scores == NULL) return 1;

// 像使用数组一样使用 scores!
for (int i = 0; i < n; i++)
{
    printf("请输入第 %d 个学生的成绩:", i + 1);
    scanf("%d", &scores[i]);                // ← scores 明明是指针,却用了 [i]
}

for (int i = 0; i < n; i++)
{
    printf("%d ", scores[i]);               // ← 这里也是
}

scores 是一个 int * 类型的指针变量,为什么能对它写 scores[i]?要回答这个问题,得先从 [ ] 这个符号本身说起。

1. [ ] 的真相——为什么指针也能用下标访问?

这背后的机制其实在第七章已经埋下了:p[i]*(p + i) 是完全等价的。 指针可以加一个整数做移动——p + i 就是从 p 当前位置向高地址方向移动 i 个元素。编译器看到 p[i],内部就是把它翻译成 *(p + i) 来处理的。

换句话说,上面第十章代码中的 scores[i],在编译器眼里就是 *(scores + i)。而 scores + i 正好指向第 i 个 int 的位置——这一切和 scores 是不是”数组”毫无关系,它只要求 scores 是一个指针、且它指向的内存是连续排列的。

[ ] 只是指针算术运算的一种简写形式,从来就不是数组的”专利”。

2. 指针算术运算——为什么 arr[i]*(arr + i) 等价

理解了 [ ] 的本质,再来看数组名。数组名在表达式中会退化为首元素地址,那么”第 i 个元素”的地址显然可以通过首地址加上偏移量算出:

首地址 + 偏移量 → 第 i 个元素的地址

C 语言的指针加法会自动根据指向的类型进行缩放。例如 int *p 加 1,地址值实际增加的是 sizeof(int) 个字节(通常是 4),正好跳过一个 int

int arr[5] = {10, 20, 30, 40, 50};

// 下面两行完全等价
printf("%d\n", arr[2]);       // 输出 30
printf("%d\n", *(arr + 2));   // 输出 30

arr[i] 的本质就是 *(arr + i)——这不是巧合,而是 C 语言标准规定的语义。编译器看到 arr[i],先让 arr 退化为首地址,再按 *(首地址 + i) 的方式去访问内存。同样的逻辑也适用于任何指向连续内存的指针。

回到第十章的 scoresscores[i] 被翻译成 *(scores + i),而 scores + i 正是通过 malloc 分配的那块堆内存中第 i 个 int 的位置。这也正是第十章代码中,注释里写下 scores + i*(scores + i) 的原因——它们和 &scores[i]scores[i] 分别是完全等价的写法。

3. 用指针遍历数组

[i] = *(p + i) 意味着遍历同一段连续内存可以有多种写法。直接用第十章的 scores 为例,三种方式的对比一目了然:

// 沿用第十章的变量:int *scores, int n
// scores 指向堆上一段连续的 int 空间

// 方法1:下标法(最常用,第十章原版写法)
for (int i = 0; i < n; i++)
{
    printf("%d ", scores[i]);
}

// 方法2:指针偏移法(等价写法)
for (int i = 0; i < n; i++)
{
    printf("%d ", *(scores + i));    // scores[i] == *(scores + i)
}

// 方法3:移动指针法(p 自己往前走)
int *p = scores;                      // p 指向首元素
for (int i = 0; i < n; i++)
{
    printf("%d ", *p);
    p++;                               // p 移动到下一个元素
}

三种写法输出完全一致,各自适用的场景也不同:

  • 下标法:最直观——明确表达了”访问第几个元素”的语义,推荐日常使用
  • 指针偏移法:更贴近底层真相——scores + i 算出地址,* 取出值,有助于理解编译器的视角
  • 移动指针法:在字符串处理、链表遍历等算法中非常常见,是 C 语言惯用的遍历模式

4. malloc 指针为什么能当数组用?

现在可以完整回答了。第三章讲过,数组的本质是一段连续排列的内存空间——元素一个接一个,中间没有缝隙。而第十章中 malloc 做的事情,恰恰也是在内存中划出一块连续的区域并返回其起始地址。

对比一下第三章的栈数组和第十章的堆内存:

栈数组:int arr[5];       → 栈上分配 5 个连续的 int
堆内存:int *scores = malloc(n * sizeof(int));  → 堆上分配 n 个连续的 int

虽然一个在栈上、一个在堆上,但它们在内存中的排布方式是完全一样的——都有一个首地址,后面紧挨着一排同类型的元素。而 [i] 只是”从首地址出发、向高地址移动 i 步、取出那个位置的值”的简写形式——内存在栈上还是堆上,丝毫不影响这个机制的运作。

5. 栈数组和堆内存的对比

相似性说完了——那它们在使用上到底有什么区别?回到第十章的 scores 和第三章的 int arr[5],对比一下:

栈数组 int arr[5]malloc 指针(第十章 scores
大小决定时机编译时写死,5 必须写常量运行时按需决定,n 来自 scanf
生命周期自动管理,离开 { } 就释放手动管理,不 free 就永远占着
sizeof 结果整个数组的字节数(20)指针变量本身的大小(8 字节)
能否改变大小不能可以通过 realloc 调整

一句话总结:栈数组和 malloc 指针在”用下标访问元素”这件事上用法完全一致,但在”从哪来、活多久、占多大”这些维度上是两套完全不同的规则。 这也正是第三章(固定数组)和第十章(动态内存)各自独立成章的缘由——它们解决的是同一个问题(管理一组同类型数据),但适用的场景和掌控的复杂度完全不同。


三、动手练习

#include <stdio.h>

int main(void)
{
    // 练习1:用三种不同方式输出数组
    int arr[6] = {1, 3, 5, 7, 9, 11};

    printf("练习1:三种遍历方式\n");
    // 方式1:下标法
    for (int i = 0; i < 6; i++) printf("%d ", arr[i]);
    printf("\n");

    // 方式2:指针偏移法
    for (int i = 0; i < 6; i++) printf("%d ", *(arr + i));
    printf("\n");

    // 方式3:移动指针法
    int *p = arr;
    for (int i = 0; i < 6; i++) printf("%d ", *p++);
    printf("\n\n");

    // 练习2:验证 sizeof 的区别
    printf("练习2:sizeof 区别\n");
    printf("sizeof(arr) = %d(整个数组)\n", (int)sizeof(arr));
    printf("sizeof(p)   = %d(指针本身)\n\n", (int)sizeof(p));

    // 练习3:指针数组和数组指针的简单演示
    int a = 10, b = 20, c = 30;

    // 指针数组:数组里存指针
    int *ptr_arr[3] = {&a, &b, &c};
    printf("练习3:指针数组\n");
    for (int i = 0; i < 3; i++)
    {
        printf("ptr_arr[%d] 指向的值:%d\n", i, *ptr_arr[i]);
    }

    // 数组指针:指向整个数组
    int matrix[3] = {100, 200, 300};
    int (*arr_ptr)[3] = &matrix;
    printf("\n数组指针:通过 (*arr_ptr)[i] 访问\n");
    for (int i = 0; i < 3; i++)
    {
        printf("(*arr_ptr)[%d] = %d\n", i, (*arr_ptr)[i]);
    }

    return 0;
}