這篇博客是一次課程作業。
| 項目 | 內容 | 
|---|---|
| 班級:北航2020春軟件工程 006班(羅傑、任健 周五) | 博客園班級博客 | 
| 作業:設計程序求幾何對象的交點集合,支持命令行和GUI | 結對項目作業 | 
| 個人課程目標 | 系統學習軟件工程,訓練軟件開發能力 | 
| 這個作業在哪個具體方面幫助我實現目標 | 體驗結對編程;學習Windows下動態鏈接庫的編譯與使用 | 
| 項目地址 | GitHub: clone/https | 
本作業涉及到的小組及其成員:
| 學號 | CnblogProfile | GitHubRepo | 
|---|---|---|
| 17231145 * (結對伙伴) | @FuturexGO | *Repository | 
| 17373331 * (本文作者) | @MisTariano | |
| 17231162 † (另一小組) | @PX_L | †Repository | 
| 17373321 † (另一小組) | @youzn99 | 
個人部分PSP與反思
| PSP2.1 | 預估耗時(分鍾) | 實際耗時(分鍾) | 
|---|---|---|
| Planning | 30(tot) | 20(tot) | 
| · Estimate | 30 | 20 | 
| Development | 860(tot) | 1230 (tot) | 
| · Analysis | 90 | 200 | 
| · Design Spec | 30 | 30 | 
| · Design Review | 60 | 60 | 
| · Coding Standard | 20 | 40 | 
| · Design | 120 | 60 | 
| · Coding | 360 | 720 | 
| · Code Review | 60 | 60 | 
| · Test | 120 | 60 | 
| Reporting | 90(tot) | 40(tot) | 
| · Test Report | 30 | 0 | 
| · Size Measurement | 30 | 10 | 
| · Postmortem & Process Improvement Plan | 30 | 30 | 
| In Total | 980 | 1290 | 
本次作業耗時超出預估,主要原因集中在對Windows下類庫管理與編譯流程的不熟悉上。在Linux上,一個.so共享庫可以非常簡單地被編譯使用,加之Linux下編譯器版本借助apt管理,環境變量往往比較清晰,因此借助cmake等管理工具很少出現編譯上的問題。但是windows下的編譯更為復雜,比如MinGW GNU及MSVC兩條編譯工具鏈間的異同、動態鏈接頭.dll.a的使用、PATH內不同編譯器間的沖突管理都是需要考慮的細節。在編譯Qt GUI的時候我就由於系統內兩套Qt類庫(新安裝的5.14與此前Anaconda自帶的5.9)間的版本兼容問題遲遲不能運行打包好的可執行文件。
此外,由於我和同伴對松耦合的期望較高,在接口設計上我們花了比預估更久的時間來打磨一份足夠好用的設計。總的來說,這次作業雖然時間超過了一開始的預估,但總歸沒有太多被浪費。
設計思想與方法
Information Hiding與Loose Coupling
好的設計總是高內聚而低耦合的。我們的設計思路正是遵循這樣的原則展開:
- 只暴露恰到好處的細節,隱藏用戶不需要也不應該關心的內部數據結構、算法流程、底層操作,避免程序被以錯誤的(我們不希望的或未預期的)方式調用,達到高內聚。雖然很殘忍,但我們必須假想用戶是魯莽而瘋狂的——他有可能多次初始化一個重要的內存池,或用電視遙控器把火箭發動機關閉。
 - 將整個計算模塊合理封裝,對外暴露多個功能單一的無狀態接口,實現接口與接口、接口與實現間的低耦合,當遇到新的接口格式/標准時可以方便地借助轉接器等設計模式通過組合現有接口實現接口擴展/接口遷移。歷史告訴我們,忠於開閉原則的人終將得到嘉賞。
 
這其實是一套老生常談的東西,能把哭泣的小孩煩到閉嘴。然而一旦做到了這兩點,我們就可以快速制定一套標准化的外層接口,並將程序接入——這個時候接口函數的聲明與定義就可以完全分離了——我們就可以借助動態鏈接庫提供代碼定義,使程序可以被熱更新。這就達成了作業要求的代碼松耦合。
Code Contract
契約式設計我們已經在大二的OO課程中有關JML的章節里初步接觸過了,簡單的說這是一種先建立接口抽象約束(契約),再依照契約實現接口並檢驗功能的開發流程。它十分類似於一種防御式編程、測試驅動開發及內聯文檔的結合體,對於瀑布式開發模型這種需求先得到完備設計再完成編碼的工作流,契約可以貫穿開發始終,確保所有需求都被恰到好處地執行。然而對於更快速的迭代模式(比如課程關注的敏捷開發),編寫契約將耗費大量精力而降低開發效率,與追求快速迭代適應需求、極小化文檔的宗旨矛盾。另一方面,契約語言(如JML)的編寫往往由需求提出方給出,這要求相關人員有一定的開發與專業基礎。加之工具鏈不成熟、相關工具開發難度較大等因素,如今並沒有得到廣泛應用。
對於JML工具鏈及契約編程的簡單介紹,我在大二時曾寫過一篇相關博客,感興趣可以移步查閱。
接口的具體設計與實現*
我們一開始按照自己的接口設計(私有接口,或customInterface)對核心模塊進行了封裝。
之后為了驗證松耦合,我們與另一組的同學@PX_L共同設計了在各自接口標准之上的新標准(公有接口,StdInterface)。
這樣我們都以新標准接口導出DLL可以方便地驗證GUI、命令行、測試模塊與核心模塊的松耦合,之前各自已經完成的對customInterface的單元測試也無需重構,只需在customInterface基礎上加殼即可。
我們設計的customInterface和StdInterface的主要特點為:
- 考慮到我們希望接口能具有較強的兼容性,我們使用C風格接口,不使用C++的面向對象、STL等特性。這樣今后稍微改動就可以被C、C++、Python等各種語言調用。
 - 動態內存管理由接口內部實現,外部調用者無需直接操作指針。
 - 數據被良好封裝,外部調用者無法更改和直接訪問數據,也無法知道數據的地址。
 - 支持多例,即支持一個GUI程序中開多畫布,每個畫布數據獨立。
 
