第二次作业
从零实现一个命令行文本统计工具:C 语言课程作业复盘
项目地址:https://gitee.com/wang01130/second-assignment
开发环境:Windows + Dev-C++ 5.11 + C99
项目功能:字符数、单词数、句子数、代码行、空行、注释行统计
前言
这次课程作业要求实现一个类似 Linux wc 的命令行文本统计程序。基础功能是统计纯英文 txt 文件中的字符数、单词数和句子数;扩展功能则要求统计代码行、空行和注释行,其中注释行必须正确识别 C 语言的 // 和 /* ... */。
一开始我以为最难的是命令行参数,真正写起来才发现,难点主要在两个地方:
- 句子结束符的判定不能简单地数
.、!、?; - 注释统计不能只判断某一行里有没有
//或/*,还要处理字符串、字符常量、多行注释和代码行混排的情况。
这篇文章记录我从空项目做到 v0.3 的过程,包括设计思路、关键代码、单元测试、性能测试和一些踩坑记录。
一、需求分析
程序的使用方式如下:
wc.exe [参数] [文件名]
基础功能:
| 参数 | 功能 |
|---|---|
-c |
统计字符数 |
-w |
统计单词数 |
-s |
统计句子数 |
扩展功能:
| 参数 | 功能 |
|---|---|
-l |
统计代码行 |
-b |
统计空行 |
-m |
统计注释行 |
开发约束是使用纯 C 语言和 C99 标准,不引入第三方库,代码尽量简单易懂。程序一次接收一个参数和一个文件名,输出对应的统计结果。
二、项目结构
项目按功能拆分成几个部分:
text-counter\
├─ src\
│ ├─ main.c 命令行入口
│ ├─ wc.c 核心统计函数
│ ├─ wc.h 函数声明
│ ├─ wc.dev Dev-C++ 项目
│ ├─ sample.txt 普通示例文本
│ └─ comment_sample.txt 带注释的示例文本
├─ tests\
│ ├─ test_wc.c 单元测试
│ ├─ run_tests.bat 双击运行单元测试
│ ├─ performance_test.c 性能测试
│ └─ run_performance.bat
├─ docs\
│ ├─ screenshots\ 运行截图
│ └─ performance\ 性能测试结果
├─ README.md
├─ AI_Prompts.md
└─ .gitignore
main.c 只负责参数解析、文件读取和结果输出;wc.c 负责真正的统计逻辑。这样拆分之后,单元测试可以直接调用 wc.c 里的函数,不需要每次都启动程序。
三、文件读取
每次统计只读取一次文件,把内容保存到一块动态内存中,然后交给不同统计函数使用:
char *wc_read_file(const char *filename)
{
FILE *fp;
long size;
size_t read_count;
char *text;
fp = fopen(filename, "r");
if (fp == NULL) {
return NULL;
}
if (fseek(fp, 0, SEEK_END) != 0) {
fclose(fp);
return NULL;
}
size = ftell(fp);
if (size < 0) {
fclose(fp);
return NULL;
}
rewind(fp);
text = (char *)malloc((size_t)size + 1);
if (text == NULL) {
fclose(fp);
return NULL;
}
read_count = fread(text, 1, (size_t)size, fp);
text[read_count] = '\0';
fclose(fp);
return text;
}
这里用 fseek、ftell 得到文件大小,再用 malloc 分配缓冲区。Windows 下以文本模式打开文件时,CRLF 会被转换成 LF,所以字符数统计的是转换后的文本内容。
四、基础功能实现
4.1 字符数
字符数最简单,直接使用 strlen:
long wc_count_chars(const char *text)
{
return (long)strlen(text);
}
空格、标点和换行都会计入字符数。
4.2 单词数
单词按空白字符分隔。用一个 in_word 标记当前是否在单词内部,遇到空白就结束当前单词,遇到非空白且之前不在单词内就增加计数:
long wc_count_words(const char *text)
{
long count = 0;
int in_word = 0;
size_t i;
for (i = 0; text[i] != '\0'; i++) {
if (isspace((unsigned char)text[i])) {
in_word = 0;
} else if (!in_word) {
count++;
in_word = 1;
}
}
return count;
}
4.3 句子数
句子统计不能只数标点,因为 ...、?! 这类连续标点会出现。我的规则是:
- 遇到
.、!、?时,如果前面有实际内容,就结束一句; - 连续的标点只算一次结束;
- 最后一段没有标点但包含文字,也算一句;
- 只有标点的内容不算句子。
核心代码如下:
long wc_count_sentences(const char *text)
{
long count = 0;
int has_content = 0;
size_t i;
for (i = 0; text[i] != '\0'; i++) {
unsigned char c = (unsigned char)text[i];
if (c == '.' || c == '!' || c == '?') {
if (has_content) {
count++;
has_content = 0;
}
} else if (!isspace(c)) {
has_content = 1;
}
}
if (has_content) {
count++;
}
return count;
}
示例文件 sample.txt 的运行结果:
Characters: 120
Words: 23
Sentences: 3
五、扩展功能:代码行、空行、注释行
5.1 三个统计口径
先明确三个概念:
| 类型 | 判定方式 |
|---|---|
| 代码行 | 这一行包含代码。代码后面带注释,也算代码行 |
| 空行 | 只有空白字符,没有代码,也没有注释 |
| 注释行 | 只有注释,没有代码 |
例如:
int x = 0; // 这是代码行,不是注释行
// 这是注释行
多行注释 /* ... */ 内部的每一行都算注释行;多行注释内部的空行也算注释行,因为它仍然属于注释块。
5.2 注释识别的状态机
注释统计最难的地方是:不能看到 // 或 /* 就直接当成注释,因为字符串里也可能出现这些字符:
printf("http://example.com");
printf("/* not a comment */");
这两行都是代码行,不能把 http:// 里的 // 当成注释。
我的处理方式是逐行扫描,同时维护两个状态:
in_block_comment:当前是否位于多行注释内部;- 当前行的
has_code和has_comment。
扫描时大致按下面的优先级处理:
if (in_block_comment) {
/* 在块注释中,只寻找结束标记 */
} else if (c == '/' && next == '/') {
/* 单行注释开始 */
} else if (c == '/' && next == '*') {
/* 多行注释开始 */
} else if (c == '"' || c == '\'') {
/* 字符串或字符常量,跳过其中的内容 */
} else if (!isspace(c)) {
has_code = 1;
}
几个关键细节:
- 字符串和字符常量要整体跳过,否则
"http://..."会被误判为注释。 - 代码后面的注释仍然算代码行,只有整行都是注释时才计入注释行。
- 多行注释跨行保持状态,下一行开始时如果还在注释内部,先标记
has_comment = 1。 - 多行注释内部的空行算注释行,不算空行。
- 注释结束符后面的代码要重新识别,例如
*/ int x;这一行包含代码,应算代码行。
comment_sample.txt 的测试结果:
Code lines: 6
Blank lines: 1
Comment lines: 4
这个文件一共 11 行,三种类型的和为 11,说明分类没有遗漏。
六、单元测试
项目使用 C 语言自己写了一个极简测试框架,不依赖第三方库。测试文件是 tests\test_wc.c,编译后直接运行。
测试覆盖了:
- 空文本;
- 空文件;
- 普通英文文本;
- 空格、制表符、换行符;
- 连续句末标点;
- 没有句末标点的最后一句;
- 代码行、空行、注释行;
- 单行注释和多行注释;
- 字符串中的
//和/* ... */; - 文件不存在。
运行方式:
cd /d D:\c-projects\text-counter
tests\run_tests.bat
测试结果:
34 tests run, 0 failed
All unit tests passed.
测试代码里也踩过一个坑:文件读取测试最初使用相对路径,如果不在项目根目录运行,就会找不到测试文件。后来把测试脚本固定为先 cd 到项目根目录,问题就解决了。
七、性能测试
性能测试使用 C 标准库的 clock() 函数,测试文本由重复的英文段落生成,大小约 0.82 MB,各统计函数执行 100 次。
测试命令:
cd /d D:\c-projects\text-counter
tests\run_performance.bat
测试结果:
=== wc performance test ===
Input size: 0.82 MB
Iterations: 100
CLOCKS_PER_SEC: 1000
Characters : 0.002 s result=860000
Words : 0.131 s result=170000
Sentences : 0.116 s result=20000
Line stats : 0.131 s result=10000
Total measured time: 0.380 s
字符统计直接使用 strlen,所以耗时非常短;单词、句子和行统计都需要逐字符扫描,耗时相对多一些。clock() 的分辨率与机器有关,这组数据只代表当前电脑上的一次运行结果。
八、版本管理与码云
项目按照作业要求拆成三个版本:
| 版本 | 内容 |
|---|---|
| v0.1 | 空项目,完成 Dev-C++ 工程骨架 |
| v0.2 | 完成 -c / -w / -s 基础功能和 24 项单元测试 |
| v0.3 | 完成 -l / -b / -m 扩展功能、34 项测试和性能测试 |
每一版都有对应的 Git 提交和标签:
v0.1 空项目初始化
v0.2 完成基础功能
v0.3 完成扩展功能
项目中加入了 .gitignore,wc.exe、main.o、Makefile.win 这类编译产物不会提交,Dev-C++ 的 wc.dev 项目文件会保留。
码云地址:
https://gitee.com/wang01130/second-assignment
九、运行截图
基础功能:

