数组和指针

掌握一维与多维数组、指定初始化、数组形参、const、VLA、复合字面量和有边界的指针运算

学习目标

  • 能用普通与指定初始化器创建一维、二维数组,并解释合法下标和尾后指针边界
  • 能说明数组表达式何时转换为首元素指针,并写出带长度、const 元素约束的数组函数接口
  • 能比较固定数组、C11 条件支持的 VLA 与复合字面量的生存期,并正确访问二维数组

机制总览

数组和指针:机制路径

  1. 1

    为什么需要「数组」和「指针」?

    想象一排带编号的小抽屉:第 0 格放周一温度,第 1 格放周二……你要算一周平均,就得依次打开每一格。如果给每个温度单独起名 mon 、 tue ……变量名会爆炸,代码也无法用循环处理「第 i 个」。

  2. 2

    维数组:连续内存里的一排元素

    scores[5] 表示 5 个 int 占一段 连续 内存。合法下标是 0 到 4 ——访问 scores[5] 已越界,行为未定义(可能崩溃、可能看似正常)。

  3. 3

    指针基础:地址、解引用、取地址

    指针也有类型: int p 是“指向 int 的指针”,可保存有效 int 对象地址、同一数组的尾后地址或空指针值;只有指向有效对象时才能解引用。

先按顺序建立机制,再进入实验切换阶段并检查失效证据。

章级决策实验

数组和指针:机制与证据

切换《数组和指针》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。

选择推理阶段

当前阶段 · 为什么需要「数组」和「指针」?

想象一排带编号的小抽屉:第 0 格放周一温度,第 1 格放周二……你要算一周平均,就得依次打开每一格。如果给每个温度单独起名 mon 、 tue ……变量名会爆炸,代码也无法用循环处理「第 i 个」。

可核验证据

用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「为什么需要「数组」和「指针」?」的实际行为。

学完《数组和指针》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。

失效—证据矩阵

数组和指针:失效与核验

为什么需要「数组」和「指针」?

典型失效

若只记语法而忽略「为什么需要「数组」和「指针」?」的类型范围、求值顺序或资源边界,输入变化后可能出现未定义行为或错误结果。

核验证据

用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「为什么需要「数组」和「指针」?」的实际行为。

维数组:连续内存里的一排元素

典型失效

若只记语法而忽略「维数组:连续内存里的一排元素」的类型范围、求值顺序或资源边界,输入变化后可能出现未定义行为或错误结果。

核验证据

用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「维数组:连续内存里的一排元素」的实际行为。

指针基础:地址、解引用、取地址

典型失效

若只记语法而忽略「指针基础:地址、解引用、取地址」的类型范围、求值顺序或资源边界,输入变化后可能出现未定义行为或错误结果。

核验证据

用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「指针基础:地址、解引用、取地址」的实际行为。

每个判断都必须能落到观测、测试或产物,不能只凭代码表面推测。

为什么需要「数组」和「指针」?

想象一排带编号的小抽屉:第 0 格放周一温度,第 1 格放周二……你要算一周平均,就得依次打开每一格。如果给每个温度单独起名 montue……变量名会爆炸,代码也无法用循环处理「第 i 个」。

数组就是 C 语言给你的「一整排编号抽屉」:一次声明 double temps[7],用下标 06 访问。指针则是「写着某格门牌号的纸条」——知道地址,就能找到那格里的值,还能用 p+1 走到下一格。

这一章把数组的排布、指针的含义、以及两者如何配合讲清楚。搞懂这些,后面字符串、动态内存、函数传数组都会顺很多。

一维数组:连续内存里的一排元素

声明写法:

int scores[5] = {0};              /* 5 个 int,全部初始化为 0 */
double temps[7] = {20.1, 21.0};   /* 前 2 个有初值,其余为 0 */
int primes[] = {2, 3, 5, 7};      /* 长度由初值个数推断为 4 */

scores[5] 表示 5 个 int 占一段连续内存。合法下标是 04——访问 scores[5] 已越界,行为未定义(可能崩溃、可能看似正常)。

C99/C11 的 允许跳到某个元素,适合稀疏表和枚举索引:

enum day { MON, TUE, WED, THU, FRI, DAYS };
int hours[DAYS] = {
    [MON] = 8,
    [WED] = 6,
    [FRI] = 4
};
 
int matrix[2][3] = {
    [1][2] = 9
};

未显式指定的元素初始化为 0。后续未带指定符的初始化项从上一个指定位置之后继续,因此混用时要逐项核对位置;同一元素被多次初始化时以后出现的初始化覆盖先前值,但诊断和副作用细节不适合作为程序逻辑。

