跳转到内容

Lesson 08: 数字 9 的个数 CNB

练习任务

编写一个 C 程序,统计 1~100 的所有整数中,数字 9 一共出现了多少次。你需要设计一个函数 find(num, digit),用逐位提取算法计算某个整数中包含多少个指定的数字,然后在 main 中遍历 1~100 累加调用结果。

期望输出:

sum = 20

提示:把问题拆成两个子问题——(1)如何从一个数中逐位提取数字?(提示:% 10 取最后一位,/ 10 去掉最后一位)(2)如何遍历 1~100 并累加结果?先设计 find 函数再写 main。注意:99 中包含两个 9,你的函数必须能正确识别。

核心知识点

  • 函数定义与调用 — 声明(declaration)、定义(definition)、调用(call)、返回(return)四阶段
  • 形参与实参 — 形参是函数签名中的占位变量,实参是调用时传入的具体值,按位置绑定
  • 按值传递(pass-by-value) — C 语言默认的参数传递方式,实参被复制到形参
  • 函数返回值 — return 将结果传回调用处,类型必须与声明一致
  • 函数签名设计 — 参数类型选择、参数个数确定、返回类型语义
  • 逻辑分解 — 将复杂问题拆为独立子问题,每个函数负责一件事
  • 单一职责原则(SRP) — 每个函数只做一件事,做好一件事
  • 逐位提取算法 — num % 10 取个位 → num / 10 去掉个位 → 循环至 0
  • do-while 循环 — 先执行后判断,保证循环体至少运行一次
  • while vs do-while — 选择原则与实际应用场景对比
  • 累加器模式 — 遍历序列,对每个元素调用函数并累加返回值
  • 累加器变量初始化 — sum = 0 的必要性与常见错误
  • begin/end 命名常量 — 用命名变量替代魔数(Magic Number),提高可读性与可维护性
  • #define 符号常量 — 编译期文本替换,零运行时开销
  • const 变量 — 运行时类型安全常量,与 #define 的对比
  • 计数器变量命名 — countercountcnt 的命名语义与习惯
  • K&R 注释风格 — /* */ 块注释用于函数头文档,@param 参数逐行说明
  • 单行注释 — // 风格(C99 引入),适用于简短说明
  • 函数隔离测试 — 独立验证函数逻辑正确性的单元测试思维
  • 函数复用性 — find(num, digit) 设计适用于任意数字、任意范围
  • 执行流程追踪表 — 用表格记录变量变化,理解程序执行过程
  • 性能分析初步 — 迭代次数计数与大 N 场景下的算法复杂度直觉

代码框架

count_digit_nine.c
c
#include <stdio.h>

/*
 * find - calculate how many digit in num
 * @num:   the number we want to find
 * @digit: the digit we search in num
 *
 * Return value: how many digit in this num
 */
int find(int num, int digit)
{
    int counter = 0;

    // 用循环逐位提取 num 的每一位
    // 每次: 取 num % 10 与 digit 比较,相等则 counter++
    //       然后 num = num / 10 去掉最后一位
    //       直到 num == 0 退出

    return counter;
}

int main(void)
{
    int begin = 1;
    int end = 100;
    int i;
    int sum = 0;

    for (i = begin; i <= end; i++)
    {
        sum += find(i, 9);
    }

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

    return 0;
}

填充以上框架的关键思考:逐位提取用什么循环——while 还是 do-while?提取数字的顺序是从个位到高位,这对计数结果有影响吗?如果 num 初始为 0,循环应该执行多少次?counter 的初始值为什么必须是 0?sum 的初始值为什么也必须是 0?

TIP

先不要往下翻看参考解答。尝试实现 find 函数体,想想为什么使用 do-while 而非 while——如果 num 初始为 0 会怎样?99 中包含两个 9,你的循环能正确检测到吗?


深度讲解

1. 函数:C 语言的模块化基石

函数是 C 语言中最核心的抽象机制。它将一段逻辑封装为一个命名单元,使得代码可以复用、测试和独立理解。本课通过设计 find 函数,系统性地讲解函数的所有关键概念。

1.1 函数的四要素

C 语言中一个完整的函数由四个部分组成——每一个部分都有明确的语义和语法要求:

function_anatomy.c
c
int find(int num, int digit)     // 1. 返回类型 + 2. 函数名 + 3. 形参列表
{                                // 4. 函数体(花括号内)
    int counter = 0;
    do {
        if (num % 10 == digit)
            counter++;
        num = num / 10;
    } while (num != 0);

    return counter;              // 返回语句
}
要素说明本例
返回类型函数计算结果的数据类型,写在函数名之前int
函数名标识函数用途的命名,遵循标识符命名规则find
形参列表函数需要的输入,每个参数必须声明类型和名称int num, int digit
函数体花括号 { ... } 中的语句序列,定义函数的具体行为逐位提取与计数逻辑

返回类型的选择find 的返回值是"数字在数中出现的次数",这是一个非负整数,所以用 int 是自然的选择。如果函数不返回任何值(如只打印输出),则返回类型为 void

return_type_examples.c
c
void say_hello(void)           // 无返回值,无参数
{
    printf("Hello!\n");
    // 没有 return 语句,或可以写 return; (不带值)
}

int get_answer(void)           // 返回 int,无参数
{
    return 42;
}

double average(int a, int b)   // 返回 double,两个 int 参数
{
    return (a + b) / 2.0;
}

IMPORTANT

C89 标准中,如果函数没有显式声明返回类型,编译器默认返回类型为 int。但这是一种不良实践——始终显式写出返回类型,即使是 int。C99 及之后的标准已废弃隐式 int

1.2 函数的声明与定义:分离关注点

C 语言区分函数声明(declaration)和函数定义(definition):

declaration_vs_definition.c
c
// 函数声明(declaration)——只告诉编译器函数"长什么样"
int find(int num, int digit);   // 注意末尾的分号!没有函数体

// 函数定义(definition)——提供函数的完整实现
int find(int num, int digit)    // 注意这里没有分号
{
    int counter = 0;
    // ... 函数体 ...
    return counter;
}

声明告诉编译器函数的签名(返回类型 + 函数名 + 参数类型列表),让编译器能够在函数被定义之前就检查调用是否正确。定义提供函数体的完整实现。

为什么需要分离?考虑以下场景:

forward_declaration.c
c
#include <stdio.h>

// 声明:告诉编译器 find 函数存在
int find(int num, int digit);

int main(void)
{
    // 此时 find 尚未定义,但编译器知道它的签名
    printf("%d\n", find(99, 9));  // OK:编译器已知道 find 的签名
    return 0;
}

// 定义:在 main 之后才给出具体实现
int find(int num, int digit)
{
    int counter = 0;
    do {
        if (num % 10 == digit) counter++;
        num /= 10;
    } while (num != 0);
    return counter;
}

这种"先声明后定义"的模式称为前向声明(forward declaration),在大型项目中非常常见——头文件(.h)中包含声明,源文件(.c)中包含定义。

NOTE

在本课的代码框架中,find 的定义写在 main 之前,因此不需要额外的声明——编译器在遇到 mainfind(i, 9) 调用时,已经见过了 find 的定义。

1.3 函数调用机制:从调用到返回

main 中执行 sum += find(i, 9) 时,发生了以下步骤:

1. 调用方(main):
   - 计算实参表达式 i 9 的值
   - 将实参的值按位置复制给形参 num digit
   - 保存当前执行位置(返回地址)
   - 跳转到 find 函数的第一条指令

