这一篇从循环控制过渡到数组。

循环解决“重复做一件事”,数组解决“批量组织一组同类型数据”。两者放在一起看很自然:大多数数组操作都离不开循环,很多越界、死循环、下标错误和内存覆盖问题,也正是从这里开始出现的。

6.0 嵌套循环

嵌套循环就是循环里面再写循环。最常见的场景是处理二维数据,比如表格、矩阵、地图、棋盘、二维数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>

int main(void)
{
for (int row = 0; row < 3; row++)
{
for (int col = 0; col < 4; col++)
{
printf("(%d,%d) ", row, col);
}

printf("\n");
}

return 0;
}

外层循环控制行,内层循环控制列。每执行一次外层循环,内层循环都会完整执行一轮。

嵌套循环的执行顺序

步骤 说明
1 外层 row = 0
2 内层 col = 0, 1, 2, 3
3 外层 row = 1
4 内层再次从 col = 0 开始
5 直到外层条件不成立

打印九九乘法表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>

int main(void)
{
for (int i = 1; i <= 9; i++)
{
for (int j = 1; j <= i; j++)
{
printf("%d*%d=%-2d ", j, i, i * j);
}

printf("\n");
}

return 0;
}

复盘嵌套循环时,不要只看最终输出。可以用断点看 ij 的变化,弄清楚每一层循环负责什么。

6.1 break / continue 跳转语句

breakcontinue 都会改变循环的正常执行流程。

语句 作用
break 直接结束当前循环
continue 跳过本轮剩余代码,进入下一轮循环判断

break

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>

int main(void)
{
for (int i = 0; i < 10; i++)
{
if (i == 5)
{
break;
}

printf("%d\n", i);
}

return 0;
}

输出到 4 后结束循环。

continue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>

int main(void)
{
for (int i = 0; i < 10; i++)
{
if (i % 2 == 0)
{
continue;
}

printf("%d\n", i);
}

return 0;
}

遇到偶数时跳过输出,所以只打印奇数。

嵌套循环里的 break

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>

int main(void)
{
for (int row = 0; row < 3; row++)
{
for (int col = 0; col < 4; col++)
{
if (col == 2)
{
break;
}

printf("row=%d col=%d\n", row, col);
}
}

return 0;
}

这里的 break 只结束内层循环,不会直接结束外层循环。

复盘 breakcontinue 时一定要说清楚:它影响的是哪一层循环。嵌套循环里最容易误判跳转范围。

6.2 goto 无条件跳转

goto 可以直接跳转到指定标签。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

int main(void)
{
int value = -1;

if (value < 0)
{
goto error;
}

printf("normal process\n");
return 0;

error:
printf("invalid value\n");
return 1;
}

为什么不建议滥用 goto

goto 会让执行路径变得不直观。代码一多,就很难判断程序到底从哪里跳到哪里。

适合保留印象的场景:

  1. 统一错误处理。
  2. 多层资源释放。
  3. 跳出复杂嵌套结构。

但初学阶段,能用 ifbreakcontinuereturn 写清楚,就不要急着用 goto

统一清理资源的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>

int main(void)
{
FILE *fp = fopen("demo.txt", "r");

if (fp == NULL)
{
goto cleanup;
}

printf("file opened\n");

cleanup:
if (fp != NULL)
{
fclose(fp);
fp = NULL;
}

return 0;
}

这里的 goto cleanup 不是为了炫技,而是为了把清理动作集中到一个位置。后续写 C 项目、系统代码或读开源代码时会见到这种风格。

6.3 反汇编角度分析循环流程效率

循环在底层通常会变成比较和跳转。

1
2
3
4
for (int i = 0; i < 5; i++)
{
sum += i;
}

从底层直觉看,大致会出现:

  1. 初始化循环变量。
  2. 比较循环条件。
  3. 条件不满足时跳出循环。
  4. 执行循环体。
  5. 更新循环变量。
  6. 跳回条件判断位置。

whilefor 不必硬比快慢

很多时候,whilefor 只是写法不同,编译器优化后可能生成非常接近的指令。现在复盘时更应该关注:

