跳转到内容

Lesson 17: 统计单词个数 CNB

练习任务

编写一个 C 程序,从标准输入读取一行文本,统计其中有多少个单词,并依次打印每个单词。单词的定义是:由字母(a-z, A-Z)组成的连续字符序列,单词之间由非字母字符(空格、标点、换行等)分隔。

期望输入输出示例:

输入: This is a book.
输出:
word 1 found!
This
word 2 found!
is
word 3 found!
a
word 4 found!
book
there is 4 words found!

提示:核心问题是——程序怎么「知道」一个新单词开始了?想象你在一个字符序列中行走,只有两种状态:你正在单词「里面」还是在单词「外面」。当从「外面」遇到字母时,一个新单词就开始了;当从「里面」遇到非字母时,当前单词结束。用变量记录当前状态,这种编程模式叫做状态机

核心知识点

  • 状态机编程思想 — 用 state 变量记录系统当前状态,根据输入决定状态转换
  • IN/OUT 状态模型 — 两种状态(在单词内 / 在单词外),四种转换分支
  • 字符分类函数 — get_input_type() 将字符抽象为输入类型(字母 / 非字母)
  • 指针记录单词起始 — p = &buf[i] 记录新单词的首字符地址
  • 计数器累计长度 — counter 从 0 递增,记录当前单词的字符数
  • switch-case 状态机 — 用 switch(state) 替代多层 if-else,语义更清晰
  • fgets 读取输入 — 从 stdin 读一行,处理换行符和 EOF
  • 状态转换表 — 将 (状态, 输入) → (新状态, 动作) 形式化为二维表格
  • ctype.h 标准库 — isalpha()isspace() 等可移植字符分类函数
  • K&R 状态机范例 — \b 退格符与单词计数的经典实现

代码框架

word_count.c
c
#include <stdio.h>

int get_input_type(char c)
{
    // 判断 c 是否是字母 (a-z 或 A-Z)
    // 如果是 → 返回 1
    // 如果不是 → 返回 0
}

int main(void)
{
    char buf[512];
    int state = 0;       // 0: 单词外, 1: 单词内
    int i = 0;
    int words = 0;       // 单词计数
    char *p = NULL;      // 指向当前单词首字符
    int counter = 0;     // 当前单词长度

    fgets(buf, sizeof(buf), stdin);

    // 去掉末尾的换行符
    for (i = 0; buf[i]; i++)
        if (buf[i] == '\n')
        {
            buf[i] = '\0';
            break;
        }

    i = 0;

    // 在这里实现状态机主循环:
    // state == 0 && input == 0 → 连续空白, 保持 state=0
    // state == 0 && input == 1 → 新单词开始, state=1, 记录 p, counter=1
    // state == 1 && input == 0 → 单词结束, state=0, words++, 打印单词
    // state == 1 && input == 1 → 单词继续, counter++
    // 遇到 '\0' → 退出循环, 处理最后一个可能未结束的单词

    printf("there is %d words found!\n", words);
    return 0;
}

填充框架的关键思考:p 应该在什么时刻赋值?counter 什么时候清零?如果最后一个单词后面没有空格(如 "hello"),循环结束后需要额外处理吗?

TIP

先不要往下翻看参考解答。尝试先独立实现状态机主循环的四个分支。状态机的核心是「当前状态 + 当前输入 → 下一个状态 + 输出动作」,画一个简单的状态转换图可以帮助理清逻辑。


深度讲解

1. 状态机:程序如何「记住」上下文

1.1 问题本质:程序为什么需要「状态」

回顾 Lesson 08 的逐位提取算法——num % 10 取个位、num / 10 去个位——每次循环只关心当前位的数字,不需要记住之前发生了什么。再回顾 Lesson 13 的 switch-case,它根据一个确定的值(枚举星期)选择分支执行,也是「看一眼就知道怎么办」。

但统计单词不同——程序读到字符 'a' 时,它需要知道:这个 'a' 是一个新单词的第一个字母,还是当前单词的第三个字母?答案取决于之前发生了什么

context_problem.c
c
// 考虑以下两个位置读到的字母 'o':
// "hello world" — 第二个 'o' 是单词 "hello" 的延续
// "  one two"  — 第一个 'o' 是新单词 "one" 的开始
//
// 两个 'o' 的处理方式不同,但程序不能靠 'o' 本身来区分——
// 它必须记住「我读到 'o' 之前是在单词里面还是在单词外面」

这就是上下文依赖——当前输入的处理方式取决于历史。状态机正是解决这类问题的标准工具。

1.2 状态机的基本概念

一个有限状态自动机(Finite State Automaton, FSA)由三个要素组成:

  • 状态(State):系统在某时刻的「位置」或「模式」——有限个离散值
  • 输入(Input):驱动状态转换的外部信号——来自字符、传感器、网络等
  • 转换规则(Transition):在状态 S 下收到输入 I,系统转换到新状态 S',并可附带动作

用我们的话说:状态机 = 状态集合 + 输入集合 + 转换表

state_machine_concept.c
c
// 伪代码:状态机的基本结构
int state = INITIAL_STATE;      // 初始状态

while (input_available())
{
    int input = read_input();   // 获取当前输入

    // 根据 (state, input) 决定下一步
    switch (state)
    {
        case STATE_A:
            if (input == X) { do_action_1(); state = STATE_B; }
            else            { do_action_2(); state = STATE_C; }
            break;
        case STATE_B:
            // ...
    }
}

这不是一个花哨的算法——它就是「用一个变量记住你在哪里」这种朴素思想的系统化表达。

1.3 单词计数的状态机模型

对于「统计单词」这个任务,我们定义:

状态集合

状态值含义说明
0 (OUT)在单词「外面」当前处于空白区域,还没进入或已经离开一个单词
1 (IN)在单词「里面」当前正在遍历一个单词的字符

输入集合

输入值含义说明
0非字母字符空格、标点、数字、换行等——「分隔符」
1字母字符a-z 或 A-Z——「单词内容」

转换规则

当前状态  输入  新状态  动作
─────────────────────────────────────────
 OUT(0)    0    →  OUT(0)  →  什么也不做(连续空白)
 OUT(0)    1    →   IN(1)  →  新单词开始!记录起始位置,counter=1
  IN(1)    0    →  OUT(0)  →  单词结束!计数器++,打印单词
  IN(1)    1    →   IN(1)  →  单词继续,counter++

这就是本课的核心——全部四个分支覆盖了所有可能的情况。