int ar[5] = {10, 20, 30, 40, 50};数组元素在内存中占一段连续区域,下标从 0 开始下标 i[0][1][2][3][4]10ar[0]20ar[1]30ar[2]40ar[3]50ar[4]地址 → 0x1000+0+4+8+12+16连续内存(int 通常 4 字节/格)`sizeof ar` = 5 × sizeof(int) = 20 字节。`ar[0]` 与 `ar[4]` 相邻,中间没有「空洞」。
一维数组的所有元素紧挨着存放在内存里。知道起始地址和下标,就能算出每个元素的位置。

sizeof scores 是整个数组的字节数(通常 5×4=20),不是「指针大小」。用循环遍历是数组最经典的用法:

int i, sum = 0;
for (i = 0; i < 5; i++)
    sum += scores[i];

指针基础:地址、解引用、取地址

里存的是地址,不是普通数值:

int num = 42;
int *ptr;      /* ptr 将指向 int */
ptr = &num;    /* & 取 num 的地址 */
printf("%d\n", *ptr);  /* * 解引用:取该地址上的 int 值 → 42 */
  • &x
  • *p

指针也有类型:int *p 是“指向 int 的指针”,可保存有效 int 对象地址、同一数组的尾后地址或空指针值;只有指向有效对象时才能解引用。double *q 的目标类型是 double。类型决定解引用访问的对象类型,也决定合法指针算术的元素步长。

指针与数组:名字、下标、等价式

在大多数表达式里,

int ar[5] = {10, 20, 30, 40, 50};
int *p = ar;       /* 等价于 p = &ar[0] */
printf("%d\n", ar[2]);    /* 30 */
printf("%d\n", *(ar + 2)); /* 同样 30 */

C 标准保证:

ar[i]*(ar + i)。下标只是让代码好读的语法糖,底层仍是指针算术。

用指针遍历数组:

int *p;
for (p = ar; p < ar + 5; p++)
    printf("%d ", *p);

p < ar + 5 表示指针尚未到达尾后位置。ar + 5 可以形成并参与同一数组内的比较,却不可解引用;形成超出尾后位置的指针本身就不在 C 指针算术保证内。

指针算术:p+1 不是加 1 字节

的步长是 sizeof(指向的类型)

int ar[] = {10, 20, 30, 40, 50};
int *p = ar;       /* 指向 ar[0] */
printf("%d\n", *p);       /* 10 */
printf("%d\n", *(p + 1)); /* 20 — p+1 指向 ar[1] */
printf("%d\n", p[2]);     /* 30 — 指针也可下标 */
int *p = ar; /* p 指向 ar[0] */ar[0]10ar[1]20ar[2]30ar[3]40ar[4]50① p → ar[0]*p == 10地址 0x1000② p + 1 → ar[1]*(p + 1) == 20地址 0x1004(+4 字节)③ p + 2 → ar[2]*(p + 2) == 30地址 0x1008(再 +4)p+2③ p+n 指向 ar[n];*(p+n) 等价于 ar[n]指针加减的是「元素个数」,编译器自动乘以 sizeof(指向的类型)。
对 int *p 执行 p+1,地址增加 sizeof(int) 字节,指向数组下一个元素,而非下一个字节。
分步1 / 3

第一步:p 指向首元素

int *p = ar; /* p 指向 ar[0] */ar[0]10ar[1]20ar[2]30ar[3]40ar[4]50① p → ar[0]*p == 10地址 0x1000② p + 1 → ar[1]*(p + 1) == 20地址 0x1004(+4 字节)③ p + 2 → ar[2]*(p + 2) == 30地址 0x1008(再 +4)p① p 保存 ar[0] 的地址指针加减的是「元素个数」,编译器自动乘以 sizeof(指向的类型)。
对 int *p 执行 p+1,地址增加 sizeof(int) 字节,指向数组下一个元素,而非下一个字节。

int *p = ar; 让 p 保存 ar[0] 的地址。*p 得到 10。

同一数组对象内(含尾后位置)的两个指针可以相减,结果类型为 ptrdiff_t,表示相隔的元素个数。对无关对象的指针做有序比较或相减没有可移植含义;只有相等/不等比较可用于任意兼容对象指针值。

多维数组:二维表格在物理内存中一行接一行

声明:

int matrix[3][4];                           /* 3 行 4 列 */
int grid[2][3] = {{1, 2, 3}, {4, 5, 6}};   /* 按行初始化 */