2. 被调方(find):
   - 执行函数体中的语句
   - 遇到 return counter; 时:
     a. 计算 counter 的值
     b. 将返回值放入约定的位置(通常是寄存器 eax/rax)
     c. 跳转回调用方保存的返回地址

3. 调用方(main):
   - 从约定的位置取出返回值
   - 将返回值用于 sum += ... 表达式
   - 继续执行下一条语句

这个过程在汇编层面体现为 callret 指令,参数通过寄存器或栈传递(取决于调用约定)。虽然初学阶段不需要深入汇编,但理解这个流程有助于建立"函数调用不是魔法"的直觉。


2. 形参与实参:接口的契约

2.1 术语辨析:Parameter vs Argument

这两个词在中文中常被混用,但在 C 语言中有严格区别:

parameter_vs_argument.c
c
int find(int num, int digit)   // num, digit 是形参(parameter)
{                              // ——函数签名中的占位符变量
    // ...
}

int main(void)
{
    int result = find(99, 9);  // 99, 9 是实参(argument)
    //                      ↑  ↑  ——调用时传入的具体值
    //          实参 99 绑定到形参 num
    //                     实参 9 绑定到形参 digit
}
维度形参(Parameter)实参(Argument)
出现位置函数定义/声明的参数列表中函数调用的参数列表中
本质变量声明——在函数被调用时才分配内存表达式——调用时求值后传给形参
生命周期函数调用开始时创建,返回时销毁调用方的作用域决定
类比酒店房间的"预订名"实际入住的"客人"

2.2 按位置绑定:顺序决定一切

C 语言的参数绑定是严格按位置的——第一个实参绑定第一个形参,第二个实参绑定第二个形参,以此类推。形参名称不影响绑定:

positional_binding.c
c
int find(int num, int digit) { /* ... */ }

// 以下两个调用等价——形参名不影响绑定:
find(99, 9);    // 99→num, 9→digit  ✓
find(9, 99);    // 9→num, 99→digit  ✗ 语义完全错误!

find(99, 9) 中,我们想表达"在 99 中找数字 9"。如果写成 find(9, 99),就变成了"在 9 中找数字 99"——虽然编译不会报错(类型匹配),但语义完全错误。

WARNING

参数顺序错误是最隐蔽的 bug 之一——编译器无法检测语义层面的错误。防御策略:(1)参数命名要有区分度(num vs digit 而非 a vs b);(2)调用时思考每个实参的含义是否与形参名匹配。

2.3 实参与形参的类型兼容性

实参的类型必须能够隐式转换为形参的类型:

type_compatibility.c
c
void process(int x) { /* ... */ }

int main(void)
{
    process(42);       // ✓ int → int,完全匹配
    process(3.14);     // ⚠ double → int,小数部分被截断,结果为 3
    process('A');      // ⚠ char → int,'A' 的 ASCII 值 65
    process("hello");  // ✗ char* → int,编译错误或警告
    return 0;
}

C 语言会进行一些隐式类型转换(如 doubleint 截断、charint 提升),但这些转换可能丢失信息或产生意外结果。最佳实践是确保实参类型与形参类型完全匹配。

2.4 形参数量必须匹配

c
find(99);          // ✗ 编译错误:实参太少
find(99, 9, 5);    // ✗ 编译错误:实参太多
find(99, 9);       // ✓ 正确

编译器会严格检查参数数量——每个形参必须有对应的实参,不能多也不能少。这一点与 Python 的默认参数或 JavaScript 的 arguments 对象完全不同。


3. 按值传递(Pass by Value):C 语言的参数传递机制

3.1 核心原理:复制而非引用

C 语言中所有函数参数都是按值传递(pass by value)的——实参的值被复制到形参中,函数内部对形参的修改不影响调用方的实参变量:

pass_by_value_demo.c
c
#include <stdio.h>

void try_modify(int x)
{
    printf("  inside: before x = %d\n", x);
    x = 100;                          // 修改形参 x
    printf("  inside: after  x = %d\n", x);
}

int main(void)
{
    int a = 5;
    printf("before call: a = %d\n", a);
    try_modify(a);                     // 传递 a 的值(5)给形参 x
    printf("after  call: a = %d\n", a);
    return 0;
}

输出:

before call: a = 5
  inside: before x = 5
  inside: after  x = 100
after  call: a = 5 a 没有被修改!

内存视角:调用 try_modify(a) 时,系统在栈上为形参 x 分配了独立的内存空间,并将 a 的值 5 复制进去。函数内部修改的是 x 的副本,a 的原始内存位置从未被触及。

栈内存布局(简化):

调用前:                     调用 try_modify 时:
┌──────┐                    ┌──────────┐
 a: 5 a: 5 main 的栈帧
└──────┘                    ├──────────┤
 x: 5 try_modify 的栈帧
   (副本)  │     (x = 100 只修改这里)
                            └──────────┘

3.2 按值传递在 find 中的体现

find 函数中,我们直接修改了形参 num

find_modifies_num.c
c
int find(int num, int digit)
{
    int counter = 0;
    do {
        if (num % 10 == digit)
            counter++;
        num = num / 10;     // 修改形参 num
    } while (num != 0);
    return counter;
}

num = num / 10 直接修改了形参——这在按值传递下是完全安全的。因为 num 是调用方实参的一个副本,无论我们在函数内怎么改,调用方的原始值都不会受影响。如果 C 语言使用"按引用传递",那 find(i, 9) 之后 i 的值也会变成 0——这显然不是我们想要的。

3.3 为什么 scanf 需要 &:按值传递的必然结果

回顾 Lesson 04 中学到的 scanf 需要 & 取地址符:

c
int x;
scanf("%d", &x);   // 必须传 &x,不能传 x

这正是按值传递的直接后果:如果传 xscanf 拿到的是 x 值的副本,修改副本无法影响 x 本身。传 &x 则传递了 x地址——虽然地址本身也是按值传递的(地址值被复制),但通过地址可以间接修改 x 的内容。这引出了 C 语言中"模拟按引用传递"的技术——传指针。

simulate_pass_by_reference.c
c
#include <stdio.h>

// 通过指针模拟按引用传递
void real_modify(int *p)
{
    *p = 100;    // 通过解引用修改 p 指向的变量
}

int main(void)
{
    int a = 5;
    printf("before: a = %d\n", a);
    real_modify(&a);           // 传递 a 的地址
    printf("after:  a = %d\n", a);  // a 被修改了!
    return 0;
}

NOTE

关于指针的详细讲解将在后续课程中展开。目前只需记住:C 语言所有参数都是按值传递的。这一事实影响深远——它决定了哪些操作会影响调用方,哪些不会。


4. 逐位提取算法:%10 与 /10 的数学原理

4.1 核心思想:用除法和取余拆解数字

逐位提取算法利用的是十进制数的位权结构。对于任意非负整数 num

  • num % 10 给出 num个位数字(除以 10 的余数)
  • num / 10 给出去掉个位后的剩余部分(整数除法,舍去小数)

这个过程可以反复进行,每次"剥离"出当前的个位:

num = 395

 1 步:  num % 10 = 395 % 10 = 5   (提取个位: 5)
           num / 10 = 395 / 10 = 39  (去掉个位后剩余: 39)

 2 步:  num % 10 = 39 % 10 = 9    (提取十位: 9)
           num / 10 = 39 / 10 = 3    (去掉十位后剩余: 3)

 3 步:  num % 10 = 3 % 10 = 3     (提取百位: 3)
           num / 10 = 3 / 10 = 0     (去掉百位后: 0 停止)