用圖可以闡釋如下(為了理解方便,可能與代碼有出入,如變量名等):

C風格 私有接口 customInterface:弱耦合
我們首先定制的接口,將計算類的基本操作進行了簡單封裝。代碼請見src/interface.h:
#ifndef GEOMETRY_INTERFACE_H
#define GEOMETRY_INTERFACE_H
#include <unordered_set>
#include "Shapes.h"
#include "StdInterface.h"
// 數據管理實例類
struct gManager {
    std::vector<Geometry> *shapes;
    std::unordered_set<Point, hashCode_Point, equals_Point> *points;
    gPoint upperleft;
    gPoint lowerright;
};
// 初始一個數據管理實例
gManager *createManager();
// 關閉實例,釋放其占用的內存資源
void closeManager(gManager *inst);
// 清空實例中當前緩存的所有形狀和交點
void cleanManager(gManager *inst);
// 向實例中添加形狀
ERROR_INFO addShape(gManager *inst, char objType, int x1, int y1, int x2, int y2,
                    gPoint *buf, int *posBuf);
// 根據文件輸入向實例中批量添加形狀
ERROR_INFO addShapesBatch(gManager *inst, FILE *inputFile, gPoint *buf, int *posBuf);
// 獲得當前交點總數
int getIntersectionsCount(gManager *inst);
// 獲得當前實例中交點綜述
int getGeometricShapesCount(gManager *inst);
// 獲得當前所有交點,寫入buf為首地址的連續內存中
void getIntersections(gManager *inst, gPoint *buf);
// 獲得當前所有圖形,寫入buf為首地址的連續內存中
void getGeometricShapes(gManager *inst, gShape *buf);
#endif //GEOMETRY_INTERFACE_H
 
        注意到這套接口提供了一個數據管理器gManager,允許外部程序創建、持有計算中需要持久化的數據區並控制內存釋放時機,這樣所有的計算操作都不需要再維護全局緩存數據,這使所有接口都是無狀態的,更易於解耦與使用。
這套封裝已經已經足以覆蓋對交點計算庫的使用需求,但注意到兩個問題:
- 由於追求泛用性使用C風格接口,以結構體的方式給出的
gManager中類型為Geometry的數據是可以被直接訪問到的,而這個類型實際上是std::variant<Line, Circle>(在src/Shapes.h中定義),用戶的程序可以以我們不希望的方式添加對這個類型數據的操作,產生對這個類的耦合。一旦其他實現中沒有提供這個類型,就不可能在不修改用戶代碼並重新編譯的前提下將用戶程序遷移至新的計算庫實現。因此,這里並沒有滿足信息隱藏的原則,且難以實現松耦合。 - 由於接口中用到了
Shapes.h定義的結構,Shapes.h被引入了用戶項目,對用戶不透明。因此用戶不僅可以調用interface.h中聲明的接口函數,還可以調用Shapes.h中聲明的各種計算操作——這也是不滿足信息隱藏原則的。實際上這還會導致Point.h的引用——用戶程序在編譯時必須同時包含這三個頭文件,提供的動態庫也必須完整實現這三個頭文件中聲明的函數,這使得程序借助松耦合取得的修改與擴展空間變得極小。 
因此為了實現松耦合,我們在與另一組協商后設計了下述公有接口標准,對這些接口的引用關系與數據定義進行了集中整理。
C風格 公有接口 StdInterface:松耦合
我們的公有接口標准保留了C風格接口和無狀態兩個設計點,在單個頭文件中給出了用戶程序需要關注與使用的全部數據結構定義與函數聲明。請見src/StdInterface.h:
// 錯誤代碼枚舉
enum ERROR_CODE {
    SUCCESS,
    WRONG_FORMAT,
    VALUE_OUT_OF_RANGE,
    INVALID_LINE, INVALID_CIRCLE,
    LINE_OVERLAP, CIRCLE_OVERLAP,
};
// 運行狀態與錯誤信息封裝
struct ERROR_INFO {
    ERROR_CODE code = SUCCESS;
    int lineNoStartedWithZero = -1;
    char messages[50] = "";
};
// 形狀結構
struct gShape {
    char type;
    int x1, y1, x2, y2;
};
// 交點結構
struct gPoint {
    double x;
    double y;
};
// 畫布結構,類似私有接口中的gManager,為用戶程序提供數據管理
struct gFigure {
    unsigned int figureId;
    gShape *shapes;  // only available after updateShapes()
    gPoint *points;  // only available after updatePoints()
    gPoint upperleft;
    gPoint lowerright;
};
// 創建畫布
gFigure *addFigure();
// 釋放畫布資源
void deleteFigure(gFigure *fig);
// 清空畫布中的形狀與交點
void cleanFigure(gFigure *fig);
// 將形狀添加至畫布
ERROR_INFO addShapeToFigure(gFigure *fig, gShape obj);
// 將字符串desc中描述的形狀添加至畫布
ERROR_INFO addShapeToFigureString(gFigure *fig, const char *desc);
// 讀取文件名為filename的文件並將其描述的圖形批量導入畫布
ERROR_INFO addShapesToFigureFile(gFigure *fig, const char *filename);
// 從標准輸入流中讀取形狀並加入畫布
ERROR_INFO addShapesToFigureStdin(gFigure *fig);
// 根據給定的形狀索引移除形狀
void removeShapeByIndex(gFigure *fig, unsigned int index);
// 獲取交點總數
int getPointsCount(const gFigure *fig);
// 獲取形狀總數
int getShapesCount(const gFigure *fig);
// 將修改形狀數據后新的交點數據同步到給定畫布數據區points字段中
void updatePoints(gFigure *fig);
// 將修改形狀數據后新的形狀數據同步到給定畫布數據區shapes字段中
void updateShapes(gFigure *fig);
 
        不難發現這份接口涵蓋的功能是上文中私有接口的超集。同時注意到在私有接口中存在的數據隱藏等問題,在這份接口定義中已經不存在了。
