SE_Work3_结队项目
项目 | 内容 |
---|---|
课程:北航-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的第三方库支持直线的绘制,不像我傻傻地去计算直线与边界的交点。
基本上这次模块交换非常便捷,我们时限就定义好了接口。只需要根据对方的定义的改一下名称和习惯,导入对应的库,就能很快的生成:
他们的接口:
我们的接口: