跳转到内容

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++++ii----i(前置 vs 后置)
  • 计数器变量命名 — ijkcntcount 等约定
  • 循环边界 — < vs <= 的 off-by-one 错误
  • 嵌套循环 — 外层每执行一次,内层完整执行一轮
  • 无限循环 — for(;;)while(1)
  • break — 提前退出循环
  • continue — 跳过当前迭代
  • printf("%d") — 格式化整数输出
  • 循环展开 — 编译器优化概念
  • 汇编视角 — for 循环如何变成 jmp/jne 指令
  • 常见错误 — 无限循环、多一次迭代、用错变量
  • 追踪表技术 — 调试循环的纸笔方法

代码框架

loop_counting.c
c
#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-whilefor 实现同样的效果。


深度讲解

1. 变量声明与 int 类型

在编写循环之前,你需要一个变量来记录当前的计数值。这就是变量的基本用途——存储会变化的数据。

1.1 变量声明语法

C 语言中声明一个变量的完整语法为:

c
type name = initial_value;

其中:

  • type:数据类型,决定变量占多大内存、能存什么值
  • name:变量名,由字母、数字和下划线组成,不能以数字开头
  • = initial_value:可选的初始化值。如果不写,局部变量的值是未定义的(垃圾值)

声明和赋值可以分开写,这在教学中尤其有用:

declare_vs_assign.c
c
int counter;          // 声明:分配一块内存(通常 4 字节)
counter = 0;          // 赋值:将 0 写入那块内存

也可以合二为一:

c
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,6480x80000000)没有对应的正数——取反后仍然是它自己(溢出)。

1.3 变量名的命名规则与约定

C 语言的变量名遵循以下规则:

  • 只能包含字母(A-Za-z)、数字(0-9)和下划线(_
  • 不能以数字开头1counter 不合法)
  • 大小写敏感counterCounterCOUNTER 是三个不同的变量)
  • 不能使用 C 语言的关键字(如 intwhilefor

对于循环计数器,社区有强烈的命名约定:

变量名使用场景
i最常用的循环计数器(来自数学中的 index)
j嵌套循环的第二层计数器
k嵌套循环的第三层计数器
cntcount计数用途的变量,比 i 更具语义
n表示数量上限(如 i < n
rowcol二维场景的行列计数器
naming_convention.c
c
// 经典约定: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 语言中不是「等于」,而是赋值——将右侧的值复制到左侧的变量中:

assignment_operator.c
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(假):

equality_operator.c
c
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 语言中完全合法:

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)。每次看到 = 出现在条件位置,都应该停下来检查是不是应该用 ==

一种防御性编程技巧是将常量放在左边:

c
if (5 == x) { ... }   // 如果误写成 5 = x,编译器会报错

因为 5 = x 试图给常量赋值,编译不通过。不过这种写法可读性较差,团队使用前需要达成共识。

TIP

开启编译器的 -Wall -Wextra 选项可以捕获大多数 === 的混淆。现代 GCC/Clang 会对 if (x = 5) 产生 -Wparentheses 警告。


3. while 循环:前测循环

while 循环是最基本的循环结构。它先检查条件,再决定是否执行循环体。

3.1 while 循环的语法与执行流程

c
while (条件表达式) {
    // 循环体
}

执行流程:

        ┌──────────────┐
   条件表达式 每次进入前检查
        └──────┬───────┘

        ┌──────▼──────┐
  条件为真?
        └──┬───────┬──┘


    ┌──────▼──┐
  循环体
    └──────┬──┘

           └───┐


          ┌───────────┐
  继续执行
  循环后语句
          └───────────┘

while 循环的核心特征:条件在循环体之前检查,所以循环体可能执行零次。如果条件一开始就为假,循环体一次都不执行。

3.2 循环三要素

所有循环(无论是 whiledo-while 还是 for)都由三个核心要素构成:

  1. 初始化(Initialization):设定循环的起始状态——计数器从几开始?
  2. 条件判断(Condition):决定循环是否继续——什么时候停下来?
  3. 更新(Update):改变循环状态——每次迭代后计数器如何变化?

这三个要素缺一不可:

  • 缺初始化 → 计数器值是未知的垃圾值,行为不可预测
  • 缺条件 → 无限循环,程序永远不会停
  • 缺更新 → 同样是无限循环,因为条件永远不变

3.3 用 while 实现「从 1 数到 10」

将三要素映射到本题:

while_counting.c
c
#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 < 10counter++printf 输出
10 < 10 → 真counter = 1counter = 1
21 < 10 → 真counter = 2counter = 2
32 < 10 → 真counter = 3counter = 3
............
109 < 10 → 真counter = 10counter = 10
1110 < 10循环终止

追踪表是一种强大的调试技术——当你对循环行为有疑问时,拿出纸笔,一行行模拟执行,就能发现逻辑错误。这比盯着代码猜测有效得多。

3.4 while 的适用场景

while 循环最适合以下场景:

  • 迭代次数不确定,取决于运行时条件(如「读到文件末尾」「用户输入 q 退出」)
  • 条件比较复杂,不适合压缩到一行
  • 循环体可能一次都不执行是正确的行为
read_until_quit.c
c
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-whilewhile 的变体,唯一的区别是条件检查在循环体之后

4.1 do-while 的语法

c
do {
    // 循环体
} while (条件表达式);   // 注意这里的分号!

执行流程:

        ┌──────────────┐
    循环体 先执行一次
        └──────┬───────┘

        ┌──────▼──────┐
  条件表达式 再检查条件
        └──┬───────┬──┘


           └───┐


          ┌───────────┐
  继续执行
          └───────────┘

WARNING

do-while 末尾的分号 ; 不能省略} while (条件); 是一个完整的语句。省略分号会导致编译错误。这是初学者最容易犯的 do-while 语法错误。