状态转换图

           input=0                    input=1
       ┌──────────┐               ┌──────────┐

     ┌─────┐    ┌─────┐         ┌─────┐    ┌─────┐
 OUT │◄───│ OUT OUT │───►│  IN
 (0) │    │ (0) │         │ (0) │    │ (1) │
     └─────┘    └─────┘         └─────┘    └─────┘


       └──────────┘                         └────┘
          input=1                      input=1
       ┌──────────┐               ┌──────────┐

     ┌─────┐    ┌─────┐         ┌─────┐    ┌─────┐
  IN │───►│ OUT  IN │───►│  IN
 (1) │    │ (0) │         │ (1) │    │ (1) │
     └─────┘    └─────┘         └─────┘    └─────┘
          input=0                    input=1

理解这张图就理解了本课 80% 的内容。后续的所有代码都是这四种转换的具体实现。


2. 字符分类:从字符到输入类型

2.1 get_input_type 的设计动机

为什么需要一个专门的函数来「分类」字符?直接在 main 中写 if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') 不行吗?

why_separate_function.c
c
// 方案 A:直接在 main 中判断
while ((c = buf[i++]) != '\0')
{
    if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')
    {
        // 字母的处理逻辑...
    }
    else
    {
        // 非字母的处理逻辑...
    }
}

// 方案 B:用函数封装字符分类
while ((c = buf[i++]) != '\0')
{
    int input = get_input_type(c);   // 1 或 0
    // 现在用 input 而不是原始字符做判断
}

方案 B 的优势

方面方案 A(直接判断)方案 B(函数封装)
可读性if (c >= 'a' && ...) 冗长if (input == 1) 简洁
可修改性改分类规则要改所有出现处只改一个函数
语义层混在字符层面提升到「输入类型」抽象层
扩展性增加新字符类型很麻烦扩展函数返回值即可

这是软件工程中「抽象分层」思想的体现——状态机不应该关心 'a''Z' 的 ASCII 值,它只需要知道「这是字母」还是「这不是字母」。

2.2 从手写判断到 ctype.h 标准库

C 标准库 <ctype.h> 提供了一系列字符分类函数,它们比手写的范围判断更可靠、更可移植:

ctype_demo.c
c
#include <ctype.h>
#include <stdio.h>

int main(void)
{
    printf("isalpha('A') = %d\n", isalpha('A'));   // 非零(真)
    printf("isalpha('9') = %d\n", isalpha('9'));   // 0(假)
    printf("isalpha(' ') = %d\n", isalpha(' '));   // 0(假)
    printf("isspace(' ') = %d\n", isspace(' '));   // 非零(真)
    printf("isspace('\\n') = %d\n", isspace('\n')); // 非零(真)
    printf("isdigit('5') = %d\n", isdigit('5'));   // 非零(真)
    printf("islower('g') = %d\n", islower('g'));   // 非零(真)
    printf("isupper('G') = %d\n", isupper('G'));   // 非零(真)
    return 0;
}

为什么 ctype.h 比手写更好

  1. 可移植性:不同平台的字符编码可能不同(虽然现在大多是 ASCII/UTF-8),isalpha 由标准库保证正确行为
  2. 性能优化:标准库实现通常用查表法——一个 256 字节的位标记数组,比多个 if 判断快得多
  3. 可读性isalpha(c) 的意图比 c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' 清晰百倍

ctype.h 查表法的原理

ctype_lookup_table.c
c
// 标准库 isalpha 的典型实现方式(简化示意)
static const unsigned char _ctype[256] = {
    // ... 每个字符对应一个位标记
    // bit 0: 大写, bit 1: 小写, bit 2: 数字, bit 3: 空白, ...
};

#define _U  0x01   // 大写字母标记位
#define _L  0x02   // 小写字母标记位

int isalpha(int c)
{
    return (_ctype[(unsigned char)c] & (_U | _L));
    //       查表                       位运算判断
    //       O(1) 常数时间,无需逐个比较
}

对比

方法时间复杂度代码量可移植性
if (c >= 'a' && c <= 'z' || ...)O(1)(但多条件)1 行(但冗长)依赖 ASCII 假设
isalpha(c)O(1)(查表)1 行(简洁)标准保证

TIP

在实际项目中,永远优先使用 <ctype.h> 中的标准函数。本课练习中手写 get_input_type 是为了让你理解字符分类的底层逻辑——知其然,更知其所以然。理解之后,你应该知道 isalpha() 背后在做什么。

2.3 扩展字符分类:更多输入类型

如果我们不仅要区分「字母」和「非字母」,还想区分「数字」、「标点」、「空白」等更细的类型,get_input_type 的返回值可以从 0/1 扩展到枚举:

extended_input_type.c
c
// 定义更多输入类型
enum InputType {
    TYPE_SPACE   = 0,   // 空白字符(空格、\t、\n)
    TYPE_ALPHA   = 1,   // 字母 (a-z, A-Z)
    TYPE_DIGIT   = 2,   // 数字 (0-9)
    TYPE_PUNCT   = 3,   // 标点符号
    TYPE_OTHER   = 4,   // 其他
};

int get_input_type_extended(char c)
{
    if (c == ' ' || c == '\t' || c == '\n')
        return TYPE_SPACE;
    if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')
        return TYPE_ALPHA;
    if (c >= '0' && c <= '9')
        return TYPE_DIGIT;
    if (c == '.' || c == ',' || c == '!' || c == '?'
        || c == ';' || c == ':')
        return TYPE_PUNCT;
    return TYPE_OTHER;
}

有了更多输入类型,状态机可以处理更复杂的任务——比如同时统计单词数、数字个数、标点数量。这正是词法分析器的雏形(Lesson 21 会深入探讨)。


3. 状态机的实现:if-else 链

3.1 四个转换分支

回到本课的核心——实现单词计数状态机。以下是完整的 if-else 链实现:

word_count_ifelse.c
c
#include <stdio.h>

int get_input_type(char c)
{
    if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')
        return 1;
    return 0;
}

int main(void)
{
    char buf[] = "This is a book.";
    int state = 0;       // 0: OUT, 1: IN
    int i = 0;
    int words = 0;
    char *p = NULL;
    int counter = 0;
    int k;

    while (1)
    {
        char c = buf[i];
        int input;

        if (c == '\0')
            break;

        input = get_input_type(c);

        // 分支 1: OUT + 非字母 → 保持 OUT(连续空白)
        if (state == 0 && input == 0)
        {
            state = 0;
        }
        // 分支 2: OUT + 字母 → 进入 IN(新单词开始!)
        else if (state == 0 && input == 1)
        {
            state = 1;
            p = &buf[i];          // 记录单词起始地址
            counter = 1;          // 计数器从 1 开始
        }
        // 分支 3: IN + 非字母 → 回到 OUT(单词结束!)
        else if (state == 1 && input == 0)
        {
            state = 0;
            words++;
            printf("word %d found!\n", words);
            for (k = 0; k < counter; k++)
                printf("%c", p[k]);   // 逐字符打印单词
            printf("\n");
        }
        // 分支 4: IN + 字母 → 保持 IN(单词继续)
        else if (state == 1 && input == 1)
        {
            state = 1;
            counter++;
        }

        i++;
    }

    printf("there is %d words found!\n", words);
    return 0;
}

