【BUAA软工】结对编程作业
项目 | 内容 |
---|---|
课程:2020春季软件工程课程博客作业(罗杰,任健) | 博客园班级链接 |
作业:BUAA软件工程结对编程项目作业 | 作业要求 |
课程目标 | 学习大规模软件开发的技巧与方法,锻炼开发能力 |
作业目标 | 完成结对编程项目 |
教学班 | 周五上午006班 |
项目GitHub地址 | GitHub链接 |
结对伙伴博客地址 | 博客园链接 |
PSP
2.在开始实现程序之前,在下述 PSP 表格记录下你估计将在程序的各个模块的开发上耗费的时间。
- 在开始设计之前,进行了PSP规划如下
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | ||
Estimate | 估计这个任务需要多少时间 | 60 | 80 |
Development | 开发 | ||
Analysis | 需求分析 (包括学习新技术) | 300 | 560 |
Design Spec | 生成设计文档 | 60 | 85 |
Design Review | 设计复审 (和同事审核设计文档) | 30 | 46 |
Coding Standard | 代码规范 (为目前的开发制定合适的规范) | 5 | 10 |
Design | 具体设计 | 60 | 148 |
Coding | 具体编码 | 360 | 1373 |
Code Review | 代码复审 | 60 | 227 |
Test | 测试(自我测试,修改代码,提交修改) | 60 | 294 |
Reporting | 报告 | ||
Test Report | 测试报告 | 30 | 38 |
Size Measurement | 计算工作量 | 10 | 15 |
Postmortem & Process Improvement Plan | 事后总结, 并提出过程改进计划 | 30 | 63 |
合计 | 1065 | 2939 |
- 其中实际花费时间比预估时间要多得多的几个环节分别是:需求分析,具体编码,代码复审阶段。因为一开始项目设计思路是沿用上一次的有理数设计思路设计的,但是在进行测试是发现有理数在实现上存在一些困难(后续章节分析),因此重新进行了一次设计,改由使用双精度浮点数实现。因此这几个环节会比预估要多一倍
- 在测试环节,因为要做到高覆盖率测试,因此对于各个小函数基本都做了单元测试。
core模块设计
3.看教科书和其它资料中关于 Information Hiding,Interface Design,Loose Coupling 的章节,说明你们在结对编程中是如何利用这些方法对接口进行设计的。
信息隐藏是结构化设计与面向对象设计的基础。在结构化中函数的概念和面向对象的封装思想都来源于信息隐藏。信息隐藏能够防止对象内部一些局部的关键值,隐私值暴露给用户,一方面能够防止其他程序员的恶意篡改,也能避免出现不小心改到一些隐私变量值的情况,从而使类对象能够更安全顺利的完成程序员设计的函数。在隐蔽信息的同时,也需要暴露一些接口函数,让其他程序员能够使用到你设计的程序。
在本次作业的接口设计中也十分注意这一点。例如在设计core模块时,考虑到core模块仅需要对外提供相关的接口操作函数,而对于这些函数的具体实现,包括使用的数据结构,都是完全隐蔽的。例如对容器add相关几何对象,需要提供外部的Interface比如addLine
addCircle
这些函数,但是外部并不知道这些Line以什么形式保存在哪个数据结构中,这就是Information Hiding
class GeometryFactory{
private:
PointMap points; // 交点集合<无理数点,点存在于多少几何对象上>
LineMap lines; // <k&b, <ID, Lines>>
CircleSet circles; // <Circles>
IdLineMap line_ids; // <ID, Line>
IdCircleMap circle_ids; // <ID, Circle>
int line_counter = 1; // Line ID 累加器
int circle_counter = 0; // Circle ID 累加器
void line_line_intersect(Line &l1, Line &l2); // 线线交点
void line_circle_intersect(Line &l1, Circle &c1); // 线圆交点
void circle_circle_intersect(Circle &c1, Circle &c2); // 圆圆交点
inline void increase_point(Point* p); // ..
inline void decrease_point(Point* p); // ..
void removeLine(Line &l); // 移除Line对象
void removeCircle(Circle &c); // 移除Circle对象
inline bool point_on_line(Point *p, Line &l);
inline bool point_on_circle(Point *p, Circle &c);
inline bool point_in_line_range(double x, double y, Line &l);
public:
GeometryFactory();
/* Modification */
int addLine(int type, long long x1, long long x2, long long y1, long long y2);
int addCircle(long long x, long long y, long long r);
int addObjectFromFile(char* message);
void remove(int id);
/* Query */
Line getLine(int id);
Circle getCircle(int id);
void getPoints(double *px, double *py, int count);
int getPointsCount();
};
例如上述代码为本次core模块的容器类定义,其中对于内部函数,内部数据结构完全是private形式,并不对外开放,而public的函数就是接口对外提供的操作函数,这些操作函数足以体现出容器类的功能。并且调用者调用这些函数时只会对容器类内部变量做安全的操作,而不会特意破坏到内部数据结构。
关于Interface Design,接口的设计是为了让用户能够在不了解内部core的实现逻辑的前提下,仅通过阅读接口函数的声明,就能大概了解core的主要功能并且能够正确的操控core去实现自定义的功能。在我们最初的版本设计中,因为一开始为了能够以面向对象的思想去提供接口形式,因此接口都设计为各种类的形式。这样能够让用户在读到类名和类函数,就大概清楚core的功能。
Loose Coupling模块松耦合,讲究的是模块之间的功能是否能够明确的相互分离,并且又不会造成部分代码的冗余。例如本次作业中涉及到三种模块:计算模块,命令行解析模块,GUI模块。显然计算模块就应该专注于对几何对象的管理,交点的求解,是其他两个模块的基础。因此在计算模块中要提供对几何对象修改,求解的方法。而后面两个模块都是用户交互模块,他们需要拥有一个共同的功能:处理并解析用户提供的输入。如果这两个模块分别实现解析功能,这样势必会造成代码的冗余,而显然这部分代码是可以复用的。因此为了使复用方便,我们将字符串信息解析模块内嵌在计算模块中,当做向计算模块增加对象的一种方式。
4.计算模块接口的设计与实现过程。设计包括代码如何组织,比如会有几个类,几个函数,他们之间关系如何,关键函数是否需要画出流程图?说明你的算法的关键(不必列出源代码),以及独到之处。
本次作业的core模块框架大部分沿用上一次的设计。首先是对于类的设计,这次作业中需要实现对三种直线类集合对象(直线,线段,射线),以及附加题中的圆提供支持。因此考虑实现两种对数据进行封装的几何对象类:Line
类和Circle
类。此外还有因为GUI需要描绘所有交点,因此需要增加一个对交点信息进行封装的Point
类。
之后就是core模块最主要的类:GeometryFactory
容器类。容器类不仅要负责添加,存储几何对象,同时还要支持对交点的求解。因此对于容器类的设计参考了工厂模式,但并不是严格意义上的工厂模式。GeometryFactory
类拥有创造直线,圆的方法,即addLine
和addCircle
方法,用户只需要提供相关参数就可以向容器中添加几何对象。同时也能够求解交点,并生产的getPoints
方法。具体类图见下一个问题解答。
其中容器类几个主要函数介绍如下:
GeometryFactory类:
关于主要的数据结构如下:
unordered_map<Point*, int> points
:管理求解得到的所有交点,其中key是交点的指针,value是该交点的权重。对于权重为0时,将point移出集合(即交点不存在)。关于权重的计算方法见后续解释。map<int, Line> IdLineMap
:管理添加的直线,索引为id,值为直线map<int Circle> IdCircleMap
:管理添加的圆,索引为id,值为圆
关于该类的对外接口如下:
int addLine(int type, long long x1, long long x2, long long y1, long long y2)
:向容器中添加直线/射线/线段,并计算该直线/射线/线段和其他几何对象的交点,返回对象的id,执行具体步骤:- 查看添加该直线是否会造成异常(重合,范围等等)
- 遍历所有几何对象,求解和该直线的交点,并记录
- 返回id
int addCircle(int a, int b, int r)
:向容器中添加圆,并计算和其他几何对象的交点,返回对象的id- 查看添加该圆是否会造成异常(重合,范围等)
- 遍历所有几何对象,求解和该圆的交点并记录
- 返回id
int addObjectFromFile(char* message)
:通过字符串的形式向容器添加集合对象,该函数解析字符串并添加对象,能够抛出格式错误异常- 判断该字符串是否符合格式
- 解析字符串,并添加相应的圆和直线
- 返回id
Line getLine(int id)
:通过id从容器中获取几何对象Circle getCircle(int id)
:通过id从容器中获取几何对象getPoints(double *px, double *py, int count)
:获取所有的交点集合,其中px为x坐标数组,py为y坐标数组,count为点个数。int getPointsCount()
:获取交点的个数void remove(int id)
:删去几何对象,根据id判断为圆还是直线,再调用相应的removeLine或者removeCircle函数
关于该类的主要私有函数如下:
private void line_line_intersect(Line &l1, Line &l2)
:求解线线交点- 根据算法求解交点
*p
,算法后续解释 - 判断求解得到的交点是否在线上(是否在线段,射线的范围内)
- 若在,则对
points[p] += 1
,若p
不存在在points
中,则初始化points[p] = 1
- 根据算法求解交点
private void line_circle_intersect(Line &l1, Circle &c)
:求解线圆交点,管理逻辑同上private void circle_circle_intersect(Circle &c1, Circle &c2)
:求解圆圆交点,管理逻辑同上。private void removeLine(Line l)
:删除直线,执行步骤如下:- 遍历所有几何对象(除该直线外),求解交点
*p
- 令
points[p] -= 1
,若points[p] == 0
,则将该交点从集合中删除。
- 遍历所有几何对象(除该直线外),求解交点
private void removeCircle(Circle c)
:删除圆,执行步骤同上。
Line 类:
该类为直线封装类,没有特别的成员方法,仅是对数据做封装,具体内容如下:
long long a, b, c
:直线的参数,根据传入的点坐标计算得到,直线表示为ax + by + c = 0
long long x1, y1, x2, y2
:传入的点坐标参数long long x_min, x_max, y_min, y_max
:直线的范围,该参数主要用于判断直线范围间是否存在重合,其中用最大值100000代表无穷,即直线的x_min = y_min = -100000, x_max = y_max = 100000
。int type
:线的种类,表明是直线还是射线还是线段。Line(long long x1, long long y1, long long x2, long long y2, int type)
:构造函数,具体步骤如下:- 计算直线的
a, b, c
值 - 计算直线的斜率
k
和截距b
,若k
不存在则置k
为可能的最大值100000 - 计算线的范围:
x_min, x_max, y_min, y_max
,其中使用+-100000表示无穷
- 计算直线的
Circle 类:
该类为圆封装类,封装信息如下:
long long a, b, r
:圆的三个参数,代表圆心坐标,半径。
对于几个关键的算法如下解释:
线线交点求解算法:
线线交点的求解,考虑将直线化为ax + by + c = 0
的形式求解交点,其中a,b,c的求解公式如下:
- \(a = y_1 - y_2\)
- \(b = x_2 - x_1\)
- \(c = x_1 y_2 - x_2 y_1\)
根据求得的上述参数进行联立,可以得到两条直线的交点公式如下:
线圆交点求解算法:
线圆交点求解算法使用代数法,将直线公式的\(y\)换成\(x\)表示,代入圆公式中,求解一元二次方程来求解x坐标。其中需要考虑直线\(k\)存在和\(k\)不存在两种情况。
- 将\(ax + by + c = 0\)表示为\(y = \frac{-c - ax}{b}\)代入圆方程\((x - x_0)^2 + (y - y_0)^2 = r^2\),得到:
- 其中圆方程的\(\delta = B^2 - 4AC\),因此求得的交点为:
求解交点过程中,需要判断相切问题。由于代入法在计算到\(\delta\)步骤时,已经接近最后一步,误差较大,因此判断线圆相切时不应该使用\(\delta\)进行判断。因此考虑使用几何法来判断:
- 假设直线方程为\(ax + by + c = 0\),则圆心到直线的距离为\(distance = \frac{| ax_0 + by_0 + c |}{\sqrt{a^2 + b^2}}\)
- 若\(distance == r\),则代表相切,仅计算切点,若\(distance < r\),则代表相交,需要计算两个交点。
圆圆交点求解算法:
圆圆交点求解可以化为线圆交点来进行求解,其中根据定理:两圆方程作差可以得到圆相交弦方程。根据所求的直线方程将问题化为求线圆交点。但需要事先判断两圆是否相交或者相切。同理,相切的判定使用几何法判定。
- 由于圆有内切和外切两种相切情况,因此方法如下:
- 计算两个圆心间距平方\(distance^2 = (x_1 - x_2)^2 + (y_1 - y_2)^2\)
- 若\(distance^2 == (r_1 - r_2)^2\)或\(distance^2 == (r_1 + r_2)^2\),则说明两圆相切,仅计算切点即可
- 若\(distance^2 > (r_1 - r_2)^2\)且\(distance^2 < (r_1 + r_2)^2\),则说明两圆相交,计算相交弦和其中一个圆的两个交点即可。
关于点在线上的判定:
由于本次作业新增了线段和射线类,则说明根据上述方法求得的交点并不一定真正位于线上,有可能该点只是和线段或者射线位于同一条直线上,但是并不是位于线段或者射线范围内。因此需要在求完交点后进行范围判断
在GeometryFactory
类里我们实现了一个函数bool point_in_line_range(Line l, Point p)
,该函数来判断点是否处在线范围上。由于该函数仅被求解交点函数调用,因此传入的点参数必然是一个在同一条直线上的点,因此不用再带入方程验证,仅需要判断范围即可。因此函数具体步骤如下:
- 若为直线,则返回true
- 若为线段:
- 若
l.x1 != l.x2
,则返回min(l.x1, l.x2) <= p.x <= max(l.x1, l.x2)
- 若
l.x1 == l.x2
,则返回min(l.y1, l.y2) <= p.y <= max(l.y1, l.y2)
- 若
- 若为射线:
- 若
l.x1 < l.x2
,则返回p.x >= l.x1
- 若
l.x1 > l.x2
,则返回p.x <= l.x1
- 若
l.x1 == l.x2
,则使用l.y
来进行判断,逻辑一致。
- 若
关于直线重合的判定:
本次作业需要支持对线性对象是否存在重合进行判断。由于Line在构造的时候已经将线的范围进行了设定,因此关于线的重合判定采用以下算法:
- 对于k,b值相同的线,则显然位于同一直线上:
- 若
x_min != x_max
:如果max(l1.x_min, l2.x_min) < min(l1.x_max, l2.x_max)
,则存在重合 - 若
x_min == x_max
:如果max(l1.y_min, l2.y_min) < min(l1.y_max, l2.y_max)
,则存在重合
- 若
- 对于k,b值不同的线,则显然不可能重合
因此对于每一条新加的直线,需要和已有的和其k,b值相同的所有线进行重合判定。因此需要一定的数据结构对相同k,b值的线进行归类。
因此设计了class LineKey
,其中拥有两个成员变量k和b,对该对象重写hash函数和equal函数后,使用unordered_map<LineKey, Line> LineKeyMap
来对具有相同的kb值的直线进行管理。每一次添加直线时,从LineKeyMap
中查找相同kb值的线,来进行重合判定。
5.阅读有关 UML 的内容:https://en.wikipedia.org/wiki/Unified_Modeling_Language。画出 UML 图显示计算模块部分各个实体之间的关系(画一个图即可)。
Core模块性能改进
6.计算模块接口部分的性能改进。记录在改进计算模块性能上所花费的时间,描述你改进的思路,并展示一张性能分析图(由VS 2015/2017的性能分析工具自动生成),并展示你程序中消耗最大的函数。
对模块喂入一条具有600w级别的交点数据,进行30秒性能检测,结果如下:
观察发现,对于前几的函数是主函数里调用的大范围函数,因此占用率必然高。除去那几个函数后发现,increase_point
函数的占用率较高。查看详细报告后,发现其中的代码占用率如下:
发现到对于新增节点的函数竟然占用高达一半以上的占用率。分析得到大概有两点原因:
- 数据结构使用的是
unordered_map
,对于map初始化并没有给其预设多大的容量,因此中途当map容量到达极限时,需要对扩充新的容量,对map元素进行复制。而扩充新的容量时务必造成较大的时间花费。 points[point]
函数的执行效率可能不如points.insert()
函数的效率
为了查找具体原因,对这两点问题进行了相应的修改:
- 在对象初始化时进行map容量预设:
GeometryFactory::GeometryFactory() {
points.reserve(5000000);
points.rehash(5000000);
lines.reserve(500000);
lines.rehash(500000);
line_ids.reserve(500000);
line_ids.rehash(500000);
circles.reserve(500000);
circles.rehash(500000);
}
- 对
points[point]
函数修改为points.insert()
函数。
对上面两处修改分别进行实验,发现第二点并不是问题的原因,在进行第一点修改后,性能有了进一步的提升:
可以发现increase_point
函数的占用率大幅度下降,观察具体代码的占用率发现:
发现insert操作占用率下降了\(20%\),这说明其中的扩大容量,拷贝操作减少了,性能提升了。
7.看 Design by Contract,Code Contract 的内容:
http://en.wikipedia.org/wiki/Design_by_contract
http://msdn.microsoft.com/en-us/devlabs/dd491992.aspx
描述这些做法的优缺点,说明你是如何把它们融入结对作业中的。
Design by Contract指的是契约设计,具体定义可以描述为:服务方和客户方在编写程序前商定一套接口,服务方需要明确好提供给客户端什么样的条件,什么样的数据;客户端在接收到服务方的结果后需要计算出服务方需要的结果和数据。契约设计中有很重要的一点就是要严格遵守定义好的规范。
例如服务方提供给客户端的内容,可以是数据内容,数据格式,条件等。无论内容是什么,但必须严格遵守事先定义的规范,条件可以增加但不可以削减;客户端在接收到数据时需要对数据进行检查验证。检查的内容可以削减但是不可增强。同理,在客户端反馈给服务端的数据时,也应该遵守上述规范。
Code Contract是类似于一套代码检查工具,他可以根据契约设计提供的各种规范,形式化的定义成一套检查规则语言,之后对编写的代码进行规格检查。例如在上学期学习的OO课程中就曾经涉及到了一套代码规范检查语言:JML语言,他可以对Java语言的各种函数接口定义一套函数执行行为规范。可以定义函数的先验条件,函数执行的主要逻辑,函数吐出的结果的规范。并且JML的可读性非常高,容易被程序员看懂。此外还有各种开源工具,可以根据JML语言的定义,检查你的代码是否能够通过JML测试。例如上学期使用过的openJML工具,可以自动生成测试数据对程序进行检查。此外,在程序员能够读懂JML规范的前提下,也可以通过构建单元测试的方式进行测试。
契约设计自然有他的优点,他可以明确接口的要求,并且制定严格的先验后验条件,这样可以提高程序的安全性,不必担忧奇怪的输入数据带来的后果,因为所有的数据都要经过先验后验两次检查。此外接口的严格规范,就可以进一步提高程序的松耦合程度。例如A和B以契约设计的思想设计了一套接口,由于这套接口有了严格的规范,具有标准性和严格的行为约束,因此在C了解了这套接口后,也可以尝试去完成相应的前端或者后端程序,这样ABC写的程序就可以无忧无虑的进行对接。
当然契约设计也有一定的缺点。由于接口传输的数据具有严格的定义,那么在进行数据交流时必定要经过一系列的正确性验证。这些验证不仅在服务端进行一次,也要在客户端进行一次,势必造成检查的冗余,甚至可能影响效率。此外,由于契约设计严格的验证机制,用断言解决是最好的方式,但是有的语言并没有程序断言机制,这样程序在实现和测试上务必要再自行实现一套检查机制,务必造成代码的可读性降低。并且如果服务方和客户方想要对接口做一些修改,那很可能会牵一发而动全身。因此接口必须要保证在设计的时候完全没有一点差错。
在本次作业中在最后的松耦合测试上运用到了契约精神的思想,两个小组为了对接互相完成的程序,进行了一套标准化接口的设计(见附加题定义),并且约定好了接口函数的执行规范和返回数据规范。因此在实现的时候,各自需要严格遵守接口规范,但至于内部如何实现,完全由自己把握。因此在接口完成后,互相交换程序都可以非常好的接上各自的其他模块,成功达到松耦合目的。
Core模块单元测试
8.计算模块部分单元测试展示。展示出项目部分单元测试代码,并说明测试的函数,构造测试数据的思路。并将单元测试得到的测试覆盖率截图,发表在博客中。要求总体覆盖率到 90% 以上,否则单元测试部分视作无效。
对于单元测试的设计思路,主要考虑以下几点:
- 对于各个小函数的功能进行小范围的单元测试,测试其基本功能是否达到了预想的效果,如
point_in_line_range
函数line_line_intersect
函数等 - 在小函数功能测试完成后,对大函数进行单元测试,测试小函数的相互调用以及大函数的逻辑是否有问题,如测试
addLine
和addCircle
函数 - 使用几个大函数进行交叉测试,测试模块的基础功能,看大函数的交叉调用是否会造成逻辑上的失误。
对于小函数的单元测试,以point_in_line_range
和point_on_line
等为例:
TEST_METHOD(GeometryFactory_point_in_line_range)
{
GeometryFactory *test = new GeometryFactory();
Point *p = new Point(3.0, 4.0);
Line l1(3, 4, 5, 8, LIMITED_LINE);
Line l2(3, 4, -10, -33, SINGLE_INFINITE_LINE);
Line l3(-3, -4, -6, -8, SINGLE_INFINITE_LINE);
Line l4(0, 0, 1, 1, DOUBLE_INFINITE_LINE);
CHECK(test->point_in_line_range(3.0, 4.0, l1), true);
CHECK(test->point_in_line_range(3.0, 4.0, l2), true);
CHECK(test->point_in_line_range(3.0, 4.0, l3), false);
CHECK(test->point_in_line_range(3.0, 4.0, l4), true);
delete(test);
delete(p);
}
TEST_METHOD(GeometryFactory_point_on_line)
{
GeometryFactory *test = new GeometryFactory();
Point *p1 = new Point(2.999999999999999001, 3.0);
Point *p2 = new Point(3.000000000123, 3.0);
Point *p3 = new Point(4.0, -4.0);
Line l1(3, 3, 4, 4, DOUBLE_INFINITE_LINE);
CHECK(test->point_on_line(p1, l1), true);
CHECK(test->point_on_line(p2, l1), false);
CHECK(test->point_on_line(p3, l1), false);
delete p1;
delete p2;
delete p3;
delete test;
}
TEST_METHOD(GeometryFactory_point_on_circle)
{
GeometryFactory *test = new GeometryFactory();
Point *p1 = new Point(1.00000000000000003, 0.0);
Point *p2 = new Point(1.0000000000223, 1.0);
Point *p3 = new Point(1.0000000000223, 0.0);
Circle c(0, 0, 1);
CHECK(test->point_on_circle(p1, c), true);
CHECK(test->point_on_circle(p2, c), false);
delete test;
delete p1;
delete p2;
delete p3;
}
测试思路非常简单,构建点在边界,点在线上,点在线外的情况,调用point_in_line_range
,测试功能。后续的函数同理。
对于大函数的测试,主要测试返回值是否预想,对数据结构的改变是否是预想结果。例如调用addLine
函数后是否正常返回id,正常插入直线,是否求对了交点,如果有异常是否有抛出。
TEST_METHOD(add_line_test_1)
{
GeometryFactory test;
CHECK(1,test.addLine(DOUBLE_INFINITE_LINE, 0, 1, 0, 1));
CHECK(3, test.addLine(DOUBLE_INFINITE_LINE, 0, 2, 0, 4));
bool catch_flag = false;
try {
test.addLine(SINGLE_INFINITE_LINE, 0, -1, 0, -1);
}
catch (LineCoincidenceException &e) {
catch_flag = true;
CHECK("Error: this line has been coincident!", e.what());
}
CHECK(true, catch_flag);
}
TEST_METHOD(add_line_test_3)
{
GeometryFactory test;
CHECK(1, test.addLine(DOUBLE_INFINITE_LINE, 0, 1, 0, 1));
bool flag = false;
try {
test.addLine(DOUBLE_INFINITE_LINE, 0, 0, 1, 1);
}
catch (CoordinateCoincidenceException &e) {
CHECK("Error: coordinate coincident!", e.what());
flag = true;
}
CHECK(true, flag);
flag = false;
try {
test.addLine(DOUBLE_INFINITE_LINE, 100000, 0, 0, 0);
}
catch (CoordinateRangeException &e) {
CHECK("Error: coordinate is out of range!", e.what());
flag = true;
}
CHECK(true, flag);
}
对于大函数的相互调用测试,则先构建容器类对象,然后交叉调用addLine
和addCircle
,观察是否会抛出意料之外的异常,求得的结果是否正确:
TEST_METHOD(add_line_add_circle)
{
GeometryFactory test;
CHECK(1, test.addLine(DOUBLE_INFINITE_LINE, 0, 324, 0, 332));
CHECK(3, test.addLine(DOUBLE_INFINITE_LINE, 0, -3, 0, 322));
CHECK(0, test.addCircle(0, 0, 8));
CHECK(2, test.addCircle(0, 0, 7));
CHECK(5, test.addLine(DOUBLE_INFINITE_LINE, -32, -33, 32, 22));
}
TEST_METHOD(get_line_remove_line)
{
GeometryFactory test;
CHECK(1, test.addLine(DOUBLE_INFINITE_LINE, 0, 3, 0, 3));
CHECK(3, test.addLine(DOUBLE_INFINITE_LINE, 0, 43, 0, 23));
Line l = test.getLine(1);
CHECK((int)l.x1, 0);
CHECK((int)l.x2, 3);
CHECK((int)l.y1, 0);
CHECK((int)l.y2, 3);
CHECK((size_t)2, test.line_ids.size());
test.remove(1);
CHECK((size_t)1, test.line_ids.size());
bool flag = false;
try {
test.remove(33);
}
catch (ObjectNotFoundException &e) {
CHECK("Error: line not found or invalid id!", e.what());
flag = true;
}
CHECK(true, flag);
}
最终Core模块的各个文件单元测试覆盖率都打到了90%以上,具体如下(其中隐藏了非core模块的main函数文件)
Core模块异常处理设计
9.计算模块部分异常处理说明。在博客中详细介绍每种异常的设计目标。每种异常都要选择一个单元测试样例发布在博客中,并指明错误对应的场景。
异常类型的设计主要分为两种:有关几何对象的异常,无关几何对象的异常。其中有关几何对象的异常又可以分为:数据异常,直线类异常,圆类异常等。因此最终异常根据分类可以设计出以下情况:
- 有关几何对象异常
- 数据异常
CoordinateRangeException
:坐标超出范围异常,即输入值大于100000,在添加集合对象时检测并触发
- 直线类异常
LineCoincidenceException
:存在重合异常,即输入的直线和之前添加的直线有重合,在添加集合对象时检测并触发CoordinateCoincidenceException
:直线坐标重合异常,即构成直线的两点相同,在添加集合对象时检测并触发
- 圆类异常
CircleCoincidenceException
:圆重合异常,在添加几何对象时检测并触发NegativeRadiusException
:圆半径为负数异常,在添加几何对象时检测并触发。
- 数据异常
- 无关几何对象异常
ObjectNotFoundException
:几何对象不存在异常,即通过id找不到几何对象,在get函数和remove函数中触发WrongFormatException
:格式错误异常,即输入的字符串不符合要求的格式,在addObjectFromFile中触发。其中每一行要求的格式为:- 每行仅有一个几何对象描述
- 第一个字符描述集合对象类型,后续n个字符描述几何对象参数
- 对于每一种几何对象,参数不可多也不可少
- 不可有除描述用之外的其他字符,不可有多余的空格,即参数之间有且仅有一个空格,行末行首没有多余空格。
对于异常的单元测试,由于大部分异常基本都可以在add函数中检测并抛出,因此在add函数中对异常进行单元测试:
TEST_METHOD(add_line_test_1)
{
GeometryFactory test;
CHECK(1,test.addLine(DOUBLE_INFINITE_LINE, 0, 1, 0, 1));
CHECK(3, test.addLine(DOUBLE_INFINITE_LINE, 0, 2, 0, 4));
bool catch_flag = false;
try {
test.addLine(SINGLE_INFINITE_LINE, 0, -1, 0, -1);
}
catch (LineCoincidenceException &e) {
catch_flag = true;
CHECK("Error: this line has been coincident!", e.what());
}
CHECK(true, catch_flag);
}
TEST_METHOD(add_line_test_3)
{
GeometryFactory test;
CHECK(1, test.addLine(DOUBLE_INFINITE_LINE, 0, 1, 0, 1));
bool flag = false;
try {
test.addLine(DOUBLE_INFINITE_LINE, 0, 0, 1, 1);
}
catch (CoordinateCoincidenceException &e) {
CHECK("Error: coordinate coincident!", e.what());
flag = true;
}
CHECK(true, flag);
flag = false;
try {
test.addLine(DOUBLE_INFINITE_LINE, 100000, 0, 0, 0);
}
catch (CoordinateRangeException &e) {
CHECK("Error: coordinate is out of range!", e.what());
flag = true;
}
CHECK(true, flag);
}
TEST_METHOD(add_circle_test1)
{
GeometryFactory test;
CHECK(0, test.addCircle(0, 0, 3));
CHECK(2, test.addCircle(0, 0, 8));
bool flag = false;
try {
test.addCircle(0, 0, -4);
}
catch (NegativeRadiusException &e) {
flag = true;
CHECK("Error: radius of circle is illegal!", e.what());
}
CHECK(true, flag);
flag = false;
try {
test.addCircle(0, 0, 100000);
}
catch (CoordinateRangeException &e) {
flag = true;
CHECK("Error: coordinate is out of range!", e.what());
}
CHECK(true, flag);
flag = false;
try {
test.addCircle(0, 0, 3);
}
catch (CircleCoincidenceException &e) {
flag = true;
CHECK("Error: this circle has been added!", e.what());
}
CHECK(true, flag);
}
对于其他的例如get和remove触发的异常,在get和remove函数中进行单元测试:
TEST_METHOD(get_line_remove_line)
{
GeometryFactory test;
CHECK(1, test.addLine(DOUBLE_INFINITE_LINE, 0, 3, 0, 3));
CHECK(3, test.addLine(DOUBLE_INFINITE_LINE, 0, 43, 0, 23));
Line l = test.getLine(1);
CHECK((int)l.x1, 0);
CHECK((int)l.x2, 3);
CHECK((int)l.y1, 0);
CHECK((int)l.y2, 3);
CHECK((size_t)2, test.line_ids.size());
test.remove(1);
CHECK((size_t)1, test.line_ids.size());
bool flag = false;
try {
test.remove(33);
}
catch (ObjectNotFoundException &e) {
CHECK("Error: line not found or invalid id!", e.what());
flag = true;
}
CHECK(true, flag);
}
界面模块的设计(GUI)
10.界面模块的详细设计过程。在博客中详细介绍界面模块是如何设计的,并写一些必要的代码说明解释实现过程。
对于界面模块我们基于Qt-4.15.0
开发,并使用Qt
自带的QtCreator
进行界面的可视化设计,在所有的参考资料中,对我们的开发影响比较大的资料有以下两个;
- https://www.qcustomplot.com/index.php/introduction - 图形界面的绘制。
- https://www.bilibili.com/video/av29968785 - QtCreator的使用入门。
我们最终的GUI完成效果如下,其具有以下几方面的特点:
- 使用勾选框的可同时删除多个几何部件
- 自适应的图形界面显示
- 操作LOG日志:成功添加与失败添加、错误提示。
- 输入体验:不允许输入非法格式数据、提供
Line
型几何对象类型的选择。
界面设计
用户界面共分为两个部分,第一部分是主界面,第二部分是自适应画图的界面,两者都使用QMainWindow
类进行实现,但不同的是,前者我们使用了QCreator
自带的设计师功能进行了可视化的界面设计,而后者则是参考了一个名叫QCustomPlot
的第三方Qt
包人工进行书写的。
主窗口:
在Qt
的设计师中,拖动各种形式的部件进行窗口化设计,我们主要用到的有一下几种部件:
pushBotton
:按键,所有与用户交互所执行的功能都是通过clicked()
的槽函数延伸开的。SpinLine
:输入框,通过限制范围来方式非法输入。Text Editor
:编辑框,在本程序中用于显示交点数量的结果。label
: 界面上的一些文字。listview
:构建log框和object框的容器。
事件触发:我们所设计的界面中,所有的事件都是由click所触发的,因此对于每个按钮,仅需要右键点击后选择-转到槽-clicked()即可自动建立按钮与函数之间的槽连接,在所划定的函数中写对应的功能代码,即可进行运行。
获取文本:对于主窗口中的一些值的获取,在qmake和构建后,即可通过使用以下代码获取:
// 当点击“添加线类图形”后,使用一下代码获取线的类型和输入框中的内容。
type = ui->comboBox_line_type->currentText();
x1 = ui->spin_line_x1->text();
x2 = ui->spin_line_x2->text();
y1 = ui->spin_line_y1->text();
y2 = ui->spin_line_y2->text();
ListView与删除功能:
ListView
是一个大白框——容器,通过setModel()
的方法,我们可以将对应的实质部件加入其中,我们使用了QStandardItemModel
加入这个容器,以下是一些代码的节选片段。
// 主界面初始化时,将listView的model进行设置
item_model = new QStandardItemModel(0, 1);
ui->objectView->setModel(item_model);
// 当新增一个线型的部件到Object框里去时,使用的函数
void SuperWindow::add_to_item(int id, int x, int y, int r)
{
QStandardItem *item = new QStandardItem(QString("C\t%0\t%1\t%2").arg(x).arg(y).arg(r)); // 新建Item(一行)
item->setEditable(false); // 内容不可变
item->setCheckable(true); // 增加勾选框
item_model->appendRow(item);// 加入到ListView.ItemModel中
item_id[item] = id; // 建立Item与部件id之间的映射。
// report(log)
string message("[√] Add Circle C " + to_string(x) + " " +
to_string(y) + " " + to_string(r) + " successfully!");
report(message);
}
// 当点击删除按钮后,删除勾选的项目时
void SuperWindow::on_pushBotton_DeleteObject_clicked()
{
// 扫描是否勾选
foreach (QStandardItem *item, item_model->findItems("*", Qt::MatchWildcard)) {
// 该部件被勾选,需要被删除
if (item->checkState()) {
// delete item;
int id = item_id[item]; // 查询该行Item所对应的部件的id。
try {
this->core->remove(id);
// get name
string object = item->text().toStdString();
// remove plot graph
this->plot->remove_object(id);
item_model->removeRow(item->index().row());
report(ret);
} catch (exception e) {
QErrorMessage error(this);
error.showMessage(tr(e.what()));
}
}
}
}
绘图接口:
对于绘图部分,我们没有“白手起家”仅依靠Qt
的函数进行构建,而是参考了第三方的工具包QCustomPlot
进行开发,其利用cpp和h文件将Qt
的功能进一步封装实现,被封装在一个MainWindow
的类中,我们在使用时仅需要调用其show
函数即可进行展示。
同时,对于画图功能,我们还新增了自适应界面,由于边界是在变化,而且最大边界的范围不好预估,因此我们采取的是每次画图时,将所有的部件拿出来进行重新绘图。对于边界的划定,我们使用的一下几个内容进行确认:
- 所有交点的坐标。
- 对于线型几何对象时,所指定的两个点的坐标。
- 对于圆型几何对象,使用x-r, x+r, y-r, y+r四个参数进行确认。
边界通过取min(), max()
完成,并保证上述的点坐标均被包含在之内,而后我们在生成的边界框上画一条透明的对角线,运用槽函数即可实现边界的自适应显示。
// 使用槽函数进行自适应界面显示。
connect(ui->customPlot->xAxis, SIGNAL(rangeChanged(QCPRange)), ui->customPlot->xAxis2, SLOT(setRange(QCPRange)));
connect(ui->customPlot->yAxis, SIGNAL(rangeChanged(QCPRange)), ui->customPlot->yAxis2, SLOT(setRange(QCPRange)));
/*
* Plot transparent box to fit entire box.
*/
int MainWindow::plot_transparent_box()
{
cout << x_max << " " << y_max << " " << x_min << " " << y_min << endl;
ui->customPlot->graph(1)->setPen(QColor(0, 0, 0, 0));
ui->customPlot->graph(1)->setLineStyle(QCPGraph::lsNone);
QVector<double> vx, vy;
vx.push_back(x_max);vy.push_back(y_max);
vx.push_back(x_min);vy.push_back(y_min);
ui->customPlot->graph(1)->setData(vx, vy);
ui->customPlot->graph(1)->rescaleAxes();
ui->customPlot->replot();
return 0;
}
当点击显示按钮时,会执行以下四部分功能:
void SuperWindow::on_pushButton_show_clicked()
{
// 1 重设范围
plot->reset_scale();
// 1.1 用交点更新范围
int count = core->getPointsCount();
double* point_x = new double[u_int(count)];
double* point_y = new double[u_int(count)];
core->getPoints(point_x, point_y, core->getPointsCount());
this->plot->update_scale_by_intersects(point_x, point_y, core->getPointsCount());
// 1.2 用线和圆更新范围
for(auto it = plot->id_graph.begin(); it != plot->id_graph.end(); it++){
update_scale_by_object(...);
}
// 2适当放宽范围
plot->smooth_scale();
// 3.1 根据新的范围绘制几何部件
for(auto it = plot->id_graph.begin(); it != plot->id_graph.end(); it++){
plot(...);
}
// 3.2 绘制交点
plot->plot_intersects(point_x, point_y, count);
delete[] point_x;
delete[] point_y;
// 4. 绘画透明框,限制显示范围
plot->plot_transparent_box();
plot->show();
}
界面模块与计算模块的对接
11.界面模块与计算模块的对接。详细地描述 UI 模块的设计与两个模块的对接,并在博客中截图实现的功能。
对于gui
模块和core
模块之间的对接,是我们本次结对编程所遇到的一个困难,虽然结对编程以领航员—驾驶员的形式展开,同一时刻仅有一位编程人员,但在远程环境下,有关系统环境和编译环境的差异是无法避免的。整体来说,分为*.dll
、*.lib
文件的导出、QtCreator
导入外部库和程序的打包三步。
DLL与LIB文件的生成
首先,在VS2017上完成了核心计算模块的编写和测试后,我们在VS2017上新建了一个DLL动态链接库的项目,用于生成模块文件。
将原工程的代码与头文件迁入后,在*.h
头文件中外部可见的程序接口加入如下的函数签名,并进行项目的生成,在项目文件夹下即可看到lib
和dll
文件,其中,lib
文件作为外部库需要在界面模块的项目中引入,而dll
文件是作为可执行程序的链接文件。将生成文件与没有附加导出签名的头文件放在一起形成文件夹,即可进入在QtCreator
的IDE中导入改模块进行运行的过程。
在IDE中导入动态链接库
对于QtCreator
,其项目中提供了导入外部库的工具,在项目名上右键-添加库,选择外部库,而后选择库文件点击确认即可导入,在确认后,.pro
文件会显示项目组织变化的部分以让你确定保存(因为其自动化添加的关于外部库的内容可能是不正确的)。
在网络的教程中,也有手动地向.pro
文件加入项目组织的方法,代码如下。其中LIBS指定了*.lib
和*.dll
文件的位置,-l
参数和后面紧跟的文件名之间不能有空格,而INCLUDEPATH
和DEPENDPATH
则指定了外部库的依赖地址,有了这两句以后,便可以在原本的工程文件里通过#include
操作将接口包含进来进行调用。
unix|win32: LIBS += -L$$PWD/lib/ -lGeometryCore
INCLUDEPATH += $$PWD/lib
DEPENDPATH += $$PWD/lib
界面交互程序的导出
对于QtCreator
生成好的可执行程序,我们测试过其在系统环境变量中如果不加入相应Qmake
的bin
路径下是无法运行的,因此需要对文件进行打包,关于打包的具体操作流程可以参考windeployqt,即可将可执行程序所依赖的Qt
动态链接库文件进行批量的导出。
坑
之前说过,这部分是我们所要的较困难的一部分,其在于中间走过了很多弯路,在此做一些总结。
- 问题:链接库与界面工程的编译环境不同
- 表现:Undefined Reference for .....
QtCreator
默认使用MinGW编译器对文件进行编译,而外部链接库的文件则是用MSVC2017
生成,一种编译器所生成的外部库是无法作为其他编译器的项目调用的。
在生成时编译器所报告的错误并不明显,因此耗费的大量的时间查阅StackOverflow
的博客才得出问题。由于QtCreator-5.14.0
仅支持MSVC2017
的版本,而合作者的电脑装载的是MSVC2019
的版本,因此发现问题后还需要手动配置编译器,所幸虽然QtCreator
发出了版本不适配的警告,但MSVC2019
和MSVC2017
是兼容的,因此成功地解决了此问题。
- 问题:接口定义规范
- 表现:核心模块返回STL标准容器的函数在被界面模块调用时会出现崩溃。(例如vector
getPoints())
对于这点我们我们能够较快地定位到位置,并猜测可能是因为使用了较为高级的STL容器导致的问题,但是对于接口返回一些我们自定义的几何类,则没有出现该问题。
关于其原理我们至今不是特别清楚,但在后续的解耦合附加题中,为了解耦的有效性,我们使用了纯C接口的方式完成核心模块的调用。
-
问题:导入lib时.pro文件设置的问题
-
d后缀:在上文中的导入流程图第三张图中可以看到,存在一句为debug版本添加后缀-d,这一点要和核心模块导出的内容形式做到一致,对于没有d的则不要添加。
-
LIB路径:由于IDE自动检测路径,但并不一定正确,因此需要手工确认,否则
include
包含外部库的头文件。
结对过程描述
12 描述结对的过程,提供两人在讨论的结对图像资料(比如 Live Share 的截图)。关于如何远程进行结对参见作业最后的注意事项。
我们使用了腾讯会议和TIM进行结对编程,主要使用腾讯会议完成驾驶员和领航员的模式,当环境配置等问题出现时,使用TIM的远程控制进行。
此外我们也使用了TIM的远程桌面控制功能,在一人代码时,另一人也可以更好的协助修改。
13.看教科书和其它参考书,网站中关于结对编程的章节,例如:http://www.cnblogs.com/xinz/archive/2011/08/07/2130332.html ,说明结对编程的优点和缺点。同时描述结对的每一个人的优点和缺点在哪里(要列出至少三个优点和一个缺点)。
结对编程中,很重要的一点是需要明确结对两人的角色。在《构建之法》中提到,结对编程的两人中,一个为领航员,一位为驾驶员,将代码复审的效率尽可能的提高的百分百。当驾驶员在进行代码编写时,领航员需要时刻注意当前的进度,告知下一步做什么,当前这一步做的是否存在问题。领航员一定要时刻把握好进度,给予驾驶员最好的领航计划,否则在编程过程中很有可能会出现效率大大降低;而驾驶员需要时刻把握好方向盘,即以最高的效率编写代码,在一边编写代码的同时,也要听取领航员的建议及时作出适当的方向把控。
结对编程的优点显而易见,就是将代码复审的效率提升到百分之百,在一人写代码的时候,有另一人帮你检查是否写出了bug,并且还能告知下一步的方向,这在一定程度上降低了bug出现的概率,使得编写出来的程序具有更高的鲁棒性。
由于结对编程是需要两人一起写同一份代码,而不是两人分别写两份代码,这样在工作上可能会降低效率,没办法一人完成模块一的时候,另一人设计模块二,工作仅能一件一件的做,而不能并发的处理。
在本次结对编程作业中,进行自我总结,我感觉我本人有以下的优缺点:
- 优点:
- 编写代码的效率较高
- 单元测试时考虑到的条件比较细致
- 在算法设计方面考虑的比较细致
- 缺点:
- 学习新技术的能力较弱,写的代码鲁棒性不高
结对伙伴的优缺点有:
- 优点:
- 学习新技术能力较好
- 写的代码鲁棒性比较高
- 在设计过程比较规范,比较严格
- 缺点:
- 设计时考虑的问题太过于细致,容易超时
附加题:松耦合测试
合作小组同学学号:17373331, 17231145, github地址
在完成完前面工作后,进行松耦合测试时,由于接口没有事先商定,因此和另外一组同学商定了一个标准的接口来完成本次松耦合测试:
关于标准接口的设计,由于为了使更多人能够正常使用到接口程序,因此接口需要能够自适应各种版本的编译器。因为c++的标准比较多样化,有c++98和c++11等,这两个标准在一些特定的情况下会出现一些定义存在多重意思。另外编译器有MSVC,MinGW,Clang等等,这些编译器对不同版本的c++支持又有其自己独特的标准。因此为了使接口的鲁棒性更好,考虑设计成类C函数接口。
接口的具体定义如下:
#ifndef GEOMETRY_STDINTERFACE_H
#define GEOMETRY_STDINTERFACE_H
__declspec(dllexport) struct gPoint {
double x;
double y;
};
__declspec(dllexport) struct gShape {
char type;
int x1, y1, x2, y2;
};
__declspec(dllexport) struct gFigure {
unsigned int figureId = 0;
gPoint *points = 0; // only available after updatePoints()
gShape *shapes = 0; // only available after updateShapes()
gPoint upperleft = { -5, 5 };
gPoint lowerright = { 5, -5 };
};
__declspec(dllexport) enum ERROR_CODE {
SUCCESS,
WRONG_FORMAT,
VALUE_OUT_OF_RANGE,
INVALID_LINE, INVALID_CIRCLE,
LINE_OVERLAP, CIRCLE_OVERLAP,
};
__declspec(dllexport) struct ERROR_INFO {
ERROR_CODE code = SUCCESS;
int lineNoStartedWithZero = -1;
char messages[50] = "";
};
__declspec(dllexport) gFigure* addFigure();
__declspec(dllexport) void deleteFigure(gFigure *fig);
__declspec(dllexport) void cleanFigure(gFigure *fig);
__declspec(dllexport) ERROR_INFO addShapeToFigure(gFigure *fig, gShape obj);
__declspec(dllexport) ERROR_INFO addShapeToFigureString(gFigure *fig, const char *desc);
__declspec(dllexport) ERROR_INFO addShapesToFigureFile(gFigure *fig, const char *filename);
__declspec(dllexport) ERROR_INFO addShapesToFigureStdin(gFigure *fig);
__declspec(dllexport) void removeShapeByIndex(gFigure *fig, unsigned int index);
__declspec(dllexport) void updatePoints(gFigure *fig);
__declspec(dllexport) void updateShapes(gFigure *fig);
__declspec(dllexport) int getPointsCount(const gFigure *fig);
__declspec(dllexport) int getShapesCount(const gFigure *fig);
#endif //GEOMETRY_STDINTERFACE_H
其中标准接口使用gFigure
结构体来特定标识一个容器类对象,在后续对对象的各种操作中,都是基于对gFigure
结构体进行操作。此外接口还提供了两种封装结构gShape
和gPoint
,来表示特定的几何对象。
在定义新接口后,我们通过对老接口函数的特定操作,来完成新接口的各种要求,具体代码见github项目的dev-combine
分支
松耦合测试
由于两组同学使用的编译环境并不相同(我们使用windows+MSVC,另一组使用MacOS+MinGW),因此直接使用对方构建的dll库会出现程序不能加载dll的问题。因此需要从对方github仓库clone代码,然后进行dll生成。
使用对方的dll后,命令行程序运行结果(包括异常报告)
和我们的dll运行结果进行对比:
对比后发现,对于字符串异常处理,我们的模块处理比较粗糙,仅是进行WrongFormat报错,而对方的报错信息会告知,Line需要什么样的参数,这样方便用户检错;而对于异常种类的划分,对方模块仅进行了3中大类型异常信息返回,而我们模块的异常信息返回更加丰富一些。
对于性能测试,进行600w级别的点数据进行测试,结果如下:
可以发现,对方组的程序在600w级别的性能上比我们要好的多。具体细节了解到,对方组的数据结构比较简洁,并没有大规模的使用STL的各种容器,而我们在维护数据的时候太过于依赖STL的标准容器,导致在STL容器在维护过程会出现各种不必要的拷贝耗费时间。
此外,我们存储的点是使用new方法进行存储的,而new构造一般会比一般构造要耗费更多的时间,这也导致我们在性能上会更差一些。
同时,新的标准接口和我们旧的接口相比,我们的接口的安全性和可用性都比较低。首先我们的旧接口参数有STL的容器类,甚至传了引用参数,而不同环境下的这些实现都是不同的,因此在可用性上会稍差。并且旧接口在导出的时候必须要导出所有的内部类,因为之间具有依赖关系,这样就暴露了内部实现,安全性降低。因此在接口设计时,用类C接口会比较好。
GUI松耦合展示
使用我们的gCore模块搭建的dll的部分错误展示:
- 添加一条非法直线:
- 添加两条重合直线:
- 添加文件内容格式错误(存在不符合参数要求的直线
L 0 0 1 1 1
):
使用对方的gCore模块搭建的dll的部分错误展示:
- 添加一条非法直线:
- 添加两条重合直线:
- 添加文件内容格式错误(存在不符合参数要求的直线
L 0 0 1 1 1
):