Lesson 03: 循环计数 CNB
练习任务
编写一个 C 程序,使用循环从 1 数到 10,每行输出 counter = 数字。
期望输出:
counter = 1
counter = 2
...
counter = 10提示:你需要一个计数器变量,用
while循环控制它不断增加,直到满足结束条件。思考:计数器从 0 还是 1 开始?比较条件用<还是<=?先自增还是先打印?
核心知识点
- 变量声明 —
type name = initial_value;的完整语法 int数据类型 — 大小(通常 4 字节)、范围(-2^31 到 2^31-1)- 赋值运算符
=与相等运算符==— 初学者最常见的陷阱 for循环语法 —for (初始化; 条件; 更新) { 循环体 }- 循环执行顺序 — 初始化 → 条件检查 → 循环体 → 更新 → 条件检查 → ...
- 循环变量作用域 — C89 块级作用域 vs C99 for 作用域
while循环 — 前测循环,循环体可能执行零次do-while循环 — 后测循环,循环体至少执行一次- 自增/自减运算符 —
i++、++i、i--、--i(前置 vs 后置) - 计数器变量命名 —
i、j、k、cnt、count等约定 - 循环边界 —
<vs<=的 off-by-one 错误 - 嵌套循环 — 外层每执行一次,内层完整执行一轮
- 无限循环 —
for(;;)和while(1) break— 提前退出循环continue— 跳过当前迭代printf("%d")— 格式化整数输出- 循环展开 — 编译器优化概念
- 汇编视角 —
for循环如何变成jmp/jne指令 - 常见错误 — 无限循环、多一次迭代、用错变量
- 追踪表技术 — 调试循环的纸笔方法
代码框架
#include <stdio.h>
int main(void)
{
int counter;
counter = /* 初始值 */;
while (counter /* 比较条件 */ 10)
{
/* 更新 counter */
printf("counter = %d\n", counter);
}
return 0;
}请填充以上框架。思考:counter 应该从 0 还是 1 开始?比较条件用 < 还是 <=?先自增还是先打印?
TIP
先不要往下翻看参考解答。尝试用 while 写出第一版,然后再思考能否用 do-while 和 for 实现同样的效果。
深度讲解
1. 变量声明与 int 类型
在编写循环之前,你需要一个变量来记录当前的计数值。这就是变量的基本用途——存储会变化的数据。
1.1 变量声明语法
C 语言中声明一个变量的完整语法为:
type name = initial_value;其中:
type:数据类型,决定变量占多大内存、能存什么值name:变量名,由字母、数字和下划线组成,不能以数字开头= initial_value:可选的初始化值。如果不写,局部变量的值是未定义的(垃圾值)
声明和赋值可以分开写,这在教学中尤其有用:
int counter; // 声明:分配一块内存(通常 4 字节)
counter = 0; // 赋值:将 0 写入那块内存也可以合二为一:
int counter = 0; // 声明并初始化将声明和赋值分开写的好处在于,它能让你清楚地看到循环三要素中的「初始化」步骤,与 for 循环的 init 段形成直观对应。我们会在 Lesson 03 的讲解中采用分开写的风格,但在工程实践中,定义时初始化是推荐做法(减少未初始化风险)。
1.2 int 数据类型详解
int 是 C 语言中最常用的整数类型。它的具体大小取决于平台,但有几个重要事实你需要记住:
| 属性 | 典型值(32/64 位平台) | 说明 |
|---|---|---|
| 大小 | 4 字节(32 位) | sizeof(int) 返回的字节数 |
| 最小值 | -2,147,483,648 | 即 -2^31,定义在 <limits.h> 中为 INT_MIN |
| 最大值 | +2,147,483,647 | 即 2^31 - 1,定义在 <limits.h> 中为 INT_MAX |
| 编码方式 | 补码(two's complement) | C99 标准强制要求有符号整数使用补码 |
NOTE
C 标准只规定 int 至少 16 位(-32768 到 32767),但现代 32/64 位平台上几乎都是 32 位。嵌入式系统中可能会遇到 16 位的 int(如 AVR 微控制器)。使用 <limits.h> 中的 INT_MAX / INT_MIN 可以写出可移植的代码。
补码表示法有一个有趣的特点:负数范围比正数多一个值。-2,147,483,648(0x80000000)没有对应的正数——取反后仍然是它自己(溢出)。
1.3 变量名的命名规则与约定
C 语言的变量名遵循以下规则:
- 只能包含字母(
A-Z、a-z)、数字(0-9)和下划线(_) - 不能以数字开头(
1counter不合法) - 大小写敏感(
counter、Counter、COUNTER是三个不同的变量) - 不能使用 C 语言的关键字(如
int、while、for)
对于循环计数器,社区有强烈的命名约定:
| 变量名 | 使用场景 |
|---|---|
i | 最常用的循环计数器(来自数学中的 index) |
j | 嵌套循环的第二层计数器 |
k | 嵌套循环的第三层计数器 |
cnt、count | 计数用途的变量,比 i 更具语义 |
n | 表示数量上限(如 i < n) |
row、col | 二维场景的行列计数器 |
// 经典约定:i 遍历行,j 遍历列
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
printf("%4d", matrix[i][j]);
}
printf("\n");
}2. 赋值运算符 = 与相等运算符 ==
这两个运算符是 C 语言初学者最容易混淆的地方,也是大量 bug 的根源。
2.1 赋值运算符 =
= 在 C 语言中不是「等于」,而是赋值——将右侧的值复制到左侧的变量中:
int counter;
counter = 0; // 将 0 赋给 counter(counter 现在是 0)
counter = counter + 1; // 取出 counter 的值(0),加 1,再存回 counter(counter 现在是 1)注意 counter = counter + 1 在数学上无意义(一个数不可能等于自己加 1),但在 C 语言中这是最常见的操作之一——它表示「读取 counter 的当前值,加 1,再把结果写回 counter」。
2.2 相等运算符 ==
== 才是数学意义上的「等于」——它比较左右两侧的值是否相等,返回 1(真)或 0(假):
int a = 5, b = 5, c = 7;
printf("%d\n", a == b); // 输出 1(真:5 等于 5)
printf("%d\n", a == c); // 输出 0(假:5 不等于 7)2.3 最常见的陷阱:= 和 == 的混淆
这是一个灾难性的错误,而且在 C 语言中完全合法:
int counter = 0;
while (counter = 10) { // 本意是 counter == 10!
printf("counter = %d\n", counter);
counter++;
}这段代码会怎样?counter = 10 是一个赋值表达式,它的值是 10。在 C 语言中,任何非零值都是真,所以条件永远为真——循环永远停不下来(无限循环)。
CAUTION
if (x = 5) 和 while (x = 5) 在 C 语言中完全合法!编译器只会给一个警告(需要开启 -Wall)。每次看到 = 出现在条件位置,都应该停下来检查是不是应该用 ==。
一种防御性编程技巧是将常量放在左边:
if (5 == x) { ... } // 如果误写成 5 = x,编译器会报错因为 5 = x 试图给常量赋值,编译不通过。不过这种写法可读性较差,团队使用前需要达成共识。
TIP
开启编译器的 -Wall -Wextra 选项可以捕获大多数 = 和 == 的混淆。现代 GCC/Clang 会对 if (x = 5) 产生 -Wparentheses 警告。
3. while 循环:前测循环
while 循环是最基本的循环结构。它先检查条件,再决定是否执行循环体。
3.1 while 循环的语法与执行流程
while (条件表达式) {
// 循环体
}执行流程:
┌──────────────┐
│ 条件表达式 │ ← 每次进入前检查
└──────┬───────┘
│
┌──────▼──────┐
│ 条件为真? │
└──┬───────┬──┘
│ 真 │ 假
│ │
┌──────▼──┐ │
│ 循环体 │ │
└──────┬──┘ │
│ │
└───┐ │
│ │
▼ ▼
┌───────────┐
│ 继续执行 │
│ 循环后语句 │
└───────────┘while 循环的核心特征:条件在循环体之前检查,所以循环体可能执行零次。如果条件一开始就为假,循环体一次都不执行。
3.2 循环三要素
所有循环(无论是 while、do-while 还是 for)都由三个核心要素构成:
- 初始化(Initialization):设定循环的起始状态——计数器从几开始?
- 条件判断(Condition):决定循环是否继续——什么时候停下来?
- 更新(Update):改变循环状态——每次迭代后计数器如何变化?
这三个要素缺一不可:
- 缺初始化 → 计数器值是未知的垃圾值,行为不可预测
- 缺条件 → 无限循环,程序永远不会停
- 缺更新 → 同样是无限循环,因为条件永远不变
3.3 用 while 实现「从 1 数到 10」
将三要素映射到本题:
#include <stdio.h>
int main(void)
{
int counter;
counter = 0; // 要素 1:初始化——从 0 开始
while (counter < 10) { // 要素 2:条件——小于 10 时继续
counter++; // 要素 3:更新——每次加 1
printf("counter = %d\n", counter);
}
return 0;
}让我们逐行追踪前三次迭代,用追踪表技术来理解程序行为:
| 迭代 | 进入条件 counter < 10 | counter++ 后 | printf 输出 |
|---|---|---|---|
| 1 | 0 < 10 → 真 | counter = 1 | counter = 1 |
| 2 | 1 < 10 → 真 | counter = 2 | counter = 2 |
| 3 | 2 < 10 → 真 | counter = 3 | counter = 3 |
| ... | ... | ... | ... |
| 10 | 9 < 10 → 真 | counter = 10 | counter = 10 |
| 11 | 10 < 10 → 假 | — | 循环终止 |
追踪表是一种强大的调试技术——当你对循环行为有疑问时,拿出纸笔,一行行模拟执行,就能发现逻辑错误。这比盯着代码猜测有效得多。
3.4 while 的适用场景
while 循环最适合以下场景:
- 迭代次数不确定,取决于运行时条件(如「读到文件末尾」「用户输入 q 退出」)
- 条件比较复杂,不适合压缩到一行
- 循环体可能一次都不执行是正确的行为
char input[10];
printf("Enter 'quit' to exit: ");
scanf("%s", input);
while (strcmp(input, "quit") != 0) {
printf("You entered: %s\n", input);
printf("Enter 'quit' to exit: ");
scanf("%s", input);
}
// 如果用户第一次就输入 "quit",循环体一次都不执行4. do-while 循环:后测循环
do-while 是 while 的变体,唯一的区别是条件检查在循环体之后。
4.1 do-while 的语法
do {
// 循环体
} while (条件表达式); // 注意这里的分号!执行流程:
┌──────────────┐
│ 循环体 │ ← 先执行一次
└──────┬───────┘
│
┌──────▼──────┐
│ 条件表达式 │ ← 再检查条件
└──┬───────┬──┘
│ 真 │ 假
│ │
└───┐ │
│ │
▼ ▼
┌───────────┐
│ 继续执行 │
└───────────┘WARNING
do-while 末尾的分号 ; 不能省略!} while (条件); 是一个完整的语句。省略分号会导致编译错误。这是初学者最容易犯的 do-while 语法错误。
4.2 do-while 实现「从 1 数到 10」
#include <stdio.h>
int main(void)
{
int counter;
counter = 0;
do {
counter++;
printf("counter = %d\n", counter);
} while (counter < 10); // 注意分号
return 0;
}在本例中,输出和 while 版本完全相同。但原因是什么?因为 counter = 0 < 10 成立,所以 while 版本的第一次迭代也必然会执行。
关键区别体现在边界情况。假设 counter 初始值已经是 10:
// while 版本:条件 10 < 10 为假 → 循环体不执行 ✓
counter = 10;
while (counter < 10) {
counter++;
printf("counter = %d\n", counter);
}
// 无输出——正确!
// do-while 版本:先执行循环体,再检查条件
counter = 10;
do {
counter++; // counter 变成 11
printf("counter = %d\n", counter); // 输出 counter = 11 ✗
} while (counter < 10);
// 输出了一行——错误!4.3 do-while 的最佳适用场景
do-while 适合「先执行,再判断」的场景。最经典的应用是用户输入验证:
int age;
do {
printf("Enter your age (1-120): ");
scanf("%d", &age);
} while (age < 1 || age > 120);
// 用户至少输入一次;输入不合法就再来一次菜单驱动的控制台程序也常用 do-while:
int choice;
do {
printf("\n=== Menu ===\n");
printf("1. Play\n");
printf("2. Settings\n");
printf("3. Quit\n");
printf("Choice: ");
scanf("%d", &choice);
// 处理 choice...
} while (choice != 3);5. for 循环:三段式结构
for 循环将循环三要素集中在一行,是迭代次数已知时最优雅的写法。
5.1 for 循环的语法
for (初始化; 条件; 更新) {
// 循环体
}三个表达式用分号分隔(不是逗号!),它们各自的含义:
| 表达式 | 执行时机 | 典型内容 |
|---|---|---|
| 初始化 | 循环开始前,只执行一次 | int i = 0 |
| 条件 | 每次迭代之前检查 | i < 10 |
| 更新 | 每次迭代之后执行 | i++ |
5.2 循环执行顺序详解
for (初始化; 条件; 更新) {
循环体;
}完整执行顺序:
初始化
↓
┌─→ 条件检查(假则退出)
│ ↓ 真
│ 循环体
│ ↓
│ 更新
│ ↓
└───┘IMPORTANT
更新表达式在循环体之后执行,这个顺序对理解 continue 的行为至关重要——在 for 循环中,continue 会先执行更新表达式,再检查条件。
5.3 for 循环实现「从 1 数到 10」
#include <stdio.h>
int main(void)
{
for (int counter = 0; counter < 10; counter++) {
printf("counter = %d\n", counter + 1);
}
return 0;
}注意这里的细节:counter 从 0 到 9(共 10 次迭代),输出时用 counter + 1 得到 1 到 10。这保持了从 0 开始的约定,同时满足输出要求。
5.4 for 是 while 的语法糖
for 循环完全可以展开为等价的 while 循环:
// for 版本
for (初始化; 条件; 更新) {
循环体;
}
// 等价的 while 版本
初始化;
while (条件) {
循环体;
更新;
}这种等价性意味着:
- 任何
for循环都可以改写为while循环 - 两者的汇编代码完全相同(编译后无区别)
for的存在纯粹是为了代码可读性——将循环控制逻辑集中在一起
5.5 for 循环三个表达式的省略
for 循环的三个表达式都可以省略,但分号不能省:
// 1. 省略初始化:变量已在外部定义
int i = 0;
for (; i < 10; i++) {
printf("%d\n", i);
}
// 2. 省略条件:条件永远为真 → 无限循环
for (int i = 0; ; i++) {
if (i >= 10) break; // 需手动退出
printf("%d\n", i);
}
// 3. 省略更新:在循环体内手动更新
for (int i = 0; i < 10; ) {
printf("%d\n", i);
i++; // 手动更新
}
// 4. 全部省略:经典无限循环惯用法
for (;;) {
// 等价于 while (1)
}| 省略部分 | 默认行为 | 风险 |
|---|---|---|
| 初始化 | 不执行初始化 | 变量可能未初始化 |
| 条件 | 永远为真(非 0) | 必须用 break 退出 |
| 更新 | 不执行更新 | 可能死循环,需手动更新 |
for(;;) 和 while(1) 都表示无限循环,两者等价。for(;;) 在某些编译器上不会产生「条件表达式恒为真」的警告,是 C 语言中表示无限循环的惯用法。
6. 循环变量作用域
循环中定义的变量能「活」多久、「看」多远,取决于 C 语言的作用域规则。
6.1 C89 vs C99 的 for 作用域
在 C89/ANSI C 中,变量只能在代码块开头声明。这意味着 for 循环的计数器必须在循环外部声明:
/* C89 风格:计数器在循环外声明 */
int i;
for (i = 0; i < 10; i++) {
printf("%d\n", i);
}
/* i 在此仍然可见! */
printf("Final i = %d\n", i); // 输出 10C99 引入了在 for 的初始化段中声明变量的能力:
/* C99 风格:计数器在 for 内部声明 */
for (int i = 0; i < 10; i++) {
printf("%d\n", i);
}
/* i 在此不可见——作用域仅限于 for 循环 */
// printf("%d\n", i); // 编译错误:i 未声明IMPORTANT
C99 的 for 作用域是更安全的做法——计数器不会「泄漏」到循环外部。现代 C 编程(C99 及以后)推荐在 for 内部声明循环变量。本教程的所有代码默认使用 C99 及以上标准。
6.2 块级作用域与变量遮蔽
循环体 {} 内部定义的变量,作用域仅限于该代码块:
int counter = 0;
while (counter < 3) {
int inner = counter * 10; // inner 只在 {} 内可见
printf("inner = %d\n", inner);
counter++;
}
// printf("%d\n", inner); // 编译错误:inner 不可见每次循环迭代都会重新创建局部变量(尽管编译器可能复用同一块内存地址),未初始化的局部变量每次迭代都是垃圾值:
int counter = 0;
while (counter < 3) {
int x; // 每次迭代重新创建,值不确定!
printf("x = %d\n", x); // 可能打印不同的垃圾值
counter++;
}变量遮蔽规则同样适用于循环体内(回顾 Lesson 01 的作用域遮蔽):
int x = 100;
for (int i = 0; i < 2; i++) {
int x = 200; // 遮蔽外层的 x
printf("%d\n", x); // 输出 200(每次迭代)
}
printf("%d\n", x); // 输出 100(外层的 x 未被修改)7. 自增与自减运算符
++ 和 -- 是 C 语言中最常用的运算符之一,也是循环的天然搭档。
7.1 前置 vs 后置
自增运算符 ++ 根据位置不同有两种语义:
后置自增 i++(Post-increment)——先用值,再加 1:
int i = 5;
int a = i++; // a 得到 i 的当前值 5,然后 i 变成 6
// 结果:i = 6, a = 5等价于:
int a = i;
i = i + 1;前置自增 ++i(Pre-increment)——先加 1,再用值:
int i = 5;
int a = ++i; // i 先变成 6,然后 a 得到 6
// 结果:i = 6, a = 6等价于:
i = i + 1;
int a = i;自减运算符 -- 完全对称:
| 运算符 | 名称 | 语义 | 示例(i=5) |
|---|---|---|---|
i++ | 后置自增 | 先用后加 | a = i++ → a=5, i=6 |
++i | 前置自增 | 先加后用 | a = ++i → a=6, i=6 |
i-- | 后置自减 | 先用后减 | a = i-- → a=5, i=4 |
--i | 前置自减 | 先减后用 | a = --i → a=4, i=4 |
7.2 在循环中的使用
当自增/自减作为独立的更新语句(不被其他表达式使用其返回值)时,前置和后置完全等价:
// 这两种写法的行为完全相同
for (int i = 0; i < 10; i++) { ... }
for (int i = 0; i < 10; ++i) { ... }编译器会优化掉返回值的使用差异,生成相同的机器代码。在 C 语言中,选择 i++ 还是 ++i 纯粹是风格偏好——i++ 更常见。
7.3 在表达式中的陷阱
将自增嵌入复杂表达式是危险的:
int i = 5;
int result = i++ + ++i; // 未定义行为!在同一个表达式中多次修改同一个变量是未定义行为(Undefined Behavior),因为 C 标准没有规定子表达式的求值顺序。不同编译器可能给出不同结果,同一编译器的不同优化级别也可能产生不同结果。
CAUTION
黄金规则:一条语句中,同一个变量最多只能被修改一次。i++ + ++i、arr[i++] = i 等写法都违反了这条规则。
7.4 自增嵌入 printf 的问题
// 不推荐:自增隐藏在 printf 中
while (counter < 10)
printf("counter = %d\n", ++counter);
// 推荐:自增独立一行
while (counter < 10) {
counter++;
printf("counter = %d\n", counter);
}嵌入自增的问题:
- 可读性差:修改变量的操作被隐藏在输出语句中,读者容易遗漏
- 容易出错:
++counter和counter++产生不同输出,搞混了难以察觉 - 难以调试:无法在自增处单独设置断点
- 违反单一职责原则:一行代码做了两件不相关的事
8. 循环边界:off-by-one 错误
「差一错误」(off-by-one error)是循环编程中最常见、最隐蔽的逻辑错误。
8.1 < vs <= 的区别
// 写法 A:从 0 开始,使用 <(推荐)
for (int i = 0; i < 10; i++) // 执行 10 次:i = 0,1,2,...,9
// 写法 B:从 0 开始,使用 <=
for (int i = 0; i <= 10; i++) // 执行 11 次:i = 0,1,2,...,10
// 写法 C:从 1 开始,使用 <=
for (int i = 1; i <= 10; i++) // 执行 10 次:i = 1,2,...,10
// 写法 D:从 1 开始,使用 <
for (int i = 1; i < 10; i++) // 执行 9 次:i = 1,2,...,9每种写法都能表达「10 次迭代」的意图,但清晰度不同。比较它们的可读性:
for (int i = 0; i < N; i++) // 一眼看出:N 次
for (int i = 0; i <= N; i++) // 需要想:N+1 次?
for (int i = 1; i <= N; i++) // 需要想:N 次?IMPORTANT
最佳实践:循环计数器从 0 开始,使用 < 作为比较运算符。这样迭代次数 = 上界值,无需心算。这是 C 语言社区数十年沉淀下来的约定——数组索引从 0 开始,循环计数器也应从 0 开始。
8.2 常见的 off-by-one 场景
场景 1:数组越界
int arr[10]; // 有效索引:0 到 9
for (int i = 0; i <= 10; i++) // i=10 时访问 arr[10]——越界!
arr[i] = i;场景 2:字符串遍历
char str[] = "hello"; // 6 个字符:h,e,l,l,o,\0
int len = 5; // 字符串长度(不含 \0)
for (int i = 0; i <= len; i++) // 执行 6 次,最后一次处理 \0——可能是故意的
printf("%c", str[i]);场景 3:循环次数少一次
// 意图:打印 1 到 10
for (int i = 1; i < 10; i++) // 实际打印 1 到 9——少了 10!
printf("%d\n", i);8.3 防御策略
几种减少 off-by-one 错误的方法:
- 统一使用「从 0 开始,< 上界」模式
- 给上界一个有意义的变量名:
for (int i = 0; i < num_students; i++)比for (int i = 0; i < 35; i++)清晰 - 用追踪表验证边界:模拟第一次和最后一次迭代
- 写测试:测试 n=0、n=1、n=最大值的情况
9. 嵌套循环
当一个循环体内包含另一个循环时,就构成了嵌套循环。
9.1 嵌套循环的执行模式
for (int i = 0; i < 3; i++) { // 外层:3 次
for (int j = 0; j < 4; j++) { // 内层:每次 4 次
printf("(%d,%d) ", i, j);
}
printf("\n");
}输出:
(0,0) (0,1) (0,2) (0,3)
(1,0) (1,1) (1,2) (1,3)
(2,0) (2,1) (2,2) (2,3)外层循环每执行一次,内层循环完整执行一轮。总迭代次数 = 外层迭代次数 × 内层迭代次数(本例:3 × 4 = 12)。
9.2 嵌套循环的经典例子:九九乘法表
#include <stdio.h>
int main(void)
{
for (int row = 1; row <= 9; row++) {
for (int col = 1; col <= 9; col++) {
printf("%4d", row * col);
}
printf("\n");
}
return 0;
}要点:
- 外层循环控制行(被乘数),内层循环控制列(乘数)
%4d保证每列宽度一致,右对齐- 总迭代次数:9 × 9 = 81
9.3 嵌套循环与时间复杂度
嵌套循环是算法复杂度分析的基础。一个简单的规则:
| 嵌套层数 | 时间复杂度 | 示例 |
|---|---|---|
| 1 层 | O(n) | 遍历一维数组 |
| 2 层 | O(n²) | 九九乘法表、冒泡排序 |
| 3 层 | O(n³) | 矩阵乘法(朴素算法) |
理解嵌套循环的迭代次数,是理解算法效率的第一步。
10. 循环控制语句:break 与 continue
break 和 continue 让你能在循环体内改变默认的执行流程。
10.1 break — 立即退出循环
break 使程序立即跳出当前最内层的循环,执行循环后面的语句:
#include <stdio.h>
int main(void)
{
for (int i = 0; i < 100; i++) {
if (i == 42) {
break; // 遇到 42,立即退出循环
}
printf("%d\n", i);
}
// 程序跳到这里
printf("Loop exited at i = 42\n");
return 0;
}break 只退出最内层的循环,不影响外层:
for (int i = 0; i < 5; i++) {
for (int j = 0; j < 5; j++) {
if (j == 2) break; // 只退出内层循环
printf("(%d,%d) ", i, j);
}
printf("\n"); // 外层循环继续执行
}10.2 continue — 跳过本次迭代
continue 跳过当前迭代的剩余部分,直接进入下一次迭代:
#include <stdio.h>
int main(void)
{
for (int i = 0; i < 10; i++) {
if (i % 2 == 0) {
continue; // 偶数:跳过 printf,直接进入 i++
}
printf("%d\n", i); // 只打印奇数
}
return 0;
}
// 输出:1 3 5 7 910.3 continue 在不同循环中的行为差异
这是 continue 最容易出错的地方:
| 循环类型 | continue 跳到哪里 |
|---|---|
for | 跳到更新表达式(如 i++),然后检查条件 |
while | 跳到条件检查 |
do-while | 跳到条件检查 |
for 循环的 continue 安全性最高——更新表达式一定会被执行。while 循环的 continue 有陷阱:
// 危险!可能导致无限循环
int i = 0;
while (i < 10) {
if (i == 5) {
continue; // 跳过 i++!
}
printf("%d\n", i);
i++; // continue 会跳过这行
}
// 当 i == 5 时,continue 跳过 i++,i 永远停在 5,无限循环!CAUTION
在 while 循环中使用 continue 时,必须确保更新语句在 continue 之前执行。for 循环不存在这个问题——更新表达式一定会执行。如果必须在 while 中用 continue,将更新语句放在循环体开头。
11. 无限循环
有时你需要程序永远运行下去(如操作系统内核、服务器主循环)。C 语言提供了几种表示无限循环的方式。
11.1 两种经典写法
// 写法 1:for(;;)
for (;;) {
// 处理请求...
// 用 break 在某个条件下退出
}
// 写法 2:while(1)
while (1) {
// 处理请求...
// 用 break 在某个条件下退出
}两者完全等价。for(;;) 是更「正统」的 C 语言无限循环惯用法——省略条件表达式意味着「永远为真」。某些编译器会对 while(1) 产生「条件表达式恒为真」的警告,而 for(;;) 不会。
11.2 无意中的无限循环
大部分无限循环是 bug,而非刻意设计:
// 错误 1:忘了更新
int i = 0;
while (i < 10) {
printf("%d\n", i);
// 忘了 i++!i 永远是 0,条件永远为真
}
// 错误 2:条件永远为真
for (int i = 0; i >= 0; i++) { // i 永远不会 < 0
printf("%d\n", i);
}
// 错误 3:在循环体内错误地修改了计数器
for (int i = 0; i < 10; i++) {
// ... 一些代码 ...
i = 0; // 每次循环重置 i,永远到不了 10
}11.3 如何发现和调试无限循环
- 加打印:在循环体内打印计数器的值
- 设断点:用调试器(gdb)在循环条件处设断点,观察变量变化
- 追踪表:拿出纸笔,手动模拟前 3-5 次迭代
- 检查三要素:初始化是否正确?条件是否可能变为假?更新是否真的在改变状态?
12. 循环的底层实现:从 C 到汇编
理解循环在汇编层面的实现,能帮助你理解不同循环结构的性能特征和本质等价性。
12.1 while 循环的汇编
while (counter < 10) {
counter++;
}对应汇编(x86-64 AT&T 语法,无优化):
jmp .Lcheck # 无条件跳转到条件检查(前测循环的特征)
.Lloop:
addl $1, counter(%rip) # counter++
.Lcheck:
cmpl $9, counter(%rip) # 比较 counter 和 9
jle .Lloop # 如果 counter <= 9,跳回循环前测循环需要一条额外的 jmp 指令来跳到条件检查——这在 do-while 中是不需要的。
12.2 do-while 循环的汇编
do {
counter++;
} while (counter < 10);.Lloop:
addl $1, counter(%rip) # counter++
cmpl $9, counter(%rip) # 比较 counter 和 9
jle .Lloop # 如果 counter <= 9,跳回循环do-while 比 while 少一条 jmp 指令,代码更紧凑。在极端性能敏感的场景(嵌入式系统、内核热路径),do-while 的天然优势可能仍有意义。
12.3 for 循环的汇编
for 循环编译后的汇编代码与等价的 while 循环完全相同——因为 for 本质上就是 while 的语法糖。编译器会先将 for 展开为 while,再生成汇编。
12.4 编译优化的影响
现代编译器(-O2 或 -O3)会做激进优化:
while和do-while可能被优化为相同形式- 简单循环可能被循环展开(loop unrolling)——将循环体复制多次以减少跳转指令
- 循环可能被向量化(vectorization)——使用 SIMD 指令一次处理多个数据
// 原始代码
for (int i = 0; i < 4; i++) {
arr[i] = 0;
}编译器展开后可能变成:
// 展开后(等价优化,编译器自动完成)
arr[0] = 0;
arr[1] = 0;
arr[2] = 0;
arr[3] = 0;NOTE
循环展开减少了跳转指令和分支预测失败的开销,以代码体积换取执行速度。这是编译器优化的经典手段,不需要程序员手动操作。但在学习阶段了解它,有助于理解「为什么简单的循环代码也能跑得很快」。
13. 常见循环错误与调试技巧
总结初学者最常见的循环错误,以及如何系统地调试它们。
13.1 错误清单
| 错误类型 | 症状 | 示例 |
|---|---|---|
| 无限循环 | 程序不停止 | 忘了更新计数器,或条件永远为真 |
| Off-by-one | 多一次或少一次迭代 | 用 <= 代替 <,或初始值差 1 |
| 用错变量 | 输出不符合预期 | 在外层循环中用了内层的 j |
| 未初始化计数器 | 输出随机值 | int counter; 没有 counter = 0 |
= 误写为 == | 无限循环或一次不执行 | while (counter = 10) |
continue 跳过更新 | 无限循环 | while 中 continue 跳过了 i++ |
| 分号陷阱 | 循环体不执行 | while (condition); 多余分号 |
| 循环体内修改上界 | 不可预测的迭代次数 | for (int i = 0; i < n; i++) { n++; } |
13.2 分号陷阱
// 错误:while 后面多了一个分号
int i = 0;
while (i < 10); // 分号让 while 的循环体变成空语句
{
printf("%d\n", i);
i++;
}
// 这段代码:while 永远检查 i < 10(i 永远是 0,无限循环)
// 花括号内的代码块只在循环结束后执行一次正确的写法是 while (i < 10) { ... },右括号后不需要分号。
13.3 追踪表调试技术
追踪表是调试循环最有效的纸笔技术。当循环行为不符合预期时,创建一个表格,逐行模拟执行:
示例:调试「打印 1 到 5 的累加和」
int sum = 0;
for (int i = 1; i <= 5; i++) {
sum += i;
printf("i=%d, sum=%d\n", i, sum);
}追踪表:
| 迭代 | 进入条件 i <= 5 | sum += i 后 | printf 输出 |
|---|---|---|---|
| 1 | 1 <= 5 → 真 | sum = 1 | i=1, sum=1 |
| 2 | 2 <= 5 → 真 | sum = 3 | i=2, sum=3 |
| 3 | 3 <= 5 → 真 | sum = 6 | i=3, sum=6 |
| 4 | 4 <= 5 → 真 | sum = 10 | i=4, sum=10 |
| 5 | 5 <= 5 → 真 | sum = 15 | i=5, sum=15 |
| 6 | 6 <= 5 → 假 | — | 循环终止 |
追踪表的优势:
- 可视化每一步的状态变化
- 验证边界:第一次和最后一次迭代是否正确
- 发现逻辑错误:预期值和实际值在哪一步开始偏离?
13.4 用 printf 调试循环
在循环体内加临时打印是最快捷的调试手段:
for (int i = 0; i < n; i++) {
printf("[DEBUG] iteration %d: before processing, i=%d\n", i, i);
// ... 实际处理 ...
printf("[DEBUG] iteration %d: after processing, i=%d\n", i, i);
}调试完成后记得删除这些临时打印,或者用条件编译包裹:
#ifdef DEBUG
printf("[DEBUG] i = %d\n", i);
#endif参考解答
写法 A:while — 先自增后打印(推荐)
#include <stdio.h>
int main(void)
{
int counter;
printf("hello, NCCL!\n");
counter = 0;
while (counter < 10)
{
counter++;
printf("counter = %d\n", counter);
}
return 0;
}为什么推荐这种写法:
counter = 0:从 0 开始是 C 语言的自然起点,与数组索引惯例一致counter < 10:使用<,迭代次数 = 10,一目了然counter++在printf之前:自增和输出逻辑清晰分离,单一职责
写法 B:do-while — 先自增后打印
#include <stdio.h>
int main(void)
{
int counter;
printf("hello, NCCL!\n");
counter = 0;
do {
counter++;
printf("counter = %d\n", counter);
} while (counter < 10);
return 0;
}在本例中,do-while 和 while 的输出完全相同,因为 counter = 0 < 10 成立,while 的第一次迭代也会执行。但如果 counter 初始值已经 >= 10,do-while 会错误地执行一次——这是 do-while 的潜在风险。
写法 C:for 循环
#include <stdio.h>
int main(void)
{
printf("hello, NCCL!\n");
for (int counter = 0; counter < 10; counter++)
{
printf("counter = %d\n", counter + 1);
}
return 0;
}for 循环将三要素集中在一行:int counter = 0(初始化)、counter < 10(条件)、counter++(更新)。注意 counter + 1 使输出从 1 到 10,而 counter 本身从 0 到 9——保持了从 0 开始的约定。
对照检查:你的
counter初始值是 0 还是 1?比较条件用的是<还是<=?打印语句放在自增语句之前还是之后?
课堂讨论
- 第 1 种(while)和第 2 种(do-while)写法都能得到正确结果,哪种更好?为什么?
- 第 3 种写法(先打印后自增:
counter=1; while(counter<=10))也正确,为什么说它是不好的写法? - 把
counter++写到printf语句中(如printf("%d\n", ++counter)),是否是好的写法? counter的初值为什么不在定义时就赋值,写在循环外面有什么好处?- 在
while循环里面可以定义变量吗?变量名字可以重名吗? for循环的三个表达式(初始化、条件、更新)中,哪些可以省略?省略后意味着什么?
讨论答案
Q1: while 和 do-while 哪种更好?
while 更好。 尽管两者在本例中输出完全相同,但 while 在通用性和安全性上更优。
// while — 前测循环
counter = 0;
while (counter < 10) {
counter++;
printf("counter = %d\n", counter);
}
// do-while — 后测循环
counter = 0;
do {
counter++;
printf("counter = %d\n", counter);
} while (counter < 10);为什么 while 更好:
- 意图更清晰:
while的条件在最前面,一眼就能看到终止条件——读代码的人不需要翻到末尾才知道循环什么时候结束 - 安全性:
while允许循环体执行零次,do-while至少执行一次。如果counter初始值已经是 10,while会正确跳过,do-while会错误地执行一次 - 主流习惯:
while是更常用的循环结构,大多数场景下它是更自然的选择
do-while 的最佳适用场景——「先做再问」:
int age;
do {
printf("Enter your age (1-120): ");
scanf("%d", &age);
} while (age < 1 || age > 120);
// 用户至少输入一次,输入不合法就重复Q2: 先打印后自增的写法为什么不推荐?
counter = 1;
while (counter <= 10) {
printf("counter = %d\n", counter);
counter++;
}虽然输出正确,但存在三个问题:
1. 初始值不自然:C 语言数组索引从 0 开始,计数器从 0 开始是自然约定。counter = 1 需要额外的心理转换——为什么是 1 而不是 0?
2. 条件使用 <= 容易引发 off-by-one 错误:
for (int i = 0; i < N; i++) // 明显:N 次
for (int i = 0; i <= N; i++) // 需要思考:N+1 次?
for (int i = 1; i <= N; i++) // 需要思考:N 次?使用 < 和从 0 开始的模式,迭代次数 = 上界值,无需心算。
3. 可读性差:当循环体较长时,条件 <= 10 与初始值 1 分处两端,需要读者自己在脑中拼凑出「哦,这是 10 次迭代」。
最佳实践:循环计数器从 0 开始,使用
<作为比较运算符,遵循「从 0 到 N-1」模式。这在 C 语言社区是几乎普遍的约定。
Q3: 把 counter++ 写到 printf 中是否好?
不是好的写法。 将自增嵌入 printf 会降低代码可读性,且容易引发错误。
// 不推荐:自增嵌入 printf
while (counter < 10)
printf("counter = %d\n", ++counter);
// 推荐:自增独立一行
while (counter < 10) {
counter++;
printf("counter = %d\n", counter);
}问题:
- 可读性差:修改操作被隐藏在输出语句中,读者需要解析
printf的参数列表才能发现counter被修改了 - 容易出错:
++counter和counter++在这里结果不同:cprintf("%d\n", ++counter); // 先自增再打印 → 打印 1..10 printf("%d\n", counter++); // 先打印再自增 → 打印 0..9 - 难以调试:无法在自增处单独设断点——断点打在
printf行上,你不知道counter是自增前还是自增后 - 违反单一职责:一行代码做了两件事(修改变量 + 输出),这是代码坏味道
Q4: counter 的初值为什么不在定义时就赋值?
// 写法 A:定义时赋值
int counter = 0;
while (counter < 10) { ... }
// 写法 B:定义后赋值
int counter;
counter = 0;
while (counter < 10) { ... }两种写法效果相同,但写法 B 在教学中有明确好处:
- 强调「声明」和「赋值」是两个概念:声明
int counter;分配空间(栈上 4 字节),赋值counter = 0;给这块空间写入初始值。在 C 语言中,这两步是独立的概念 - 突出循环结构:初始化、条件、更新三个要素各自独立一行,与
for循环的三段式形成直观对应——帮助你理解for循环本质上是while的语法糖 - 便于复用:如果需要再次计数(重置计数器),只需
counter = 0;即可,不需要重新声明
工程实践中,定义时初始化 int counter = 0; 是更常见的推荐做法——它消除了使用未初始化变量的风险。这里的分开写法主要是教学目的。
Q5: while 循环内可以定义变量吗?可以重名吗?
可以定义变量(C99+)。 循环体 {} 内部定义的变量,作用域仅限于该代码块。
#include <stdio.h>
int main(void)
{
int counter = 0;
while (counter < 3)
{
int inner = counter * 10; // C99 允许在代码块中间声明变量
printf("inner = %d\n", inner);
counter++;
}
// printf("%d\n", inner); // inner 在此不可见
return 0;
}可以重名——遵循作用域遮蔽规则(回顾 Lesson 01 的变量遮蔽):
int x = 100; // 文件作用域
int main(void)
{
int x = 200; // 遮蔽文件作用域的 x
for (int i = 0; i < 2; i++) {
int x = 300; // 遮蔽上一层的 x
printf("%d\n", x); // 输出 300
}
printf("%d\n", x); // 输出 200
return 0;
}WARNING
循环内部每次迭代都会重新创建局部变量(虽然编译器可能复用同一块内存地址)。如果变量未初始化,每次迭代得到的都是不确定的垃圾值——这个值在每次迭代之间没有任何关系。
Q6: for 循环的三个表达式哪些可以省略?
三个表达式都可以省略,但分号不能省。
// 1. 省略初始化:变量在外部已定义
int i = 0;
for (; i < 10; i++) { ... }
// 2. 省略条件:条件永远为真 → 无限循环
for (int i = 0; ; i++) {
if (i >= 10) break; // 需在循环体内手动退出
}
// 3. 省略更新:在循环体内手动更新
for (int i = 0; i < 10; ) {
...
i++; // 手动更新
}
// 4. 全部省略:经典无限循环惯用法
for (;;) {
// 等价于 while (1) { ... }
}| 省略部分 | 默认行为 | 风险 |
|---|---|---|
| 初始化 | 不执行初始化 | 可能使用未定义的值 |
| 条件 | 永远为真(非 0) | 必须用 break 退出,否则无限循环 |
| 更新 | 不执行更新 | 可能死循环,需手动更新 |
for(;;) vs while(1):两者等价,for(;;) 在某些编译器上不会产生「条件表达式恒为真」的警告。这是 C 语言中表示无限循环的惯用法——常见于操作系统内核和嵌入式固件的主循环。
课后练习
练习 1:倒计数
将程序改为从 10 倒数到 1,每行输出 counter = 数字。
知识点提示:自减运算符
--,条件改为counter >= 1。注意初始化和更新的方向。
参考解答
#include <stdio.h>
int main(void)
{
for (int counter = 10; counter >= 1; counter--)
{
printf("counter = %d\n", counter);
}
return 0;
}输出:counter = 10 到 counter = 1
思考:为什么用 >= 1 而不是 > 0?前者意图清晰——「大于等于 1」,直接表达「数到 1 为止」。两者在整数情况下等价,但 >= 1 的意图更直观。
练习 2:从 1 加到 10 求和
新增一个变量 sum,对从 1 加到 10 进行求和,打印 sum = 55。
知识点提示:累加器
sum初始化为 0,每次循环sum += counter。复合赋值运算符+=等价于sum = sum + counter。注意sum的初始值为什么必须是 0——如果未初始化,累加结果不可预测。
参考解答
#include <stdio.h>
int main(void)
{
int sum = 0;
for (int counter = 1; counter <= 10; counter++)
{
sum += counter;
}
printf("sum = %d\n", sum); // 输出 sum = 55
return 0;
}累加过程追踪:
| 迭代 | counter | sum += counter | sum |
|---|---|---|---|
| 1 | 1 | 0 + 1 | 1 |
| 2 | 2 | 1 + 2 | 3 |
| 3 | 3 | 3 + 3 | 6 |
| 4 | 4 | 6 + 4 | 10 |
| 5 | 5 | 10 + 5 | 15 |
| 6 | 6 | 15 + 6 | 21 |
| 7 | 7 | 21 + 7 | 28 |
| 8 | 8 | 28 + 8 | 36 |
| 9 | 9 | 36 + 9 | 45 |
| 10 | 10 | 45 + 10 | 55 |
验证:高斯公式 ( \frac{10 \times 11}{2} = 55 ),结果正确。
关键思考:为什么 sum 初始化为 0?累加器需要从「零」开始——0 是加法的单位元。如果 sum 未初始化,累加的结果是不可预测的垃圾值。这体现了变量初始化的基本纪律。
练习 3:倒计数 + 求和(do-while 版)
使用 do-while 同时实现从 10 倒数到 1 和求和。
知识点提示:
do-while保证至少执行一次。注意初始化和更新的位置。这个练习让你体会do-while和while/for在实际编码中的细微差异。
参考解答
#include <stdio.h>
int main(void)
{
int i;
int sum = 0;
i = 10;
do {
printf("counter = %d\n", i);
sum += i;
i--;
} while (i >= 1);
printf("sum = %d\n", sum); // 输出 sum = 55
return 0;
}注意:如果 i 初始值为 0,do-while 仍会执行一次(打印 counter = 0),而 while 会直接跳过。这是两者最核心的区别——do-while 总是至少执行一次循环体。
练习 4:拆解数字的每一位
打印一个数字的个位、十位、百位……直到最高位(从低位到高位)。
知识点提示:
num % 10取个位数字,num / 10去掉个位。循环直到num == 0。这是数字处理中的经典循环模式。
参考解答
#include <stdio.h>
int main(void)
{
int num = 12345;
printf("Digits of %d (from low to high):\n", num);
while (num > 0)
{
int digit = num % 10; // 取出个位
printf("%d\n", digit);
num = num / 10; // 去掉个位
}
return 0;
}输出:
Digits of 12345 (from low to high):
5
4
3
2
1核心原理:
num % 10:取个位数字(如12345 % 10 = 5)num / 10:去掉个位(如12345 / 10 = 1234,整数除法截断小数部分)- 循环直到
num == 0
追踪表:
| 迭代 | num(进入时) | num % 10 | 输出 | num / 10(更新后) |
|---|---|---|---|---|
| 1 | 12345 | 5 | 5 | 1234 |
| 2 | 1234 | 4 | 4 | 123 |
| 3 | 123 | 3 | 3 | 12 |
| 4 | 12 | 2 | 2 | 1 |
| 5 | 1 | 1 | 1 | 0 |
| 6 | 0 | — | — | 循环终止 |
扩展挑战:从高位到低位打印(需要先反转数字或使用递归)。
练习 5(挑战):九九乘法表
用嵌套 for 循环打印标准的九九乘法表。
知识点提示:外层循环控制行(被乘数
row),内层循环控制列(乘数col)。使用%4d保证每列宽度一致,右对齐。总迭代次数 = 9 × 9 = 81。
参考解答
#include <stdio.h>
int main(void)
{
for (int row = 1; row <= 9; row++)
{
for (int col = 1; col <= 9; col++)
{
printf("%4d", row * col);
}
printf("\n");
}
return 0;
}要点:
- 外层
for每执行一次,内层for完整执行一轮(9 次) - 总迭代次数:9 × 9 = 81
%4d保证每列宽度为 4 个字符,右对齐,表格整齐- 内层循环结束后
printf("\n")换行,开始下一行
参考资料
- C99 标准第 6.8.5 节 — 循环语句 — ISO C 标准中循环语句的正式定义
- The C Programming Language — Kernighan & Ritchie,C 语言的「圣经」,第 3 章讲解控制流
- Off-by-one error — Wikipedia — 编程中最常见的逻辑错误之一
- x86 跳转指令与循环优化 — 理解 C 循环如何映射到机器指令
- Loop unrolling — Wikipedia — 编译器如何优化循环以提高性能
"When in doubt, use brute force." — Ken Thompson