每一步都在"折叠"数字的十进制表示——这就是为什么 num / 10 最终会变为 0:每一次除以 10,数字就少一位。

4.2 为什么 /10 最终一定到达 0

这涉及整数的阿基米德性质:对于任意正整数 n,反复除以 10 必然在有限步内到达 0。因为每次除法至少将数字缩小 10 倍(严格来说是缩小到 floor(n/10)),而正整数不能无限缩小。

n 的位数 = floor(log10(n)) + 1

循环次数 = n 的位数(对于 n > 0)

例如:
n = 1 1 1 次循环
n = 99 2 2 次循环
n = 100 3 3 次循环
n = 10000 5 5 次循环

这意味着对于任意数字,算法的循环次数等于该数字的十进制位数——这是一个非常高效的性质。

4.3 提取顺序:从低位到高位

逐位提取是从个位向高位进行的。这对计数结果有影响吗?

提取 395 的顺序: 5 9 3(个位→十位→百位)

提取 399 的顺序: 9 9 3(个位→十位→百位)
   数字 9 出现 2 次: 个位和十位各一个 9

因为我们只关心"某个数字出现了多少次",而不关心它出现在什么位置,所以提取顺序对计数结果没有影响。无论从高位到低位还是从低位到高位,只要每一位都被检查到,计数就是正确的。

但如果需求变为"数字 9 出现在十位上的次数",那提取顺序(或额外的位置信息)就变得重要了——这体现了需求决定算法设计的原则。

4.4 逐位提取算法的通用性

这个算法的本质是以 10 为基数的进制分解。如果将基数从 10 换成其他值,就能提取其他进制下的"位":

digit_extraction_base.c
c
// 以 10 为基数:逐位提取十进制数字
int find(int num, int digit)
{
    int counter = 0;
    do {
        if (num % 10 == digit) counter++;
        num /= 10;
    } while (num != 0);
    return counter;
}

// 以 2 为基数:逐位提取二进制位(检查是否为 1)
int count_ones(int num)
{
    int counter = 0;
    do {
        if (num % 2 == 1) counter++;   // % 2 取最低二进制位
        num /= 2;                       // / 2 右移一位
    } while (num != 0);
    return counter;
}

这种"进制无关"的通用性是 % base/ base 组合的核心优势。


5. do-while 循环:先执行,后判断

5.1 语法结构与语义

do_while_syntax.c
c
do {
    // 循环体——至少执行一次
    语句1;
    语句2;
} while (条件);   // ← 注意这个分号!绝对不能省略

while 的对比:

while_vs_do_while.c
c
// while:先判断,后执行(可能执行 0 次)
while (条件) {
    循环体;
}

// do-while:先执行,后判断(至少执行 1 次)
do {
    循环体;
} while (条件);

执行流程图

while:                    do-while:
  ┌─────────┐               ┌─────────┐
 判断条件 循环体
  └────┬─────┘               └────┬─────┘
 true

  ┌─────────┐               ┌─────────┐
 循环体 判断条件
  └────┬─────┘               └────┬─────┘
 true
       └──────→ 回到判断
 false false
                    退出循环
    退出循环

5.2 为什么逐位提取适合 do-while

关键原因:任何非负整数至少有一位数字。即使是 num = 0,它也有数字"0"这一位。因此循环体至少需要执行一次——先提取 num % 10,再判断 num / 10 后是否还有数字。

do_while_digit_extraction.c
c
// do-while 版本(正确且自然)
int counter = 0;
do {
    if (num % 10 == digit)
        counter++;
    num = num / 10;
} while (num != 0);

如果把 do-while 改成 while

while_digit_extraction.c
c
// while 版本——对 1~100 也能工作
int counter = 0;
while (num != 0) {           // 如果 num 初始为 0,循环体一次也不执行!
    if (num % 10 == digit)
        counter++;
    num = num / 10;
}

对于本课的范围(1~100),num 永远不为 0,所以两种写法结果相同。但如果 find 被调用为 find(0, 0)

写法find(0, 0) 的行为结果
do-while先执行一次:0 % 10 == 0 成立 → counter++num = 0/10 = 0num != 0 假 → 退出counter = 1
while先判断:num != 0 为假 → 跳过循环体counter = 0

find(0, 0) 的正确结果应该是 1(数字 0 在 0 中出现了 1 次)。do-while 给出了正确答案,while 则给出了错误的 0。

IMPORTANT

这个边界条件揭示了 do-while 的核心价值:当循环体至少需要执行一次时,用 do-while 表达这种语义,让代码更准确地反映设计意图。即使当前的数据范围碰巧让 while 也能工作,选择正确的循环结构是对未来维护者的尊重。

5.3 do-while 与 while 的选择原则

判断标准whiledo-while
循环体是否至少执行一次?不一定一定
终止条件是否依赖于循环体内的计算?可能在循环前已知通常在循环后才知道
循环次数可能为 0 吗?(如空文件读取)

典型应用场景:

场景推荐原因
逐位提取数字do-while数字至少有一位,必须至少处理一次
菜单交互do-while先显示菜单,再判断用户是否选择退出
输入验证do-while先读入一次数据,再判断是否有效,无效则重新读入
读取文件while文件可能为空,先判断是否读到 EOF 再处理内容
遍历链表while链表可能为空(head == NULL),不能假设至少有一个节点
游戏主循环do-while先运行一局,再问是否再来一局

5.4 do-while 的常见错误

do_while_pitfalls.c
c
// 错误 1:漏掉分号
do {
    printf("Hello\n");
} while (count < 10)    // ✗ 缺少分号!编译错误
//                    ↑ 这里必须有分号

// 错误 2:花括号不匹配
do
    printf("A\n");
    printf("B\n");      // ✗ 这一行不在循环体内!
while (count < 10);     // 只有 printf("A\n"); 被循环

// 正确写法:用花括号明确循环体范围
do {
    printf("A\n");
    printf("B\n");
} while (count < 10);

CAUTION

do-while 末尾的分号是最容易被遗忘的语法元素之一。while 循环的花括号后没有分号,但 do-whilewhile(条件)必须有分号。这是 C 语法中少数几个"看似多余的"分号之一。


6. 逻辑分解与模块化思维

6.1 问题拆解树

面对"统计 1~100 中数字 9 的总出现次数"这个问题,直接写一大段代码是低效的。更有效的方法是逻辑分解——将宏观问题拆为独立子问题,每个子问题单独解决,最后组合:

统计 1~100 中数字 9 的总出现次数

├── 子问题 1: 给定一个数和一个目标数字,数出这个数中包含多少个目标数字
   └── 方案: find(num, digit) 函数
       ├── 输入: 被搜索的数 num、目标数字 digit
       ├── 算法: do-while + %10 + /10 逐位提取
       └── 输出: 该数中目标数字的出现次数

└── 子问题 2: 1~100 的每个数,累加 find(i, 9) 的结果
    └── 方案: for 循环 + sum 累加器
        ├── 遍历: for (i = 1; i <= 100; i++)
        ├── 累加: sum += find(i, 9)
        └── 输出: printf("sum = %d\n", sum)

6.2 为什么拆分而不写一个大函数

如果把所有逻辑写在一个大函数中:

monolithic_bad.c
c
// 不推荐的「大杂烩」写法
int main(void)
{
    int i, sum = 0;
    for (i = 1; i <= 100; i++)
    {
        int n = i;
        do {
            if (n % 10 == 9) sum++;
            n /= 10;
        } while (n != 0);
    }
    printf("sum = %d\n", sum);
    return 0;
}