3.2 逐行解析:指针 p 和计数器 counter

**分支 2(新单词开始)**是理解本程序的关键:

new_word_start.c
c
else if (state == 0 && input == 1)
{
    state = 1;
    p = &buf[i];          // ← 核心操作:记住单词的首字符地址
    counter = 1;          // ← 计数器从 1 开始(已经包含第一个字符)
}
  • p = &buf[i]:用指针 p 保存当前字符在 buf 中的地址。之后打印单词时,通过 p[0]p[1]、...、p[counter-1] 依次访问单词的每个字符
  • counter = 1:计数器从 1 开始而不是 0——因为当前字符 buf[i] 已经是单词的一部分

**分支 4(单词继续)**中 counter++ 让计数器递增,记录单词长度。

**分支 3(单词结束)**中用 for (k = 0; k < counter; k++) printf("%c", p[k]) 打印整个单词——这就是 pcounter 的配合:p 告诉我们从哪里开始,counter 告诉我们有多长。

3.3 处理特殊情况:末尾没有空格

考虑输入 "hello"——只有一个单词,末尾没有空格或标点:

字符:    h    e    l    l    o    \0
状态: 0→1  1→1  1→1  1→1  1→1   ?

                        循环在 \0 break,但 state 仍是 1!

循环在遇到 '\0'break 退出,但此时 state 可能仍是 1(IN)——最后一个单词还没被「结束」。这会导致漏计:

missing_last_word.c
c
// 有 bug 的版本:最后一个单词可能被漏掉
while (1)
{
    char c = buf[i];
    if (c == '\0')
        break;            // 直接退出,不管 state 是不是 1
    // ... 状态机逻辑 ...
}

修复:在循环结束后检查 state,如果为 1 说明还有未结束的单词:

fix_last_word.c
c
while (1)
{
    char c = buf[i];
    if (c == '\0')
        break;
    // ... 状态机逻辑 ...
    i++;
}

// 处理最后一个可能未结束的单词
if (state == 1)
{
    words++;
    printf("word %d found!\n", words);
    for (int k = 0; k < counter; k++)
        printf("%c", p[k]);
    printf("\n");
}

WARNING

忘记处理循环结束后 state == 1 的情况是一个常见 bug。在测试时不仅要测 "a b c"(单词后都有空格),还要测 "a b c" 末尾不带空格的情况。

3.4 处理连续多个空格

输入 " one two " 中有多个连续空格——状态机如何应对?

让我们追踪状态变化:

字符: ' '  ' '  'o'  'n'  'e'  ' '  ' '  ' '  't'  'w'  'o'  ' '  ' '

input: 0    0    1    1    1    0    0    0    1    1    1    0    0
state:0→0  0→0  0→1  1→1  1→1  1→0  0→0  0→0  0→1  1→1  1→1  1→0  0→0
      分支1 分支1 分支2 分支4 分支4 分支3 分支1 分支1 分支2 分支4 分支4 分支3 分支1

关键观察:分支 1(state=0, input=0 → state=0) 默默吞掉了所有连续的空白字符。无论开头、中间还是结尾有多少个空格,状态机都在 OUT(0) 状态下安静地路过它们,直到遇到第一个字母触发分支 2。

这正是状态机的优雅之处——连续空白的处理不需要任何特殊逻辑,它只是「保持 OUT 状态」的自然结果。


4. 从 if-else 到 switch-case

4.1 if-else 链的问题

回顾上面的实现,四个分支用 if / else if / else if / else if 链表达:

ifelse_chain.c
c
if (state == 0 && input == 0)
    state = 0;
else if (state == 0 && input == 1)
{
    state = 1;
    p = &buf[i];
    counter = 1;
}
else if (state == 1 && input == 0)
{
    state = 0;
    words++;
    // 打印单词...
}
else if (state == 1 && input == 1)
{
    state = 1;
    counter++;
}

这能工作,但有几个问题:

  1. 条件重复:每个分支都重复 state == 0state == 1,阅读时需要在脑中匹配
  2. 顺序依赖else if 的求值顺序是固定的,虽然这里不影响正确性,但弱化了「这是四种并列情况」的语义
  3. 扩展困难:如果要增加第三个状态(比如 state == 2 表示「在数字中」),if-else 链会进一步膨胀

4.2 switch-case 重构

回顾 Lesson 13 中学到的 switch-case 语法——它按整数值多路分支,天然适合状态机的 state 变量:

word_count_switch.c
c
int state = 0;
int i = 0;
int words = 0;
char *p = NULL;
int counter = 0;

while (1)
{
    char c = buf[i];
    if (c == '\0') break;

    int input = get_input_type(c);

    switch (state)        // 先按 state 分大类
    {
        case 0:           // state == OUT
            if (input == 0)
                state = 0;          // 保持 OUT
            else  // input == 1
            {
                state = 1;
                p = &buf[i];
                counter = 1;
            }
            break;

        case 1:           // state == IN
            if (input == 0)
            {
                state = 0;
                words++;
                printf("word %d found!\n", words);
                for (int k = 0; k < counter; k++)
                    printf("%c", p[k]);
                printf("\n");
            }
            else  // input == 1
            {
                state = 1;
                counter++;
            }
            break;
    }

    i++;
}

对比两种写法

方面if-else 链switch-case
结构四个平级的 else if两个 case + 各自内部的 if
分组逻辑按 (state, input) 组合分先按 state 分大类,再按 input 分小类
新增状态增加 2+ 个 else if增加 1 个 case
语义四种情况的列表状态驱动的行为定义

switch-case 版本更清晰地表达了「状态驱动」的语义——先看当前在哪个状态,再根据输入决定做什么。这与状态机的数学模型(状态 → 输入 → 新状态 + 动作)更接近。

TIP

在 Lesson 13 中我们学了 switch-case 的穿透(fall-through)特性——不写 break 时会继续执行下一个 case。在本课的状态机中我们不利用穿透,因为每个 case 块内部用 if-else 做了完整的逻辑分叉。但在更复杂的状态机中,穿透可以让多个状态共享代码(回顾 Lesson 13 中多个尾号共享同一限行日的例子)。


5. 状态转换表:将逻辑数据化

5.1 从代码到表格