问题 说明
循环条件是否正确 会不会多循环一次或少循环一次
循环变量是否更新 会不会死循环
循环体是否有越界访问 数组下标是否安全
是否有提前退出 breakreturn 是否符合预期

学习反汇编时可以先把 C 代码写得非常简单,然后打开 Debug 反汇编窗口观察。代码越复杂,编译器优化越多,越不适合初学阶段建立直觉。

6.4 数组概念

数组是一组连续存放的同类型元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

int main(void)
{
int scores[5] = {80, 90, 75, 88, 100};

for (int i = 0; i < 5; i++)
{
printf("scores[%d] = %d\n", i, scores[i]);
}

return 0;
}

数组的几个关键词

概念 说明
元素 数组里的每一个数据
下标 元素的位置编号,从 0 开始
长度 数组能存放多少个元素
元素类型 数组中每个元素的类型
连续内存 数组元素在内存里连续排列

下标从 0 开始

长度为 5 的数组,合法下标是 0 ~ 4

1
2
3
4
5
6
int arr[5] = {0};

arr[0] = 10;
arr[4] = 50;

// arr[5] = 60; // 越界

数组越界是 C 语言里非常重要的安全问题。编译器不一定帮你拦住,程序也不一定马上崩溃,但它可能已经改坏了别的内存。

6.5 数组特性

数组有几个非常关键的特性。

固定长度

1
int arr[10];

这个数组可以存放 10 个 int。在普通栈数组场景下,长度确定后不能像动态容器一样自动变大。

同类型

数组里每个元素类型相同。

1
2
int nums[3] = {1, 2, 3};
char name[6] = {'R', 'u', 'i', 'q', 'y', '\0'};

可以用 sizeof 计算元素个数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>

int main(void)
{
int nums[] = {10, 20, 30, 40};
size_t count = sizeof(nums) / sizeof(nums[0]);

printf("count = %zu\n", count);

for (size_t i = 0; i < count; i++)
{
printf("%d\n", nums[i]);
}

return 0;
}

这种写法适合在数组仍然是“真数组”的作用域里使用。后面数组作为函数参数传递时,会退化成指针,到那时 sizeof 的结果就不再是整个数组大小。

6.6 数组内存布局

数组元素在内存中连续排列。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

int main(void)
{
int arr[4] = {10, 20, 30, 40};

for (int i = 0; i < 4; i++)
{
printf("&arr[%d] = %p, value = %d\n", i, (void *)&arr[i], arr[i]);
}

return 0;
}

如果 int 占 4 字节,那么相邻元素地址通常相差 4。

数组名和首元素地址

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

int main(void)
{
int arr[3] = {1, 2, 3};

printf("arr = %p\n", (void *)arr);
printf("&arr[0] = %p\n", (void *)&arr[0]);
printf("&arr = %p\n", (void *)&arr);

return 0;
}

arr 在很多表达式里会退化成首元素地址,&arr 是整个数组的地址。它们打印出来的数值可能一样,但类型含义不同。这个点后面学指针时非常重要。

数组之所以和安全关系很近,是因为它是连续内存。下标越界时,访问的不是“空气”,而是相邻的真实内存。

6.7 数组计算公式

一维数组访问元素时,本质上是在计算地址。

1
arr[i] 的地址 = 数组首地址 + i * sizeof(元素类型)
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

int main(void)
{
int arr[5] = {10, 20, 30, 40, 50};

for (int i = 0; i < 5; i++)
{
printf("arr[%d] address = %p\n", i, (void *)&arr[i]);
}

return 0;
}

下标和指针形式

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int main(void)
{
int arr[3] = {11, 22, 33};

printf("%d\n", arr[1]);
printf("%d\n", *(arr + 1));

return 0;
}

arr[1] 可以理解成 *(arr + 1)。这里的 + 1 不是地址数值加 1 个字节,而是移动 1 个元素大小。

6.8 二维数组内存布局

二维数组可以理解成“行和列”,但在内存里依然是连续排列的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>