4.2 do-while 实现「从 1 数到 10」

do_while_counting.c
c
#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:

do_while_pitfall.c
c
// 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 适合「先执行,再判断」的场景。最经典的应用是用户输入验证

input_validation.c
c
int age;
do {
    printf("Enter your age (1-120): ");
    scanf("%d", &age);
} while (age < 1 || age > 120);
// 用户至少输入一次;输入不合法就再来一次

菜单驱动的控制台程序也常用 do-while

menu_loop.c
c
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 循环的语法

c
for (初始化; 条件; 更新) {
    // 循环体
}

三个表达式用分号分隔(不是逗号!),它们各自的含义:

表达式执行时机典型内容
初始化循环开始前,只执行一次int i = 0
条件每次迭代之前检查i < 10
更新每次迭代之后执行i++

5.2 循环执行顺序详解

for (初始化; 条件; 更新) {
    循环体;
}

完整执行顺序:

初始化

┌─→ 条件检查(假则退出)

   循环体

   更新

└───┘

IMPORTANT

更新表达式在循环体之后执行,这个顺序对理解 continue 的行为至关重要——在 for 循环中,continue 会先执行更新表达式,再检查条件。

5.3 for 循环实现「从 1 数到 10」

for_counting.c
c
#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 循环:

c
// for 版本
for (初始化; 条件; 更新) {
    循环体;
}

// 等价的 while 版本
初始化;
while (条件) {
    循环体;
    更新;
}

这种等价性意味着:

  • 任何 for 循环都可以改写为 while 循环
  • 两者的汇编代码完全相同(编译后无区别)
  • for 的存在纯粹是为了代码可读性——将循环控制逻辑集中在一起

5.5 for 循环三个表达式的省略

for 循环的三个表达式都可以省略,但分号不能省:

for_omissions.c
c
// 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_style.c
c
/* C89 风格:计数器在循环外声明 */
int i;
for (i = 0; i < 10; i++) {
    printf("%d\n", i);
}
/* i 在此仍然可见! */
printf("Final i = %d\n", i);  // 输出 10

C99 引入了在 for 的初始化段中声明变量的能力:

