GitHub倉庫地址:https://github.com/ZCplayground/personal-project
PSP 表格
PSP2.1 | Personal Software Process Stages | 預估耗時(分鍾) | 實際耗時(分鍾) |
---|---|---|---|
Planning | 計划 | 20 | 60 |
· Estimate | · 估計這個任務需要多少時間 | 20 | 60 |
Development | 開發 | 440 | 745 |
· Analysis | · 需求分析 (包括學習新技術) | 60 | 60 |
· Design Spec | · 生成設計文檔 | 30 | 30 |
· Design Review | · 設計復審 | 30 | 10 |
· Coding Standard | · 代碼規范 (為目前的開發制定合適的規范) | 20 | 15 |
· Design | · 具體設計 | 60 | 30 |
· Coding | · 具體編碼 | 120 | 330 |
· Code Review | · 代碼復審 | 60 | 30 |
· Test | · 測試(自我測試,修改代碼,提交修改) | 60 | 240 |
Reporting | 報告 | 80 | 60 |
· Test Repor | · 測試報告 | 60 | 40 |
· Size Measurement | · 計算工作量 | 10 | 10 |
· Postmortem & Process Improvement Plan | · 事后總結, 並提出過程改進計划 | 10 | 10 |
合計 | 540 | 865 |
需求分析
對程序的功能進行需求分析如下:
- 統計文件有多少個字符,包括空格制表符換行符等。
- 統計文件的有效行數,含非空白字符的行的數量,也就是跳過 空行 的行數。
- 統計單詞的總數,本題中“單詞”的定義是:
- 以4個英文字母
A-Z,a-z
開頭,后續可以是字母和數字A-Z, a-z,0-9
。file123
是一個單詞,123file
不是一個單詞。(換言之,如果有一行內容是123file
,那么“單詞”是file
。)(9/10注:此處題意理解有誤,請看文章末尾的更新) - 分割符是非字母數字符號,空格。
- 不區分大小寫,例如
file
和FILE
是同一個單詞。
- 以4個英文字母
- 統計文件中各單詞的出現次數,然后輸出頻率最高的10個,單詞定義同上,按照如下格式輸出:頻率相同的單詞,優先輸出字典序靠前的單詞。
非功能性方面的分析:
- 使用GitHub進行代碼管理,代碼有進展即簽入Github。對代碼簽入的具體要求如下:根據需求划分功能后,每做完一個功能,編譯成功后,應至少commit一次。
- 是一個命令行程序,參數是一個輸入文件的文件名,
input.txt
- 希望把“統計字符數”、“統計單詞數”、“統計最多的10個單詞詞頻”這三個功能獨立出來,成為獨立的模塊。
- 除了核心模塊之外,還需要有一定的界面和必要的輔助功能。
- 單元測試,要設計至少10個測試用例。
思路
拿到題目之后,可以說要求中的第1和第2點都不算太難,C語言的練習題的難度。看到要求3一下子想到了有限自動機(Deterministic Finite Automaton,DFA) 。上學期在計算理論上課上學到了在數學上自動機和正則表達式是等價的,就打算實現一個DFA來做。對於功能4,首先想到了用哈希表(unordered_map)來統計出現的單詞數量,這樣在查詢時時間復雜度較小,為O(1)。統計最多的十個,自然想到了堆/優先隊列(priority_queue),但發現有字典序排序的要求,想了想是否可以用紅黑樹(map)存儲后直接輸出,但實現了紅黑樹版本之后,分析一番發現可以通過只維護10個單詞的大小的堆處理所有單詞,最后排序輸出,可以剩下大量的空間,並小部分優化時間復雜度。大概花了半個小時畫了畫自動機的圖,理清需要分幾個模塊,以及要求123的思路;再花了二十分鍾左右分析了要求4的算法。
設計文檔
輸入輸出
輸入由命令行參數指定需要進行統計的文件名。普通的后綴為.txt
的文本文件,不考慮漢字的存在。
示例輸入,對於一個內容如下input.txt
的文件:
a
a
a
abcd
abcd1234
abcd
abcd
file
FILE
123file
運行指令 > .\WordCount.exe .\input.txt
應該得到如下的示例輸出:
In this file:
Number of characters: 48
Number of non-empty lines: 10
Number of words: 7
Top 10 words:
3 abcd
3 file
1 abcd1234
解釋:
- 共有49個字符,10個非空行,7個單詞;
a
由於沒有4個連續字母,故不是單詞;abcd
和abcd1234
是不同的單詞;abcd
和abcd(字母前有一個空格)
都算作單詞abcd
、123file
和file
算同一個單詞;(9/10注:此處題意理解有誤,請看文章末尾的更新)file
和FILE
算同一個單詞;abcd
和file
都出現了三次,要先輸出字典序靠前的abcd
。
環境
- 操作系統:Windows 10
- IDE:Visual Studio 2017 Community
- 編程語言:C++
代碼規范
翻閱了《構建之法(第三版)》P68的代碼規范內容,選取了部分用的上的規范應用到本項目中。
- 行寬限制為100字符。不過在編碼時沒有注意看一行的字符數,在編完整個項目后找了句最長的語句看也沒有超過90個字符。
- 斷行與空白的大括號行,選擇了左大括號和當前語句同一行,右大括號獨占一行的形式。
- 命名風格。
- 變量使用Camel:由多個單詞組成的變量名,第一個單詞的首字母小寫,隨后單詞的第一個字母大寫。
- 函數使用Pascal:所有單詞的第一個字母大寫。
- 注釋
- 不要注釋How(程序怎樣工作),而是注釋What和Why(程序做了什么,為什么這么做)。好的代碼自身就可以解釋How。
- 注釋不要用中文。
- 在頭文件中對函數進行詳細的注釋,格式參考了這一篇博客
舉個例子,下面是我對統計有效行數CountLines()
這個函數的注釋:
/*
* Function name: CountLines
* Description:
* Count the number of lines of the file, skip empty lines.
* Parameter:
* @filename: File that need to be counted
* Return:
* @int: total number of lines
*/
int CountLines(char * filename);
具體設計
代碼文件結構組織
為了達到設計要求中的“統計字符數”、“統計單詞數”、“統計最多的10個單詞詞頻”三個功能獨立成為獨立的模塊,將本項目的代碼文件結構進行如下的組織:
ArgumentParser.h
:用於解析命令行參數;CountChar.h
:用於統計一個文件內的字符數;CountLines.h
:用於統計一個文件內的有效行數;CountWords.h
:用於統計一個文件內的有效單詞數量;WordFrequency.h
:用於分析一個文件內的詞頻數據,並輸出最高的十個;main.cpp
:調用如上文件內的函數,向用戶顯示統計結果;UnitTest1
:單元測試內容。
算法
- 統計文件內的字符數,打開文件后循環讀入字符直到文件尾,計數即可。
- 統計一個文件內的有效行數,設置一個狀態,每次讀到一個換行符
\n
時,判斷該行狀態是否為空行,只有不為空行時才計數。 - 統計一個文件內的有效單詞數量:使用了有限狀態自動機思想
- 共有兩個狀態,
Out of Word
表示當前沒有識別到合法單詞,是,Valid Word
狀態表示當前識別到了合法單詞。 Out of Word
要識別連續4個字母,轉變為Valid Word
狀態。- 進入
Valid Word
狀態后,輸入字母和數字都會繼續保持在Valid Word
狀態。 - 識別到分隔符和非字母數字的字符,會進入
Out of Word
狀態。 - 從
Valid Word
狀態轉移至Out of Word
狀態時,單詞計數器加1。自動機如下圖所示:
- 共有兩個狀態,
- 統計一個文件內的詞頻,並輸出最多的10個單詞:
- 記錄識別到的單詞。在上一個有限自動機的基礎上做一些小小改動就可以實現。
- 每識記錄了一個合法的完整單詞,都插入哈希表中。插入前先在哈希表中查找此單詞:
- 若沒有找到,則說明是新單詞,設置其計數器counter=1,將
<word, counter=1>
插入哈希表; - 若找到,說明是已經出現過的單詞,counter++即可。
- 若沒有找到,則說明是新單詞,設置其計數器counter=1,將
- 建立一個小頂堆。
- 遍歷hashtable中的每一個單詞,准備將其放入堆中。
- 若堆內元素大小小於10,則直接push進堆即可。返回步驟4
- 若堆內元素大小達到10,則需要對比堆頂單詞的計數器和當前單詞的計數器,若堆頂單詞出現次數更少,則彈出堆頂單詞,插入新單詞。若計數器一樣,就比較字典序,字典序小的會被淘汰。返回步驟4
- 所有單詞處理完畢后,將堆中的10個(或小於10個)單詞按要求排序並輸出即可。
算法復雜度
- 統計字符、行數、單詞數都是O(N),N為
input.txt
的文件內容的長度。由於用了自己寫的自動機,也許常數會(相較成熟的正則表達式庫)大一點。 - 輸出10個出現次數最多的單詞。設N為
input.txt
的文件內容的長度,設M為input.txt
合法單詞的個數,設K為小頂堆的size,也就是要輸出K個最常見的單詞,這里K=10。- O(N)掃描文本,找出所有單詞;
- 共有M個單詞,將所有單詞插入hashtable,復雜度O(1)
- 最壞情況是有M個不同的單詞,這M個單詞都要執行優先隊列的操作。優先隊列的大小是K,復雜度 M*O(log K)
- 總時間復雜度為 O(N) + M*O(log K)
流程圖 & 結構圖
- 工程框架
- 有窮自動機 & 插入HashTable
- 統計詞頻算法
編碼 & 展示部分關鍵代碼
(博文中用中文進行注釋,實際項目文件中是英文):
1.使用有窮自動機識別合法單詞,並存儲到一個string對象中。識別到一個完整的單詞后,插入hashtable。
#define OUTWORD 0 // 5個自動機狀態
#define P1 1
#define P2 2
#define P3 3
#define VALIDWORD 4
#define ERROR 5
int TransitionStoreWord(int state, char input, string & word)
{
switch (state)
{
case OUTWORD:
if (!isalpha(input) || isspace(input)) return OUTWORD;
else if (isalpha(input)) { word += input; return P1; } // 在形成合法單詞的過程中,將其記錄在一個string對象中
case P1:
if (isalpha(input)) { word += input; return P2; }
else { word.clear(); return OUTWORD; } // 若要回到 OUTWORD 狀態,將string對象清空
case P2:
if (isalpha(input)) { word += input; return P3; }
else { word.clear(); return OUTWORD; }
case P3:
if (isalpha(input)) { word += input; return VALIDWORD; }
else { word.clear(); return OUTWORD; }
case VALIDWORD:
if (isalnum(input)) { word += input; return VALIDWORD; }
else {
InsertToHashTable(word); // 得到一個完整的合法單詞后,就插入到HashTable中
word.clear();
return OUTWORD;
}
}
return ERROR;
}
2.按照格式要求顯示詞頻最高的十個單詞:
int TopTenWords()
{
for (hash_iter = hash_table.begin(); hash_iter != hash_table.end(); hash_iter++) { // 遍歷hashtable中的每個單詞
pair<int, string> currentWord = make_pair(hash_iter->second, hash_iter->first); // 當前處理的單詞
if (wordQueue.size() == 10) { // 若優先隊列內已有10個單詞
pair<int, string> minFreqWord = wordQueue.top(); // 查看堆頂(小頂堆,所以就是出現次數目前排第十位的)單詞的出現次數
if (currentWord.first > minFreqWord.first ||
(currentWord.first == minFreqWord.first && currentWord.second > minFreqWord.second)) {
// 若當前處理的單詞的出現次數比堆頂更大,或出現次數相同但字典序靠前,就將堆頂拋出,將新單詞入堆
wordQueue.pop();
wordQueue.push(currentWord);
}
}
else { // 若優先隊列內不滿10個單詞,直接入隊
wordQueue.push(currentWord);
}
}
if (wordQueue.size() == 0) {
return -1;
}
int count = wordQueue.size();
vector<pair<int, string>> Top10words;
while (!wordQueue.empty()) {
Top10words.push_back(wordQueue.top());
wordQueue.pop();
}
sort(Top10words.begin(), Top10words.end(), MySort); // 按要求排序並輸出
vector<pair<int, string>>::iterator iter;
for (iter = Top10words.begin(); iter != Top10words.end(); iter++) {
cout << iter->first << " " << iter->second << endl;
}
hash_table.clear();
return count;
}
性能分析報告 & 改進性能
參考博客是劉乾學長的培訓文檔。測試時使用的輸入文件和設計文檔中的樣例輸入文件內容是一樣的。將main函數循環執行10000次,花費時間是56.201秒。性能分析圖如下:
main函數占了93%的執行時間,這是因為測試性能時就是循環執行main函數。在main函數中調用的幾個函數中,TopTenWords()
占用了30.75%的執行時間,WordFrequency()
占用了22.61%的執行時間。
對於TopTenWords()
,可以看到一行字符串的C++標准輸出語句占用了大量的運行時間:
於是,將文件內所有的類似cout<<...<<...
輸出語句改成如下的C語言風格的輸出模式,運行時間從56秒優化至了39秒。
for (iter = Top10words.begin(); iter != Top10words.end(); iter++) {
// cout << iter->first << " " << iter->second << endl;
// 修改原本的C++輸出語句為下列形式:
const char *word = iter->second.c_str();
printf("%s: %d\n", word, iter->first);
}
單元測試
在VisualStdio中構建單元測試參考了鄒欣老師給出的鏈接里劉乾學長的這篇博客。
以下是我設計的十個單元測試內容:
單元測試名稱 | 解釋 | 被測試文件 | 期待輸出 |
---|---|---|---|
WrongInputFileName | 打開錯誤的文件名 | CountChar.h | 能夠正確返回錯誤信息 |
CountCharTest | 測試CountChar函數 | CountChar.h | 能夠正確地統計字符數 |
EmptyFileTest | 傳入一個空文件 | 全部 | 統計字符數、行數、單詞數的結果都應該為0 |
EmptyLineTest | 傳入一個文件,只包含空格、tab和換行符 | CountLines.h | 統計有效行數,應為0 |
ValidLineTest | 傳入一個文件,既有空行也有有效行,有效行是空白符和字符的混合 | CountLines.h | 應顯示正確的有效行數 |
WrongWord | 傳入一個文件,里面每一行都是一個不能構成題目要求的有效單詞的字符串 | CountWords.h | 統計單詞數量,應為0 |
ValidWord | 傳入一個文件,里面每一行都是一個有效單詞 | CountWords.h | 能正確統計單詞數量 |
CaseInsensitive | 傳入一個文件,里面的內容是file和FILE | WordFrequency.h | 統計詞頻時,應將這兩個單詞識別為同一個單詞file,計數器應該為2 |
WordWithNumber | 單詞形如file123 的,字母加數字組合類型 |
WordFrequency.h | 能正確統計此種類型的詞頻 |
TenMoreWord | 傳入一個文件,里面有超過十個的合法單詞 | WordFrequency.h | 只顯示前十個的單詞,且按照字典序 |
單元測試的運行結果如下圖:
我挑選了三個單元測試代碼在這里列出並給出中文注釋。全部的單元測試代碼在github倉庫中的UnitTest1項目中可以看到。
namespace EmptyFileTest // 傳入空文件的單元測試
{
TEST_CLASS(UnitTest1)
{
public:
TEST_METHOD(TestMethod1)
{
char filename[100] = "EmptyFile.txt";
int count = CountChar(filename);
int numOfLines = CountLines(filename);
int numOfWords = CountWords(filename);
// 統計的結果應該都為零
Assert::IsTrue(count == 0 && numOfLines == 0 && numOfWords == 0);
// TODO: 在此輸入測試代碼
}
};
}
namespace WrongWord // 傳入不屬於題目要求的“單詞”的字符串
{
TEST_CLASS(UnitTest1)
{
public:
TEST_METHOD(TestMethod1)
{
char filename[100] = "WrongWord.txt";
int numOfWord = CountWords(filename);
// 統計單詞的結果應該為0,因為字符串都不符合“單詞”的要求
Assert::IsTrue(numOfWord == 0);
// TODO: 在此輸入測試代碼
}
};
}
namespace CaseInsensitive // 傳入 file 和 FILE
{
TEST_CLASS(UnitTest1)
{
public:
TEST_METHOD(TestMethod1)
{
char filename[100] = "CaseInsensitive.txt";
WordFrequency(filename);
int count = TopTenWords();
Assert::IsTrue(count == 1); // 這兩個單詞應該被識別為同一個單詞
// TODO: 在此輸入測試代碼
}
};
}
代碼覆蓋率
在做這項工作時先進行了搜索,得知 VisualStudio Community 版本沒有自帶的分析代碼覆蓋率功能。經過一番搜索找到了開源的插件——OpenCppCoverage,這個插件也是比較易用的,直接下載后重啟VS就可以加載。
代碼覆蓋率的截圖如下:
可以看到總體的代碼覆蓋率是不錯的,除了Parse_Args()
這個文件的覆蓋率低得比較明顯,只有64%。究其原因,是因為這個文件是為了檢測不符合要求的命令行參數的,而運行正確的測試時要傳入正確的命令行參數,所以異常處理的代碼就沒有用到。如下圖所示:
用到有限自動機的文件,例如CountWord.cpp
,為其設計了一個出錯的狀態(防止不測),這些防御性編程的代碼沒有在測試時被運行到,如下圖所示:
異常處理
我設計了如下異常處理:
ArgumentParser.h
這個文件,用於解析命令行參數,如果出現以下情況:
- 沒有傳入文件名
- 傳入過多的命令行參數
- 文件不能打開
都會返回錯誤代碼並提示對應的提示信息:
int Parse_Args(int argc, char ** argv)
{
if (argv[1] == NULL) {
printf("No input file name!\n");
return -1;
}
if (argv[2] != NULL) {
printf("Input too many argument!\n");
}
std::fstream file;
file.open(argv[1]);
if (!file) {
printf("Failed to open file: %s\n", argv[1]);
return -1;
}
file.close();
return 0;
}
int main(int argc, char **argv)
{
int ret = Parse_Args(argc, argv);
if (ret == -1) {
return -1;
}
/*......*/
}
在算法中,有對應的異常處理和檢查。例如TopTenWords
中對於空文件的處理:
if (wordQueue.size() == 0) {
return -1;
}
總結和感想
首先自己在這個項目中獲得的提升是從這個作業要求中得到的:代碼有進展即簽入Github。按照我以前的習慣做法,可能是一天晚上准備睡覺前 git commit 一次。而這次的作業要求每次有新功能,代碼有新進展,每一次編譯都要commit,這樣子做才是符合軟件工程的要求的。為此我還對怎么寫好的 commit 信息進行了學習,一趟下來感覺頗有收獲。每次對項目有新的修改后就commit,加上清晰的commit信息,這樣才有利於在項目出現問題時回退解決。
看書看到“不要注釋How(程序怎樣工作),而是注釋What和Why(程序做了什么,為什么這么做)。好的代碼自身就可以解釋How。”,這點時表示很受用,暑假看了一些開源項目時就發現了他們的代碼和注釋風格就符合這一要求。所以要寫出邏輯清晰的高質量代碼,而不是寫完爛代碼加一堆注釋。
作業要求我們在開始動手之前,要完成PSP的表格中的預測。一開始很不解:為什么還要估計要花多少時間?而且當時覺得估計自己要花多少時間還是挺困難的,想了很久。所以重讀了《構建之法》,里面講到“為了記錄工程師如何實現需求的效率”。再閱讀博客,得知之所以要估計各個模塊耗費的時間並記錄下來,也是為了更好地管理自己的時間。此外,“工程師在需求分析和測試這兩方面明顯地要比在校學生花更多的時間,從學生到程序員並不是更加沒完沒了地寫程序”,對這一表述我深表贊同,所以這一次完成這個作業時,我先仔細地完成了需求分析和設計方面的文檔。雖然自己以前就挺注重測試,以及程序的完備性,能處理各種出錯的輸入,以及對各個輸入有正確的輸出。但我花在測試方面的時間比我預估的要多很多,主要是花在了學習使用代碼覆蓋率工具,以及設計十個單元測試上。
通過閱讀《構建之法》和博客上的例子,我大概知道了單元測試是用來干什么的:“單元測試應該准確、快速地保證程序基本模塊的准確性。……單元測試測試的是程序最基本的單元——C++中的類”。雖然自己寫了個基本上是C語言的純過程語法,所幸的是還是有仔細地封裝各個接口,可以使用單元測試來測試各個接口的正確性。繼續看書:“最好在設計的時候就寫好單元測試”,這是我沒做到的地方——應該先寫單元測試再寫代碼——自己想當然地認為單元測試可以放到整個程序都完成之后再來做。我的做法是編碼時直接手動運行程序,手動在文件中自己打一些樣例來測試,就是延續了在做算法題時的思路。通過讀書,我得知了我這樣的做法不符合軟件工程的要求,沒有達到單元測試的一些好處:“單元測試應該產生可重復、一致的結果”、“單元測試應該集成到自動測試的框架,這樣每個人都能隨時隨地運行單元測試”。比較可惜的是單元測試是在整個程序寫完時才寫,而不是先寫單元測試再寫程序(或者最起碼單元測試和程序要一起寫),這個做法應該下次要改進。
一年前就看了《構建之法》,但當時只是純粹地看書,僅了解過作業的形式但沒有去做。今天是實打實地完成了個人項目,才體會到“紙上得來終覺淺”。
更新
9/10
1.補上了輸出到文件result.txt
的功能,調用方法如下:
int main(int argc, char **argv)
{
WordFrequency(argv[1]);
auto topTenWordList = TopTenWords();
StandardOutput(topTenWordList); // 標准輸出
OutputToFile(topTenWordList); // 輸出到文件
}
2.經 @王彬 同學提醒,誤解了題意。題意里“123file
不是單詞”。意思是,123file
后續再跟着多少字母都不算單詞了,而是要遇到一個分隔符之后,才能再次進入判斷是否為連續4個字母開頭的分支。分隔符是“空格,或非數字字母的字符”。為此定義一個宏:
#define IsNum(x) (x >= '0' && x <= '9')
#define Separator(x) (isspace(x) || (!IsNum(x) && !isalpha(x)))
並更改有窮自動機如下:
解釋:如果在形成有效單詞的過程中掃描到了數字,應該進入 Not A Word 狀態,只有遇到分隔符才能回到初始狀態。修復bug后,123file
不是再合法單詞,也不會被識別成合法單詞file
,而abc123d,file
、abc123d file
可以被識別成合法的單詞file
,因為在串abcd123d
之后有分隔符(逗號或空格)。