状态机的四种转换规则可以形式化为一张二维表:

              input=0 (非字母)          input=1 (字母)
           ┌─────────────────────┬─────────────────────┐
 state=0 新状态: 0 (保持OUT)  │ 新状态: 1 (进入IN)    │
 (OUT)      动作: 动作: 记录p, counter=1│
           ├─────────────────────┼─────────────────────┤
 state=1 新状态: 0 (回到OUT)  │ 新状态: 1 (保持IN)    │
 (IN)       动作: words++, 打印 动作: counter++
           └─────────────────────┴─────────────────────┘

5.2 查表法实现

如果我们把这张表用 C 语言的数据结构表示,可以实现「零分支」的状态机:

table_driven_fsm.c
c
#include <stdio.h>

// 状态转换表: next_state[state][input]
int next_state[2][2] = {
    // input=0  input=1
    {     0,       1    },   // state=0 (OUT)
    {     0,       1    },   // state=1 (IN)
};

int main(void)
{
    char buf[] = "This is a book.";
    int state = 0;
    int i = 0;
    int words = 0;
    char *p = NULL;
    int counter = 0;

    while (buf[i] != '\0')
    {
        int input = (buf[i] >= 'a' && buf[i] <= 'z')
                 || (buf[i] >= 'A' && buf[i] <= 'Z');

        // 检测状态转换:从 OUT 到 IN → 新单词开始
        if (state == 0 && input == 1)
        {
            p = &buf[i];
            counter = 1;
        }
        // 检测状态转换:从 IN 到 OUT → 单词结束
        else if (state == 1 && input == 0)
        {
            words++;
            printf("word %d found!\n", words);
            for (int k = 0; k < counter; k++)
                printf("%c", p[k]);
            printf("\n");
        }
        // 保持在 IN 且收到字母 → 单词延续
        else if (state == 1 && input == 1)
        {
            counter++;
        }

        // 状态更新:查表
        state = next_state[state][input];
        i++;
    }

    printf("there is %d words found!\n", words);
    return 0;
}

查表法的核心优势

特性说明
状态转换逻辑与动作逻辑分离next_state[][] 只管「到哪去」,if 只负责「做什么」
可验证性表格一目了然——覆盖了所有 (2×2) 组合吗?
可扩展性增加第三个状态只需扩展数组到 [3][2]
性能数组访问 O(1),编译器可能进一步优化

5.3 查表法的动作函数化

更进一步,动作也可以纳入表中——用函数指针:

table_with_actions.c
c
#include <stdio.h>

// 定义动作函数类型
typedef void (*action_func)(void *ctx, int pos);

// 动作函数声明
void action_new_word(void *ctx, int pos);
void action_end_word(void *ctx, int pos);
void action_continue_word(void *ctx, int pos);
void action_nothing(void *ctx, int pos);

// 完整的状态转换表
struct Transition {
    int next_state;
    action_func action;
};

struct Transition fsm[2][2] = {
    // state=0 (OUT)
    {
        {0, action_nothing},        // input=0: 保持 OUT, 无动作
        {1, action_new_word},       // input=1: 进入 IN, 记录单词起始
    },
    // state=1 (IN)
    {
        {0, action_end_word},       // input=0: 回到 OUT, 单词结束
        {1, action_continue_word},  // input=1: 保持 IN, 计数器++
    },
};

NOTE

函数指针数组在 C 中很强大但语法稍复杂。本课不需要掌握这种写法——它展示的是「状态机可以被完全数据化」的思想。在实际项目中(如网络协议解析器、游戏 AI 行为树),表驱动状态机是工业级方案。


6. 从定长字符串到标准输入

6.1 fgets:安全地读一行

本课的练习使用 fgets 从标准输入读取一行:

fgets_demo.c
c
#include <stdio.h>

int main(void)
{
    char buf[512];

    printf("请输入一行文本: ");
    fgets(buf, sizeof(buf), stdin);
    //   ↑        ↑           ↑
    // 缓冲区   缓冲区大小    从哪里读

    printf("你输入了: %s", buf);   // 注意:buf 末尾包含换行符 \n
    return 0;
}

fgets 的特性

  • 读取最多 sizeof(buf)-1 个字符,保证不越界
  • 遇到换行符 '\n' 时停止,换行符会被存入缓冲区
  • 读到文件末尾(EOF)时返回 NULL
  • gets() 不同——gets() 无法限制读取长度,已被 C11 标准废弃

6.2 去掉末尾的换行符

fgets 读到的字符串末尾带有 '\n',在状态机处理前需要去掉:

strip_newline.c
c
char buf[512];
fgets(buf, sizeof(buf), stdin);

// 方法 1:遍历找到 \n 并替换为 \0
for (int i = 0; buf[i]; i++)
    if (buf[i] == '\n')
    {
        buf[i] = '\0';
        break;
    }

// 方法 2:用 strlen 定位末尾(需要 #include <string.h>)
size_t len = strlen(buf);
if (len > 0 && buf[len - 1] == '\n')
    buf[len - 1] = '\0';

为什么去换行符很重要?因为 '\n'get_input_type 中会被分类为 input=0(非字母)。如果不去掉它,末尾的换行符会触发分支 3(IN → OUT),导致一个多余的「单词结束」事件——虽然在这个特定程序中影响不大(因为 \0 在前),但保留换行符在 buf 中是一个坏习惯,可能在其他场景引起 bug。

6.3 getchar 逐字符读取

另一种输入方式是用 getchar() 逐字符读取,直到遇到 EOF:

getchar_loop.c
c
#include <stdio.h>

int get_input_type(char c)
{
    if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')
        return 1;
    return 0;
}

int main(void)
{
    int state = 0;
    int words = 0;
    char *p = NULL;
    int counter = 0;
    char buf[512];
    int i = 0;
    int c;

    // 逐字符读取并存入 buf
    while ((c = getchar()) != EOF && c != '\n')
    {
        buf[i++] = c;
    }
    buf[i] = '\0';    // 手动添加终止符

    // ... 后续状态机逻辑 ...
}

getchar() 返回 int 而不是 char——这是为了能表示 EOF(通常是 -1),它与任何合法字符值(0~255)都不冲突。

两种输入方式对比

方面fgetsgetchar 循环
代码复杂度一行搞定需要手动管理缓冲区和终止符
换行符处理自动包含 \n,需手动去掉循环条件中过滤掉,无需后处理
缓冲区安全自动(传入大小)需手动确保不越界
适用场景读一行文本逐字符处理,流式输入

6.4 delspace.c 的启示:去除连续空格

练习包中还有一个 delspace.c 文件,它的任务不同——去除连续空格,只保留一个:

delspace_concept.c
c
// delspace.c 的状态机有 3 个状态:
// state=0: 正常输出(OUT)
// state=1: 遇到第一个空格(输出它)
// state=2: 连续空格中的后续空格(不输出,跳过)

