先給出github上的代碼鏈接以及項目需求
1. 項目簡介
這個項目的需求可以概括為:對程序設計語言源文件統計字符數、單詞數、行數,統計結果以指定格式輸出到默認文件中,以及其他擴展功能,並能夠快速地處理多個文件。我個人對C++比較熟悉,各種文件輸入輸出流也會用,所以選擇使用C++完成。當然C++也有它的缺陷,比如所有的字符串都要規定一個最大長度(可以選擇用string,但我對於string的拼接,以及逐字符操作不是很熟悉,只好含淚用char[])。
這個項目其實也算是個小項目,一開始我覺得450分鍾內肯定完成,就是一整天的事情。結果最后我實際上用了兩天。兩個原因吧,一個是我低估了這個項目的代碼量。把這個項目的功能從基本功能到擴展功能實現了一遍,居然寫了我五百多行代碼(主要是有限狀態機模型不會用,就自己按照邏輯硬剛下來了,功能倒是實現了)。第二個是連續工作實在太累了,到最后專注度直線下降,基本上有效編碼時間只有百分之五十了。不過最后還是剛下來了,一定要找時間犒勞一下自己,吃頓好的。
項目的開發過程嚴格遵照軟件工程的要求,從需求分析,到最后的測試,一個不落。這種開發方式,起步的速度會慢一些,不過寫出來的代碼非常好看,也易於修改。下面附上一張PSP表格。
| PSP2.1 | PSP階段 | 預估耗時(分鍾) | 實際耗時(分鍾) | PSP2.1 | PSP階段 | 預估耗時(分鍾) | 實際耗時(分鍾) | |
|---|---|---|---|---|---|---|---|---|
| Planning | 計划 | 10 | 2 | Development | 開發 | 340 | 597 | |
| · Estimate | · 估計這個任務需要多少時間 | 10 | 2 | · Analysis | · 需求分析 (包括學習新技術) | 30 | 32 | |
| · Design Spec | · 生成設計文檔 | 60 | 60 | |||||
| Reporting | 報告 | 100 | **95 ** | · Design Review | · 設計復審 (和同事審核設計文檔) | 20 | 30 | |
| · Test Report | · 測試報告 | 60 | 60 | · Coding Standard | · 代碼規范 (為目前的開發制定合適的規范) | 20 | 5 | |
| · Size Measurement | · 計算工作量 | 10 | 5 | · Design | · 具體設計 | 60 | 45 | |
| · Postmortem & Process Improvement Plan | · 事后總結, 並提出過程改進計划 | 30 | 30 | · Coding | · 具體編碼 | 60 | 335 | |
| · Code Review | · 代碼復審 | 30 | 15 | |||||
| · Test | · 測試(自我測試,修改代碼,提交修改) | 60 | 75 | |||||
| 合計 | 450 | 694 |
2. 大體思路
這個項目的大體思路還是很明確的,我也在github上傳了相關的設計文檔。
我把這個項目分為六個模塊:主函數、指令解析、遞歸搜索文件、統計准備工作、統計、結果輸出。
主函數
主函數可以從控制台接收用戶輸入的指令,然后將這些指令拼接成一個完整的字符串並交給其它函數處理。
指令解析
指令解析可以提取用戶輸入的指令中的有效信息,從而決定了之后程序該執行哪些功能。盡管用戶的指令可能是各種順序的組合(比如,同一個指令,他既可以寫成-w stoptest.c -e stoplist.txt,又可以寫成 stoptest.c -e stoplist.txt -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 -e stoplist.txt |
| 6 | 文件夾遍歷測試 | -s -w -a C:\Users\Star\Desktop\SoftTest*.c |
| 7 | 輸出測試 | -s -a -w -c -l C:\Users\Star\Desktop\SoftTest*.c -o output.txt |
| 8 | 全套測試 | -s -a -w -c -l C:\Users\Star\Desktop\SoftTest*.c -o output.txt -e stoplist.txt |
全套測試是為了查看,如果將程序里支持的所有功能都同時使用會不會得出正確結果。我們期望的結果是像這樣,得到一個詳細的文檔,里面記錄了給定路徑下所有形如 "*.c" 的文件中,字符數、單詞數、行數和特殊行數:

然而實際的輸出卻很慘——目標文件並未出現任何字符。
經過了一番斷點調試,我終於找到了原因。由於指令過長,沒有設置足夠的數組長度來存儲指令,導致解析失敗。之后,我將指令最大長度設置為150,這下得到了正確結果。
這些測試用例都以及相應的測試結果可以在我給出的github鏈接中找到。
當然,我並不認為通過了這八個互相獨立的測試用例,就能確保程序正確。於是我又補充了兩個測試用例,他們十分特殊,跟之前八個都不一樣。
| 測試編號 | 測試內容 | 用戶指令 |
|---|---|---|
| 9 | 錯誤指令測試 | -c -d char.c charwithspace.c |
| 10 | 錯誤指令測試 | -e -c char.c |
錯誤指令測試是想看看如果用戶輸入了錯誤的指令,程序會不會崩潰。事實證明,程序可以一定程度上地分析出用戶指令,雖然不會得出用戶期望的輸出,但是至少它不會崩潰,我們認為這是程序健壯性良好的一個體現。
具體的測試方法,就是在編譯環境里給程序入口傳遞參數,然后編譯器就可以正確地將我們預設的指令傳給程序。我們只需要在目標輸出文件內找到實際輸出,和我們的期望輸出進行比對即可。

當然,也可以使用測試腳本來測試,測試腳本十分方便,可以讓系統批處理地執行exe文件,並且自動傳參。它的部分代碼看上去是這樣的:
start wc.exe wc.exe -s -w -a C:\Users\Star\Desktop\SoftTest\*.c
start wc.exe wc.exe -s -a -w -c -l C:\Users\Star\Desktop\SoftTest\*.c -o output.txt
總結
總體來說,由於這次的項目相對簡單,而且又嚴格遵照了軟件工程的開發要求,等所有模塊的思路都清晰了以后再開始編碼,所以測試過程十分愉快,基本上除了一些很容易改正的粗心問題,沒有別的思路上或者結構上的問題。
可憐的是我這么一個美好的周末就這樣廢了:(
