2018軟工實踐第二次作業
Github項目地址:https://github.com/Professorchen/personal-project
1. 寫在前面
剛看到作業的時候我的心情如圖,十分後悔沒有退了這門實踐選修課。
完成作業之後我的心情
收獲還是十分大的。
2. PSP表格
PSP2.1 | Personal Software Process Stages | 預估耗時(分鐘) | 實際耗時(分鐘) |
---|---|---|---|
Planning | 計劃 | 40 | 30 |
· Estimate | · 估計這個任務需要多少時間 | 40 | 30 |
Development | 開發 | 540 | 675 |
· Analysis | · 需求分析 (包括學習新技術) | 60 | 80 |
· Design Spec | · 生成設計文檔 | 20 | 30 |
· Design Review | · 設計復審 | 30 | 20 |
· Coding Standard | · 代碼規範 (為目前的開發制定合適的規範) | 10 | 5 |
· Design | · 具體設計 | 60 | 70 |
· Coding | · 具體編碼 | 300 | 360 |
· Code Review | · 代碼復審 | 30 | 50 |
· Test | · 測試(自我測試,修改代碼,提交修改) | 30 | 60 |
Reporting | 報告 | 85 | 130 |
· Test Repor | · 測試報告 | 60 | 90 |
· Size Measurement | · 計算工作量 | 15 | 20 |
· Postmortem & Process Improvement Plan | · 事後總結, 並提出過程改進計劃 | 10 | 20 |
合計 | 665 | 835 |
3. 解題思路
3.1 需求分析
基本功能
統計文件的字符數
- 這個功能十分簡單,只需要逐個字符讀取文本就可以統計了。
- 唯一需要註意的就是什麽樣的字符需要統計。在與 tomvii 同學討論之後,知道了只需要統計 ASCII碼:32~126(可視字符)、9(水平制表符)、10(換行符) 這些。
- 這個功能十分簡單,只需要逐個字符讀取文本就可以統計了。
統計文件的單詞總數
- 單詞的定義在作業描述裏面寫的十分明確:至少以4個英文字母開頭,跟上字母數字符號,單詞以分隔符分割,不區分大小寫。
- 只需要按照定義把單詞取出來從文本中統計即可。
統計文件的有效行數
- 有效行數的定義是包含非空白字符的行
- 這裏的非空白字符指的是 ASCII 碼:32~126(可視字符)。逐行讀取文本之後再對每一行逐個字符判斷即可。
- 有效行數的定義是包含非空白字符的行
統計文件中各單詞的出現次數,最終只輸出頻率最高的10個,頻率相同的單詞,優先輸出字典序靠前的單詞。
- 在統計文件的單詞總數的時候每當判斷得到一個單詞就先保存,之後再使用排序算法得到頻率最高的10個單詞。
其他需求
要實現一個命令行程序,輸入輸入文件名以命令行參數傳入,例如在命令行輸入: WordCount.exe input.txt
- 主函數 int main(int argc,char argv[]) 中參數 argv[ ] 字符串數組,用來存放指向你的字符串參數的指針數組。其中 argv[1](如果存在) 就是表示 DOS 命令行中執行程序名後的第一個字符串。
把基本功能裏的 “統計字符數”、“統計單詞數”、“統計最多的10個單詞詞頻” 這三個功能獨立出來,成為獨立的模塊。
- 將這三個功能封裝在一個 Count 類中,並且定義在 Count.h 文件中,這樣即使其他的程序也可以使用。
寫出至少10個測試用例確保你的程序能夠正確處理各種情況。
- 使用 Visual Studio Community 2017 自帶的測試功能進行編寫測試用例,盡可能覆蓋所有的模塊。
3.2 程序實現
統計文件的字符數
- 逐個字符讀取文本,符合條件的每個字符存進一個 string 類型的 charBuf,最後得到 charBuf 的 size。
統計文件的單詞總數
- 逐行讀取文本,將每一行存入 vector
- 如果當前字符既不是字母也不是數字則對 wordBuf 進行判斷——長度是否大於等於4 && 前四個字符是否都為字母,符合則為一個單詞。若確定 wordBuf 為一個單詞,就將其轉為小寫字母然後存入 map<string, int> 類型的 wordMap。
- 至於為什麽要選擇 map 容器來存單詞,在下面會解釋。
當然,在一開始也考慮使用正則表達式,不過在網上查找資料之後發現似乎正則表達式的效率十分低,因此沒有選擇采用正則表達式進行單詞分割。
統計文件的有效行數
- 同樣逐行讀取文本,然後同樣每一行逐個字符判斷是否為非空白字符,一行只要出現非空白字符即可停止判斷。
- 同樣逐行讀取文本,然後同樣每一行逐個字符判斷是否為非空白字符,一行只要出現非空白字符即可停止判斷。
統計文件中各單詞的出現次數,最終只輸出頻率最高的10個,頻率相同的單詞,優先輸出字典序靠前的單詞。
- 遍歷 10 遍 wordMap 取出頻率最高的 10 個單詞存入 vector<pair<string, int> > 類型的 wordVector(當然也可能沒有 10 個單詞,因此要取 wordMap.size() 與 10 的最小值)。
- 這個方法時間復雜度為 O(n),而采用其他算法的復雜度至少為 O(nlogn),那麽在單詞數多於 100 個的時候其他算法就更慢了。從時間復雜度的角度考慮選擇了遍歷 10 遍的方法。
(還好不需要輸出全部單詞):-)
- 之所以前面選擇 map 容器存單詞是因為在單詞頻率相同的時候要按字典序排序,而 map 容器底層采用紅黑樹實現,本身就可以對 key 字典序排序。
- 遍歷 10 遍 wordMap 取出頻率最高的 10 個單詞存入 vector<pair<string, int> > 類型的 wordVector(當然也可能沒有 10 個單詞,因此要取 wordMap.size() 與 10 的最小值)。
4. 設計實現過程
- 代碼組織
Count.h
—— 聲明統計相關的函數。定義一個Count
類,將各種統計相關的函數做成類中的成員函數。int countCharNum(string &charBuf);
//統計字符,傳入參數為文件的每一個字符組成的 stringint countWordNum(vector<string> &linesBuf);
//統計單詞數,傳入參數為文件的每一行組成的 vectorint countLineNum(vector<string> &linesBuf);
//統計行數,傳入參數同上vector<pair<string, int> > countTop10Word();
//統計頻率最高的 10 個單詞,不需要傳入參數,但是需要先執行int countWordNum(vector<string> &linesBuf);
inline bool isLetter(string::iterator it);
//判斷字符是否為字母,傳入參數為 string 的叠代器inline bool isLetter(const char ch);
//判斷字符是否為字母,傳入參數為字符inline bool isDigit(string::iterator it);
//判斷字符是否為數字,傳入參數為 string 的叠代器inline bool isDigit(const char ch);
////判斷字符是否為數字,傳入參數為字符
Count.cpp
—— 實現Count.h
中聲明的函數FileIO.h
—— 聲明與文件讀寫相關的函數static string readChar(int argc, char *argv[]);
//逐個字符讀取文件,傳入參數為 main() 的參數列表static vector<string> readLines(int argc, char *argv[]);
//逐行讀取文件,傳入參數同上static void outputToFile(int characterCount, int lineCount, int wordCount, vector<pair<string, int> > &top10Word);
//結果輸出至文件,傳入參數為四個需要統計的值和頻率最高 10 個(也許沒有)的單詞static string getFileName(int argc, char *argv[]);
//返回需要打開的文件名,傳入參數為 main() 的參數列表
FileOI.cpp
—— 實現FileIO.h
中聲明的函數
- 文件結構
031602507
|- src
|- WordCount.sln
|- WordCount
|- Count.cpp
|- Count.h
|- File.h
|- File.cpp
|- input.txt
|- LastCoverageResults.log
|- result.txt
|- WordCount.cpp
|- WordCount.vcxproj
|- WordCount.vcxproj.filters
|- WordCount.vcxproj.user
|- wordTest
|- stdafx.cpp
|- stdafx.h
|- targetver.h
|- test1.txt
|- test2.txt
|- test3.txt
|- test4.txt
|- test5.txt
|- test6.txt
|- test7.txt
|- test8.txt
|- test9.txt
|- test10.txt
|- unittest1.cpp
|- wordTest.vcxproj
|- wordTest.vcxproj.filters
|- wordTest.vcxproj.user
- 關鍵函數
//統計出現頻率最高的10個單詞
vector<pair<string, int> > Count::countTop10Word()
{
vector<pair<string, int> > wordVector;
for (map<string, int>::iterator it = wordMap.begin(); it != wordMap.end(); it++)
{
wordVector.push_back(make_pair(it->first, it->second));
}
for (int i = 0; i < int(wordMap.size()) && i < 10; i++)
{
auto maxFreWord = wordMap.begin();
for (auto it = wordMap.begin(); it != wordMap.end(); it++)
{
if (it->second > maxFreWord->second)
{
maxFreWord = it;
}
}
top10Word.push_back(make_pair(maxFreWord->first, maxFreWord->second));
maxFreWord->second = -1;
}
return top10Word;
}
- 流程圖見前面。
- 函數本身十分簡單,唯一需要取舍的就是排序算法。是遍歷10遍的時間復雜度 O(10n) 還是采用快排或其他時間復雜度為 O(nlogn) 的算法?兩者的區別在於單詞數的大小。如果單詞數 > 100,那麽前者更快。如果單詞數 < 100,那麽後者更快,但是單詞數這麽少的情況下對性能的提升是十分有限的。因此還是采用前者。
5. 性能分析與改進
5.1 性能分析
- 選擇一份文件運行 1000 遍,結果如下如圖,運行時間為 19.288 秒。
可以看出花費最多的函數是統計單詞個數。再看下去:
在這個函數中花費時間最多的是使用叠代器進行遍歷和判斷字符是否為字母或數字。
5.2 性能改進
isLetter()
和isDigit()
這兩個函數我只使用了最簡單的 if-else 進行判斷,耗時多的原因應該在於頻繁使用而不是算法效率低。- 使用叠代器進行遍歷則進行了改進。不使用叠代器而是使用使用下標進行遍歷。改進結果如圖:
時間節約了將近 4 秒,性能得到了些許提升。
6. 單元測試
總共設計了 10 個單元測試,具體如下:
測試內容 | 測試目的 | 預計輸出 |測試結果
---|---|---|---
無符合條件的字符 |能否識別正確的字符 |字符數為0 |通過
無有效行 |能否正確判斷是否包含非空白字符 |行數為0 |通過
無滿足定義的單詞 |能否正確判斷是否為單詞定義的字符串 |單詞數為0 |通過
隨機生成的文本內容|統計功能的函數是否正常|行數25,字符數1065,單詞數61 |通過
相同的單詞,但是大小寫不同 |能否判斷大小寫單詞|單詞數4 |通過
數字打頭的單詞,例如"12asda”|能否正確判斷是否為單詞定義的字符串|單詞數0 |修改代碼後通過
高頻單詞小於10個 |統計高頻單詞的函數是否考慮到單詞數不足10個|沒有發生vector越界 |通過
高頻單詞大於10個 |統計高頻單詞的函數是否正常|與人工識別的標準答案相同 |通過
文末無換行|是否會多讀一行|字符數4,行數1 |通過
文末有換行|是否少讀一行|3字符數5,行數2 |通過
- 以下是 測試相同的單詞,但是大小寫不同 的測試代碼
string readChar(string filename)
{
ifstream rf("D:\\Study\\SoftwareStudy\\WordCount\\wordTest\\"+filename);
string charBuf;
char c;
while ((c = rf.get()) != EOF)
{
charBuf += c;
}
return charBuf;
}
vector<string> readLines(string filename)
{
ifstream rf("D:\\Study\\SoftwareStudy\\WordCount\\wordTest\\" + filename);
string tempStr;
vector<string> lineBuf;
while (getline(rf, tempStr))
{
lineBuf.push_back(tempStr);
}
return lineBuf;
}
namespace wordTest
{
TEST_CLASS(UnitTest1)
{
public:
TEST_METHOD(TestMethod5)//測試大小寫單詞
{
Count count;
vector<string> linesBuf = readLines("test5.txt");
int wordCount = count.countWordNum(linesBuf);
vector<pair<string, int> > top10Word = count.countTop10Word();
vector<pair<string, int> > stdAns;
stdAns.push_back(make_pair("abcd",4));
for (int i = 0; i < int(stdAns.size()); i++)
{
Assert::AreEqual(stdAns[i].first, top10Word[i].first);
Assert::AreEqual(stdAns[i].second, top10Word[i].second);
}
}
};
}
測試文本為
abcd
Abcd
abcD
ABCD
7. 代碼覆蓋率
插件 OpenCppCoverage 使用參考了 tomvii 同學的博客 [1]。覆蓋率如下:
沒有被覆蓋的代碼是用於處理打開文件失敗的,而測試是給定了正確的文件名,因此沒有被執行。
8. 異常處理
之所以前面的單元測試沒有進行文件讀取的測試,就是在這裏進行“容錯性”設計。
文件名出錯,將打開默認的文件:input.txt
參數有錯,多或者少都會打開默認文件:input.txt
文件打開失敗,將打開默認文件:input.txt
9. 心得與體會
這次作業有太多第一次嘗試了,第一次用 VS,第一次將自己代碼頻繁提交GitHub,第一次對功能進行封裝,第一次對文件進行操作...所有的第一次都成了我寶貴的經驗!
從PSP表格可以看出大部分任務我都超時了,尤其是寫代碼那一部分。這是因為太久沒打代碼了,甚至忘記了 C++ 的輸入輸出語句是什麽。
在我看別人的博客的時候我發現了我的效率與別人相比十分的低。問題出在我使用了 map 容器,而底層的實現是十分低效的。也就是說封裝程度越高的容器你對它的性能越不能報太多期望。由於時間的問題,沒有時間進行大修改了,先把這個坑留在這裏,等有空了再回過頭來解決。
構建之法第三章第四節中提到的技能的反面是“Problem Solving”——“解決問題”。我十分贊同這句話。從這次的經歷來看我大部分寫代碼的時間都花費在“解決底層次問題”上,例如文件的讀取,map 容器的用法,vector 容器的用法...而這些問題都是因為我對 C++ 掌握程度太低導致的,就像構建之法裏面說的那樣
那怎麽提高技能呢?答案很簡單,通過不斷的練習,把那些低層次的問題都解決了,變成不用大腦的自動操作,然後才有時間和能力來解決較高層次的問題。
本次作業從技術角度上來說並沒有難度,也就是C++練習題的水平,主要考察的是規範化的軟件工程能力。這是需要投入多少時間都不夠的任務,只有有越多越好。但是由於我擔任學院學生會副主席一職,這次作業恰好趕上了迎新工作,導致我基本上只能在每天晚上十點多才能開始投入時間做作業,今天更是請了兩節課進行補作業。所以這次的完成度我自己十分不滿意。我想我要重新看一下棟哥推薦的《高效能的七個習慣》這本書,對我自己進行時間管理了!
10. 參考與感謝
[1] https://www.cnblogs.com/tomvii/p/9622508.html
[2] 暢暢醬的博客
[3] C++對文件進行讀寫操作
最後特別感謝 cbattle 同學對我的幫助,在我 Debug 的時候鼎力相助以及提供了大量測試用例。
2018軟工實踐第二次作業