因此用戶程序在編譯時只需要包含這份頭文件,就可以加載任何實現了這套接口的動態庫並正常運行。
我們的松耦合實驗便基於此接口完成。具體流程是:在完成標准制定后,參與的兩組先協作完成這份頭文件的編寫,再分別將自己的私有接口組合以實現公有接口函數,最終分別編譯動態庫並進行互換實驗。
核心模塊內的設計——UML闡釋

核心模塊性能評估與改進*
性能評估與改進的工作主要由 @FuturexGO 完成,約用時3h。
由於我們繼承了個人作業的思路,使用std::unordered_set<>進行去重,因此一個值得關注的問題是:
花在計算交點上的時間多,還是花在去重交點上的時間多?
如何分別優化兩部分?
計算交點部分的優化
於是,在最初的版本,我們基於一個隨機生成的樣例對程序進行Profile,結果如下:

可以看出在該版本下,計算部分占用了30.6%的總時長,維護HashSet部分占用了13.2%+5.9%等時間,最后delete一個大數據結構占用了20.4%的時長。
對於我們的設計,一個通常的函數調用路徑可以看成(為了介紹簡便進行了再加工):
1. std::vector<Point> intersection(Shape1 &x, Shape2 &y);
<--> (std::visit 解析std::variant 和 Template Shape1&Shape2)
2. std::vector<Point> intersection(Line &x, Line &y);
<--> (進一步的邏輯)
3. bool checkPointLine(Line &l, Point &p);
   bool checkPointHalfline(Line &l, Point &p);
   bool checkPointSegment(Line &l, Point &p);
   bool checkHalflineOverlap(Line &x, Line &y);
   ...
 
        展開函數調用樹(此處未展示)我們發現,計算最密集的第三層(使用點積、叉積等計算幾何方法檢查點是否在給定范圍上、檢查是否重疊導致無窮多交點等)花費的時間並不占第一層的子樹下的絕對多數(\(<70\%\))。
於是我們想到,既然兩個圖形的交點至多為2個,其實並沒有必要使用std::vector<>作為返回值。並且上述的一些被頻繁調用的子邏輯函數可以內聯。
進一步,我們的思路為:
-  
避免拷貝。使用引用或指針將容器傳進參數里,作為“輸出參數”。但是
std::visit(visitor{}, arg1, arg2,...)所要求函數的參數必須都是std::variant類型,不支持其它類型,因此此處不適用。 -  
合理內聯。上述的幾個bool函數可以內聯,以減少反復更改調用棧的開銷。
 -  
減少使用大規模的STL容器,用靈活的小STL替換。此處使用
std::vector<>實在沒有必要。並且在之后的更新中我們加入了判斷兩個圖形相交是否合法(是否產生無窮多交點異常)的邏輯,因此需要將返回值除了交點外添加一個bool值。於是,我們使用了靈活的std::tuple<>將其重構成:typedef std::tuple<bool, int, Point, Point> point_container_t; point_container_t intersection(..., ...);其中第一個位置bool表示相交的合法性,第二個位置int表示有幾個交點,第三第四個位置表示交點。如果只有一個交點,則第四個位置的變量無意義;如果沒有交點,則兩個Point對象均無意義。
 
於是改進后,我們對同一組數據進行Profile,得到結果:

可以看出,保持closeManager()函數沒變,其占用率從之前的20.4%上升到了24.6%,說明整個程序主體部分的運行時間減少了\(1-20.4\%/24.6\%=17\%\),是一個很大的提升(我們管這個叫“delete測速法” 😃 )。
同時可以看出,現在計算部分的時間占用只是主體部分的1/3了,其中std::ordered_set::insert維護哈希表占的時間是計算部分的兩倍!
維護去重HashSet部分的優化
為了方便對比,讓我們先去除closeManager()部分(delete了幾個大容器):

可以看出,在某些數據上,維護集合的開銷占到了70%!於是,我們在重寫的hash函數和equals函數中加入計數,統計兩個函數分別被調用的次數,發現:
對於最終unique點為28,527,681個的數據,hashCode()被調用了28,527,710次左右,這意味着重復的點只有0.0001%!對於這樣的數據,hashset花了接近30秒去重了30個數據?並不是,我們發現std::unordered_map::rehash()方法占用了一定的時間,同時重載的equals()被調用了超過60,000,000次!
我們通過查找資料和猜想,產生了如下思路:
- hashset/hashmap內部是由若干個buckets(桶)組成。每當數據占滿了總bucket數的
max_load_factor比率,就會進行rehash()操作進行擴容。 - 在擴容中會重新排布數據點,可能發生迭代、沖突和判等。
 - 因此,既然我們已知總圖形數,我們可以根據圖形數估算總交點數,並提前rehash,開出空間留出適當的buckets數,使hashset在運行過程中盡可能少擴容。
 
因此我們在批量輸入模式(如命令行調用、標准輸入、GUI打開文件導入)下,一旦輸入了圖形數objCount,就進行如下操作:
points_set->rehash(int(objCount * objCount / 2.0 / 0.75) + 1);
 
        該語句的策略是,假設objCount個圖形會產生約C(objCount, 2)數量級的交點數。同時,我們希望能有75%左右的桶子占用率(這是個magic number,也有人使用70%,可能是經驗值)。
經過上述優化,再次進行Profile的結果為:

