| 項目 | 內容 |
|---|---|
| 課程:北航-2020-春-軟件工程 | 博客園班級博客 |
| 要求:求交點個數 | 結對項目作業 |
| 班級:005 | Sample |
| GitHub地址 | intersect |
| 北航網盤地址 | SE結隊項目 |
1. PSP 表格記錄下你估計將在程序的各個模塊的開發上耗費的時間
| PSP2.1 | Personal Software Process Stages | 預估耗時(分鍾) | 實際耗時(分鍾) |
|---|---|---|---|
| Planning | 計划 | ||
| · Estimate | · 估計這個任務需要多少時間 | 10 | 10 |
| Development | 開發 | ||
| · Analysis | · 需求分析 (包括學習新技術) | 30 | 180 |
| · Design Spec | · 生成設計文檔 | 30 | 30 |
| · Design Review | · 設計復審 (和同事審核設計文檔) | 5 | 0 |
| · Coding Standard | · 代碼規范 (為目前的開發制定合適的規范) | 5 | 0 |
| · Design | · 具體設計 | 60 | 120 |
| · Coding | · 具體編碼 | 240 | 400 |
| · Code Review | · 代碼復審 | 60 | 0 |
| · Test | · 測試(自我測試,修改代碼,提交修改) | 120 | 240 |
| Reporting | 報告 | ||
| · Test Report | · 測試報告 | 60 | 120 |
| · Size Measurement | · 計算工作量 | 10 | 10 |
| · Postmortem & Process Improvement Plan | · 事后總結, 並提出過程改進計划 | 240 | 240 |
| 合計 | 870 | 1350 |
之所以實際耗時遠在估計耗時之上,是因為結隊雙方沒有充分交流,因為互相之間干擾強烈,最后變成了各做各的項目。最后實際上所有部分(包括計算模塊和UI模塊)都是一個人完成的。我只能說:通過live的遠程交流,真是太太太太不方便了!而結隊編程本身並未達到其應有的效果。
2. 接口設計
看教科書和其它資料中關於 Information Hiding,Interface Design,Loose Coupling 的章節,說明你們在結對編程中是如何利用這些方法對接口進行設計的。(5')
信息隱藏、接口設計、松耦合都是面向對象設計的重要方法,都是使程序設計時更接近日常認識,在大模塊之間關系中不用過於擔心細節,只需在模塊設計時下功夫。
信息隱藏:
- 在類中,定義的變量和方法可以再前面加上一個下划線"_"來標識,這是一個好的命名規范,可以避免無意中對私有成員進行賦值
- 類與類之間交換信息時,要交流私有變量時,要用事先設計好的方法來訪問,這樣如果我們在其它類里面調用另外一個類的私有變量,那么我們必須定義set和get方法
- 在實現代碼過程中,過度的靈活性反而會帶來錯誤率的提升,故我們可以使得類中的信息對外不可見
接口設計:
- 一個好的接口能夠提供給后面的程序設計一個良好的框架
- 在這次結隊項目里,Diagram作為一切圖形(包括Line, Circle)的父類(接口),而Line同時包括了線段和射線
- 我們通過Diagram能很快的調用其intersect、tostring方法,而不用關心具體是哪一個圖形實現的;這樣我們的軟件測試也變得更簡單了
松耦合:
- 這種類與類之間依賴性低的設計方法,使一個類與另外一個類仿佛隔開了,它們之間只是通過消息來聯系的,所以設計一類時,可以不用擔心破壞另外一個類。如Line和Circle類
- 當代碼有改動時,可以不用大規模的改動我們的代碼,我們只用定位於一個出問題的模塊,然后對其進行更改就好了,而且能做到不改變其它模塊的服務
- 在核心模塊中只有兩個函數add_diagram和sub_diagram,和一個全局變量point_set是可以直接調用的。故任何在核心模塊的錯誤,只在核心模塊去測試改正,而不用去動界面模塊的代碼
當然,面向接口應當適度使用,也為很多情況下,接口的實現是定死的,比如說,如果線型只有直線、線段、射線三種,都有兩個端點屬性,就不需要單獨創建Ray和Segment兩個類了,只需要在Line中添加一個type字段,否則顯得更累贅。“為了接口而寫接口”的做法是愚蠢的,應該是“為了需求而寫接口”。
3. 計算模塊接口的設計與實現過程
設計包括代碼如何組織,比如會有幾個類,幾個函數,他們之間關系如何,關鍵函數是否需要畫出流程圖?說明你的算法的關鍵(不必列出源代碼),以及獨到之處。(7')
計算模塊實現擴展射線與線段,添加/刪減圖形,計算交點,進行部分錯誤處理的核心功能。
-
首先為了存儲交點,建立了類
Dot,使用C++STL的set。因為C++的set采用紅黑樹生成,必須重載<及=,實現方法同SE_Work2_交點個數。又因為C++不支持double相等運算,必須自己寫equals方法。#define equals(a, b) (fabs((a) - (b)) < EPS) bool operator<(const Dot &p) const {return !equals(first, p.first) ? first < p.first : second < p.second;} bool operator==(const Dot &p) const {return equals(first, p.first) && equals(first, p.first);} -
保存四類不同的圖形,建立了三個類:
Diagram,Line,Circle,為了統一接口,我們的Diagram是所有圖形的統一接口。我們必須使父類為抽象類(使用virtual函數),Diagram *才能夠動態匹配到子類上。class Diagram { public: ... virtual ~Diagram() = default; virtual string tostring() = 0; void intersect(Diagram *diagram); }; -
本次作業擴展了線段及射線兩種圖形,為了實現射線及線段與其他圖形的交點,必須判斷交點是在兩點之間還是在射線之上。所以需要給Dot類設定兩個方法:
inline bool onray(Dot *s, Dot *t) { return (first - s->first) * (t->first - s->first) >= 0 && (second - s->second) * (t->second - s->second) >= 0; } inline bool onsegment(Dot *s, Dot *t) { return (first - s->first) * (first - t->first) <= 0 && (second - s->second) * (second - t->second) <= 0; }s和t分別對應射線或線段的起點和終點,通過以上方式可以判斷該點是否在該射線或線段上,而在
intersect方法中也只用加一句:void Line::intersect(Line *l) { try { Dot *d = intersect0(l); if (!has_dot(d) || !l->has_dot(d)) return; add_pair(this, l, d); } catch (exception e) {} } -
這是本次設計中最為精彩的地方,可以說核心模塊一半的工程量都在這里!
界面模塊:支持幾何對象的添加、刪除。
是的,添加容易,但是刪除一個幾何對象,難道不是需要從頭開始對其余每個對象重新計算一次嗎?如果已經有了上千個幾何對象,刪除一個對象都需要幾分鍾的時間!雖然這一需求在界面模塊,但是如果我不提供一個高效接口來刪除一個幾何對象,根本不可能實現這一需求!
最開始,我們希望每個圖形和每個點之間有一個對應關系。也就是建立
map,但是,如果有上萬個節點和上千個圖形,就意味着有上萬個map,而map的每一個value都是集合!在空間復雜度上是完全不能接受的。后來,我們想到,其實上節點和圖形之間實際上是一個巨大的稀疏矩陣。如果節點在圖形上意味着對應的位置為1,否則為0。實際上存儲這樣一個龐大的矩陣有更高效的方式——舞蹈鏈

舞蹈鏈是一種雙向循環十字鏈表。在如圖所示的樣例中:四個圖形(1圓2線段3直線4)有五個交點(\(I_1-I_5\)),圖形作為舞蹈鏈的列首,交點作為舞蹈鏈的行首。交點在圖形上則圖形節點的列鏈上和交點的行鏈上同時出現一個節點。
這種數據結構能夠清晰地看到某個節點是哪幾個圖形相交得來的,同時通過圖形,我們也可以非常便捷地找到對應的節點。同時對於舞蹈鏈的動態構建和變化也十分靈活。
然而,正如“舞蹈鏈是一種指針的舞蹈”所說,一旦出現處理不到為的地方,很容易出現空指針或者未定義的現象。雖然舞蹈鏈對於時間和空間的占用並不大,維護一個舞蹈鏈的復雜度還是很高的。
-
舞蹈鏈實現過程
Node結構:
由於是十字雙向鏈表,含有指向上下左右四個指針,同時含有
diagram和dot字段表示該節點對應的圖形和交點。除了
Head以外,其他Node分為三種,圖形對應的Node,交點對應的Node,和(圖形,交點)這種聯系對應的Node。如下所示為三種情況下的構造方法。class Node { public: Node *up; Node *down; Node *left; Node *right; Diagram *diagram; Dot *dot; Node() : diagram(nullptr), dot(nullptr), left(this), right(this), down(this), up(this) {} Node(Diagram *d) : Node() { diagram = d; } Node(Dot *d) : Node() { dot = d; } Node(Diagram *d1, Dot *d2) : Node() { diagram = d1; dot = d2; } };(圖形,交點)關系的構建:
在求出一個交點后,要分別構建(diagram1,diagram2,dot)對應Node節點,在構建之前,需要判斷是否已經有
(圖形,交點)關系。分為以下三步:void add_pair(Diagram *d1, Diagram *d2, Dot *dot) { Node *n = get_node(dot); // 1. 找到交點對應的節點 Node *d = n->right; bool valid1 = true, valid2 = true; while (d != n) { // 2. 對於兩個圖形是否已經存在該關系 if (d->diagram == d1) valid1 = false; if (d->diagram == d2) valid2 = false; d = d->right; } if (valid1) { // 3. 如果不存在則需要重新構建 Node *p = new Node(d1, dot); n->left_insert(p); get_node(d1)->up_insert(p); } ... }圖形的刪除:
在刪除一個圖形時,通過圖形的節點,對其所有的
(圖形,交點)關系判斷(如1),中間節點對應的交點只有少於兩個圖形則刪除該交點(如2)。void Node::invalify() { if (dot == nullptr) { // 1. 該節點是Diagram頭結點 Node *d = down; Node *dd = d->down; while (d != this) { d->invalify(); d = dd; dd = d->down; } } else { // 2. 該節點是中間結點 if ((right->diagram == nullptr && left->left == right) || (left->diagram == nullptr && right->right == left)) { left->remove(); right->remove(); } } remove(); }
4. 畫出 UML 圖顯示計算模塊部分各個實體之間的關系
閱讀有關 UML 的內容:https://en.wikipedia.org/wiki/Unified_Modeling_Language。(畫一個圖即可)。(2’)

5.計算模塊接口部分的性能改進
記錄在改進計算模塊性能上所花費的時間,描述你改進的思路,並展示一張性能分析圖(由VS 2015/2017的性能分析工具自動生成),並展示你程序中消耗最大的函數。(3')
| N | 時間(ms) |
|---|---|
| 200 | 16 |
| 400 | 71 |
| 600 | 188 |
| 800 | 334 |
| 1000 | 604 |
| 2000 | 3242 |
| 3000 | 6998 |
| 4000 | 14559 |
如表所示,本次核心模塊幾乎是上次功能耗時的兩倍。通過性能分析工具得到耗時函數如下:
最耗時的是Line的構造函數:因為在構造內部還進行了邊界點的計算,為了跟UI部分進行對接,需要計算直線或射線在(-10000,10000)邊界上的點來替代其端點。
Line::Line(int x0, int y0, int x1, int y1, char ty) {
// 轉換成一般式,並保證互質,同時a要非負,a為0,b要非負
double divider = gcd(gcd(abs(y1 - y0), abs(x0 - x1)), abs(x1 * y0 - x0 * y1));
if (equals(divider, 0)) handle_error("Line::Line\ttwo dots coincide!");
a = (y1 - y0) / divider;
b = (x0 - x1) / divider;
c = (x1 * y0 - x0 * y1) / divider;
if (a < 0 || (a == 0 && b < 0)) {
a = -a;
b = -b;
c = -c;
}
s = new Dot(x0, y0);
t = new Dot(x1, y1);
type = ty;
// 更新端點值,以便后續作圖
if (type == 'L') {
if (equals(b, 0)) {
s = new Dot(-c / a, -REIGN);
t = new Dot(-c / a, REIGN);
} else if (equals(a, 0)) {
s = new Dot(-REIGN, -c / b);
t = new Dot(REIGN, -c / b);
} else {
set<Dot> dot_stack;
if (INREIGN((-c + a * REIGN) / b)) dot_stack.insert(Dot(-REIGN, (-c + a * REIGN) / b));
if (INREIGN((-c + b * REIGN) / a)) dot_stack.insert(Dot((-c + b * REIGN) / a, -REIGN));
if (INREIGN((-c - a * REIGN) / b)) dot_stack.insert(Dot(REIGN, (-c - a * REIGN) / b));
if (INREIGN((-c - b * REIGN) / a)) dot_stack.insert(Dot((-c - b * REIGN) / a, REIGN));
auto it = dot_stack.begin();
s = new Dot((*it).first, (*it).second);
it++;
t = new Dot((*it).first, (*it).second);
}
} else if (type == 'R') {
if (equals(b, 0)) {
t->second = s->second < t->second ? REIGN : -REIGN;
} else if (equals(a, 0)) {
t->first = s->first < t->first ? REIGN : -REIGN;
} else {
Dot *dot = new Dot(-REIGN, (-c + a * REIGN) / b);
if (dot->onray(s, t)) {
t = dot;
return;
}
dot = new Dot((-c + b * REIGN) / a, -REIGN);
if (dot->onray(s, t)) {
t = dot;
return;
}
dot = new Dot(REIGN, (-c - a * REIGN) / b);
if (dot->onray(s, t)) {
t = dot;
return;
}
dot = new Dot((-c - b * REIGN) / a, REIGN);
if (dot->onray(s, t)) {
t = dot;
return;
}
}
}
}
6. 契約設計
看 Design by Contract,Code Contract 的內容:
- http://en.wikipedia.org/wiki/Design_by_contract
- http://msdn.microsoft.com/en-us/devlabs/dd491992.aspx
描述這些做法的優缺點,說明你是如何把它們融入結對作業中的。(5')
契約設計采取前置條件,后置條件和對象不變式的形式。實際上這種設計方式起源於合同,該“合同”定義:
- 供應商必須提供某種產品(義務),並有權期望客戶已支付其費用(利益)——前置條件
- 客戶必須支付費用(義務),並有權獲得產品(利益)——后置條件
- 雙方必須履行適用於所有合同的某些義務,例如法律和法規——不變式
優點:
- 跳過方法的實現,直接描述方法的功能
- 規范化的注釋,並且能夠被自動檢測正確性
- 定義詳細的函數接口,使用時不必再擔心函數具體實現流程,開發函數時也有明確的需求,不必擔心需求的變動
缺點:
- 部署自動化軟件進行檢測代價大而復雜
- 書寫規范化的JML代碼甚至比直接寫源代碼還要復雜
關於JML的實現,在面向對象課程中OO_Unit3_JML規格模式已經有所領教,本次作業主要是使用契約設計進行了接口設計。在UI模塊只需要計算模塊的兩個函數及一個map,計算模塊本身保證了其實現求交點、添加圖形、刪減圖形的功能正確性。
7. 計算模塊部分單元測試展示
展示出項目部分單元測試代碼,並說明測試的函數,構造測試數據的思路。並將單元測試得到的測試覆蓋率截圖,發表在博客中。
單元測試的設計主要在對於不同形狀的增添與刪減上。在原有基礎上增添一個圖形,或者刪減一個圖形一共8種情況分別進行了單元測試。測試的對象為 add_diagram和sub_diagram。

如圖所示,雖然最后總體覆蓋率為88.89%,但測試的樣例基本上已經覆蓋8種情況,由於時間的原因,沒有進行深入覆蓋。
8. 計算模塊部分異常處理說明
在博客中詳細介紹每種異常的設計目標。每種異常都要選擇一個單元測試樣例發布在博客中,並指明錯誤對應的場景。(5')
| 錯誤類型 | 輸入(其中一種) | 描述 | 輸出 |
|---|---|---|---|
| 線型圖形的重合 | 2 L 1 2 3 4 R 0 1 -1 0 |
線型圖形共線,有無數個交點 | add_diagram repeated lines or collinear lines |
| 圓的重合 | 2 C 0 0 1 C 0 0 1 |
- | add_diagram repeated circles |
| 線型圖形的輸入點重合 | 1 L 25 72 25 72 |
端點重合,不能確定 | Line::Line two dots coincide! |
| 文件無法打開 | intersect.exe | 無法讀取文件 | cannot open file: <name> |
| 輸入格式錯誤 | L 1 2 3 4 R 0 1 -1 0 |
缺少數量參數 | why not input a N? |
| 圖形類型未定義 | 1 A 25 72 25 23 |
未定義類型A | line <i> undefined type! |
| (UI)刪除未定義圖形 | - | 在UI界面內刪除某圖形,但該圖像不存在 | required diagram not found! |
| (cmd)不合要求的命令行參數 | intersect.exe in.txt | 在cmd界面沒有命令行參數選項 | please type right input! |
9. 界面模塊的詳細設計過程
在博客中詳細介紹界面模塊是如何設計的,並寫一些必要的代碼說明解釋實現過程。(5')
我使用了QT進行圖像繪制,QT基於C++開發,本身也是一門很復雜的編程軟件,光是學習QT的使用方法,就花了整整半天,可以說本次作業量實在是太大了,並且有問題的是:QT的dll文件與VS不兼容!需要在QT中重新封裝模塊。基於QWidget組件進行坐標系及圖形繪制,UI模塊需要支持的功能:
-
拖拽文件進入界面作為輸入
在
Widget類中定義相應函數實現文件拖拽進行輸入///判斷是否為有效的文件 virtual bool IsValidDragFile(QDropEvent *e); ///接受目錄 /// @note 遍例目錄,調用AcceptFile virtual void AcceptFolder(QString folder); ///接受文件 virtual void AcceptFile(QString pathfile);在
AcceptFile中進行詳細的輸入定義及錯誤處理:void Widget::AcceptFile(QString pathfile) { ifstream file; cout<<"reading " << pathfile.toStdString()<<endl; file.open(pathfile.toStdString()); if(!file) handle_error("cannot open file: "+pathfile.toStdString()); char s; int num, x0, y0, x1, y1; try{ file>>num; } catch(exception()) { handle_error("why not input a N?"); } for (int i = 0; i < num; i++) { if (file >> s) { if (s == 'L' || s == 'R' || s == 'S') { if (file >> x0 >> y0 >> x1 >> y1) add_diagram(s, x0, y0, x1, y1); } else if (s == 'C') { if (file >> x0 >> y0 >> x1) add_diagram(s, x0, y0, x1, 0); } else { handle_error("line " + DoubleToString(i + 1) + " format error"); } } else { handle_error("need more lines"); } } } -
在文字框中輸入,可以使用“添加圖像”或“刪減圖像”
定義槽,並設計UI界面:
private slots: void on_add_diagram_clicked(); void on_sub_diagram_clicked();
實現相應的槽函數:
void Widget::on_add_diagram_clicked() { stringstream streambuf(ui->input->text().toStdString()); char s; int x0, y0, x1, y1; if (streambuf >> s) { if (s == 'L' || s == 'R' || s == 'S') { if (streambuf >> x0 >> y0 >> x1 >> y1) { add_diagram(s, x0, y0, x1, y1); return; } } else if (s == 'C') { if (streambuf >> x0 >> y0 >> x1) { add_diagram(s, x0, y0, x1, 0); return; } } } handle_error("input format error"); } -
繪制圓、線型、點,並顯示出所有交點的個數
在
paintEvent()函數中實現刷新繪制功能,該函數每幀調用一次,能實現窗口視圖的實時刷新:void Widget::paintEvent(QPaintEvent *event) { ... QPainter painter2(&image); QRectF rec(DisplayPtoObjectP(rect_topl), DisplayPtoObjectP(rect_bottomr)); // cout<<"repaint! "<<circles.size()<<" "<<lines.size()<<endl; for(auto &it:circles) { drawCircle(it.x, it.y, it.r, &painter2); } for (auto &it:lines) { drawLine(it.s->first, it.s->second, it.t->first, it.t->second, &painter2); } for (auto &it:point_map) { drawPoint(it.first.first, it.first.second, &painter2); } ui->textBrowser->clear(); ui->textBrowser->append(QString::number(point_map.size())); ... painter.drawImage(paint_org, image); }由於繪圖坐標系(相對)與
QWidget坐標系(絕對)之間存在轉換關系,故必須對繪制的圖形和點進行坐標系變換,同時因為點在屏幕上現實過小,必須在點周圍畫一個小圓來強調,這種圓不會隨着圖像的縮放而變動:QPointF Widget::ValuePtoObjectP(QPointF valPoint) { return DisplayPtoObjectP(QPointF(valPoint.rx() * pixel_per_mm + offsetv_x, valPoint.ry() * pixel_per_mm + offsetv_y)); } void Widget::drawLine(double x1, double y1, double x2, double y2, QPainter* painter) { painter->drawLine(ValuePtoObjectP(QPointF(x1, y1)), ValuePtoObjectP(QPointF(x2, y2))); } void Widget::drawCircle(double x, double y,double r, QPainter* painter){ painter->drawEllipse(ValuePtoObjectP(QPointF(x, y)), r * pixel_per_mm, r * pixel_per_mm); } void Widget::drawPoint(double x, double y, QPainter* painter){ painter->drawPoint(ValuePtoObjectP(QPointF(x,y))); painter->drawEllipse(ValuePtoObjectP(QPointF(x, y)), 3, 3); } -
標出坐標系及相應刻度,並且能進行縮放,平移
這方面較為復雜,要實現以下函數,在此略:
QPointF scaleIn(QPointF pos_before, QPointF scale_center, double scale_value); QPointF scaleOut(QPointF pos_before, QPointF scale_center, double scale_value); void paintEvent(QPaintEvent *event); void wheelEvent(QWheelEvent *event); void mousePressEvent(QMouseEvent *event); void mouseMoveEvent(QMouseEvent *event);
10. 界面模塊與計算模塊的對接
詳細地描述 UI 模塊的設計與兩個模塊的對接,並在博客中截圖實現的功能。(4')
接口設計

計算模塊封裝成dll文件,其中頭文件有以上全局變量和函數。有circles和lines兩個集合是為了繪制圖形,有point_map是為了繪制交點。調用add_diagram及sub_diagram即可進行圖像的增加和刪除。
- 在
void Widget::paintEvent(QPaintEvent *event)中調用了point_map,circles,lines進行繪圖 - 在
on_add_diagram_clicked()中調用了add_diagram加入圖形 - 在
on_sub_diagram_clicked()中調用了sub_diagram刪去圖形
實現功能

注:以上窗口中坐標系可通過縮放及平移,並且我們可以通過拖拽.txt文件進行輸入。
11. 描述結對的過程
提供兩人在討論的結對圖像資料(比如 Live Share 的截圖)。關於如何遠程進行結對參見作業最后的注意事項。(1')

如圖是使用了騰訊會議的桌面共享功能和QQ交流的截圖。
12. 結隊編程優缺點
看教科書和其它參考書,網站中關於結對編程的章節。例如:http://www.cnblogs.com/xinz/archive/2011/08/07/2130332.html ,說明結對編程的優點和缺點。同時描述結對的每一個人的優點和缺點在哪里(要列出至少三個優點和一個缺點)。(5')
| 結隊編程 | 我 | 結隊伙伴 | |
|---|---|---|---|
| 優點 | 1.兩個人考慮問題的方式會比一個人更全面;2.有監督效果使得編程不會那么放松,更能集中注意力;3.經過雙人復審,有效減少bug數 | 代碼熟練;執行力快;擅長學習新知識 | 心細踏實;能很快找到軟件bug;思考全面 |
| 缺點 | 監督編程可能會干擾到對方,雙方的代碼風格及習慣可能不兼容,磨合期不能成功渡過就無法完成項目 | 輕視軟件測試部分 | 代碼書寫速度較慢 |
13. 結隊模塊交換
由於和對方團隊(15061025、 17373263 )提前商量好了接口,因此模塊的替換較為容易,基本無需更改。
但是由於對方沒有計算直線與邊界的端點,我們繪制的圖像只能按照線段的方式來繪制。如下圖所示:

雖然繪制出來還是線段,點都標明的很清楚。但經過與對方小組的討論,我們發現有QT的第三方庫支持直線的繪制,不像我傻傻地去計算直線與邊界的交點。

基本上這次模塊交換非常便捷,我們時限就定義好了接口。只需要根據對方的定義的改一下名稱和習慣,導入對應的庫,就能很快的生成:
他們的接口:

我們的接口:

