C++实现⽂件内字符数、单词数、⾏数的统计
先给出以及
1. 项⽬简介
这个项⽬的需求可以概括为:对程序设计语⾔源⽂件统计字符数、单词数、⾏数,统计结果以指定格式输出到默认⽂件中,以及其他扩展功能,并能够快速地处理多个⽂件。我个⼈对C++⽐较熟悉,各种⽂件输⼊输出流也会⽤,所以选择使⽤C++完成。当然C++也有它的缺陷,⽐如所有的字符串都要规定⼀个最⼤长度(可以选择⽤string,但我对于string的拼接,以及逐字符操作不是很熟悉,只好含泪⽤char[])。
这个项⽬其实也算是个⼩项⽬,⼀开始我觉得450分钟内肯定完成,就是⼀整天的事情。结果最后我实际上⽤了两天。两个原因吧,⼀个是我低估了这个项⽬的代码量。把这个项⽬的功能从基本功能到扩展功能实现了⼀遍,居然写了我五百多⾏代码(主要是有限状态机模型不会⽤,就⾃⼰按照逻辑硬刚下来了,功能倒是实现了)。第⼆个是连续⼯作实在太累了,到最后专注度直线下降,基本上有效编码时间只有百分之五⼗了。不过最后还是刚下来了,⼀定要时间犒劳⼀下⾃⼰,吃顿好的。
项⽬的开发过程严格遵照软件⼯程的要求,从需求分析,到最后的测试,⼀个不落。这种开发⽅式,起步的速度会慢⼀些,不过写出来的代码⾮常好看,也易于修改。下⾯附上⼀张表格。
PSP2.1PSP阶段预估耗时
(分钟)实际耗时
(分钟)
PSP2.1PSP阶段预估耗时
(分钟)
实际耗时
(分钟)
Planning计划102Development开发340597
· Estimate · 估计这个任务需要
多少时间
102· Analysis
·
需求分析 (包括学习新技
术)
3032
· Design
Spec
· ⽣成设计⽂档6060
Reporting报告100**95 **· Design
Review
· 设计复审 (和同事审核设
计⽂档)
2030
·
Test Report· 测试报告6060· Coding
Standard
· 代码规范 (为⽬前的开发
制定合适的规范)
205
· Size Measurement· 计算⼯作量105· Design· 具体设计6045
· Postmortem & Process Improvement Plan · 事后总结, 并提出
过程改进计划
3030· Coding· 具体编码60335
· Code
Review
·
代码复审3015
· Test
· 测试(⾃我测试,修改
代码,提交修改)
6075
合计450694
2. ⼤体思路
这个项⽬的⼤体思路还是很明确的,我也在github上传了。
我把这个项⽬分为六个模块:主函数、指令解析、递归搜索⽂件、统计准备⼯作、统计、结果输出。
主函数
主函数可以从控制台接收⽤户输⼊的指令,然后将这些指令拼接成⼀个完整的字符串并交给其它函数处理。
指令解析
指令解析可以提取⽤户输⼊的指令中的有效信息,从⽽决定了之后程序该执⾏哪些功能。尽管⽤户的指令可能是各种顺序的组合(⽐如,同⼀个指令,他既可以写成-w stoptest.c -,⼜可以写成 stoptest.c - -e),但是我们仍然可以到⼀种简单的解析⽅式,可以处理各种形式下的有效命令。
我们顺序地去遍历存储了⽤户指令的字符串。如果遇到 '-' ,那么我们就知道它将会和下⼀个字符⼀起构成⼀个操作指令,那么我就⽴即检测下⼀个字符。如果下⼀个字符是 'e' 或 'o' ,那么我们还会知道,它接下来会紧跟着⼀个⽂件路径。当然,如果我们遇到的是 '-' 以外的可显⽰字符,那么它也将会是⼀个⽂件路径的⾸字符,只不过这个路径是待统计⽂件的所在路径。
从⽤户指令中提取路径相对简单,既然我们已经到了路径的⾸字符,我们就可以顺序遍历,直到遇见⼀个不可显⽰字符位置,中间的⼀段就构成了我们要提取的路径。
递归搜索⽂件
解析了⽤户指令以后,我们这⾥将⾯临第⼀个分⽀。如果⽤户指令中没有出现 "-s",那么问题变得很简单,⽤户给出的⽂件路径对应的就是我们唯⼀要统计的那个⽂件;但是如果⽤户指令中出现了 "-s" ,那么我们就需要得到⽤户指定路径下所有符合条件的⽂件名。
这个模块需要⽤到和。有了这两个武器,我们就可以先到⼀个⽬录下的所有⽂件,然后再逐⼀和⽤户给定的⽂件名进⾏匹配,然后把匹配成功的⽂件名、⽂件路径存放在⼀个⽂件链表中。
令我头痛的是,⽤户给出的路径通常都是 "F:\codes\java\try\src*.c" 这种形式。也就是说,⽂件夹的路径和⽂件名存储在同⼀个字符串⾥。我需要把他们分开。这⾥我从字符串最后⼀个字符逆序遍历,到第⼀个 '\' 字符后,它的左边就是⽂件夹路径,它的右边是⽂件名,分别拷贝到两个字符串,就完成了路径的分割。
统计准备⼯作
在这⼀步中,我们需要得到打开停⽤词⽂件,读取其中所有的停⽤词,然后建⽴⼀个链表去存储这些停⽤词。我们不⽤管到底⽤户有没有要求启⽤停⽤词,反正我们知道,只要⽤户没有给出停⽤词⽂件所在路径,我们就不到这些停⽤词。具体的⽂件读取策略,我使⽤的是。
接下来,我们只要利⽤停⽤词表和待统计⽂件的路径信息,就能得到统计结果了。
统计
这个模块就是项⽬的核⼼了。⼀开始我觉得很简单,因为字符统计、单词统计、⾏统计是C语⾔最基本的算法之⼀,基本上就是逐字符读取⼀遍⽂件,每次读取,字符数+1;字符由可显⽰字符变为不可显⽰
字符,单词数+1;读到 '\n' ,⾏数+1。
然⽽坑的是那些扩展功能,也就是对于代码⾏、注释⾏、空⾏的判断。这⾥我建⽴了⼀个状态模型。现在,每次读取⼀个字符之后,根据字符类型(是否可显⽰,是否是换⾏符,是否是 ' / ' 或者 ' * ' )状态就会进⾏迁移。当遇到换⾏符时,就会根据当前所处的状态进⾏结算。举个例⼦:如果当前处于代码⾏状态,那么代码⾏就会+1;如果当前处于临界⾏1状态,那么还要判断这⼀⾏是否已经经历过临界⾏1(因为"/" 和 "{/**/}" 这两⾏最终都会停留在临界⾏1,但前者是空⾏,后者是代码⾏)。
这个状态迁移模型被我搞得相当繁琐,很多地⽅的判断不能单单根据当前所处的状态判断。如果你选择直接使⽤我这⼀段代码,我不保证会不会出现⼀些诡异的情况(不过应对正常的⽤例还是绰绰有余的),我还是建议你⾃⼰写⼀个。也许你可以设置多⼀些状态,我之所以只设置了这么⼏个状态,是因为我觉得⽤画图软件画状态图是在太蠢了,最后实在画不下去了,就草草收⼿。总之,如果你有更好的状态模型,欢迎在下⾯评论区提出来。
结果输出
结果输出相对是⼀个⽐较温柔的模块(当然没有主循环那么温柔),唯⼀的分⽀是查看⼀下⽤户是否给出了 "-o" 指令,如果有,我们需要改变默认的结果⽂件输出路径。最后的输出需要⽤到⼀些的知识,不过这个并不难。最后按照需求中规定的顺序,把⽤户想要的统计量输出就⼤功告成了。
具体的定义
上述所有模块涉及到的函数头和结构体的定义在下⾯给出。
//这个结构体⽤于记录指令解析的结果
struct Command {
bool _c;  //是否统计字符数
bool _w;  //否统计单词总数
bool _l;  //是否统计总⾏数
bool _o;  //是否将结果输出到指定⽂件
bool _s;  //是否递归处理⽬录下符合条件的所有⽂件
bool _a;  //是否统计代码⾏/空⾏/注释⾏
bool _e;  //是否开启停⽤词表
char filePath[MAX_PATH_LENGTH];  //⽂件路径
char outFile[MAX_PATH_LENGTH];  //输出结果路径
char stopFile[MAX_PATH_LENGTH];  //停⽤词路径
};
//这个链表⽤于记录所有要进⾏统计的⽂件信息,当然如果⽤户没有输⼊-s指令,那么这个链表就只有⼀个节点了
struct SourceFile {
char filePath[MAX_PATH_LENGTH];        //路径⽤于寻⽂件、输出最后的⽂件名
char fileName[MAX_PATH_LENGTH];        //⽂件名⽤于进⾏通配符匹配
int charNum;
int wordNum;
int lineNum;
int blankLineNum;
int codeLineNum;
int noteLineNum;
SourceFile *next;
};
//这个链表⽤于记录所有的停⽤词
struct StopWord {
char word[MAX_STOPWORD_LENGTH];
StopWord *next;
};
void mainLoop();          //程序主循环
void analyseCommand(char commandStr[], Command &command); //解析⽤户指令
void getFileName(char path[], SourceFile *head);  //递归得到⽬录下所有⽂件
void wordCount(SourceFile *head, char stopPath[]);  //单词统计的预备⼯作
void wordCount(SourceFile *sourceFile, StopWord *head);  //单词统计
void outPut(SourceFile *head, Command &command);  //向⽂本输出
////////////////////////////////////////////////////////////////////////////////////////////////////////////
//本段为递归查⽬录函数
#include<io.h>
void getFiles(string path, string path2, SourceFile *head, char* pattern);
////////////////////////////////////////////////////////////////////////////////////////////////////////////
//本段为引⽤的字符串匹配(带通配符)函数
#include <ctype.h>
int WildCharMatch(char *src, char *pattern, int ignore_case);
////////////////////////////////////////////////////////////////////////////////////////////////////////////
3. 部分代码分析
主函数函数main(int argc, char *argv[])是组织程序按顺序执⾏的核⼼。它对于你理解程序的架构很有帮助,尽管它很简单,但是我还是把它放在这⾥,也便于以对照着去理解上下⽂。
int main(int argc, char *argv[]) {
char commandStr[MAX_COM_LENGTH] = "";
for(int i=1;i<argc;i++){  //将⽤户输⼊的指令拼接成⼀个完整的字符串传给程序
strcat(commandStr, argv[i]);
strcat(commandStr, " ");
}
Command command;
analyseCommand(commandStr, command);    //解析⽤户指令
SourceFile *head = new SourceFile();
if (command._s) getFileName(command.filePath, head); //递归寻⽬录下的⽂件
else {            //否则直接利⽤相对路径查⽂件
SourceFile *p = new SourceFile();
p->next = head->next;
head->next = p;
strcpy(p->fileName, command.filePath);字符串长度统计
strcpy(p->filePath, command.filePath);
}
wordCount(head, command.stopFile);    //统计单词数
outPut(head, command);        //结果输出到⽂件
delete head;
return 0;
}
还有⼀个重要的事情我们前⾯没有提到,那就是在⽂件统计时,⼀般来说我们习惯于使⽤下⾯这样的代码来结束我们的逐字读取:
if((c = in.get() == EOF)) break;
它表⽰当我们读到⽂件结束标志时,就跳出循环。但是这⾥存在着⼀个问题,前⾯我们提到过,“字符由可显⽰字符变为不可显⽰字符,单词数+1。”在这⾥,字符也可能是由可显⽰字符变为不可显⽰字符,但是我们的循环直接结束了,也就是说,这个单词没有统计到!同样,在统计⾏数时,我们也是仅在遇到 '\n' 时才会进⾏⾏数的结算,那这⾥也会造成遗漏。所以我们将这⾥进⾏了扩写:
c = in.get();
if (c == EOF) {
//在⽂件结尾处,还要对单词数、⾏数等进⾏最后的结算
if (wordFlag) {
sourceFile->wordNum++;
}
if (state == 1) {//这⾥是对⾏数进⾏结算,仍然是根据状态迁移模型
if (hasPassState2) sourceFile->noteLineNum++;
else sourceFile->blankLineNum++;
}
if (state == 2) {
if (hasPassState2) sourceFile->noteLineNum++;
else sourceFile->blankLineNum++;
}
if (state == 3) sourceFile->codeLineNum++;
if (state == 5) {
if (hasPassState2) sourceFile->codeLineNum++;
else sourceFile->blankLineNum++;
}
if (state == 6 || state == 7 || state == 8) sourceFile->noteLineNum++;
if (strcmp(currentWord, "") != 0) {//不要忘了对于停⽤词表也要重新结算
StopWord *pH = head->next;
while (pH != NULL) {
if (strcmp(currentWord, pH->word) == 0) {
sourceFile->wordNum--;
break;
}
pH = pH->next;
}
}
break;
}
由于这段代码没有给出上下⽂,所以理解起来有些⿇烦(主要还是我的状态迁移模型写得太差了),我的建议还是详细地在github上通读整个代码。
4. 测试设计
根据⽤户可能输⼊的各种不同指令,我们将可能的分⽀⽤流程图来表⽰。
显然,可以看出它的环复杂度为8。于是,⾸先我设计了8个相互独⽴的测试⽤例。
测试编号 | 测试内容 | ⽤户指令
| - | - | -
1|基本字符测试|–c char.c
2|不可显⽰字符测试|-c charwithspace.c
3|单词和⾏数测试|-w -l wordtest.c
4|扩展⾏数测试|-a atest.c
5|停⽤词测试|-w stoptest.c -
6|⽂件夹遍历测试|-s -w -a C:\Users\Star\Desktop\SoftTest*.c
7|输出测试|-s -a -w -c -l C:\Users\Star\Desktop\SoftTest*.c -
8|全套测试|-s -a -w -c -l C:\Users\Star\Desktop\SoftTest*.c - -
全套测试是为了查看,如果将程序⾥⽀持的所有功能都同时使⽤会不会得出正确结果。我们期望的结果是像这样,得到⼀个详细的⽂档,⾥⾯记录了给定路径下所有形如 "*.c" 的⽂件中,字符数、单词数、⾏数和特殊⾏数:
然⽽实际的输出却很惨——⽬标⽂件并未出现任何字符。
经过了⼀番断点调试,我终于到了原因。由于指令过长,没有设置⾜够的数组长度来存储指令,导致解析失败。之后,我将指令最⼤长度设置为150,这下得到了正确结果。
这些测试⽤例都以及相应的测试结果可以在我给出的中到。
当然,我并不认为通过了这⼋个互相独⽴的测试⽤例,就能确保程序正确。于是我⼜补充了两个测试⽤例,他们⼗分特殊,跟之前⼋个都不⼀样。
测试编号 | 测试内容 | ⽤户指令
| - | - | -
9|错误指令测试|-c -d char.c charwithspace.c
10|错误指令测试|-e -c char.c
错误指令测试是想看看如果⽤户输⼊了错误的指令,程序会不会崩溃。事实证明,程序可以⼀定程度上地分析出⽤户指令,虽然不会得出⽤户期望的输出,但是⾄少它不会崩溃,我们认为这是程序健壮性良好的⼀个体现。
具体的测试⽅法,就是在编译环境⾥给程序⼊⼝传递参数,然后编译器就可以正确地将我们预设的指令传给程序。我们只需要在⽬标输出⽂件内到实际输出,和我们的期望输出进⾏⽐对即可。
当然,也可以使⽤来测试,测试脚本⼗分⽅便,可以让系统批处理地执⾏exe⽂件,并且⾃动传参。它的部分代码看上去是这样的:
wc.exe -s -w -a C:\Users\Star\Desktop\SoftTest\*.c
wc.exe -s -a -w -c -l C:\Users\Star\Desktop\SoftTest\*.c -
总结
总体来说,由于这次的项⽬相对简单,⽽且⼜严格遵照了软件⼯程的开发要求,等所有模块的思路都清晰了以后再开始编码,所以测试过程⼗分愉快,基本上除了⼀些很容易改正的粗⼼问题,没有别的思路上或者结构上的问题。
可怜的是我这么⼀个美好的周末就这样废了:(

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。