// 转换表:
// state=0, input=0 (非空格) → state=0, 输出
// state=0, input=1 (空格)   → state=1, 输出这个空格
// state=1, input=0          → state=0, 输出
// state=1, input=1          → state=2, 不输出(跳过)
// state=2, input=0          → state=0, 输出
// state=2, input=1          → state=2, 不输出(跳过)

这展示了状态机的一个重要特性:增加状态数量可以处理更复杂的模式。从 2 个状态(IN/OUT)扩展到 3 个状态(NORMAL/FIRST_SPACE/SUBSEQUENT_SPACE),程序就能区分「第一个空格」和「后续空格」。


7. K&R 状态机:单词计数的经典

7.1 《The C Programming Language》中的 wc 程序

Kernighan 和 Ritchie 在《The C Programming Language》中展示了一个经典的状态机示例——统计行数、单词数和字符数(wc 命令的核心逻辑):

knr_wc.c
c
#include <stdio.h>

#define IN   1   // 在单词内
#define OUT  0   // 在单词外

int main(void)
{
    int c, nl, nw, nc, state;

    state = OUT;
    nl = nw = nc = 0;    // 多重赋值:nl=0, nw=0, nc=0

    while ((c = getchar()) != EOF)
    {
        ++nc;             // 每读一个字符,字符数 +1
        if (c == '\n')
            ++nl;         // 遇到换行,行数 +1
        if (c == ' ' || c == '\n' || c == '\t')
            state = OUT;
        else if (state == OUT)
        {
            state = IN;
            ++nw;         // 从 OUT 进入 IN → 新单词开始
        }
    }

    printf("%d %d %d\n", nl, nw, nc);
    return 0;
}

K&R 版本的巧妙之处

  1. state == OUT 检测单词开始:不需要 else if (state == OUT && input == 1) 这种双重条件。当 c 不是分隔符(空格、换行、制表符)且当前 state == OUT 时,意味着一个新单词开始了——此时 ++nw 计数
  2. 不需要记录单词内容:因为只统计数量,不需要 pcounter
  3. 不需要处理单词结束:不需要 else if (state == IN && input == 0)。分隔符简单地设置 state = OUT,下一个非分隔符字符会触发新单词计数
  4. 极简而完整:三行计数(字符、行、单词)交织在一起,所有逻辑浓缩在不到 20 行

IMPORTANT

K&R 的状态机是「检测进入 IN 的时刻」来计数单词,而不是「检测从 IN 离开的时刻」。这是两种等价的计数策略——前者在单词开始时计数,后者在单词结束时计数。本课练习选择了后者(因为需要打印完整单词后再计数),但两种思想都值得理解。

7.2 两种计数策略的对比

two_strategies.c
c
// 策略 A:进入时计数(K&R 风格)
// 优点:不需要处理循环结束后的残留状态
// 缺点:单词刚开始时还不知道它的长度
if (state == OUT && c_is_alpha)
{
    state = IN;
    words++;           // 立即计数
}

// 策略 B:离开时计数(本课风格)
// 优点:计数时已经知道完整单词内容
// 缺点:需要处理循环结束后 state==IN 的情况
if (state == IN && !c_is_alpha)
{
    state = OUT;
    words++;           // 单词完整了才计数
    print_word(p, counter);
}
方面进入时计数(K&R)离开时计数(本课)
计数时机遇到单词首字符遇到单词后的分隔符
代码简洁度更简洁(不需要后处理)稍复杂(需要后处理)
单词内容计数时未知计数时已知
适用场景只统计数量需要记录/打印单词内容

参考解答

练习:单词计数状态机
word_count_solution.c
c
#include <stdio.h>

int get_input_type(char c)
{
    if (c >= 'a' && c <= 'z')
        return 1;

    if (c >= 'A' && c <= 'Z')
        return 1;

    return 0;
}

int main(void)
{
    char buf[512];
    int state = 0;
    int i = 0;
    int words = 0;
    char *p = NULL;
    int counter = 0;

    fgets(buf, sizeof(buf), stdin);

    /* 去掉换行 */
    for (i = 0; buf[i]; i++)
        if (buf[i] == '\n') { buf[i] = '\0'; break; }

    i = 0;

    while (1)
    {
        char c = buf[i];
        int input;

        if (c == '\0')
            break;

        input = get_input_type(c);

        // 分支 1: OUT + 非字母 → 保持 OUT
        if (state == 0 && input == 0)
        {
            state = 0;
        }
        // 分支 2: OUT + 字母 → 进入 IN(新单词开始)
        else if (state == 0 && input == 1)
        {
            state = 1;
            p = &buf[i];        // 记录单词起始位置
            counter = 1;        // 包含当前字符
        }
        // 分支 3: IN + 非字母 → 回到 OUT(单词结束)
        else if (state == 1 && input == 0)
        {
            state = 0;
            words++;
            printf("word %d found!\n", words);
            for (int k = 0; k < counter; k++)
                printf("%c", p[k]);
            printf("\n");
        }
        // 分支 4: IN + 字母 → 保持 IN(单词继续)
        else if (state == 1 && input == 1)
        {
            state = 1;
            counter++;
        }

        i++;
    }

    // 处理循环结束后仍处于 IN 状态的最后一个单词
    if (state == 1)
    {
        words++;
        printf("word %d found!\n", words);
        for (int k = 0; k < counter; k++)
            printf("%c", p[k]);
        printf("\n");
    }

    printf("there is %d words found!\n", words);
    return 0;
}

核心要点回顾:

  1. p = &buf[i] 用指针记住单词的起始地址
  2. counter 跟踪单词长度,每次遇到字母递增
  3. 循环结束后检查 state == 1 处理最后一个无分隔符结尾的单词
  4. 四个 if-else 分支覆盖了 (state × input) 的 2×2 全部组合
switch-case 版本
word_count_switch_solution.c
c
#include <stdio.h>

int get_input_type(char c)
{
    if (c >= 'a' && c <= 'z')
        return 1;
    if (c >= 'A' && c <= 'Z')
        return 1;
    return 0;
}

int main(void)
{
    char buf[512];
    int state = 0;
    int i = 0;
    int words = 0;
    char *p = NULL;
    int counter = 0;

    fgets(buf, sizeof(buf), stdin);
    for (i = 0; buf[i]; i++)
        if (buf[i] == '\n') { buf[i] = '\0'; break; }

    i = 0;

    while (1)
    {
        char c = buf[i];
        int input;

        if (c == '\0')
            break;

        input = get_input_type(c);

        switch (state)
        {
            case 0:   // OUT 状态
                if (input == 0)
                    state = 0;            // 保持 OUT
                else  // input == 1
                {
                    state = 1;
                    p = &buf[i];
                    counter = 1;
                }
                break;

            case 1:   // IN 状态
                if (input == 0)
                {
                    state = 0;
                    words++;
                    printf("word %d found!\n", words);
                    for (int k = 0; k < counter; k++)
                        printf("%c", p[k]);
                    printf("\n");
                }
                else  // input == 1
                {
                    state = 1;
                    counter++;
                }
                break;
        }

        i++;
    }

    if (state == 1)
    {
        words++;
        printf("word %d found!\n", words);
        for (int k = 0; k < counter; k++)
            printf("%c", p[k]);
        printf("\n");
    }

    printf("there is %d words found!\n", words);
    return 0;
}

