这一篇从循环控制过渡到数组。
循环解决“重复做一件事”,数组解决“批量组织一组同类型数据”。两者放在一起看很自然:大多数数组操作都离不开循环,很多越界、死循环、下标错误和内存覆盖问题,也正是从这里开始出现的。
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; }
|
复盘嵌套循环时,不要只看最终输出。可以用断点看 i 和 j 的变化,弄清楚每一层循环负责什么。
6.1 break / continue 跳转语句
break 和 continue 都会改变循环的正常执行流程。
| 语句 |
作用 |
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 只结束内层循环,不会直接结束外层循环。
复盘 break 和 continue 时一定要说清楚:它影响的是哪一层循环。嵌套循环里最容易误判跳转范围。
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 会让执行路径变得不直观。代码一多,就很难判断程序到底从哪里跳到哪里。
适合保留印象的场景:
- 统一错误处理。
- 多层资源释放。
- 跳出复杂嵌套结构。
但初学阶段,能用 if、break、continue、return 写清楚,就不要急着用 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; }
|
从底层直觉看,大致会出现:
- 初始化循环变量。
- 比较循环条件。
- 条件不满足时跳出循环。
- 执行循环体。
- 更新循环变量。
- 跳回条件判断位置。
while 和 for 不必硬比快慢
很多时候,while 和 for 只是写法不同,编译器优化后可能生成非常接近的指令。现在复盘时更应该关注:
| 问题 |
说明 |
| 循环条件是否正确 |
会不会多循环一次或少循环一次 |
| 循环变量是否更新 |
会不会死循环 |
| 循环体是否有越界访问 |
数组下标是否安全 |
| 是否有提前退出 |
break、return 是否符合预期 |
学习反汇编时可以先把 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;
|
数组越界是 C 语言里非常重要的安全问题。编译器不一定帮你拦住,程序也不一定马上崩溃,但它可能已经改坏了别的内存。
6.5 数组特性
数组有几个非常关键的特性。
固定长度
这个数组可以存放 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] 中:
matrix[0][0]
matrix[0][1]
matrix[0][2]
matrix[1][0]
matrix[1][1]
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; }
|
复盘时重点看:
count 是怎么计算出来的。
scores[i] 的合法下标范围是什么。
matrix[row][col] 在内存里是不是连续排列。
- 双层循环中外层和内层分别控制什么。
- 如果把循环条件写成
i <= count 会发生什么风险。
常见错误整理
| 错误 |
示例 |
问题 |
| 嵌套循环变量混用 |
内层也写 i++ |
外层逻辑被破坏 |
误解 break 范围 |
以为能跳出所有循环 |
实际只跳出当前循环 |
滥用 goto |
随意跳转 |
执行路径难以维护 |
| 数组下标越界 |
arr[5] 访问长度为 5 的数组 |
写到数组外部内存 |
| 忘记数组从 0 开始 |
第一个元素写成 arr[1] |
少处理或错处理数据 |
sizeof 用错场景 |
数组传参后再算长度 |
得到的是指针大小 |
| 二维数组行列写反 |
matrix[col][row] |
数据访问错位 |
| 循环边界差一 |
i <= count |
多访问一个元素 |
学习检查清单
| 检查项 |
状态 |
| 能写出两层嵌套循环并解释执行顺序 |
待复盘 |
能说明 break 和 continue 的区别 |
待复盘 |
能判断 break 在嵌套循环中影响哪一层 |
待复盘 |
能理解 goto 的作用和不建议滥用的原因 |
待复盘 |
| 能从反汇编角度知道循环大致会变成比较和跳转 |
待复盘 |
| 能定义、初始化和遍历一维数组 |
待复盘 |
| 能说出数组下标合法范围 |
待复盘 |
能用 sizeof(arr) / sizeof(arr[0]) 计算数组元素个数 |
待复盘 |
| 能画出一维数组的连续内存布局 |
待复盘 |
| 能写出二维数组并用双层循环遍历 |
待复盘 |
| 能解释二维数组按行优先连续存储 |
待复盘 |
能写出 matrix[row][col] 的地址计算思路 |
待复盘 |
这一篇的核心是“循环控制访问范围,数组提供连续内存”。只要下标、边界和退出条件没写清楚,数组代码就很容易变成安全问题。