逻辑上你是「行 × 列」表格;物理内存里 C 按 排成一条连续带:

int m[3][4] — 逻辑上是 3 行 × 4 列逻辑视图(二维表格)1m[0][0]2m[0][1]3m[0][2]4m[0][3]5m[1][0]6m[1][1]7m[1][2]8m[1][3]9m[2][0]10m[2][1]11m[2][2]12m[2][3]物理内存(一行接一行,行优先)123456789101112按行展开m[1][2] 是第 2 行第 3 列 → 线性下标 1×4+2 = 6 → 内存中第 7 个 int(从 0 计)。
C 把二维数组存成一条连续的一维带:先存第 0 行全部元素,再存第 1 行……这叫行优先存储。

访问 matrix[r][c]:先找到第 r 行(每行 4 个 int),再取该行第 c 列。线性下标公式(从 0 计):

index = r × 列数 + c

例如 m[1][2]int m[3][4] 中是第 1×4+2 = 6 个元素(即第 7 个 int)。

表达式 m[1] 是「第 1 行」——类型为「4 个 int 的数组」,在表达式中又退化为指向 m[1][0] 的指针,所以 m[1] + 2 指向 m[1][2]

函数数组指针:长度必须进入接口

函数参数列表中的 int values[] 会被调整为 int *values;方括号不会把数组对象复制进函数,也不会携带长度。所以下面两个声明表示同一种函数类型:

long sum_values(size_t count, const int values[]);
long sum_values(size_t count, const int *values);

完整定义把元素个数显式传入,并用 表达只读契约。这类 const 形参 约束的是经参数进行的元素访问:

#include <stddef.h>
 
long sum_values(size_t count, const int values[count]) {
    long sum = 0;
    size_t i;
    for (i = 0; i < count; ++i)
        sum += values[i];
    return sum;
}

参数声明里的 values[count] 仍会调整为指针;count 是给读者、诊断器和优化器的边界信息,不会让 sizeof values 变回整个数组。函数体内 sizeof values 得到的是指针大小。const int *values 禁止经 values 改元素,但调用方传入的非 const 数组本身并没有因此永久变成 const。

二维数组形参必须让编译器知道每行有多少列,才能计算行步长。固定列数可写:

void print_rows(size_t rows, const int matrix[][3]);

支持变长数组类型时,也可以把列数放在形参之前:

void print_matrix(size_t rows, size_t cols,
                  const int matrix[rows][cols]) {
    size_t r, c;
    for (r = 0; r < rows; ++r) {
        for (c = 0; c < cols; ++c)
            printf("%d%c", matrix[r][c], c + 1 == cols ? '\n' : ' ');
    }
}

这里 matrix 调整为“指向含 cols 个 int 的数组的指针”,不是 int **。rows 和 cols 必须与实参数组布局一致;类型系统不会自动发现调用方实际分配了几行。

变长数组与复合字面量

的长度不是整数常量表达式:

void clear_square(size_t side) {
#if defined(__STDC_NO_VLA__)
    /* 改用动态分配或固定上限。 */
#else
    if (side == 0)
        return;
    int grid[side][side];
    /* grid 只在本次块执行期间存在,且不能用初始化列表初始化。 */
    (void)grid;
#endif
}

C11 允许实现定义 __STDC_NO_VLA__ 表示不支持 VLA。块作用域 VLA 的长度必须为正,存储期随块执行结束;不能返回指向它的指针。尺寸可能很大或来自不可信输入时,应先验证乘法与容量,再考虑受控动态分配。

可以临时提供一个有真实对象身份的数组:

long total = sum_values(4, (const int[]){2, 3, 5, 7});

块作用域复合字面量具有自动存储期,生存到包含它的块结束,而不只是当前完整表达式;文件作用域复合字面量具有静态存储期。可以在有效生存期内取其地址,但不能保存指针并在对象生存期结束后继续使用。

代码详解:数组 + 指针实战

场景一:一维数组求和与平均值

#include <stdio.h>
#define SIZE 5
 
int main(void) {
    int data[SIZE] = {10, 20, 30, 40, 50};
    int i, sum = 0;
 
    for (i = 0; i < SIZE; i++)
        sum += data[i];
 
    printf("总和 = %d,平均 = %.1f\n", sum, (double)sum / SIZE);
    return 0;
}

下标循环最直观;i < SIZE 防止越界。

场景二:用指针遍历

#include <stdio.h>
 
int main(void) {
    int ar[] = {3, 1, 4, 1, 5};
    size_t n = sizeof ar / sizeof ar[0];  /* 元素个数 */
    int *p;
 
    for (p = ar; p < ar + n; p++)
        printf("%d ", *p);
    printf("\n");
    return 0;
}