switch-case 版本按状态分大类,语义更清晰。当状态数增加时(如 delspace.c 的 3 状态),switch-case 的优势会更明显。


课堂讨论

  1. 为什么要把字符分类的逻辑抽成 get_input_type() 函数?直接在主循环中判断字母范围不行吗?这样设计有什么好处?
  2. 如果不引入 state 变量,你会怎么实现单词计数?与状态机方案相比,是更简单还是更复杂?
  3. 如果把 (state, input)(新状态, 动作) 的映射做成一张表格,程序结构会发生什么变化?这种「表驱动」方式有什么优缺点?
  4. K&R 经典 wc 程序在遇到单词首字符时就计数(++nw),而本课在单词结束时计数——两种策略各有什么优劣?什么场景下应该选哪一种?
  5. 本课的单词定义为「字母序列」。如果扩展定义为「以字母开头、可包含字母和数字」的序列,get_input_type 和状态机需要怎样修改?

讨论答案

Q1: 为什么要把字符分类抽成 get_input_type() 函数?

这是抽象分层思想的典型应用。

把字符分类逻辑单独抽成函数,让状态机主循环只处理「输入类型」这个抽象概念,而不关心字符的 ASCII 值范围。这带来了几个好处:

  1. 可读性提升:主循环中 if (input == 1)if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') 简洁得多,状态机的转换逻辑一目了然
  2. 单一职责get_input_type 只负责「这个字符属于什么类型」,状态机只负责「根据类型和状态决定做什么」——各司其职
  3. 易于修改:如果以后要把单词定义从「纯字母」改为「字母+数字」,只需修改 get_input_type 一个函数,状态机主循环一行不用改
  4. 易于测试:可以单独测试 get_input_type('a') 是否返回 1、get_input_type(' ') 是否返回 0,不依赖整个程序

这正是「策略模式」的朴素形态——把易变的部分(分类规则)封装起来,让稳定的部分(状态转换逻辑)不受影响。回顾 Lesson 08 的 find(num, digit) 函数设计——把「找数字」的逻辑抽成独立函数,main 中只需关心遍历和累加,思想一脉相承。

Q2: 不用 state 变量怎么实现?

不用 state 的最直接方案是用一个 in_word 标志位——但这其实就是 state 变量。

让我们尝试几种「无状态」的方案:

方案 A:扫描分隔符:遍历字符串,每遇到一个非字母字符,就检查它后面是否跟着字母——如果是,就说明一个新单词开始了。

no_state_approach.c
c
int words = 0;
for (int i = 0; buf[i]; i++)
{
    // 检查当前字符是否是单词首字符:
    // buf[i] 是字母,且 (i==0 或 buf[i-1] 不是字母)
    if (isalpha(buf[i]) && (i == 0 || !isalpha(buf[i-1])))
        words++;
}

这确实不需要显式的 state 变量,但它本质上仍然是一种状态机的思想——i == 0 || !isalpha(buf[i-1]) 等价于「之前的字符状态是 OUT」。而且这种写法有局限:它只能计数,无法记录单词内容(因为你「回头看」时,单词已经过去了)。

方案 B:用标志位

c
int in_word = 0;   // ← 这其实就是一个 state 变量!
while (...)
{
    if (isalpha(c) && !in_word) { in_word = 1; words++; }
    if (!isalpha(c))             { in_word = 0; }
}

这与状态机方案几乎一模一样,只是变量名不同。in_word 是一个布尔状态,state 是一个整数状态——本质相同。当状态多于两个时(如 delspace.c 的三个状态),布尔标志位就不够用了——而 state 可以取任意多个整数值,这正是状态机相比简单标志位的优势。

结论:对于需要「记住之前发生了什么」的问题,某种形式的「状态记录」是不可避免的。状态机把这种需求显式化、系统化,让程序结构更清晰。

Q3: 表驱动状态机的优缺点?

表驱动状态机把转换逻辑从代码中提取到数据中

table_driven_complete.c
c
// 状态转换表
int next_state[2][2] = {
    {0, 1},   // state=0: input=0→0, input=1→1
    {0, 1},   // state=1: input=0→0, input=1→1
};

// 主循环中只需一行查表
state = next_state[state][input];

优点

  1. 可验证性:表格一目了然——2×2 = 4 个格子是否全部填了?是否有遗漏?是否有冲突?
  2. 易于修改:增加一个新状态只需扩展数组维度,不需要修改主循环逻辑
  3. 数据驱动:表格可以从外部文件读取,实现运行时配置——比如从配置文件加载状态机规则
  4. 与数学模型对应:有限状态自动机在数学上就是 (Q, Σ, δ, q0, F) 五元组,表格直接对应转移函数 δ

缺点

  1. 动作耦合困难:状态转换通常伴随「动作」(如打印单词),单纯查表只能得到新状态,动作仍需额外处理
  2. 调试不直观:出错时你看到的是「数组索引越界」而不是「第 3 个分支逻辑错误」
  3. 小规模过度设计:对于只有 2 状态 2 输入的简单场景,查表法反而比 if-else 更绕

在工业级场景(如 TCP 协议状态机有 11 个状态、数十种事件),表驱动是标准做法——Linux 内核的 TCP 实现就用了状态转换表。但在教学和简单场景中,if-else 或 switch-case 的可读性更好。

Q4: K&R 的「进入时计数」vs 本课的「离开时计数」

进入时计数(K&R)

knr_style.c
c
if (c == ' ' || c == '\n' || c == '\t')
    state = OUT;
else if (state == OUT)
{
    state = IN;
    ++nw;    // 刚进入单词就计数
}
  • 优点:代码极简,不需要处理循环结束后的残留状态(因为进入 IN 时已经计数了,即使循环在 IN 状态下退出也没关系)
  • 缺点:计数时还不知道单词的内容和长度,无法在计数前处理单词

离开时计数(本课)

lesson_style.c
c
if (state == IN && input == 0)
{
    state = OUT;
    words++;
    print_word(p, counter);   // 可以访问完整单词
}
  • 优点:计数时已经拥有完整单词(起始地址 p + 长度 counter),可以打印、保存、分析
  • 缺点:需要处理循环结束后 state == IN 的边界情况