代码行和空行:

注释行:

十、踩坑记录
1. 字符串里的 // 不是注释
最初只判断当前字符是不是 /,结果 http://example.com 被误判成注释。后来在扫描时增加了字符串和字符常量的跳过逻辑,才解决这个问题。
2. 多行注释内部的空行不是空行
从“物理空行”的角度看,/* ... */ 中间的空行确实是空行;但从代码统计的角度看,它属于注释块,应该算注释行。最后采用了“注释块内部优先算注释行”的规则。
3. 句子统计不能只数标点
Wait... what?! 里有很多标点,但只有两个句子。通过 has_content 标记和“连续标点只结束一次”的规则,才能得到正确结果。
4. 编译产物不要提交
Dev-C++ 会生成 .exe、.o、Makefile.win 等文件。如果不写 .gitignore,仓库会变得很乱。把源码、项目文件和文档提交即可。
5. 单元测试的相对路径
测试文件如果直接双击运行,工作目录不一定是项目根目录。测试脚本里先执行 cd /d "%~dp0..",再编译运行,可以避免找不到测试输入文件。
十一、总结
这个项目规模不大,但把命令行程序、文件读取、字符串扫描、状态机、单元测试和性能测试都串了一遍。最大的收获不是写出了多少个统计函数,而是意识到:
- 统计规则必须先定义清楚,否则“字符数”“注释行”很容易产生歧义;
- 注释识别不能靠简单的字符串查找,必须考虑上下文;
- 测试要覆盖空输入、边界输入和错误输入;
- 性能测试要关注算法,而不是只看程序能不能跑。
目前还有一些已知限制:
- 只处理纯英文文本;
- 句子统计对
Mr.、3.14这类内容可能误判; - 一次只能使用一个统计参数;
- 没有专门处理反斜杠续行等极端 C 语法。
如果后续继续改进,可以考虑支持多个参数组合、增加更多边界测试,以及把注释识别扩展为更完整的 C 词法分析。
附:项目与 AI 提示词
项目源码:
https://gitee.com/wang01130/second-assignment
README:
https://gitee.com/wang01130/second-assignment/blob/master/README.md
AI 对话提示词记录(按作业要求单独存放,没有写进 README):
https://gitee.com/wang01130/second-assignment/blob/master/AI_Prompts.md
本项目在 AI 辅助下完成,所有提示词均记录在 AI_Prompts.md 中。
浙公网安备 33010602011771号