这段代码虽然能正确输出 20,但有严重的设计问题:

  1. 无法复用:如果想找数字 7 的个数,需要修改内层逻辑(97)。如果想统计 1~1000,需要修改循环边界。
  2. 难以测试:无法单独验证"逐位提取"逻辑是否正确——你只能运行整个程序看最终结果。如果最终结果是 19 而非 20,你无法快速定位是逐位提取错了还是累加错了。
  3. 可读性差:混合了两个层次(逐位匹配 + 遍历累加)的逻辑,阅读者必须同时理解两件事。
  4. 修改风险高:改动内层逻辑可能意外影响外层循环的行为。

拆分为 find 函数后,所有这些问题迎刃而解:

modular_good.c
c
// 推荐:模块化写法
sum += find(i, 9);   // 找 9——函数名和参数直接表达意图
sum += find(i, 7);   // 找 7——只需改一个参数
// find 可以被独立测试、独立优化、独立复用

6.3 单一职责原则(SRP)在函数设计中的应用

单一职责原则(Single Responsibility Principle)是软件工程中的核心设计原则:每个函数只负责一个明确的任务,并且做好这一件事

find 函数的职责边界非常清晰:

find 的职责: 在单个数字中统计某个 digit 的出现次数
find 不负责: 遍历数字范围、累加结果、打印输出、读取输入

判断一个函数是否违反 SRP 的方法:尝试用一句话描述它的职责。如果描述中出现了"并且"、"或者"、"以及"等连接词,那它很可能做了不止一件事。

srp_examples.c
c
// ✓ 单一职责: "统计 num 中 digit 的出现次数"
int find(int num, int digit) { /* ... */ }

// ✓ 单一职责: "判断 n 是否为素数"
int is_prime(int n) { /* ... */ }

// ✗ 违反 SRP: "判断素数并且打印结果并且统计个数"
int process_primes(int start, int end) {
    // 做了太多事情...
}

TIP

SRP 不仅适用于函数设计,也适用于变量命名、代码块组织等各个层面。养成"一个实体只做一件事"的习惯,代码的可维护性会大幅提升。


7. 函数签名设计哲学

7.1 为什么用 find(int, int) → int

函数签名的设计是对问题理解的直接体现:

function_signature_analysis.c
c
int find(int num, int digit)
// ↑            ↑         ↑
// 返回个数     被搜索的数  目标数字

参数选择的分析

  • 两个参数:函数需要知道"在哪个数里找"(num)和"找什么"(digit),这两个信息缺一不可。既不能多(不需要额外的上下文),也不能少(缺少任何一个都无法工作)。
  • 两个都是 int:被搜索的数是整数,目标数字 0~9 虽然只有一位,但用 int 是最自然的——不需要引入 charshort 带来的隐式转换问题。
  • 命名 numdigit:清晰区分了"被搜索的数字"和"目标数字"。相比之下,abxy 会让人困惑——谁是被搜索的?谁是目标?

返回类型选择的分析

  • 返回值是"数字出现的次数",这是一个非负整数(0, 1, 2, ...)。用 int 最自然。
  • 理论上可以返回 unsigned int 来表达"不能为负"的语义,但 int 更通用、更少意外(如与 printf("%d") 的兼容性)。

7.2 函数签名的对称性与直觉性

一个好的函数签名应该具有对称性——参数的顺序和命名让调用者凭直觉就能正确使用:

c
find(99, 9)    // "在 99 中找 9" —— 被搜索的数在前,目标数字在后
find(9, 99)    // "在 9 中找 99" —— 虽然编译通过但语义荒谬

参数顺序反映了思维的自然顺序:先确定"在什么范围内找"(num),再确定"找什么"(digit)。这种设计让调用代码自文档化——读代码的人不需要查阅函数定义就能理解调用的意图。

7.3 返回值的使用:累加器模式回顾

find 的返回值直接用于累加器模式(回顾 Lesson 05 中的 sum += i):

accumulator_pattern.c
c
int sum = 0;                    // 累加器初始化

for (i = begin; i <= end; i++)
{
    sum += find(i, 9);          // find 的返回值作为 += 的右操作数
}
//  ↑   ↑
// 累加器  每个数的计数结果

C 语言的表达式设计允许函数调用直接嵌入算术表达式——sum += find(i, 9) 等价于 sum = sum + find(i, 9),编译器先调用 find(i, 9) 获取返回值,再进行加法。

7.4 累加器变量初始化的必要性

accumulator_initialization.c
c
int sum;          // ✗ 未初始化!sum 的值是随机的(垃圾值)
int sum = 0;      // ✓ 显式初始化为 0

如果累加器未初始化,sum += find(i, 9) 会在垃圾值的基础上累加——结果是不可预测的。这是一个非常常见的新手错误。

CAUTION

C 语言的局部变量不会自动初始化为 0。声明 int sum; 后,sum 的值是栈上那块内存中的随机值。任何依赖未初始化变量的行为都是未定义行为(undefined behavior)。


8. 注释的写法:从 K&R 到 Doxygen

8.1 K&R 风格的函数头注释

本课代码中的注释模板源自 Unix/Linux 社区的传统风格:

kr_comment_style.c
c
/*
 * find - calculate how many digit in num
 * @num:   the number we want to find
 * @digit: the digit we search in num
 *
 * Return value: how many digit in this num
 */
int find(int num, int digit)

这种风格的要素:

元素说明示例
第一行函数名 - 一句话描述find - calculate how many digit in num
@param 字段每个参数一行,格式 @参数名: 说明@num: the number we want to find
@return 字段返回值的含义说明Return value: how many digit in this num
每行开头的 *对齐星号——让注释块在视觉上更整洁视觉上形成一条竖线

这种风格后来演变为 Linux 内核的 kerneldoc 格式,以及更通用的 Doxygen 格式:

doxygen_style.c
c
/**
 * find - calculate how many digit in num
 * @num:   the number we want to find
 * @digit: the digit we search in num
 *
 * Return: how many digit in this num
 */
int find(int num, int digit)

Doxygen 风格用 /** 开头(两个星号),并可以生成 HTML/PDF 文档。虽然初学阶段不需要使用 Doxygen 工具,但养成这种结构化的注释习惯对后续学习(如阅读开源项目源码、参与团队协作)至关重要。

8.2 C 语言中的两种注释

comment_types.c
c
// 单行注释(C99 标准引入,C89 不支持)
// 适用于简短的、一行的说明

int counter = 0;    /* 块注释(C89 标准支持) */

/*
 * 多行块注释
 * 适用于函数头文档、长段说明
 * 星号对齐是社区约定,非语法要求
 */

/* 也可以写成紧凑形式,但可读性较差 */
/* 这段注释没有星号对齐 */
/* 阅读时不容易找到注释的起止 */

选择原则

  • 函数头文档:用 /* */ 块注释(或 /** */ Doxygen 格式),因为需要多行说明参数和返回值
  • 行内简短说明:用 // 单行注释,如 int counter = 0; // 计数器
  • 临时注释掉代码:用 // 单行注释更方便(不会与代码中已有的 /* */ 嵌套冲突)

NOTE

/* */ 块注释不支持嵌套。如果在一段已经被 /* */ 注释掉的代码中再出现 /* */,会导致编译错误。这是 C 语言注释系统的一个历史遗留限制。// 单行注释没有这个问题——从 // 到行尾的所有内容都是注释。

8.3 写好注释的原则

注释不是越多越好——糟糕的注释比没有注释更危险:

comment_quality.c
c
// ✗ 无意义的注释——只是复述代码
counter++;    // 将 counter 加 1