选择建议:如果只统计数量(如 wc -w),用 K&R 风格更简洁;如果需要记录或处理单词内容(如本课练习要求打印每个单词),用「离开时计数」更自然。

这两种策略在更大的程序中可以共存——进入时递增计数器,离开时保存单词到数组。这正是灵活运用状态机的体现:同一个状态转换可以触发多个动作。

Q5: 单词定义扩展为「字母开头+可含数字」怎么改?

需要修改两个地方:字符分类函数和状态机逻辑。

修改 1:扩展 get_input_type

当前单词定义为纯字母序列,如果允许单词包含数字(但必须以字母开头),输入类型需要从 2 种扩展到 3 种:

extended_types.c
c
enum InputType {
    TYPE_DELIM  = 0,   // 分隔符(空格、标点等)
    TYPE_ALPHA  = 1,   // 字母(可作单词首字符或内容)
    TYPE_DIGIT  = 2,   // 数字(只能出现在单词内部)
};

int get_input_type_v2(char c)
{
    if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')
        return TYPE_ALPHA;
    if (c >= '0' && c <= '9')
        return TYPE_DIGIT;
    return TYPE_DELIM;
}

修改 2:状态机增加状态

当前只有 IN/OUT 两个状态不够了——因为数字在 OUT 状态和 IN 状态下的含义不同(OUT 下遇到数字是分隔符,IN 下遇到数字是单词内容):

OUT + ALPHA IN(新单词开始)
OUT + DIGIT OUT(数字不能开头,视为分隔符)
OUT + DELIM OUT(连续分隔符)

IN  + ALPHA IN(单词继续)
IN  + DIGIT IN(单词内部的数字是合法的) 新增
IN  + DELIM OUT(单词结束)

或者引入一个新状态 IN_NUM 来区分:

three_state_fsm.c
c
enum State { OUT = 0, IN = 1, IN_NUM = 2 };

// 转换表:
// OUT + ALPHA → IN (新单词,只含字母)
// OUT + DIGIT → OUT (数字不能开头)
// OUT + DELIM → OUT
// IN  + ALPHA → IN
// IN  + DIGIT → IN_NUM (单词内部出现数字)
// IN  + DELIM → OUT (单词结束)
// IN_NUM + ALPHA → IN_NUM
// IN_NUM + DIGIT → IN_NUM
// IN_NUM + DELIM → OUT (单词结束)

这说明当「同一输入在不同上下文中有不同含义」时,就需要更多状态来区分上下文。这本质上就是正则表达式到 DFA(确定有限状态自动机)的转换过程——词法分析器正是这样工作的。


课后练习

  1. 统计数字个数:修改程序,统计输入文本中「数字」(由 0-9 组成的连续序列)的个数,而不是单词。需要修改 get_input_type 的分类逻辑和状态机的输出格式。

    知识点提示:修改 get_input_type 的返回值(字母→0,数字→1),状态机逻辑不变,只改输出提示文字

  2. delspace 连续空格去重:实现 delspace.c,从标准输入读取文本,输出时将连续多个空格压缩为一个空格。输入 "This is a book." → 输出 "This is a book."

    知识点提示:3 状态状态机(NORMAL/FIRST_SPACE/SUBSEQUENT_SPACE),参考 delspace.c 文件中的注释

  3. 同时统计单词和数字:扩展状态机,同时统计文本中单词和数字的个数。要求一次遍历完成两种统计。

    知识点提示:扩展 get_input_type 返回 3 种类型(字母/数字/分隔符),状态机需要 3 个状态(OUT/IN_WORD/IN_NUM),两组计数器

  4. 单词长度统计:修改程序,在统计单词个数的同时,记录并输出每个单词的长度。输出格式:"word 1: This (length=4)"

    知识点提示counter 变量已经记录了当前单词的长度,在分支 3(单词结束)时直接使用即可

  5. K&R wc 完整实现:实现一个简化版 wc 命令,统计标准输入的行数、单词数和字符数。单词分隔符包括空格、制表符和换行符。

    知识点提示:参考 K&R 的「进入时计数」策略,用 IN/OUT 宏代替魔数 0/1,一次遍历同时更新三个计数器

练习1: 统计数字个数
ex1_count_digits.c
c
#include <stdio.h>

int get_input_type(char c)
{
    // 修改:数字返回 1,其他返回 0
    if (c >= '0' && c <= '9')
        return 1;
    return 0;
}

int main(void)
{
    char buf[512];
    int state = 0;
    int i = 0;
    int numbers = 0;
    char *p = NULL;
    int counter = 0;

    fgets(buf, sizeof(buf), stdin);
    for (i = 0; buf[i]; i++)
        if (buf[i] == '\n') { buf[i] = '\0'; break; }

    i = 0;
    while (1)
    {
        char c = buf[i];
        int input;

        if (c == '\0')
            break;

        input = get_input_type(c);

        if (state == 0 && input == 0)
            state = 0;
        else if (state == 0 && input == 1)
        {
            state = 1;
            p = &buf[i];
            counter = 1;
        }
        else if (state == 1 && input == 0)
        {
            state = 0;
            numbers++;
            printf("number %d found: ", numbers);
            for (int k = 0; k < counter; k++)
                printf("%c", p[k]);
            printf("\n");
        }
        else if (state == 1 && input == 1)
        {
            state = 1;
            counter++;
        }

        i++;
    }

    if (state == 1)
    {
        numbers++;
        printf("number %d found: ", numbers);
        for (int k = 0; k < counter; k++)
            printf("%c", p[k]);
        printf("\n");
    }

    printf("there is %d numbers found!\n", numbers);
    return 0;
}

只需修改 get_input_type 的判定条件(从字母改为数字),状态机主体完全不变——这正是抽象分层的威力。

练习2: delspace 连续空格去重
ex2_delspace.c
c
#include <stdio.h>

int get_input_type(char c)
{
    if (c == ' ')
        return 1;
    return 0;
}

