数组和指针
掌握一维与多维数组、指定初始化、数组形参、const、VLA、复合字面量和有边界的指针运算
学习目标
- 能用普通与指定初始化器创建一维、二维数组,并解释合法下标和尾后指针边界
- 能说明数组表达式何时转换为首元素指针,并写出带长度、const 元素约束的数组函数接口
- 能比较固定数组、C11 条件支持的 VLA 与复合字面量的生存期,并正确访问二维数组
机制总览
数组和指针:机制路径
- 1
为什么需要「数组」和「指针」?
想象一排带编号的小抽屉:第 0 格放周一温度,第 1 格放周二……你要算一周平均,就得依次打开每一格。如果给每个温度单独起名 mon 、 tue ……变量名会爆炸,代码也无法用循环处理「第 i 个」。
- 2
维数组:连续内存里的一排元素
scores[5] 表示 5 个 int 占一段 连续 内存。合法下标是 0 到 4 ——访问 scores[5] 已越界,行为未定义(可能崩溃、可能看似正常)。
- 3
指针基础:地址、解引用、取地址
指针也有类型: int p 是“指向 int 的指针”,可保存有效 int 对象地址、同一数组的尾后地址或空指针值;只有指向有效对象时才能解引用。
章级决策实验
数组和指针:机制与证据
切换《数组和指针》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。
选择推理阶段
当前阶段 · 为什么需要「数组」和「指针」?
想象一排带编号的小抽屉:第 0 格放周一温度,第 1 格放周二……你要算一周平均,就得依次打开每一格。如果给每个温度单独起名 mon 、 tue ……变量名会爆炸,代码也无法用循环处理「第 i 个」。
可核验证据
用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「为什么需要「数组」和「指针」?」的实际行为。
学完《数组和指针》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。
失效—证据矩阵
数组和指针:失效与核验
为什么需要「数组」和「指针」?
典型失效
若只记语法而忽略「为什么需要「数组」和「指针」?」的类型范围、求值顺序或资源边界,输入变化后可能出现未定义行为或错误结果。
核验证据
用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「为什么需要「数组」和「指针」?」的实际行为。
维数组:连续内存里的一排元素
典型失效
若只记语法而忽略「维数组:连续内存里的一排元素」的类型范围、求值顺序或资源边界,输入变化后可能出现未定义行为或错误结果。
核验证据
用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「维数组:连续内存里的一排元素」的实际行为。
指针基础:地址、解引用、取地址
典型失效
若只记语法而忽略「指针基础:地址、解引用、取地址」的类型范围、求值顺序或资源边界,输入变化后可能出现未定义行为或错误结果。
核验证据
用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「指针基础:地址、解引用、取地址」的实际行为。
为什么需要「数组」和「指针」?
想象一排带编号的小抽屉:第 0 格放周一温度,第 1 格放周二……你要算一周平均,就得依次打开每一格。如果给每个温度单独起名 mon、tue……变量名会爆炸,代码也无法用循环处理「第 i 个」。
数组就是 C 语言给你的「一整排编号抽屉」:一次声明 double temps[7],用下标 0 到 6 访问。指针则是「写着某格门牌号的纸条」——知道地址,就能找到那格里的值,还能用 p+1 走到下一格。
这一章把数组的排布、指针的含义、以及两者如何配合讲清楚。搞懂这些,后面字符串、动态内存、函数传数组都会顺很多。
一维数组:连续内存里的一排元素
↡由相同类型元素组成、在内存中连续存放、用单个名字和下标访问的数据结构。下标从 0 开始。 声明写法:
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 占一段连续内存。合法下标是 0 到 4——访问 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。后续未带指定符的初始化项从上一个指定位置之后继续,因此混用时要逐项核对位置;同一元素被多次初始化时以后出现的初始化覆盖先前值,但诊断和副作用细节不适合作为程序逻辑。
sizeof scores 是整个数组的字节数(通常 5×4=20),不是「指针大小」。用循环遍历是数组最经典的用法:
int i, sum = 0;
for (i = 0; i < 5; i++)
sum += scores[i];指针基础:地址、解引用、取地址
↡保存另一个对象内存地址的变量。类型写为「指向 T 的指针」,如 int *p。 里存的是地址,不是普通数值:
int num = 42;
int *ptr; /* ptr 将指向 int */
ptr = # /* & 取 num 的地址 */
printf("%d\n", *ptr); /* * 解引用:取该地址上的 int 值 → 42 */指针也有类型:int *p 是“指向 int 的指针”,可保存有效 int 对象地址、同一数组的尾后地址或空指针值;只有指向有效对象时才能解引用。double *q 的目标类型是 double。类型决定解引用访问的对象类型,也决定合法指针算术的元素步长。
指针与数组:名字、下标、等价式
在大多数表达式里,↡数组名在表达式中常转换为首元素地址的指针,类型为 T*。sizeof 数组名和 &数组名 是例外。:
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 标准保证:
↡下标运算符与指针偏移再解引用等价:a[i] 与 *(a+i) 访问同一元素。 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 字节
↡对指针加减整数,按所指向类型的大小移动地址。int* 的 p+1 通常加 4 字节。 的步长是 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 — 指针也可下标 */第一步:p 指向首元素
int *p = ar; 让 p 保存 ar[0] 的地址。*p 得到 10。
同一数组对象内(含尾后位置)的两个指针可以相减,结果类型为 ptrdiff_t,表示相隔的元素个数。对无关对象的指针做有序比较或相减没有可移植含义;只有相等/不等比较可用于任意兼容对象指针值。
多维数组:二维表格在物理内存中一行接一行
↡元素本身也是数组的一维数组。如 int m[3][4] 可看作 3 行,每行 4 个 int。 声明:
int matrix[3][4]; /* 3 行 4 列 */
int grid[2][3] = {{1, 2, 3}, {4, 5, 6}}; /* 按行初始化 */逻辑上你是「行 × 列」表格;物理内存里 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,使函数不能通过该指针修改所指元素;指针值本身仍是按值传入的局部副本。 表达只读契约。这类 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 必须与实参数组布局一致;类型系统不会自动发现调用方实际分配了几行。
变长数组与复合字面量
↡长度在进入块时由运行期表达式确定的自动数组类型;C11 允许实现不支持该可选特性。 的长度不是整数常量表达式:
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)
以
(类型){初始化项}在表达式中创建未命名对象。块作用域对象存活到包含块结束,文件作用域对象具有静态存储期。