可以看出,在計算部分沒有變化的條件下,insert部分占用率從69.4%下降到了48.2%。事實上的總運行時間也縮短了5~10s。因此優化是絕對有效的。然而整個程序的主要時間開銷還是花在了unordered_set上:
它確實是\(O(1)\)的。最終的總復雜度為\(O(n^2)\cdot O(1)=O(n^2)\)的。
但經常它比先加到順序容器里、再排序、再去重的\(O(n^2\log n^2) = O(n^2\log n)\)還慢,慢得多。
以下是具體的最終版本的性能實驗數據(Apple Clang++,Release版本,文件讀入標准輸出,隨機數據):
| N | 實際運行時間(s) | 
|---|---|
| 2500 | 0.90 | 
| 5000 | 3.73 | 
| 10000 | 15.65 | 
| 20000 | >60 | 
核心模塊單元測試*
單元測試工作由 @FuturexGO 和 @MisTariano 共同貢獻完成。
由於VS自有的單元測試並不通用,我們仍然選擇了全平台支持、跨IDE支持的通用測試框架 GoogleTest,並將其集成到了各自prefer的IDE中。
由於本次作業中的基礎數據結構有理數類被取消和重構,因此我們對Shapes.h和Point.h中涉及到的函數和數據結構分別重構了單元測試。之后,我們又對核心模塊接口interface.h中的接口和異常分別構造了單元測試。四組單元測試取並集的總覆蓋率如下圖所示(interface.h未顯示覆蓋情況是因為其函數全部在對應的.cpp文件中,自己並沒有單獨的函數實現):

具體的測試代碼可見 GitHubRepo/test。這里選取其中一個測試直線/射線/線段共線情況的 例子 進行解讀:
待測試的函數為:
typedef std::tuple<bool, int, Point, Point> point_container_t;
point_container_t intersection(const Shape1 &a, const Shape2 &b);
 
        返回的(待測試的)信息為:是否觸發無窮交點異常、有幾個交點、交點坐標。這里我們的側重點是前兩個返回信息的正確性,后面坐標的正確性由其他的測試來保證。
-  
首先將直線/射線/線段都看作是線段。這樣兩條共線線段的重疊情況有(兩條線段的位置可互換是顯然的,這里不再贅述):
- “相離”。兩條線段不共點。
 - ”相切“。兩條線段共用一個點。
 - ”相交“。兩條線段有無窮多個公共點。假設一條線長一條線段短,則還有這些情況: 
            
- 部分重疊;
 - 短“內含”於長,左對齊;
 - 短“內含”於長,右對齊;
 - 長短相同,二者完全重疊。
 
 