// ✓ 有意义的注释——解释"为什么"而非"是什么"
counter++;    // 找到了一处匹配的数字 digit

// ✗ 过时的注释——代码已改但注释未更新
counter = 1;  // 初始化计数器为 0  ← 注释说 0,实际是 1!

// ✓ 注释与代码同步
counter = 1;  // 从 1 开始计数(因为第一个元素已预先处理)

好注释的原则

  1. 解释"为什么"而非"是什么":代码本身已经说明了"是什么"(counter++ 就是加 1),注释应该解释背后的意图
  2. 保持同步:修改代码时同步更新注释,过时注释比没有注释更危险
  3. 简洁精准:一行能说清楚的事不要写三行
  4. 用英文或中文保持一致:不要在同一个文件的注释中混用中英文

9. 常量定义:begin/end 与魔数之战

9.1 什么是魔数(Magic Number)

魔数(magic number)是直接出现在代码中的、没有名称解释的数字常量:

magic_number_problem.c
c
// 魔数版本——数字的含义需要读者猜测
for (i = 1; i <= 100; i++)    // 100 是什么?为什么是 1 和 100?
    sum += find(i, 9);        // 9 是什么?为什么是 9 而不是 7?

1009 本身不携带任何语义信息。阅读者必须通过上下文推断它们的含义——这在简单代码中还不算太糟,但在复杂程序中会成为理解障碍和 bug 来源。

9.2 用命名变量替代魔数

named_constants.c
c
// 命名变量版本——语义自明
int begin = 1;     // 范围的起点
int end = 100;     // 范围的终点

for (i = begin; i <= end; i++)
    sum += find(i, 9);

命名变量带来的好处:

  1. 自文档化beginend 清楚地说明了范围的含义,不需要额外注释
  2. 单点修改:如果要统计 1~200 中的 9,只需改 end = 200 一处——而不是在整个文件中搜索 100
  3. 避免遗漏:如果代码中有多处硬编码的 100(边界检查、输出格式等),改漏了一处就会导致 bug
  4. 无运行时代价int begin = 1; 和直接写 1 在编译优化后通常产生相同的机器码——可读性提升是"免费"的

9.3 #define 符号常量:编译期文本替换

在更成熟的项目中,通常使用 #define 预处理器指令定义常量:

define_constants.c
c
#include <stdio.h>

#define BEGIN 1
#define END   100
#define DIGIT 9

int find(int num, int digit) { /* ... */ }