int main(void)
{
int matrix[2][3] = {
{1, 2, 3},
{4, 5, 6}
};

for (int row = 0; row < 2; row++)
{
for (int col = 0; col < 3; col++)
{
printf("matrix[%d][%d] = %d, address = %p\n",
row, col, matrix[row][col], (void *)&matrix[row][col]);
}
}

return 0;
}

C 语言二维数组按行优先存储,也就是先放第一行,再放第二行。

地址计算公式

对于 int matrix[rows][cols]

1
matrix[row][col] 的地址 = 首地址 + (row * cols + col) * sizeof(int)

比如 matrix[2][3] 中:

  1. matrix[0][0]
  2. matrix[0][1]
  3. matrix[0][2]
  4. matrix[1][0]
  5. matrix[1][1]
  6. matrix[1][2]

它们在内存里是连续排下去的。

6.9 二维数组常见样式

二维数组常用于矩阵、表格、棋盘、地图和多条记录。

遍历二维数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>

int main(void)
{
int table[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};

for (int row = 0; row < 3; row++)
{
for (int col = 0; col < 4; col++)
{
printf("%4d", table[row][col]);
}

printf("\n");
}

return 0;
}

计算每一行的和

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>

int main(void)
{
int table[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};

for (int row = 0; row < 3; row++)
{
int sum = 0;

for (int col = 0; col < 4; col++)
{
sum += table[row][col];
}

printf("row %d sum = %d\n", row, sum);
}

return 0;
}

二维数组复盘时,要明确两件事:逻辑上它是行列结构,物理上它还是一段连续内存。

本章最小复盘代码

这段代码把嵌套循环、一维数组、二维数组和边界控制放到一起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <stdio.h>

int main(void)
{
int scores[] = {80, 92, 75, 60, 99};
size_t count = sizeof(scores) / sizeof(scores[0]);
int total = 0;

for (size_t i = 0; i < count; i++)
{
total += scores[i];
}

printf("average = %.2f\n", (double)total / count);

int matrix[2][3] = {
{1, 2, 3},
{4, 5, 6}
};

for (int row = 0; row < 2; row++)
{
for (int col = 0; col < 3; col++)
{
printf("matrix[%d][%d] = %d, address = %p\n",
row, col, matrix[row][col], (void *)&matrix[row][col]);
}
}

return 0;
}

复盘时重点看:

  1. count 是怎么计算出来的。
  2. scores[i] 的合法下标范围是什么。
  3. matrix[row][col] 在内存里是不是连续排列。
  4. 双层循环中外层和内层分别控制什么。
  5. 如果把循环条件写成 i <= count 会发生什么风险。

常见错误整理

错误 示例 问题
嵌套循环变量混用 内层也写 i++ 外层逻辑被破坏
误解 break 范围 以为能跳出所有循环 实际只跳出当前循环
滥用 goto 随意跳转 执行路径难以维护
数组下标越界 arr[5] 访问长度为 5 的数组 写到数组外部内存
忘记数组从 0 开始 第一个元素写成 arr[1] 少处理或错处理数据
sizeof 用错场景 数组传参后再算长度 得到的是指针大小
二维数组行列写反 matrix[col][row] 数据访问错位
循环边界差一 i <= count 多访问一个元素

学习检查清单

检查项 状态
能写出两层嵌套循环并解释执行顺序 待复盘
能说明 breakcontinue 的区别 待复盘
能判断 break 在嵌套循环中影响哪一层 待复盘
能理解 goto 的作用和不建议滥用的原因 待复盘
能从反汇编角度知道循环大致会变成比较和跳转 待复盘
能定义、初始化和遍历一维数组 待复盘
能说出数组下标合法范围 待复盘
能用 sizeof(arr) / sizeof(arr[0]) 计算数组元素个数 待复盘
能画出一维数组的连续内存布局 待复盘
能写出二维数组并用双层循环遍历 待复盘
能解释二维数组按行优先连续存储 待复盘
能写出 matrix[row][col] 的地址计算思路 待复盘

这一篇的核心是“循环控制访问范围,数组提供连续内存”。只要下标、边界和退出条件没写清楚,数组代码就很容易变成安全问题。