c99_style.c
c
/* 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 块级作用域与变量遮蔽

循环体 {} 内部定义的变量,作用域仅限于该代码块:

block_scope.c
c
int counter = 0;
while (counter < 3) {
    int inner = counter * 10;  // inner 只在 {} 内可见
    printf("inner = %d\n", inner);
    counter++;
}
// printf("%d\n", inner);  // 编译错误:inner 不可见

每次循环迭代都会重新创建局部变量(尽管编译器可能复用同一块内存地址),未初始化的局部变量每次迭代都是垃圾值:

loop_reinit.c
c
int counter = 0;
while (counter < 3) {
    int x;                    // 每次迭代重新创建,值不确定!
    printf("x = %d\n", x);    // 可能打印不同的垃圾值
    counter++;
}

变量遮蔽规则同样适用于循环体内(回顾 Lesson 01 的作用域遮蔽):

loop_shadow.c
c
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

post_increment.c
c
int i = 5;
int a = i++;  // a 得到 i 的当前值 5,然后 i 变成 6
// 结果:i = 6, a = 5

等价于:

c
int a = i;
i = i + 1;

前置自增 ++i(Pre-increment)——先加 1,再用值

pre_increment.c
c
int i = 5;
int a = ++i;  // i 先变成 6,然后 a 得到 6
// 结果:i = 6, a = 6

等价于:

c
i = i + 1;
int a = i;

自减运算符 -- 完全对称:

运算符名称语义示例(i=5
i++后置自增先用后加a = i++a=5, i=6
++i前置自增先加后用a = ++ia=6, i=6
i--后置自减先用后减a = i--a=5, i=4
--i前置自减先减后用a = --ia=4, i=4

7.2 在循环中的使用

当自增/自减作为独立的更新语句(不被其他表达式使用其返回值)时,前置和后置完全等价

c
// 这两种写法的行为完全相同
for (int i = 0; i < 10; i++)  { ... }
for (int i = 0; i < 10; ++i)  { ... }

编译器会优化掉返回值的使用差异,生成相同的机器代码。在 C 语言中,选择 i++ 还是 ++i 纯粹是风格偏好——i++ 更常见。

7.3 在表达式中的陷阱

将自增嵌入复杂表达式是危险的:

undefined_behavior.c
c
int i = 5;
int result = i++ + ++i;  // 未定义行为!

在同一个表达式中多次修改同一个变量是未定义行为(Undefined Behavior),因为 C 标准没有规定子表达式的求值顺序。不同编译器可能给出不同结果,同一编译器的不同优化级别也可能产生不同结果。

CAUTION

黄金规则:一条语句中,同一个变量最多只能被修改一次。i++ + ++iarr[i++] = i 等写法都违反了这条规则。

7.4 自增嵌入 printf 的问题

c
// 不推荐:自增隐藏在 printf 中
while (counter < 10)
    printf("counter = %d\n", ++counter);

// 推荐:自增独立一行
while (counter < 10) {
    counter++;
    printf("counter = %d\n", counter);
}

嵌入自增的问题:

  1. 可读性差:修改变量的操作被隐藏在输出语句中,读者容易遗漏
  2. 容易出错++countercounter++ 产生不同输出,搞混了难以察觉
  3. 难以调试:无法在自增处单独设置断点
  4. 违反单一职责原则:一行代码做了两件不相关的事

8. 循环边界:off-by-one 错误

「差一错误」(off-by-one error)是循环编程中最常见、最隐蔽的逻辑错误。

8.1 < vs <= 的区别

boundary_comparison.c
c
// 写法 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 次迭代」的意图,但清晰度不同。比较它们的可读性:

c
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:数组越界

array_off_by_one.c
c
int arr[10];                    // 有效索引:0 到 9
for (int i = 0; i <= 10; i++)   // i=10 时访问 arr[10]——越界!
    arr[i] = i;

场景 2:字符串遍历

string_off_by_one.c
c
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:循环次数少一次

loop_one_short.c
c
// 意图:打印 1 到 10
for (int i = 1; i < 10; i++)    // 实际打印 1 到 9——少了 10!
    printf("%d\n", i);

8.3 防御策略

几种减少 off-by-one 错误的方法:

  1. 统一使用「从 0 开始,< 上界」模式
  2. 给上界一个有意义的变量名for (int i = 0; i < num_students; i++)for (int i = 0; i < 35; i++) 清晰
  3. 用追踪表验证边界:模拟第一次和最后一次迭代
  4. 写测试:测试 n=0、n=1、n=最大值的情况

9. 嵌套循环

当一个循环体内包含另一个循环时,就构成了嵌套循环。

9.1 嵌套循环的执行模式

nested_loop_basic.c
c
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 嵌套循环的经典例子:九九乘法表

multiplication_table.c
c
#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

breakcontinue 让你能在循环体内改变默认的执行流程。

10.1 break — 立即退出循环

break 使程序立即跳出当前最内层的循环,执行循环后面的语句:

break_demo.c
c
#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 只退出最内层的循环,不影响外层:

break_nested.c
c
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 跳过当前迭代的剩余部分,直接进入下一次迭代:

continue_demo.c
c
#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 9

10.3 continue 在不同循环中的行为差异

这是 continue 最容易出错的地方:

循环类型continue 跳到哪里
for跳到更新表达式(如 i++),然后检查条件
while跳到条件检查
do-while跳到条件检查

for 循环的 continue 安全性最高——更新表达式一定会被执行。while 循环的 continue 有陷阱:

continue_while_trap.c
c
// 危险!可能导致无限循环
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 两种经典写法

infinite_loops.c
c
// 写法 1:for(;;)
for (;;) {
    // 处理请求...
    // 用 break 在某个条件下退出
}

// 写法 2:while(1)
while (1) {
    // 处理请求...
    // 用 break 在某个条件下退出
}

两者完全等价。for(;;) 是更「正统」的 C 语言无限循环惯用法——省略条件表达式意味着「永远为真」。某些编译器会对 while(1) 产生「条件表达式恒为真」的警告,而 for(;;) 不会。

11.2 无意中的无限循环

大部分无限循环是 bug,而非刻意设计:

accidental_infinite.c
c
// 错误 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 如何发现和调试无限循环

  1. 加打印:在循环体内打印计数器的值
  2. 设断点:用调试器(gdb)在循环条件处设断点,观察变量变化
  3. 追踪表:拿出纸笔,手动模拟前 3-5 次迭代
  4. 检查三要素:初始化是否正确?条件是否可能变为假?更新是否真的在改变状态?

12. 循环的底层实现:从 C 到汇编

理解循环在汇编层面的实现,能帮助你理解不同循环结构的性能特征和本质等价性。

12.1 while 循环的汇编

c
while (counter < 10) {
    counter++;
}

对应汇编(x86-64 AT&T 语法,无优化):

asm
    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 循环的汇编

c
do {
    counter++;
} while (counter < 10);
asm
.Lloop:
    addl $1, counter(%rip) # counter++
    cmpl $9, counter(%rip) # 比较 counter 和 9
    jle  .Lloop            # 如果 counter <= 9,跳回循环

do-whilewhile 少一条 jmp 指令,代码更紧凑。在极端性能敏感的场景(嵌入式系统、内核热路径),do-while 的天然优势可能仍有意义。

12.3 for 循环的汇编

for 循环编译后的汇编代码与等价的 while 循环完全相同——因为 for 本质上就是 while 的语法糖。编译器会先将 for 展开为 while,再生成汇编。

12.4 编译优化的影响

现代编译器(-O2-O3)会做激进优化:

  • whiledo-while 可能被优化为相同形式
  • 简单循环可能被循环展开(loop unrolling)——将循环体复制多次以减少跳转指令
  • 循环可能被向量化(vectorization)——使用 SIMD 指令一次处理多个数据
before_unrolling.c
c
// 原始代码
for (int i = 0; i < 4; i++) {
    arr[i] = 0;
}

编译器展开后可能变成:

after_unrolling.c
c
// 展开后(等价优化,编译器自动完成)
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 跳过更新无限循环whilecontinue 跳过了 i++
分号陷阱循环体不执行while (condition); 多余分号
循环体内修改上界不可预测的迭代次数for (int i = 0; i < n; i++) { n++; }

13.2 分号陷阱

semicolon_trap.c
c
// 错误: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 的累加和」

trace_table_example.c
c
int sum = 0;
for (int i = 1; i <= 5; i++) {
    sum += i;
    printf("i=%d, sum=%d\n", i, sum);
}

追踪表:

迭代进入条件 i <= 5sum += iprintf 输出
11 <= 5 → 真sum = 1i=1, sum=1
22 <= 5 → 真sum = 3i=2, sum=3
33 <= 5 → 真sum = 6i=3, sum=6
44 <= 5 → 真sum = 10i=4, sum=10
55 <= 5 → 真sum = 15i=5, sum=15
66 <= 5循环终止

追踪表的优势:

  • 可视化每一步的状态变化
  • 验证边界:第一次和最后一次迭代是否正确
  • 发现逻辑错误:预期值和实际值在哪一步开始偏离?

13.4 用 printf 调试循环

在循环体内加临时打印是最快捷的调试手段:

printf_debug.c
c
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);
}

调试完成后记得删除这些临时打印,或者用条件编译包裹:

c
#ifdef DEBUG
    printf("[DEBUG] i = %d\n", i);
#endif

参考解答

写法 A:while — 先自增后打印(推荐)
while_counting_solution.c
c
#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 — 先自增后打印
do_while_solution.c
c
#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-whilewhile 的输出完全相同,因为 counter = 0 < 10 成立,while 的第一次迭代也会执行。但如果 counter 初始值已经 >= 10,do-while 会错误地执行一次——这是 do-while 的潜在风险。

写法 C:for 循环
for_loop_solution.c
c
#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. 第 1 种(while)和第 2 种(do-while)写法都能得到正确结果,哪种更好?为什么?
  2. 第 3 种写法(先打印后自增:counter=1; while(counter<=10))也正确,为什么说它是不好的写法?
  3. counter++ 写到 printf 语句中(如 printf("%d\n", ++counter)),是否是好的写法?
  4. counter 的初值为什么不在定义时就赋值,写在循环外面有什么好处?
  5. while 循环里面可以定义变量吗?变量名字可以重名吗?
  6. for 循环的三个表达式(初始化、条件、更新)中,哪些可以省略?省略后意味着什么?

讨论答案

Q1: while 和 do-while 哪种更好?

while 更好。 尽管两者在本例中输出完全相同,但 while 在通用性和安全性上更优。

c
// 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 更好

  1. 意图更清晰while 的条件在最前面,一眼就能看到终止条件——读代码的人不需要翻到末尾才知道循环什么时候结束
  2. 安全性while 允许循环体执行零次,do-while 至少执行一次。如果 counter 初始值已经是 10,while 会正确跳过,do-while 会错误地执行一次
  3. 主流习惯while 是更常用的循环结构,大多数场景下它是更自然的选择

do-while 的最佳适用场景——「先做再问」:

do_while_best_use.c
c
int age;
do {
    printf("Enter your age (1-120): ");
    scanf("%d", &age);
} while (age < 1 || age > 120);
// 用户至少输入一次,输入不合法就重复
Q2: 先打印后自增的写法为什么不推荐?
c
counter = 1;
while (counter <= 10) {
    printf("counter = %d\n", counter);
    counter++;
}

虽然输出正确,但存在三个问题:

1. 初始值不自然:C 语言数组索引从 0 开始,计数器从 0 开始是自然约定。counter = 1 需要额外的心理转换——为什么是 1 而不是 0?

2. 条件使用 <= 容易引发 off-by-one 错误

c
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 会降低代码可读性,且容易引发错误。

c
// 不推荐:自增嵌入 printf
while (counter < 10)
    printf("counter = %d\n", ++counter);

// 推荐:自增独立一行
while (counter < 10) {
    counter++;
    printf("counter = %d\n", counter);
}

问题

  1. 可读性差:修改操作被隐藏在输出语句中,读者需要解析 printf 的参数列表才能发现 counter 被修改了
  2. 容易出错++countercounter++ 在这里结果不同:
    c
    printf("%d\n", ++counter);  // 先自增再打印 → 打印 1..10
    printf("%d\n", counter++);  // 先打印再自增 → 打印 0..9
  3. 难以调试:无法在自增处单独设断点——断点打在 printf 行上,你不知道 counter 是自增前还是自增后
  4. 违反单一职责:一行代码做了两件事(修改变量 + 输出),这是代码坏味道
Q4: counter 的初值为什么不在定义时就赋值?
c
// 写法 A:定义时赋值
int counter = 0;
while (counter < 10) { ... }

// 写法 B:定义后赋值
int counter;
counter = 0;
while (counter < 10) { ... }

两种写法效果相同,但写法 B 在教学中有明确好处

  1. 强调「声明」和「赋值」是两个概念:声明 int counter; 分配空间(栈上 4 字节),赋值 counter = 0; 给这块空间写入初始值。在 C 语言中,这两步是独立的概念
  2. 突出循环结构:初始化、条件、更新三个要素各自独立一行,与 for 循环的三段式形成直观对应——帮助你理解 for 循环本质上是 while 的语法糖
  3. 便于复用:如果需要再次计数(重置计数器),只需 counter = 0; 即可,不需要重新声明

工程实践中,定义时初始化 int counter = 0; 是更常见的推荐做法——它消除了使用未初始化变量的风险。这里的分开写法主要是教学目的。

Q5: while 循环内可以定义变量吗?可以重名吗?

可以定义变量(C99+)。 循环体 {} 内部定义的变量,作用域仅限于该代码块。

loop_scope_demo.c
c
#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 的变量遮蔽):

loop_name_shadow.c
c
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 循环的三个表达式哪些可以省略?

三个表达式都可以省略,但分号不能省。

c
// 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。注意初始化和更新的方向。

参考解答
reverse_counting.c
c
#include <stdio.h>

int main(void)
{
    for (int counter = 10; counter >= 1; counter--)
    {
        printf("counter = %d\n", counter);
    }

    return 0;
}

输出counter = 10counter = 1

思考:为什么用 >= 1 而不是 > 0?前者意图清晰——「大于等于 1」,直接表达「数到 1 为止」。两者在整数情况下等价,但 >= 1 的意图更直观。

练习 2:从 1 加到 10 求和

新增一个变量 sum,对从 1 加到 10 进行求和,打印 sum = 55

知识点提示:累加器 sum 初始化为 0,每次循环 sum += counter。复合赋值运算符 += 等价于 sum = sum + counter。注意 sum 的初始值为什么必须是 0——如果未初始化,累加结果不可预测。

参考解答
sum_1_to_10.c
c
#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;
}

累加过程追踪

迭代countersum += countersum
110 + 11
221 + 23
333 + 36
446 + 410
5510 + 515
6615 + 621
7721 + 728
8828 + 836
9936 + 945
101045 + 1055

验证:高斯公式 ( \frac{10 \times 11}{2} = 55 ),结果正确。

关键思考:为什么 sum 初始化为 0?累加器需要从「零」开始——0 是加法的单位元。如果 sum 未初始化,累加的结果是不可预测的垃圾值。这体现了变量初始化的基本纪律。

练习 3:倒计数 + 求和(do-while 版)

使用 do-while 同时实现从 10 倒数到 1 和求和。

知识点提示do-while 保证至少执行一次。注意初始化和更新的位置。这个练习让你体会 do-whilewhile/for 在实际编码中的细微差异。

参考解答
reverse_sum_do_while.c
c
#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。这是数字处理中的经典循环模式。

参考解答
digit_decompose.c
c
#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(更新后)
112345551234
2123444123
31233312
412221
51110
60循环终止

扩展挑战:从高位到低位打印(需要先反转数字或使用递归)。

练习 5(挑战):九九乘法表

用嵌套 for 循环打印标准的九九乘法表。

知识点提示:外层循环控制行(被乘数 row),内层循环控制列(乘数 col)。使用 %4d 保证每列宽度一致,右对齐。总迭代次数 = 9 × 9 = 81。

参考解答
multiplication_table_solution.c
c
#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") 换行,开始下一行

参考资料


"When in doubt, use brute force." — Ken Thompson

Released under the MIT License.