sizeof ar / sizeof ar[0] 是经典「数组元素个数」写法(只对数组对象有效,不能用于已退化的指针)。

场景三:二维数组遍历

#include <stdio.h>
 
int main(void) {
    int m[2][3] = {{1, 2, 3}, {4, 5, 6}};
    int r, c;
 
    for (r = 0; r < 2; r++) {
        for (c = 0; c < 3; c++)
            printf("%d ", m[r][c]);
        printf("\n");
    }
    return 0;
}

外循环控行,内循环控列——与行优先存储一致。

场景四:指针交换两元素(原地反转片段)

void swap_int(int *a, int *b) {
    int tmp = *a;
    *a = *b;
    *b = tmp;
}
 
/* 调用:swap_int(&ar[i], &ar[j]); 传地址才能改 ar 里的元素 */

这里已经用到「传指针改调用方数据」——与上一章值传递对比,是数组章节的自然延伸。

容易踩的坑

小结

  • 一维数组:同类型元素、连续内存、下标从 0 到 n-1
  • 指针存地址;*p 解引用,&x 取地址;类型决定步长
  • 表达式里数组名常退化为首元素指针;ar[i]*(ar+i)p[i](p 指向 ar)
  • 指针算术只在同一数组到尾后位置内有定义;尾后指针能比较但不能解引用
  • 二维数组逻辑是表格,物理行优先一行接一行;m[r][c] 线性下标 = r×列数+c
  • 数组形参会调整为指针,长度必须另传;const 元素形参表达只读访问契约
  • C11 实现可不支持 VLA;复合字面量则创建有明确存储期的未命名对象

练习

问题 1(改代码型) 下面循环想打印 4 个元素,但有 off-by-one 错误。改正后应输出 10 20 30 40

int vals[4] = {10, 20, 30, 40};
int i;
for (i = 0; i <= 4; i++)
    printf("%d ", vals[i]);

问题 2(问答型) 说明 int ar[5]; int *p = ar; 之后,* (p + 3) 访问的是哪个元素?与 ar[3] 什么关系?

问题 3(综合实现型) 写程序:读入 5 个整数到数组,用两个指针(一个从头、一个从尾)原地反转数组,再打印。不得用额外数组存副本。

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

数组(array)

相同类型元素组成的连续内存块,用单个名字和下标访问。声明如 int ar[n],下标从 0 到 n-1。详见 ArrayMemoryLayoutDiagram。

指针(pointer)

保存对象地址的变量。int *p 指向 int;*p 解引用读值,&x 取 x 的地址。

解引用(dereference)

对指针使用一元 *,访问其指向地址上的值。如 *ptr 得到 ptr 所指 int。

取地址

对对象使用一元 &,得到其在内存中的地址,类型为「指向该类型的指针」。

数组下标等价式

a[i]*(a + i) 访问同一元素。下标是指针写法的语法糖。详见 ArrayPointerEquivalenceDiagram。

数组名会「退化」为指向首元素的指针

在大多数表达式中,数组名转换为首元素指针(类型 T*),值等于 &a[0]。sizeof 数组名、&数组名 等场合除外。

指针算术(pointer arithmetic)

对指针加减整数,按 sizeof(指向类型) 移动。p+1 指向下一个同类型元素。详见 PointerArithmeticDiagram。

二维数组

元素为「一行数组」的数组,如 int m[3][4]。逻辑为 3 行 4 列,物理行优先连续存储。详见 TwoDimArrayDiagram。

行优先(row-major)

二维数组在内存中先存第 0 行全部列,再存第 1 行……C 语言采用此布局。线性下标 = 行号×列数+列号。

指定初始化器(designated initializer)

[下标] = 值 指定数组元素,或用 .成员 = 值 指定结构成员的初始化语法。未指定的数组元素按规则初始化为零。

const 元素形参

数组形参调整为指针后,以指向 const 元素的指针类型阻止函数经该参数修改元素;调用方仍须另传长度。

变长数组(VLA)

长度在进入块时由运行期表达式确定的自动数组。C11 把 VLA 支持设为实现可选,代码应识别 __STDC_NO_VLA__ 并准备替代方案。

复合字面量(compound literal)

(类型){初始化项} 在表达式中创建未命名对象。块作用域对象存活到包含块结束,文件作用域对象具有静态存储期。

资料与写作方式声明

本章以C Primer Plus, Sixth Edition, Chapter 10: Arrays and Pointers权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…