因此體現在我們的代碼中,我們對這些線段-線段的位置重合關系組合進行了生成:
case_input_list_t getCases(const std::string &overlapType, const std::string &directionType) { // case 0: -------- ......... (divide) // case 1: --------........ (cat) // case 2: ------.-.-.-...... (overlap) case_input_list_t cases; if (overlapType == "divide") { pushCaseByDirection(cases, directionType, 1, 1, 2, 2, 3, 3, 4, 4); ... // 不同參數數量級的其他 meta-case,如參數小至-1e5,如參數大至1e5等 } else if (overlapType == "cat") { pushCaseByDirection(cases, directionType, 1, 1, 2, 2, 2, 2, 3, 3); ... // 每個case是上面meta-case的交換與變換 } else { //if (overlapType == "overlap") // normal case -----.-.-.-..... pushCaseByDirection(cases, directionType, 1, 1, 3, 3, 2, 2, 4, 4); ... // left aligned -.-.-.-.-.------ pushCaseByDirection(cases, directionType, 1, 1, 3, 3, 1, 1, 4, 4); ... // right aligned ------.-.-.-.-.- pushCaseByDirection(cases, directionType, 1, 1, 4, 4, 2, 2, 4, 4); ... // complete overlap -.-.-.-.-.-.-.-. pushCaseByDirection(cases, directionType, 1, 1, 4, 4, 1, 1, 4, 4); ... } return cases; }其中每個
pushCaseByDirection()又對下面的情況進行了組合考慮: -  
接着把上面所說的線段看成向量。這樣兩個共線向量的方向組合情況有:
- 同向,向x增加
 - 同向,向x減小
 - 異向,相向
 - 異向,向背
 
因此對於上述調用的每次
pushCaseByDirection(),我們都進行向量-向量方向關系級別的再組合:void pushCaseByDirection(case_input_list_t &container, const std::string &directionType, int x1, int y1, int x2, int y2, int x3, int y3, int x4, int y4) { // - sub case 00: -------> ........> (right) // - sub case 01: <------- <........ (left) // - sub case 02: -------> <........ (face) // - sub case 03: <------- ........> (back) if (directionType == "right") { container.push_back(case_input_t{x1, y1, x2, y2, x3, y3, x4, y4}); } else if (directionType == "left") { container.push_back(case_input_t{x2, y2, x1, y1, x4, y4, x3, y3}); } else if (directionType == "face") { container.push_back(case_input_t{x1, y1, x2, y2, x4, y4, x3, y3}); } else { // if (directionType == "back") container.push_back(case_input_t{x2, y2, x1, y1, x3, y3, x4, y4}); } } -  
最后將上面所說的向量擴展成直線/射線/線段的具體對象。這樣每個對象都有三種情況,因此可以兩兩組合。我們利用了宏和輔助函數對GoogleTest的單元測試函數進行了封裝,便於我們快速構造樣例:
// Macro functions #define TEST_LINE_OVERLAP_INTERSECTED(lineTypeA, lineTypeB, overlapType, directionType, expected, numIntersections) \ TEST(ExceptionTest, lineTypeA##_##lineTypeB##_##overlapType##_##directionType) { \ runCase((LineType::lineTypeA), (LineType::lineTypeB), #overlapType, #directionType, (expected), (numIntersections)); \ } #define TEST_LINE_OVERLAP(lineTypeA, lineTypeB, overlapType, directionType, expected) \ TEST_LINE_OVERLAP_INTERSECTED(lineTypeA, lineTypeB, overlapType, directionType, expected, 0) // case 0: -------- ......... (divide) TEST_LINE_OVERLAP(LINE, LINE, divide, left, false) TEST_LINE_OVERLAP(LINE, LINE, divide, right, false) TEST_LINE_OVERLAP(LINE, LINE, divide, face, false) TEST_LINE_OVERLAP(LINE, LINE, divide, back, false) ... // (LINE, HALF, SEG) x (LINE, HALF, SEG) --> 6 combinations TEST_LINE_OVERLAP(SEGMENT_LINE, SEGMENT_LINE, divide, left, true) TEST_LINE_OVERLAP(SEGMENT_LINE, SEGMENT_LINE, divide, right, true) TEST_LINE_OVERLAP(SEGMENT_LINE, SEGMENT_LINE, divide, face, true) TEST_LINE_OVERLAP(SEGMENT_LINE, SEGMENT_LINE, divide, back, true) // case 1: --------........ (cat) TEST_LINE_OVERLAP(LINE, LINE, cat, left, false) TEST_LINE_OVERLAP(LINE, LINE, cat, right, false) TEST_LINE_OVERLAP(LINE, LINE, cat, face, false) TEST_LINE_OVERLAP(LINE, LINE, cat, back, false) ... // (LINE, HALF, SEG) x (LINE, HALF, SEG) --> 6 combinations TEST_LINE_OVERLAP_INTERSECTED(SEGMENT_LINE, SEGMENT_LINE, cat, left, true, 1) TEST_LINE_OVERLAP_INTERSECTED(SEGMENT_LINE, SEGMENT_LINE, cat, right, true, 1) TEST_LINE_OVERLAP_INTERSECTED(SEGMENT_LINE, SEGMENT_LINE, cat, face, true, 1) TEST_LINE_OVERLAP_INTERSECTED(SEGMENT_LINE, SEGMENT_LINE, cat, back, true, 1) // case 2: ------.-.-.-...... (overlap) TEST_LINE_OVERLAP(LINE, LINE, overlap, left, false) TEST_LINE_OVERLAP(LINE, LINE, overlap, right, false) TEST_LINE_OVERLAP(LINE, LINE, overlap, face, false) TEST_LINE_OVERLAP(LINE, LINE, overlap, back, false) ...其中
TEST_LINE_OVERLAP的最后一位參數true/false表明該種情況是否合法。而TEST_LINE_OVERLAP_INTERSECTED的最后兩個參數不但檢查是否合法,還檢查了是否相交、交點個數。 
我們共生成並測試了149個單元測試例,涵蓋了100%的代碼行,並在每一次Git Commit前重新測試,以達到“回歸測試”的效果。
核心模塊代碼質量分析
第一次運行分析時警告信息較多,主要問題是使用了不安全的函數和寬窄類型間隱式轉換存在溢出風險:

(這里由於終端編碼集設定問題顯示亂碼,但由於定位到代碼后彈出的氣泡框動態提示沒有亂碼因此不影響理解)

針對這些問題做了如下修改:
- 使用帶有失敗標記及溢出檢查的安全函數替換不安全的函數。這里需要說明的是,對於vs建議使用的
fopen_s等函數,由於其不是標准C++實現,考慮到兼容性我們最終沒有采用,通過顯式添加編譯頭#define _CRT_SECURE_NO_WARNINGS關閉這些警告 - 為計算過程添加顯式類型轉換。如在計算中將整形先轉換為64位長整型再進行乘法運算。
 
全部修改后再次運行代碼質量分析,不再報錯誤和警告:

核心模塊異常處理*
異常處理部分由 @MisTariano 貢獻設計與大部分代碼, @FuturexGO 調整與重構。
為了使核心模塊具有高度的兼容性,我們決定不使用異常類、try-catch等寫法,而是采用了類似操作系統中的寫法,將異常錯誤碼和錯誤信息打包進結構體,在每個函數返回錯誤碼,數據結構如下:
enum ERROR_CODE {
    SUCCESS,
    WRONG_FORMAT,
    VALUE_OUT_OF_RANGE,
    INVALID_LINE, INVALID_CIRCLE,
    LINE_OVERLAP, CIRCLE_OVERLAP,
};
struct ERROR_INFO {
    ERROR_CODE code = SUCCESS;
    int lineNoStartedWithZero = -1;
    char messages[50] = "";
};
 
        其中每一類的錯誤具體設計如下(由於錯誤檢測和拋出是公有標准接口的一部分,因此與另一組的 @PX_L 共同設計完成):
-  
WRONG_FORMAT:輸入不符合文法的情況,如缺失行、缺失字段、形狀標識符不在C、L、R、S中、字段不是整數等。實現時,我們使用scanf/fscanf/sscanf("%s", word)每次取一個字符串token,再利用strtol嘗試將其轉換為整數。單元測試樣例為:TEST(ExceptionTest, InvalidInput1) { gManager *mng = createManager(); FILE *filein = fopen("../data/invalid_input1.txt", "r"); EXPECT_EQ(addShapesBatch(mng, filein, nullptr, nullptr).code, ERROR_CODE::WRONG_FORMAT); EXPECT_EQ(getIntersectionsCount(mng), 3); closeManager(mng); } // invalid_input1.txt: 5 L 0 1 1 1 L 1 0 1 1 C 0 0 1 LL 0 1 1 1 L 0 -1 1 -1首先嘗試從文件批量添加圖形,應該得到
WRONG_FORMAT的錯誤碼。截止到發生錯誤的前一刻,成功添加的三個圖形產生了三個交點。 -  
VALUE_OUT_OF_RANGE:圖形參數超過\((-100000, 100000)\)的限制(注意到圓半徑非正不屬於此異常)。測試例為:TEST(ExceptionTest, InvalidShape7) { gManager *mng = createManager(); FILE *filein = fopen("../data/invalid_shape7.txt", "r"); EXPECT_EQ(addShapesBatch(mng, filein, nullptr, nullptr).code, ERROR_CODE::VALUE_OUT_OF_RANGE); EXPECT_EQ(getIntersectionsCount(mng), 0); closeManager(mng); } // invalid_shape7.txt: 5 L 0 1 1 1 L 1 0 10000000 1 C 0 0 1 L 0 1 1 1 L 0 -1 1 -1首先嘗試從文件批量添加圖形,應該得到
VALUE_OUT_OF_RANGE的錯誤碼。截止到發生錯誤的前一刻,成功添加的三個圖形產生了零個交點。 -  
INVALID_LINE, INVALID_CIRCLE:不是一個線/圓圖形,如直線兩點重合、圓半徑非正數。測試例為:TEST(ExceptionTest, InvalidShape3) { gManager *mng = createManager(); FILE *filein = fopen("../data/invalid_shape3.txt", "r"); EXPECT_EQ(addShapesBatch(mng, filein, nullptr, nullptr).code, ERROR_CODE::INVALID_CIRCLE); EXPECT_EQ(getIntersectionsCount(mng), 1); closeManager(mng); } // invalid_shape3.txt: 5 L 0 1 1 1 L 1 0 0 1 C 0 0 0 L 0 1 1 1 L 0 -1 1 -1 TEST(ExceptionTest, InvalidShape4) { gManager *mng = createManager(); FILE *filein = fopen("../data/invalid_shape4.txt", "r"); EXPECT_EQ(addShapesBatch(mng, filein, nullptr, nullptr).code, ERROR_CODE::INVALID_LINE); EXPECT_EQ(getIntersectionsCount(mng), 0); closeManager(mng); } // invalid_shape4.txt: 5 L 0 1 1 1 L 1 0 1 0 C 0 0 1 L 0 1 1 1 L 0 -1 1 -1在第一例中,雖然第四個圖形與第一個圖形重合,但在此之前第三個圖形圓已經非法,應該得到
INVALID_CIRCLE。截止那時,成功添加的兩個圖形產生了一個交點。在第二例中,雖然第四個圖形與第一個圖形重合,但在此之前第二個圖形直線已經非法,應該得到
INVALID_LINe。截止那時,成功添加的一個圖形產生了零個交點。 -  
LINE_OVERLAP, CIRCLE_OVERLAP:線-線產生無窮多交點、圓-圓產生無窮多交點。測試例為:TEST(ExceptionTest, OverlapFromFile) { gManager *mng = createManager(); FILE *filein = fopen("../data/overlap_batch.txt", "r"); EXPECT_EQ(addShapesBatch(mng, filein, nullptr, nullptr).code, ERROR_CODE::LINE_OVERLAP); EXPECT_EQ(getIntersectionsCount(mng), 1); closeManager(mng); } // overlap_batch.txt: 3 L 0 0 1 1 L 0 0 -1 1 L 1 1 0 0在上例中,第三個圖形與第一個圖形重合,應該得到
LINE_OVERLAP。截止那時,成功添加的兩個圖形產生了一個交點。圓-圓重合的例子較為顯然,此處略。 
GUI模塊的具體設計*
由於追求跨平台能力,我們以Qt5為基礎搭建GUI,借助QCustomPlot類庫來繪制基本圖形。支持的功能包括:
- 批量導入圖形:瀏覽並讀取txt格式的文本文件,從文件中批量導入圖形描述,並支持基本的文件格式錯誤處理。
 - 添加單個圖形:在窗口右側工作區輸入參數並選擇類型后,點擊添加按鈕可以將單個圖形加入畫布。
 - 畫布選擇並刪除圖形:在畫布上單擊圖形使其被選中(顏色變藍),再按下
Delete鍵,即可將其從畫布刪去。若按住左Ctrl則可以復選多個圖形,並批量刪除。 - 從列表選擇並刪除圖形:在窗口右側工作區的圖形列表中選擇一個或多個圖形后,按下刪除按鈕,可以將選中的列表項對應的圖形刪去。
 - 交點求解:借助核心計算庫實現的交點求解。所有畫布上的圖形間一旦產生交點會立即被以黑色實心點標出。
 - 鼠標滾輪縮放畫布。用戶使用鼠標滾輪縮放以調整視野大小。
 
以刪除圖形功能為例,我們重載了keyPressEvent以響應鍵盤事件delete鍵:
void MainWindow::keyPressEvent(QKeyEvent * event) {
    if(event->key() == Qt::Key_Delete){				   	// detected "delete" key
        auto items = ui->plot->selectedItems();		// get selected QCustomPlotItem(s)
        for(auto& item: items){
            auto name = item->objectName();
            ui->plot->removeItem(item);						// remove shape from GUI figure
            auto shape = shapes.find(name);
            if(shape != shapes.end()) {						// remove shape from GUI list
                shapeListModel.removeRow(shape->index.row());
            }
            shapes.remove(name);
        }
        cleanFigure(gfig);												// STDINTERFACE: recalculate points
        for(const auto &shapeItem: shapes.values()) {
            auto &shape = shapeItem.gshape;
            addShapeToFigure(gfig, {shape.type, shape.x1, shape.y1, shape.x2, shape.y2});
        }
        if(getShapesCount(gfig) == 0) {
            nextGraphId = 0;
        }
        replotPoints();														// replot points fetched from STDINTERFACE
        ui->plot->replot();
    }
}
 
        檢測到刪除操作后,我們從QCP(QCustomPlot)框架中拿到待刪除QCPItem,從GUI程序中維護的數據結構中查詢到其屬性,並在GUI的畫布和列表中將其刪除。之后,我們調用核心模塊的接口以重新計算剩余形狀的交點,最后在replotPoints()中取出這些交點拷貝到GUI的存儲中,再進行重繪。
GUI與核心模塊的對接*
在Qt中負責實現響應事件功能的是信號-槽機制。每個繼承自QObject的對象sender都可以發射一個信號signal以表明其狀態被改變。槽是普通的C++成員函數,作為函數指針member被關聯,每個對象receiver都可以關聯它的成員函數作為槽。
將信號-槽關聯的函數原型為:
bool QObject::connect(const QObject *sender, const char *signal,
                      const QObject *receiver, const char *member);
 
        因此為實現功能,我們定義了如下這些槽函數作為對基本信號(如點擊按鈕、點擊鼠標、按下刪除鍵)的響應:
private slots:
    void on_actionOpen_triggered();
    void on_shapeTypeComboBox_currentIndexChanged(int);
    void on_addShapeButton_clicked();
    void on_plot_mouseWheel(QWheelEvent*);
    void on_plot_mouseMove(QMouseEvent*);
    void on_plot_mousePress(QMouseEvent*);
    void on_deleteButton_clicked();
 
        這些對信號的響應可以按UI的設計邏輯組合,再調用具體的行為函數,如畫圖、畫點、重繪等:
QCPAbstractItem *drawCircle(const QString &id, int x, int y, int r);
QCPAbstractItem *drawHalfLine(const QString &id, int x1, int y1, int x2, int y2);
QCPAbstractItem *drawSegmentLine(const QString &id, int x1, int y1, int x2, int y2);
QCPAbstractItem *drawLine(const QString &id, int x1, int y1, int x2, int y2);
QString plotShape(char type, int x1, int y1, int x2, int y2);
void drawPoint(double x, double y);
void replotPoints();
 
        在這些函數中,我們StdInterface.h中的接口函數被調用。
因此總結來看,接口函數被調用的路徑是:
- 槽函數響應事件;
 - 事件組合出的邏輯(類似狀態機)調用執行特定功能的函數;
 - 執行特定功能的函數可能調用接口函數以向核心模塊或從核心模塊同步數據更新。
 
同時,為了將QCP的對象QCPItem與核心模塊內的幾何圖形建立聯系,我們使用自定義數據結構和map將它們聯系起來,實現索引的功能:
struct shape_item_t {
    gShape gshape;					// STDINTERFACE
    QCPAbstractItem *item;
    QPersistentModelIndex index;
};
QMap<QString, shape_item_t> shapes;
 
        以導入文件為例,on_actionOpen_triggered()被按鈕觸發,決定導入文件。從QFileDialog得到文件名后,我們調用接口addShapesToFigureFile對核心模塊內的數據進行更新,同時也維護GUI內部的數據和圖形界面,保持核心模塊內外同步:
void MainWindow::on_actionOpen_triggered() {
    ...
    ERROR_INFO err = addShapesToFigureFile(gfig, fname.toStdString().c_str());
    if(err.code == ERROR_CODE::SUCCESS){		// 成功計算交點, 無異常
        updateShapes(gfig);
        int nShape = getShapesCount(gfig);
        for(int i = 0; i<nShape; ++i) {
            auto &shape = gfig->shapes[i];	// 取出gShape, 一個個繪制圖形
            QString id = plotShape(shape.type, shape.x1, shape.y1, shape.x2, shape.y2);
        }
        replotPoints();											// 取出gPoint, 批量繪制交點
    } else {
        cleanFigure(gfig);									// 發生異常, 取出異常提示信息串、異常行號等信息
        QMessageBox::warning(this, "批量導入失敗", err.messages+QString("\n At line ")+QString::number(err.lineNoStartedWithZero));
    }
    ...
}
 
        效果如下圖所示:


松耦合展示
我們與 @PX_L 小組進行了松耦合實驗。我們使用CMakeFile維護跨平台的工程結構與編譯鏈接選項。因此我們的松耦合實驗可以在多平台上進行:
- 我們在MacOS上進行了命令行程序調用核心模塊的實驗,涉及到互換的動態鏈接庫為
libgCore.dylib。 - 我們在MS Windows上進行了GUI程序調用核心模塊的實驗,涉及到互換的動態鏈接庫為
libgCore.dll和libgCore.lib。 
命令行程序接入兩個版本的核心模塊
我們構造了三組樣例進行測試,分別為:
same_ans.txt。兩組的程序在它上的輸出相同。different_ans.txt。兩組的程序在它上的輸出由於精度問題處理不同,答案不相同,相差1。exception.txt。兩組的程序在它上都能檢測異常,但異常提示信息不同。
測試的過程如下:
- 兩組各自編譯出自己的動態鏈接庫,分別命名為
xwl_libgCore.*(*為dll、lib、dylib等)和lpx_libgCore.*。 - 撰寫
main.cpp,並引用接口聲明文件 StdInterface.h。由於我們各自的customInterface不同,GUI和命令行程序的需求也不同,因此我們通過一次會議統一了標准接口,規則是兼容兩組的功能需求,選取一個“最小公倍數”對現有代碼進行增加,無需舍棄各自小組之前的設計。 - 使用鏈接命令,將名稱為
libgCore.*的庫文件鏈接進main.cpp的目標可執行文件中。 - 分別在
libgCore.*缺失、拷貝自xwl_libgCore.*、拷貝自lpx_libgCore.*的情況下運行可執行文件。過程中不重新編譯,不修改代碼。 
下面是測試結果:
期望相同輸出:

期望不同輸出:

期望檢測相同異常,但拋出各自的自定義異常信息:

可以看出:
- 可執行程序在缺失動態鏈接庫的條件下無法運行。
 - 可執行程序在給定兩個動態鏈接庫中的任意一個都能正常運行。
 - 直接更換動態庫能實現接入兩組實現的不同后端的需求,並獲得不同的輸出和性能表現。
 
因此證明我們成功實現了計算模塊gCore的松耦合。
在實驗中,起初我們各自編碼時認同的 StdInterface.h 有微小的差異,其中幾個函數的簽名不同:
// xwl, et al. 認同的接口
ERROR_INFO addShapeToFigureString(gFigure *fig, const char *desc);
// lpx, et al. 認同的接口
ERROR_INFO addShapeToFigureString(gFigure *fig, char *desc);
 
        這樣,編譯生成動態鏈接庫的過程是獨立的,不會出現錯誤。然而在撰寫main.cpp時應當#include "StdInterface.h",這樣的頭文件是唯一的,必須將標准統一才能成功運行,否則將會發現找不到所需的接口函數,因為上述兩個函數的函數簽名不同,本質上它們被編譯器認為是兩個不同的函數。
因此我們將頭文件統一,修改了各自的接口的代碼,之后便成功完成實驗。
GUI程序接入兩個版本的核心模塊
類似上文中CLI環境下的測試,我們在GUI中也測試了使用不同dll的運行效果。需要注意的是,對於正樣例而言,即使更換了實現GUI的行為差異也很難看出(交點之間過於密集時會難以分辨兩種實現的差別),因此在這里我們主要測試了負例,即程序在添加圖形遇到異常時觀察GUI輸出的錯誤信息。
- case 1:嘗試添加與現有形狀存在重合的形狀。此時我們已經添加了直線\(y=x\)並嘗試再次添加
 
當可執行文件同級目錄下提供我們實現的libgCore.dll時:

當可執行文件同級目錄下提供lpx & yzn小組實現的libgCore.dll時:

- case 2:當嘗試打開存在非法形狀類型描述的文件時
 
我們嘗試打開這份測試文件:
5
L 0 1 1 1
L 1 0 1 1
C 0 0 1
? 0 1 1 1
L 0 -1 1 -1
 
        注意到第五行(從0下標開始算的第4行)的形狀類型是?,這是錯誤的,因此期望報錯。
當可執行文件同級目錄下提供我們實現的libgCore.dll時:

當可執行文件同級目錄下提供lpx & yzn小組實現的libgCore.dll時:

- case 3:當嘗試打開不完整的文件時
 
我們嘗試打開這份測試文件:
5
L 0 1 1 0
L 1 0 1 1
 
        當可執行文件同級目錄下提供我們實現的libgCore.dll時:

當可執行文件同級目錄下提供lpx & yzn小組實現的libgCore.dll時:

- 小結
 
可以看到對相同的異常情況,在加載不同dll運行時GUI程序給出了不同的報錯信息——前后沒有經過重新編譯,而只是替換了dll文件而已。可見替換dll文件動態地更改了程序運行時的行為,起到了松耦合的效果。
結對過程*
我們主要使用騰訊會議實現結對編程的實時互動。騰訊會議的主要特點是可以實時共享屏幕同時通話,但單憑騰訊會議無法做到代碼的管理。

因此我們使用GitHub進行源代碼的管理。我們使用到的主要特色包括:
-  
分支 Branch。我們將工程分為若干個階段,每開始構建一個階段就新開出一個分支,完全debug、重構、測試后,才並入master分支。

 -  
Pull Request。我們在將完成的分支合並到master時使用Pull Request進行逐行級別的代碼復審和新增功能&潛在BUG記錄。作者A主導完成某Branch后,作者A就發起一個Pull Request,並指定另一作者B為Reviewer對代碼進行審查。

 -  
Releases&Tags。我們使用tags對commit進行分支無關的標記,進度統計更加明確,無需考慮分支的分離和合並;也讓代碼分享(如與其他人交流設計公有接口)更加簡單。
 
反思結對編程
這是我第一次體驗結對開發,是相當有趣的一次體驗。在和xwl同學結對編程的過程中,無論是我寫他看還是他看我寫,都和個人開發、傳統的兩人分工開發的感覺完全不一樣。首先是溝通不再有延后性,出現了任何問題我們可以第一時間進入討論,並且不需要任一方花費額外時間去理解問題(因為兩個人都看着代碼,出現問題時雙方都很清楚自己正在面對什么情況),並且一旦一方產生疑問另一方可以立即進行答復,這無疑提升了開發時的效率。此外,審閱者也可以及時發現編碼者的錯誤,這比起事后對全部代碼進行復審無疑是更高效的。
在設計接口、重構代碼等需要大量討論的工作中,這種即時性會最大程度展現。我們在制定公有接口時,采用的就是我寫、xwl看的模式,我的設計有任何缺陷他可以立即對着代碼指出(所謂趁熱打鐵),而我有一些難以簡單描述的代碼設計思路時也可以立即寫出來偽代碼向他展示,雙方的溝通因此變得高效起來。
當然,有些工作受結對影響不大。比如查閱Qt的GUI文檔時,實際上與自己一個人開發相似,因為檢索的過程不需要太多討論與溝通——如果結對開發時大部分時間都在做類似的工作,實際上效率反而會降低。所以我們后期的做法是分工學習與結對開發結合的形式,兩人各自完成調研、檢索、文檔學習、倉庫維護等工作,再結對解決比較關鍵的開發工作。個人認為這也混合的開發模式效果很好。
在本次開發中,我們依次完成了
- 交點計算作業對射線、線段的擴展
 - 高性能、高精度的交點計算程序優化
 - 異常格式處理
 - 動態庫封裝與接口設計
 - 與另一組制定松耦合標准並實踐松耦合
 - 一個基於交點庫的Qt GUI
 
總的來說,我個人是非常滿意的,能夠在短短兩周內完成如此多高質量的工作。這離不開隊友xwl的幫助,他在代碼優化、代碼測試、接口設計、對外交流(?)等環節承擔並完成了大量的工作,同時也攥寫了本次課程博客的大部分章節。在這里表示誠摯的感謝!
同時也感謝和我們一起開展松耦合實驗的兩位同學lpx和yzn,他們不僅和我們合作完成了任務,還和我們保持積極的技術交流,為我們提供了很多設計思路。
最后,感謝課程組設計的這次作業。馬上要進入團隊開發了,希望接下來的學習繼續如此酣暢淋漓。