int main(void)
{
    int i;
    int sum = 0;

    for (i = BEGIN; i <= END; i++)
        sum += find(i, DIGIT);

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

#define 的工作机制:

  • 预处理阶段:在编译之前,预处理器将所有出现 BEGIN 的地方文本替换1,将 END 替换为 100,将 DIGIT 替换为 9
  • 无类型检查#define 只是文本替换,不进行类型检查。#define END 100 中的 100 可以是任何上下文中的 int
  • 无运行时开销:替换发生在编译前,最终生成的机器码与直接写数字完全相同
  • 约定大写命名#define 常量通常全大写(BEGINENDDIGIT),以区别于变量

9.4 #define vs const 变量

C 语言中定义常量的另一种方式是 const 关键字:

define_vs_const.c
c
// #define: 编译前文本替换,无类型,无内存分配
#define END 100

// const: 运行时类型安全的只读变量,有内存分配
const int end = 100;
维度#define END 100const int end = 100;
处理阶段预处理期(编译前)编译期
类型检查无(纯文本替换)有(类型安全)
内存分配不分配(直接嵌入代码)分配(作为只读变量)
作用域从定义处到文件尾或 #undef遵循 C 作用域规则
调试支持调试器中不可见调试器中可见
数组大小可用于数组大小声明C89 中不可用,C99 VLA 中可用
取地址不可取地址可以取地址(&end

IMPORTANT

本课用 int begin = 1; 是为了降低入门门槛——避免引入预处理器概念。但 #define 是 C 语言中定义常量的正统方式,后续课程(如整型转字符串、shell-parser)中会大量使用。const 变量则更适合需要类型安全的场景。

9.5 计数器变量命名规范

代码中出现了两个"计数"变量——counter(在 find 中)和 sum(在 main 中)。它们的命名体现了不同的语义:

naming_conventions.c
c
int counter = 0;    // 计数器:记录某个操作发生的次数
int sum = 0;        // 累加和:记录一系列值的总和

常见的计数器命名及其语义:

命名语义示例
counter通用的计数变量,记录事件发生次数counter++ 每次找到匹配的数字
countcounter 同义,更简洁count++ 计数循环迭代次数
cntcount 的缩写,在紧凑代码中常见cnt++ 不推荐初学使用,含义不够明确
sum累加和,用于 += 操作sum += value 累加一系列值
total总和,与 sum 同义total = a + b + c
result最终结果,通常作为返回值return result;

TIP

命名是代码可读性的第一道防线。好的命名让代码"读起来像句子"——sum += find(i, 9) 读作"sum 累加 find(i, 9) 的结果"。不要在命名上偷懒——多打几个字母的代价远小于理解混乱代码的代价。


10. 执行流程追踪:Trace Table

10.1 什么是 Trace Table

Trace Table(追踪表)是一种用表格记录程序执行过程中变量变化的技术。它帮助你理解程序的执行流程,也是调试时"用纸笔推演"的基本方法。

10.2 本课的完整 Trace Table

以下是 mainfor 循环的部分追踪(只展示关键迭代):

迭代ifind(i, 9) 调用num 的变化过程返回值sum(累加后)
初始----0
11find(1, 9)1 % 10 = 1 ≠ 9 → 1/10 = 0 → 退出00
22find(2, 9)2 % 10 = 2 ≠ 9 → 2/10 = 0 → 退出00
..................
99find(9, 9)9 % 10 = 9 == 9 → counter=1 → 9/10 = 0 → 退出11
1010find(10, 9)10 % 10 = 0 ≠ 9 → 10/10 = 1 → 1 % 10 = 1 ≠ 9 → 1/10 = 0 → 退出01
..................
1919find(19, 9)19 % 10 = 9 == 9 → counter=1 → 19/10 = 1 → 1 % 10 = 1 ≠ 9 → 1/10 = 0 → 退出12
..................
8989find(89, 9)89 % 10 = 9 == 9 → counter=1 → 89/10 = 8 → 8 % 10 = 8 ≠ 9 → 8/10 = 0 → 退出19
9090find(90, 9)90 % 10 = 0 ≠ 9 → 90/10 = 9 → 9 % 10 = 9 == 9 → counter=1 → 9/10 = 0 → 退出110
..................
9999find(99, 9)99 % 10 = 9 == 9 → counter=1 → 99/10 = 9 → 9 % 10 = 9 == 9 → counter=2 → 9/10 = 0 → 退出220
100100find(100, 9)100 % 10 = 0 ≠ 9 → 100/10 = 10 → 10 % 10 = 0 ≠ 9 → 10/10 = 1 → 1 % 10 = 1 ≠ 9 → 1/10 = 0 → 退出020

最终 sum = 20——恰好是正确的答案。

10.3 如何手工构建 Trace Table

  1. 列出所有变量:表格的列包含所有会变化的变量
  2. 逐行执行:按代码顺序,一行一行地更新变量的值
  3. 记录每次变化:变量每次变化都在新行中记录
  4. 特别注意循环:循环的每次迭代对应一行或多行
  5. 验证边界:检查第一次和最后一次迭代的值是否正确

Trace Table 也是发现 bug 的有力工具——当程序输出不符合预期时,构建 Trace Table 通常能定位到哪个变量的哪次变化出了问题。


11. 函数复用性与可测试性

11.1 find 的复用性设计

find(int num, int digit) 的设计使其具有天然的复用性——它不绑定到任何特定的数字或范围:

reusability_demo.c
c
find(12345, 7);     // 在 12345 中找数字 7 → 0
find(77777, 7);     // 在 77777 中找数字 7 → 5
find(2024, 2);      // 在 2024 中找数字 2 → 2
find(100, 0);       // 在 100 中找数字 0 → 2(百位和十位都是 0)

这种通用性意味着 find 可以服务于各种不同的问题:

reusability_examples.c
c
// 场景 1: 统计 1~1000 中数字 7 的个数
for (i = 1; i <= 1000; i++)
    sum += find(i, 7);

// 场景 2: 统计一个数的所有数位中数字 3 的个数
int threes_in_333 = find(333, 3);  // 结果: 3

// 场景 3: 统计 1~N 中数字 0 的个数
for (i = 1; i <= N; i++)
    sum += find(i, 0);

// 场景 4: 判断一个数是否包含数字 5
if (find(num, 5) > 0)
    printf("num contains digit 5\n");

find 函数一次编写,处处使用——这正是良好函数设计的标志。

11.2 函数隔离测试:单元测试的雏形

因为 find 是独立函数,我们可以单独测试它,而不需要运行整个程序:

unit_test_concept.c
c
#include <stdio.h>

int find(int num, int digit) { /* 实现同上 */ }

// 手动测试 find 函数的各种情况
int main(void)
{
    printf("Test 1: find(99, 9)  = %d (expected: 2)\n", find(99, 9));
    printf("Test 2: find(100, 0) = %d (expected: 2)\n", find(100, 0));
    printf("Test 3: find(1, 9)   = %d (expected: 0)\n", find(1, 9));
    printf("Test 4: find(9, 9)   = %d (expected: 1)\n", find(9, 9));
    printf("Test 5: find(0, 0)   = %d (expected: 1)\n", find(0, 0));
    printf("Test 6: find(909, 9) = %d (expected: 2)\n", find(909, 9));
    return 0;
}

这种"隔离测试"的思想是单元测试(unit testing)的雏形——将程序拆分为独立可测的单元(函数),逐个验证其正确性。在专业的软件开发中,单元测试是保障代码质量的核心实践。

11.3 可测试性与函数设计的关系

函数是否容易测试,取决于其设计质量:

设计特征可测试性示例
纯函数(相同输入→相同输出)find(99, 9) 永远返回 2
依赖全局变量函数内部依赖全局 int g_counter——需要先设置全局状态
有副作用(修改外部状态)函数修改传入的数组内容——需要检查修改后的数组状态
有 I/O 操作函数内部调用 printfscanf——需要重定向 I/O

find 是"纯函数"的典型——它不依赖任何外部状态,不修改任何全局变量,不做任何 I/O 操作。给定相同的 numdigit,永远返回相同的结果。这种特性让测试变得极其简单。


12. 性能分析初步

12.1 迭代次数分析

对于本课的程序,总操作次数可以精确计算:

外层循环: 100 次迭代(i 1 100)
每次迭代调用 find(i, 9),find 内部循环次数 = i 的十进制位数

各数字位数的分布:
- 1 位数 (1~9):     9 个数字 × 1 次循环 = 9 次
- 2 位数 (10~99):   90 个数字 × 2 次循环 = 180 次
- 3 位数 (100):     1 个数字 × 3 次循环 = 3 次

总逐位比较次数 = 9 + 180 + 3 = 192

对于现代计算机,192 次操作在微秒级别内完成——你完全感觉不到任何延迟。

12.2 扩展到更大范围

如果将范围从 100 扩展到 1000000(一百万):

位数分布:
- 1 位数 (1~9):       9 × 1 = 9
- 2 位数 (10~99):     90 × 2 = 180
- 3 位数 (100~999):   900 × 3 = 2700
- 4 位数 (1000~9999): 9000 × 4 = 36000
- 5 位数 (10000~99999): 90000 × 5 = 450000
- 6 位数 (100000~1000000): 900001 × 6 ≈ 5400006

总逐位比较次数 5888895

约 589 万次操作——对于现代 CPU(每秒数十亿次操作),这仍然只需几毫秒。但你可以直观感受到:范围扩大 10000 倍,操作次数扩大了约 30000 倍。

12.3 算法复杂度直觉

这个分析的实质是算法复杂度分析的雏形。对于统计 1~N 中某个数字的出现次数:

  • 外层循环:N 次迭代
  • 内层循环:每次迭代约 log10(i) 次(数字的位数)
  • 总复杂度:约 O(N × log N)

"O(N × log N)" 的意思是:当 N 增长时,运行时间的增长速度略快于 N 本身,但远慢于 N²。这是一种相当高效的算法。

NOTE

复杂度分析(Big-O Notation)是计算机科学中评估算法效率的标准工具。初学阶段只需要建立"不同算法的增长速度不同"的直觉——有的算法在数据量大时会变得极慢(如 O(N²)),有的则保持高效(如 O(N log N))。


参考解答

统计 1~100 中数字 9 的个数(do-while 逐位提取)
count_nine_do_while.c
c
#include <stdio.h>

/*
 * find - calculate how many digit in num
 * @num:   the number we want to find
 * @digit: the digit we search in num
 *
 * Return value: how many digit in this num
 */
int find(int num, int digit)
{
    int counter = 0;

    do {
        if (num % 10 == digit)
            counter++;
        num = num / 10;
    } while (num != 0);

    return counter;
}

int main(void)
{
    int begin = 1;
    int end = 100;
    int i;
    int sum = 0;

    for (i = begin; i <= end; i++)
    {
        sum += find(i, 9);
    }

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

    return 0;
}

核心逻辑:finddo-while 逐位提取数字——num % 10 取个位与 digit 比较,num /= 10 去掉个位。main 中遍历 1~100 累加每次 find(i, 9) 的返回值,最终 sum = 20do-while 保证即使 num = 0 也能正确处理。

使用 while 的替代写法
count_nine_while.c
c
int find(int num, int digit)
{
    int counter = 0;

    while (num != 0)
    {
        if (num % 10 == digit)
            counter++;
        num = num / 10;
    }

    return counter;
}

while 替代 do-while——对于正数,两种写法效果完全相同。但注意:如果调用 find(0, 0)while 版本循环体一次都不执行,返回 0(错误);do-while 版本至少执行一次,会检测到 0 % 10 == 0 成立,返回 1(正确)。对于 1~100 的范围,两者等价。

对照检查find 函数的形参列表是否正确(类型、个数)?逐位提取用了 % 10/ 10 组合吗?main 中的累加用了 += 吗?最终输出格式是 "sum = %d\n" 吗?countersum 都初始化为 0 了吗?


课堂讨论

  1. 示例中的 1100 为何要用 beginend 来定义,直接写在 for 循环中可以吗?用 #define 常量会不会更好?
  2. find 中的 do-while 改成 while 可以吗?什么时候用 do-whilewhile 更合适?
  3. 为什么不写一个函数直接就能计算出 1~100 中 9 的个数?这样做有什么好处和坏处?
  4. 如果要统计 1~1000 中数字 7 的个数,程序需要改动几处?为什么能这么少?
  5. 逐位提取算法中,提取顺序是从个位到高位。如果需要从高位到低位提取(如确定数字在哪个数位上),应该怎么改进?
  6. C 语言的参数传递是"按值传递"——这意味着什么?如果要在函数内部修改调用方的变量,应该怎么做?

讨论答案

Q1: begin/end 能否直接写成具体数字?用 #define 会不会更好?

可以,但不推荐。 直接写 for (i = 1; i <= 100; i++) 程序能正确运行,但有以下问题:

  1. 可读性beginend 明确表达了"范围的起点和终点"这个语义,而 1100 只是两个孤立的数字。阅读者看到 begin 就知道"这是循环的起始值",看到 end 就知道"这是循环的终止值"。

  2. 可维护性:如果要统计 1~200,用 begin/end 只需改两处赋值(begin = 1; end = 200;),硬编码则需要找到所有出现 100 的地方逐一修改——容易遗漏。

  3. 无运行时代价int begin = 1; 和直接写 1 在编译优化后通常产生相同的机器码——可读性提升是"免费"的。

至于 #define vs 普通变量:#define BEGIN 1 更好——它明确表达了"这是一个常量,不会在运行时被修改"的语义,且遵循 C 社区的命名惯例(全大写)。但初学阶段用 int begin = 1; 也完全可以接受——它避免了引入预处理器的概念,让学生先专注于函数和循环。

Q2: do-while 改成 while 可以吗?什么时候各用哪个?

对于本题可以,但有细微差异。 1~100 范围中的 num 都不为 0,while (num != 0)do { ... } while (num != 0) 效果完全相同。

差异体现在极端情况:如果调用 find(0, 0)

写法行为结果
do-while先执行一次:0 % 10 == 0 成立 → counter++num = 0/10 = 0 → 判定 num != 0 为假退出counter = 1
while先判断:num != 0 为假,跳过循环体counter = 0

选择原则

  • do-while 更合适的场景:循环体至少需要执行一次(逐位取数字、菜单交互、输入验证、游戏主循环)
  • while 更合适的场景:循环次数可能为 0(读取文件——文件可能为空、遍历可能为空的链表)

选择的关键不是"哪种也能工作",而是"哪种更准确地表达了程序员的意图"。

Q3: 为什么不写一个函数直接计算 1~100 中的 9 的个数?

好处:复用性、可测试性、可读性。

如果把遍历和逐位提取合并在一个函数中:

single_purpose_bad.c
c
int count_nines_1_to_100(void)   // 专用函数,只能统计 1~100 中的 9
{
    int i, sum = 0;
    for (i = 1; i <= 100; i++)
    {
        int n = i;
        do {
            if (n % 10 == 9) sum++;
            n /= 10;
        } while (n != 0);
    }
    return sum;
}

这个函数只能做一件事——统计 1~100 中的 9。要统计 1~1000 中的 7,必须写新函数或修改现有函数。

拆分为 find(int num, int digit) 后:

  • 可以调用 find(12345, 7) 检查任意数字中的任意 digit
  • find 可以被独立测试:手动验证 find(99, 9) == 2find(0, 0) == 1
  • 组合更灵活:find + 遍历 → 统计任意范围内的 digit 个数
  • main 变得简洁清晰:sum += find(i, 9) 一行就表达了核心逻辑

核心理念:设计函数时,让它做好一件事——不要太宽(一次做太多)也不要太窄(只能做唯一场景)。find 恰好处于理想的粒度:足够通用以复用,又足够专注以简单。

Q4: 统计 1~1000 中数字 7 的个数需要改动几处?

只需改动两处——这就是逻辑分解和函数复用性的直接优势:

change_two_places.c
c
int end = 1000;           // 改 1: 范围终点(原来是 100)
// ...
sum += find(i, 7);        // 改 2: 目标数字(原来是 9)

find 函数无需任何改动——它的设计与具体数字和范围无关,只关注"在某个数中找某个数字"这一通用任务。

如果当初把逻辑全部写在 main 中,则需要找到所有硬编码的 1009 并逐一修改——在大型程序中,这种搜索-替换的操作很容易遗漏。好的设计让修改的影响范围最小化——这是软件工程中"封装"思想的雏形。

Q5: 如何从高位到低位逐位提取数字?

逐位提取从个位开始,是由 % 10/ 10 的数学性质决定的——它天然从低位开始拆解。要反过来(从高位开始),需要先确定数字的位数,然后从最高位逐级向下:

extract_high_to_low.c
c
// 从高位到低位逐位提取
int find_high_to_low(int num, int digit)
{
    int counter = 0;
    int divisor = 1;

    // 第一步:找到最高位的除数
    // 如 num=395 → divisor=100(因为 395/100=3,395/1000=0)
    while (num / divisor >= 10)
        divisor *= 10;

    // 第二步:从高位到低位逐位提取
    while (divisor > 0)
    {
        int current_digit = num / divisor;   // 取出当前最高位
        if (current_digit == digit)
            counter++;
        num = num % divisor;                  // 去掉当前最高位
        divisor /= 10;                        // 除数降一级
    }

    return counter;
}

对于 395 的高位到低位提取过程:

divisor=100: 395/100=3 3==9? 395%100=95 divisor=10
divisor=10:  95/10=9 9==9? 95%10=5 divisor=1
divisor=1:   5/1=5 5==9? 结束

这个例子说明:算法的设计取决于需求。只关心出现次数时用低位到高位更简洁;需要输出数字的原始顺序时(如整型转字符串),用高位到低位更自然。

Q6: C 语言的按值传递意味着什么?如何修改调用方的变量?

按值传递意味着:函数调用时,实参的值被复制到形参中。函数内部对形参的任何修改,都只影响这个副本,不影响调用方的原始变量。

pass_by_value_implication.c
c
void modify(int x)
{
    x = 999;    // 只修改了局部副本 x
}

int main(void)
{
    int a = 5;
    modify(a);
    printf("%d\n", a);  // 输出 5,a 没有被修改!
    return 0;
}

如何在函数内部修改调用方的变量? 答案是:传递变量的地址(即指针)。地址本身虽然是按值传递的,但通过地址可以间接修改原变量:

modify_via_pointer.c
c
void modify(int *p)      // p 是一个指针,指向一个 int
{
    *p = 999;            // 通过解引用 *p 修改 p 指向的变量
}

int main(void)
{
    int a = 5;
    modify(&a);          // 传递 a 的地址(&a 取 a 的地址)
    printf("%d\n", a);   // 输出 999,a 被修改了!
    return 0;
}

这就是为什么 scanf("%d", &x) 需要 &——scanf 需要通过地址来修改 x 的值。关于指针的详细讲解将在后续课程中展开。


课后练习

  1. 用函数改写素数判断:将 Lesson 07 中"求 100 以内最大素数"的程序,用一个 is_prime(int n) 函数改写——函数返回 1 表示是素数,0 表示不是。注意:return 可以在发现约数时提前退出。

    知识点提示:函数返回值设计(int 表示真/假)、模块化改写、return 的提前退出机制、单一职责原则的应用

  2. 同位置数字匹配:用户输入两个数字,按从个位对齐的方式,统计这 2 个数在相同位置处数字也相同的个数。例如 123 和 5173,对齐后比较个位 3==3 ✓、十位 2==7 ✗、百位 1==1 ✓、千位 0==5 ✗——结果为 2。

    知识点提示:两数同步逐位提取、% 10/ 10 同时操作两个数、循环直到两数都为 0(用 || 而非 &&)、较短数的高位视为 0

  3. 数字频率统计:统计 1~1000 中每个数字(0~9)出现的总次数,输出每个数字的频率分布。复用本课的 find 函数。

    知识点提示:遍历 1~1000、find 函数复用、长度为 10 的计数数组 freq[10]、嵌套循环(外层遍历范围,内层遍历 0~9)、数组元素通过索引访问 freq[d]

  4. 数字自幂数检查:输入一个整数 N,判断 N 是否是"自幂数"——即 N 的每一位数字的(位数)次幂之和等于 N 本身。例如 153 = 1³ + 5³ + 3³ = 153,所以 153 是自幂数(3 位数)。

    知识点提示:先确定数字的位数(循环除以 10 计数)、再逐位提取并计算幂次、幂运算用循环实现(不用 pow)、累加比较、find 思路的变形应用

  5. 数字黑洞验证:验证"数字黑洞"现象——任选一个不完全相同的四位数,按数字降序排列得最大数,升序排列得最小数,用最大减最小得到新数,重复此过程最终会到达 6174(卡普雷卡常数)。编写程序验证任意四位数是否最终到达 6174。

    知识点提示:逐位提取四位数字存入数组、数组排序(冒泡或选择排序)、从排序后的数组构造最大数和最小数(max = d[0]*1000 + d[1]*100 + d[2]*10 + d[3])、循环迭代直到收敛、函数分解(get_digitsbuild_numbersort

练习1: 用函数改写素数判断
is_prime_refactor.c
c
#include <stdio.h>

/*
 * is_prime - check if n is a prime number
 * @n: the number to check
 *
 * Return value: 1 if n is prime, 0 otherwise
 */
int is_prime(int n)
{
    int i;

    if (n <= 1)
        return 0;          // 1 和更小的数不是素数

    for (i = 2; i * i <= n; i++)
    {
        if (n % i == 0)
            return 0;      // 发现约数,不是素数,提前退出
    }

    return 1;              // 没有约数,是素数
}

int main(void)
{
    int num;
    int max = 0;

    for (num = 2; num <= 100; num++)
    {
        if (is_prime(num))
            max = num;     // 记录当前找到的最大素数
    }

    printf("max prime is %d\n", max);

    return 0;
}

模块化后 main 变得极简——只负责遍历和记录最大值,素数判断的复杂性完全封装在 is_prime 中。return 0 在发现约数时提前退出——这比用 break 更直接,因为它直接跳出了整个函数。i * i <= n 是判断素数的高效写法——只需检查到 sqrt(n)。

练习2: 同位置数字匹配
digit_position_match.c
c
#include <stdio.h>

int main(void)
{
    int a, b;
    int count = 0;

    printf("Enter two numbers: ");
    scanf("%d %d", &a, &b);

    while (a != 0 || b != 0)    // 两个数都处理完才停止
    {
        if (a % 10 == b % 10)
            count++;             // 当前个位数字相同
        a /= 10;
        b /= 10;
    }

    printf("match count = %d\n", count);

    return 0;
}

两个数同步逐位提取——每次比较两者的个位,然后同时去掉个位。终止条件用 || 而非 &&:当较短的数变为 0 后,0 % 10 = 0,可以继续与较长的数比较(较短数的高位视为 0)。如果用 &&,较短的数变为 0 后循环立即停止,较长数的剩余高位将不被比较。

练习3: 数字频率统计
digit_frequency.c
c
#include <stdio.h>

int find(int num, int digit)
{
    int counter = 0;
    do {
        if (num % 10 == digit)
            counter++;
        num /= 10;
    } while (num != 0);
    return counter;
}

int main(void)
{
    int i, d;
    int freq[10] = {0};     // freq[d] 记录数字 d 的出现次数

    for (i = 1; i <= 1000; i++)
    {
        for (d = 0; d <= 9; d++)
        {
            freq[d] += find(i, d);
        }
    }

    for (d = 0; d <= 9; d++)
        printf("digit %d: %d times\n", d, freq[d]);

    return 0;
}

复用 find 函数,用长度为 10 的数组 freq[10] 记录每个数字的频率。freq[10] = {0} 将所有元素初始化为 0——这是 C 语言数组初始化的简洁语法。数组是 C 语言中管理多项同类型数据的基本工具——后续课程会详细讲解。

练习4: 数字自幂数检查
armstrong_number.c
c
#include <stdio.h>

int main(void)
{
    int n, original, digit;
    int digits = 0;       // 数字的位数
    int sum = 0;

    printf("Enter a number: ");
    scanf("%d", &n);
    original = n;

    // 第一步:计算位数
    int temp = n;
    do {
        digits++;
        temp /= 10;
    } while (temp != 0);

    // 第二步:逐位提取并计算幂次和
    temp = n;
    do {
        digit = temp % 10;

        // 计算 digit^digits(用循环,不用 pow)
        int power = 1;
        int j;
        for (j = 0; j < digits; j++)
            power *= digit;

        sum += power;
        temp /= 10;
    } while (temp != 0);

    if (sum == original)
        printf("%d is an Armstrong number\n", original);
    else
        printf("%d is NOT an Armstrong number\n", original);

    return 0;
}

自幂数(Armstrong number / Narcissistic number)是逐位提取算法的一个有趣应用。先确定位数(除以 10 计数),再逐位计算幂次和。注意不能用 pow()(那需要 math.h 和链接 -lm),而是用循环自己实现幂运算——这也是在练习基本控制结构。

练习5: 数字黑洞验证
kaprekar_routine.c
c
#include <stdio.h>

// 将 n 的四位数字存入数组 d[0..3]
void get_digits(int n, int d[])
{
    int i;
    for (i = 0; i < 4; i++)
    {
        d[i] = n % 10;
        n /= 10;
    }
}

// 冒泡排序(升序)
void sort_asc(int d[], int len)
{
    int i, j, tmp;
    for (i = 0; i < len - 1; i++)
    {
        for (j = 0; j < len - 1 - i; j++)
        {
            if (d[j] > d[j + 1])
            {
                tmp = d[j];
                d[j] = d[j + 1];
                d[j + 1] = tmp;
            }
        }
    }
}

// 从升序排列的数组构造最大数和最小数
// 升序: d[0] 最小, d[3] 最大
// 最大数 = d[3]*1000 + d[2]*100 + d[1]*10 + d[0]
// 最小数 = d[0]*1000 + d[1]*100 + d[2]*10 + d[3]
int build_max(int d[])
{
    return d[3] * 1000 + d[2] * 100 + d[1] * 10 + d[0];
}

int build_min(int d[])
{
    return d[0] * 1000 + d[1] * 100 + d[2] * 10 + d[3];
}

int main(void)
{
    int n, d[4];
    int max, min, next;
    int steps = 0;

    printf("Enter a 4-digit number (not all digits same): ");
    scanf("%d", &n);

    printf("Steps:\n");
    do {
        get_digits(n, d);
        sort_asc(d, 4);

        max = build_max(d);
        min = build_min(d);
        next = max - min;

        printf("  %d: %d - %d = %d\n", steps + 1, max, min, next);

        n = next;
        steps++;
    } while (n != 6174 && steps < 20);  // 最多 20 步防死循环

    if (n == 6174)
        printf("Reached Kaprekar's constant 6174 in %d steps!\n", steps);
    else
        printf("Did not reach 6174.\n");

    return 0;
}

卡普雷卡常数(6174)是数学中著名的"数字黑洞"现象——任何不完全相同的四位数经过有限次"最大排列减最小排列"操作后都会到达 6174。这个练习综合运用了逐位提取、数组操作、排序算法和循环控制——是检验本课知识的综合挑战。


参考资料

"I don't think that I have any special insight, but it has always seemed to me best to do something that you really enjoy doing." — Brian W. Kernighan

Released under the MIT License.