int main(void)
{
    char buf[512];
    int state = 0;   // 0: NORMAL, 1: FIRST_SPACE, 2: SUBSEQUENT_SPACE
    int i = 0;
    int c;

    // 逐字符读取直到 EOF 或换行
    while ((c = getchar()) != EOF && c != '\n')
        buf[i++] = c;
    buf[i] = '\0';

    i = 0;
    while (1)
    {
        char ch = buf[i];
        int input;

        if (ch == '\0')
            break;

        input = get_input_type(ch);

        // 状态转换表:
        // state=0(NORMAL), input=0(非空格) → state=0, 输出
        // state=0(NORMAL), input=1(空格)   → state=1, 输出空格
        // state=1(FIRST),  input=0         → state=0, 输出
        // state=1(FIRST),  input=1         → state=2, 不输出
        // state=2(SUB),    input=0         → state=0, 输出
        // state=2(SUB),    input=1         → state=2, 不输出

        if (state == 0 && input == 0)
        {
            state = 0;
            printf("%c", ch);
        }
        else if (state == 0 && input == 1)
        {
            state = 1;
            printf("%c", ch);
        }
        else if (state == 1 && input == 0)
        {
            state = 0;
            printf("%c", ch);
        }
        else if (state == 1 && input == 1)
        {
            state = 2;
            // 不输出,跳过连续空格
        }
        else if (state == 2 && input == 0)
        {
            state = 0;
            printf("%c", ch);
        }
        else if (state == 2 && input == 1)
        {
            state = 2;
            // 不输出,继续跳过
        }

        i++;
    }

    return 0;
}

3 状态 6 分支的状态机。关键点:state=2(SUBSEQUENT_SPACE)下所有空格都被跳过不输出,只有遇到非空格才回到 state=0

练习3: 同时统计单词和数字
ex3_count_both.c
c
#include <stdio.h>

// 扩展输入类型
#define TYPE_DELIM  0
#define TYPE_ALPHA  1
#define TYPE_DIGIT  2

int get_input_type(char c)
{
    if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')
        return TYPE_ALPHA;
    if (c >= '0' && c <= '9')
        return TYPE_DIGIT;
    return TYPE_DELIM;
}

int main(void)
{
    char buf[512];
    int state = 0;    // 0: OUT, 1: IN_WORD, 2: IN_NUM
    int i = 0;
    int words = 0, numbers = 0;
    char *p = NULL;
    int counter = 0;

    fgets(buf, sizeof(buf), stdin);
    for (i = 0; buf[i]; i++)
        if (buf[i] == '\n') { buf[i] = '\0'; break; }

    i = 0;
    while (1)
    {
        char c = buf[i];
        int input;

        if (c == '\0')
            break;

        input = get_input_type(c);

        switch (state)
        {
            case 0:   // OUT
                if (input == TYPE_ALPHA)
                {
                    state = 1;
                    p = &buf[i];
                    counter = 1;
                }
                else if (input == TYPE_DIGIT)
                {
                    state = 2;
                    p = &buf[i];
                    counter = 1;
                }
                // TYPE_DELIM: 保持 state=0
                break;

            case 1:   // IN_WORD
                if (input == TYPE_ALPHA)
                {
                    counter++;
                }
                else  // TYPE_DIGIT 或 TYPE_DELIM
                {
                    state = 0;
                    words++;
                    printf("word %d: ", words);
                    for (int k = 0; k < counter; k++)
                        printf("%c", p[k]);
                    printf("\n");
                }
                break;

            case 2:   // IN_NUM
                if (input == TYPE_DIGIT)
                {
                    counter++;
                }
                else  // TYPE_ALPHA 或 TYPE_DELIM
                {
                    state = 0;
                    numbers++;
                    printf("number %d: ", numbers);
                    for (int k = 0; k < counter; k++)
                        printf("%c", p[k]);
                    printf("\n");
                }
                break;
        }

        i++;
    }

    // 处理最后一个未结束的序列
    if (state == 1)
    {
        words++;
        printf("word %d: ", words);
        for (int k = 0; k < counter; k++)
            printf("%c", p[k]);
        printf("\n");
    }
    else if (state == 2)
    {
        numbers++;
        printf("number %d: ", numbers);
        for (int k = 0; k < counter; k++)
            printf("%c", p[k]);
        printf("\n");
    }

    printf("total: %d words, %d numbers\n", words, numbers);
    return 0;
}

三个状态(OUT/IN_WORD/IN_NUM)对应三种输入类型(字母/数字/分隔符),switch-case 结构清晰地组织了 3×3 种可能的转换。

练习4: 输出单词长度
ex4_word_length.c
c
#include <stdio.h>

int get_input_type(char c)
{
    if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')
        return 1;
    return 0;
}

int main(void)
{
    char buf[512];
    int state = 0;
    int i = 0;
    int words = 0;
    char *p = NULL;
    int counter = 0;

    fgets(buf, sizeof(buf), stdin);
    for (i = 0; buf[i]; i++)
        if (buf[i] == '\n') { buf[i] = '\0'; break; }

    i = 0;
    while (1)
    {
        char c = buf[i];
        int input;

        if (c == '\0')
            break;

        input = get_input_type(c);

        if (state == 0 && input == 0)
            state = 0;
        else if (state == 0 && input == 1)
        {
            state = 1;
            p = &buf[i];
            counter = 1;
        }
        else if (state == 1 && input == 0)
        {
            state = 0;
            words++;
            // 关键改动:在输出中加入长度信息
            printf("word %d: ", words);
            for (int k = 0; k < counter; k++)
                printf("%c", p[k]);
            printf(" (length=%d)\n", counter);
        }
        else if (state == 1 && input == 1)
        {
            state = 1;
            counter++;
        }

        i++;
    }

    if (state == 1)
    {
        words++;
        printf("word %d: ", words);
        for (int k = 0; k < counter; k++)
            printf("%c", p[k]);
        printf(" (length=%d)\n", counter);
    }

    printf("there is %d words found!\n", words);
    return 0;
}

counter 变量天然记录了当前单词的长度——在打印时直接输出即可,不需要额外计算。这体现了状态机变量设计的复用性。

练习5: K&R wc 简化实现
ex5_knr_wc.c
c
#include <stdio.h>

#define IN   1
#define OUT  0

int main(void)
{
    int c, nl, nw, nc, state;

    state = OUT;
    nl = nw = nc = 0;

    while ((c = getchar()) != EOF)
    {
        ++nc;                        // 字符计数

        if (c == '\n')
            ++nl;                    // 行计数

        if (c == ' ' || c == '\n' || c == '\t')
            state = OUT;             // 分隔符 → 退出单词
        else if (state == OUT)
        {
            state = IN;              // 非分隔符 + 之前在 OUT → 新单词
            ++nw;                    // 单词计数
        }
        // else: state == IN && 非分隔符 → 仍在单词内,什么都不做
    }

    printf("%d %d %d\n", nl, nw, nc);
    return 0;
}

极简而优雅——17 行代码完成行数、单词数、字符数的统计。核心思想:单词数 = 「从 OUT 进入 IN」的次数。K&R 用 #define IN 1#define OUT 0 代替魔数,提升可读性——这也呼应了 Lesson 08 中 begin/end 符号常量的设计理念。


参考资料

"The best way to have a good idea is to have lots of ideas." — Linus Pauling

Released under the MIT License.