交点求解大师——结对项目作业
一、作业要求简介
本文是北京航空航天大学计算机学院软件工程课程的结对项目作业,在本次作业中,两位同学一组,以结对编程的方式共同完成一项需求。
结对编程是软件工程中的一种开发方法,两个人肩并肩坐在一起,共用一块屏幕和一份键盘鼠标,共写一份代码。两个人有不同的分工,领航者负责指明方向,执行者负责动手写代码,在两人的默契配合下,形成一种无间隙的代码复审模式,使开发出的程序质量更高。
项目 | 内容 |
---|---|
本作业属于北航软件工程课程 | 博客园班级博客 |
作业要求请点击链接查看 | 结对项目作业 |
班级:006 | Sample |
GitHub项目地址 | IntersectProject |
GUI项目地址 | IntersectionGUI |
同组同学博客链接 | eitbar |
我在这门课程的目标是 | 获得成为一名软件工程师的能力 |
这个作业在哪个具体方面帮助我实现目标 | 实践结对编程 |
二、PSP表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 60 | 50 |
· Estimate | · 估计这个任务需要多少时间 | 60 | 50 |
Development | 开发 | 1800 | 1630 |
· Analysis | · 需求分析 (包括学习新技术) | 600 | 600 |
· Design Spec | · 生成设计文档 | 60 | 40 |
· Design Review | · 设计复审 (和同事审核设计文档) | 60 | 60 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 20 | 20 |
· Design | · 具体设计 | 60 | 50 |
· Coding | · 具体编码 | 600 | 500 |
· Code Review | · 代码复审 | 600(同时) | 500(同时) |
· Test | · 测试(自我测试,修改代码,提交修改) | 400 | 360 |
Reporting | 报告 | 300 | 360 |
· Test Report | · 测试报告 | 30 | 30 |
· Size Measurement | · 计算工作量 | 30 | 30 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 240 | 300 |
合计 | 2160 | 2040 |
三、接口思想与接口设计
(一)接口设计原则
- 单一指责:一个接口就干一个事
- 数据格式:输入输出定义清晰简单,不能产生歧义,最好为傻瓜式的,让调用者绝不会错用,有些经验的用户可以直接使用API而不需要阅读文档。
- 方便易用:包括命名清晰简洁,最小惊讶原则。
(二)接口实现
基于上述原则,我们设计了如下两个函数作为计算核心模块的接口,可以自由的与命令行和GUI进行对接。满足上述三条设计原则。这两个函数都会调用计算核心模块,不同之处是输入输出格式。
#ifdef IMPORT_DLL
#else
#define IMPORT_DLL extern "C" _declspec(dllimport) //指的是允许将其给外部调用
#endif
IMPORT_DLL int guiProcess(std::vector<std::pair<double,double>> *points, std::string msg);
IMPORT_DLL void cmdProcess(int argc, char *argv[]);
guiProcess
函数传入符合格式的字符串,交点集用指针返回,交点个数用返回值返回。- 由数据格式的设计原则,只使用c++标准库中的容器,而不用自定义数据类型。如CPoint要转成
std::pair<double,double>
表示,保证调用者清晰理解。 - 输入采用特定格式的字符串,符合方便易用原则,如果格式错误,由核心处理并抛出异常
- 由数据格式的设计原则,只使用c++标准库中的容器,而不用自定义数据类型。如CPoint要转成
cmdProcess
为了命令行调用方便,采用需求规定的命令行输入格式进行输入输出。
注:这两个函数并不是分别针对gui和cmd,只是输入输出格式不同,都可以任意调用,保证松耦合。
四、五、计算模块设计文档与UML
(一)PipeLine
PreProcess
- ReadShape:读取文件接收全部输入的直线和圆
- Shape construct:根据输入构建形状对象,计算直线斜率。
- Classified by Slope:按斜率将直线分组存起来。
- 【新增】CalcIns Same Slope:处理在同一直线上的线段、射线的共端点情况。
CalcIntersect
- CalcLines:计算所有直线、射线、线段之间的交点:
- 依次考虑每个平行组,按每条线遍历计算交点。平行组内的线不用计算交点。
- 查交点表,如果存在,就可以不求同一交点的其他线了。
交点表:Map<点,Vector<线>>
维护交点表:新增的交点加入交点表,线加入表中对应的线集 - 射线和线段的交点还要满足在射线和线段范围内才有效
- CalcCircles:所有线算完后,再一个个遍历圆。
- 暴力求其与之前图形的全部交点
- 同样需要考虑射线和线段的范围问题
(二)类间关系图(UML)
- CIntersect类:实现控制流,方法包含输入、计算两图形交点、计算交点总数
- CShape类:图形类基类,为每个图形实例创建唯一id,并记录图形的类型
- 【新增】CLine类:继承图形类基类,作用为表示直线、线段、射线的代数方程参数。
- CCircle类:继承图形类基类,作用为表示圆的代数方程参数。
- 直线方程两种表示方法
- 一般方程:(Ax + By +C = 0)
- 斜截方程:(y = kx + b)
- 圆方程两种表示
- 一般方程: (x^2 + y^2 + Dx + Ey +F = 0)
- 标准方程: ((x-x_0)^2 + (y-y_0)^2 = r^2)
- CSlope类和CBias类:为解决斜率无穷大设计,isInf和isNan为true时表示直线的斜率为无穷,此时k和b的具体值无效。由于要按斜率分组,采用C++STL的
unordered_set
和unordered_map
,CSlope要实现Hash方法
和等于运算符
。 - CPoint类:表示交点,作为map的key,同样需要实现
Hash方法
和等于运算符
。
(三)关键函数
-
inputShapes: 处理输入函数,直线、射线、线段按斜率分组,放到
map<double, set<CLine>>
的_k2lines
中,圆直接放到set<CCircle>
的_circles
里。【新增】:上一次需求中直线和直线平行不可能产生有限交点,但是此次需求新增的线段和射线就可能产生共线相交在端点的情况,是符合需求说明书的情况,需要特殊考虑。在此函数中实现,后续就可以正常按照平行分组计算了。
-
calcShapeInsPoint:求两个图形交点的函数,分三种情况,返回点的vector。
- 直线与直线
- 直线与圆
- 圆与圆
-
cntTotalInsPoint: 求所有焦点的函数,按先直线后圆的顺序依次遍历求焦点。已经遍历到的图形加入一个
over
集中。- 直线两个剪枝方法:
- 砍平行:依次加入每个平行组,不需计算组内直线交点,只需遍历
over
集中其它不平行直线。 - 砍共点:假若ABC共点,按ABC的顺序遍历,先计算了AB,交点为P;之后计算AC时发现交点也是P,则无需计算BC交点。方法为维护
_insp2shapes
这个map<CPoint, vector<CShape>>
数据结构,为交点到经过它的线集的映射。
- 砍平行:依次加入每个平行组,不需计算组内直线交点,只需遍历
- 再依次遍历圆,暴力求焦点。加到
_insp2shapes
里 - 函数返回
_insp2shapes.size()
即为交点个数。
- 直线两个剪枝方法:
(四)【新增】代码说明
具体说明本次需求【新增】的重要代码
1. 判断求交公式算出的交点是否在射线、线段范围内
// require: cx cy belongs to the line
// return: if point in shape range
bool CLine::crossInRange(double cx, double cy) const
{
if (type() == "Line") { // 统一接口,直线返回true
return true;
}
else if (type() == "Ray") { // 射线
if (k().isInf()) { // 斜率无穷,比较y值,dcmp为浮点数比较函数,定义见下
if (dcmp(cy, y1())*dcmp(y2(), y1()) != -1) {
return true;
}
}
else { // 正常情况,比较x
if (dcmp(cx, x1())*dcmp(x2(), x1()) != -1) {
return true;
}
}
return false;
}
else { // 线段
... //类似于射线,代码略
}
}
// 浮点数比较函数,相等返回0,前者大返回1,否则返回-1
#define EPS 1e-10
int dcmp(double d1, double d2) {
if (d1 - d2 > EPS) {
return 1;
}
else if (d2 - d1 > EPS) {
return -1;
}
else {
return 0;
}
}
2. 浮点数hash方法
浮点数由于有浮点误差,直接用hash<double>的值将不同,所以应该截取某精度,转换成整形进行hash。
#define COLLISION 100000.
class SlopeHash
{
public:
std::size_t operator()(const CSlope& s) const
{ // 乘精度,四舍五入,转整形,算Hash
unsigned long long val = dround2ull(s.val() * COLLISION);
return std::hash<bool>()(s.isInf()) + (std::hash<unsigned long long>()(val) << 16);
}
};
五、计算模块接口部分的性能改进
我们随机生成了8000条几何图形数据用于性能测试,其中直线、线段、射线、圆各2000个,最终计算得到交点数为18175002个交点。随机数生成模块为python中random包。
(一)初版性能测试
VS2019中使用性能探查器,分析CPU利用率如下:
可以看出,耗费时间最多的函数,是计算输入所有图形的交点总数的函数cntTotalInsPoint
,进入函数入内部查看分析结果:
与个人项目类似,耗费时间最多的仍然是记录交点的数据结构set
对交点的插入。由于已经使用unordered_set
相比于原本的set
时间复杂度已经低了很多,因此固有的数据结构维护时间不可避免。其次我们还注意到计算两个图形间交点的函数calcShapeInsPoint
占用了较大的时间开销。进入该函数中查看分析结果:
可以看出判断点是否在线段或射线上,花费时间较多,判断点是否在交点上的函数,内部是这样的:
仔细想想后,发现其实完全没有必要再这个函数内部再判断一次点是否在线端或射线所处直线上,因为我们本身会用到这个函数的场景,就是先计算出线段或射线所在直线与其他直线的交点,再判断交点是否在线段或射线上,因此,这个判断属于多此一举,是一个可以优化的点。
(二)优化后性能分析
删除点是否在线上的判断后,再次使用性能分析,结果如下:
可以发现总时间降低了4~5s,再次进入calcShapeInsPoint
函数中查看
可以发现crossInRange
函数已经不再是花费最多的部分,说明还是很有效果的。其余部分优化也都类似,不断对比分析,删除冗余计算结果。
六、契约式设计
契约式设计一种设计计算机软件的方法。这种方法要求软件设计者为软件组件定义正式的,精确的并且可验证的接口,这样,为传统的抽象数据类型又增加了先验条件、后验条件和不变式。这种方法的名字里用到的“契约”是一种比喻,因为它和商业契约的情况有点类似。
在面向对象课程中,我们就接触过契约式设计,将逻辑约束在设计时定义好,编码实现时只需要遵守契约式设计就可以写出正确的代码,减少了出错的可能性。利用一些现有的软件,还可以利用契约式设计做代码正确性的形式证明。
从上次作业开始,我的重要pipeline函数就采用了契约式设计模式,下面举例说明。
//算直线和圆的交点的函数
// calculate Intersections of one circ and one line
// need: para1 is CCirc, para2 is CLine
// return a vector of intersections. size can be 0,1,2.
std::vector<CPoint> calcInsCircLine(const CShape& circ, const CShape& line)
{ ... }
//算任意两图形交点的函数
// calculate all intersect points of s1 and s2
// return the points as vector
// need: s1, s2 should be CLine or CCircle.
// special need: if s1, s2 are CLine. They cannot be parallel.
std::vector<CPoint> CIntersect::calcShapeInsPoint(const CShape& s1, const CShape& s2) const
{ ... }
//计算交点总数的函数
// the main pipeline: loop the inputs and fill in _insp2shapes or _insPoints
// return the total count of intersect points
// need: _k2lines and _circles have been filled
int CIntersect::cntTotalInsPoint() { ... }
在设计时我都是先写出契约,之后的代码实现中严格遵守契约中的约束,比如使用calcShapeInsPoint
函数时就必须遵守下面约束,否则将产生错误。
// special need: if s1, s2 are CLine. They cannot be parallel.
七、单元测试
首先展示使用OpenCppCoverage
插件测试得到代码覆盖率为:99%
其中部分Uncovered line
是由于VS强大的编译优化,把一些函数在内联处理了,所以执行不到。
在询问了一些同学并且到网上查询之后,我们还是没有找到如何直接将VS的单元测试项目加入OpenCppCoverage
的代码覆盖检测中,因此我们选择将单元测试代码手动从单元测试项目中移入主函数中。主要测试结构如下:
int main() {
...
...
//从单元测试项目中转移至此的单元测试1
...
//从单元测试项目中转移至此的单元测试2
...
}
单元测试中,主要包括文件读写的测试,对一些关键函数如计算交点等的测试,对异常的测试。
部分单元测试代码展示如下:
-
测试图形之间交点的计算:该部分比较繁杂,需要考虑的情况有以下几种,个人项目中已经出现过的就不再放测试代码,与上次类似
-
直线与直线仅有一个交点、没有交点,其中需要包括直线斜率不存在、为0、其他的情况
-
直线与圆有两个交点、一个交点、没有交点,其中需要包括直线斜率不存在、为0、其他的情况
-
直线与线段有一个交点、没有交点,其中需要包括直线与线段平行、不平行有一个交点、不平行没有交点的情况。以下例子为一个交点的情况:
CLine t1 = CLine(0, 2, 0, 0, "Seg"); CLine t2 = Cline(3, 2, 4, 2); ans = ins.calcShapeInsPoint(t1, t2); Assert::AreEqual(1, (int)ans.size()); Assert::AreEqual(true, CPoint(0, 2) == ans[0]);
-
直线与射线有一个交点、没有交点,其中需要包括直线与射线平行、不平行有一个交点、不平行没有交点的情况。以下例子为没有交点的情况:
CLine t1 = CLine(0, 2, 0, 0, "Ray"); CLine t2 = Cline(3, 2, 3, 4); ans = ins.calcShapeInsPoint(t1, t2); Assert::AreEqual(0, (int)ans.size());
-
圆与圆有两个交点、一个交点、没有交点,其中需要包括外离、外切、相交、内切、内含的情况
-
圆与线段有两个交点、一个交点、没有交点,其中需要包含线段所在直线与圆相离、相切、相交以及不同交点数的情况。以下例子为两个交点的情况:
CCircle c = CCircle(0, 0, 2); CLine t1 = Cline(2, 0, 0, 2, "Seg"); ans = ins.calcShapeInsPoint(t1, c); Assert::AreEqual(2, (int)ans.size()); Assert::AreEqual(true, CPoint(0, 2) == ans[0]); Assert::AreEqual(true, CPoint(2, 0) == ans[1]);
-
圆与射线有两个交点、一个交点、没有交点,其中需要包含射线所在直线与圆相离、相切、相交以及不同交点数的情况。以下例子为一个交点的情况:
CCircle c = CCircle(0, 0, 2); CLine t1 = Cline(0, 0, 0, 2, "Ray"); ans = ins.calcShapeInsPoint(c, t1); Assert::AreEqual(1, (int)ans.size()); Assert::AreEqual(true, CPoint(0, 2) == ans[0]);
-
线段与线段有一个交点、没有交点,其中需要包含两个线段所在直线之间的各种关系以及不同交点数的情况,需要特别考虑线段与线段共线时端点重合的情况。以下例子为共线时一个交点的情况:
CLine t1 = Cline(0, 0, 0, 2, "Seg"); CLine t2 = Cline(0, -2, 0, 0, "Seg"); ans = ins.calcShapeInsPoint(t1, t2); Assert::AreEqual(1, (int)ans.size()); Assert::AreEqual(true, CPoint(0, 0) == ans[0]);
-
线段与射线有一个交点、没有交点,其中需要包含线段与射线所在直线之间的各种关系以及不同交点数的情况,需要特别考虑线段与射线共线时端点重合的情况。以下例子为共线时一个交点的情况:
CLine t1 = Cline(0, 2, 0, 0, "Seg"); CLine t2 = Cline(0, 0, 0, -2, "Ray"); ans = ins.calcShapeInsPoint(t1, t2); Assert::AreEqual(1, (int)ans.size()); Assert::AreEqual(true, CPoint(0, 0) == ans[0]);
-
射线与射线有一个交点、没有交点,其中需要包含两个射线所在直线之间的各种关系以及不同交点数的情况,需要特别考虑射线与射线共线时端点重合的情况。以下例子为共线时没有交点的情况:
CLine t1 = Cline(0, 0, 2, 0, "Ray"); CLine t2 = Cline(-1, 0, -2, 0, "Ray"); ans = ins.calcShapeInsPoint(t1, t2); Assert::AreEqual(0, (int)ans.size());
-
-
交点数统计测试:测试各种情况下,如不同图形之间有重合交点、图形数量很少、图形数量很多等,交点统计是否正确。举例:
TEST_METHOD(TestMethod10) { ifstream fin("../test/test10.txt"); if (!fin) { Assert::AreEqual(132, 0); } CIntersect ins; ins.inputShapes(fin); int cnt = ins.cntTotalInsPoint(); Assert::AreEqual(433579, cnt); }
-
异常单元测试:对各种异常情况的单元测试,主要是通过构造异常数据传入输入函数,捕捉其抛出的异常与预期异常的信息进行比较,将在第八章中详细说明,这里仅放出几个样例。
-
测试射线与射线重合的情况(其中之一)
TEST_METHOD(TestMethod_RR1) { string strin = "2 R 0 0 5 5 R 6 6 -1 -1 "; ShapeCoverException s(2, "R 6 6 -1 -1", "R 0 0 5 5"); InputHandlerException std = s; istringstream in(strin); if (!in) { Assert::AreEqual(132, 0); } CIntersect ins; try { ins.inputShapes(in); Assert::AreEqual(132, 0); } catch (InputHandlerException e) { Assert::AreEqual(true, strcmp(std.what(), e.what()) == 0); } }
-
测试射线与线段重合的情况(其中之一)
TEST_METHOD(TestMethod_SR1) { string strin = "3 S 0 0 0 4 S 0 0 4 0 R 0 -2 0 -1"; ShapeCoverException s(3, "R 0 -2 0 -1", "S 0 0 0 4"); InputHandlerException std = s; istringstream in(strin); if (!in) { Assert::AreEqual(132, 0); } CIntersect ins; try { ins.inputShapes(in); Assert::AreEqual(132, 0); } catch (InputHandlerException e) { Assert::AreEqual(true, strcmp(std.what(), e.what()) == 0); } }
-
测试线段与线段重合的情况(其中之一)
TEST_METHOD(TestMethod_SS1) { string strin = "3 S 0 -1 0 1 S 0 3 0 6 S 0 0 0 2 "; ShapeCoverException s(3, "S 0 0 0 2", "S 0 -1 0 1"); InputHandlerException std = s; istringstream in(strin); if (!in) { Assert::AreEqual(132, 0); } CIntersect ins; try { ins.inputShapes(in); Assert::AreEqual(132, 0); } catch (InputHandlerException e) { Assert::AreEqual(true, strcmp(std.what(), e.what()) == 0); } }
-
八、异常处理
计算模块异常处理大致分类如下,均继承c++标准异常:
- 输入异常:
- 有关图形数量的异常:如无法读入
N
、N
不符合规范、N
与图形数量不匹配等 - 有关图形的异常:如输入图形为非法格式、数字范围超出约束、图形重复输入、图形与已有图形重合、线类图形输入的两点重合等
- 有关图形数量的异常:如无法读入
- 计算异常:目前并没有发现需要特殊捕捉的计算过程中产生的标准异常之外的异常,待未来拓展。
- 文件读写异常:无法打开文件、文件不存在等异常。
异常类的UML图如下:
关键异常详细介绍:
-
ShapeNumberException
当无法从输入流中读入
N
、读入的N
范围不符合规范、N
与实际输入的图形数量不匹配时,抛出该异常。该异常构造方式与标准异常相同,传入错误信息字符串,可以使用
what()
函数获取错误信息,获取到的错误信息与传入错误信息相同。如:
单元测试举例如下:
TEST_METHOD(TestMethod_N6) {
string strin = "1
L 1 2 3 4
C 1 1 2";
ShapeNumberException s("The number of graphics is larger than N.");
InputHandlerException std = s;
istringstream in(strin);
if (!in) {
Assert::AreEqual(132, 0);
}
CIntersect ins;
try {
ins.inputShapes(in);
Assert::AreEqual(132, 0);
}
catch (InputHandlerException e) {
Assert::AreEqual(true, strcmp(std.what(), e.what()) == 0);
}
}
-
IllegalFormatException
当读入过程中某行既不是空行、也不符合规定的输入格式即(
L <x1> <y1> <x2> <y2>
、R <x1> <y1> <x2> <y2>
、S <x1> <y1> <x2> <y2>
、C <x> <y> <r>
四种格式中的一种)时,抛出该异常。该异常构造方式为,传入格式错误的图形序号和该行字符串,可以使用
what()
函数获取错误信息,获取到的错误信息为序号、字符串以及相应修改建议。如:
单元测试举例如下:
TEST_METHOD(TestMethod_ILLShape)
{
string strin = "1
L 1 2 3 F
";
IllegalFormatException s(1,"L 1 2 3 F");
InputHandlerException std = s;
istringstream in(strin);
if (!in) {
Assert::AreEqual(132, 0);
}
CIntersect ins;
try {
ins.inputShapes(in);
Assert::AreEqual(132, 0);
}
catch (InputHandlerException e) {
Assert::AreEqual(true, strcmp(std.what(), e.what()) == 0);
}
}
-
OutRangeException
当读入图形信息时,图形的参数超过规定的范围,抛出该异常。
该异常构造方式为,传入参数超过规定范围的图形序号和该行字符串,可以使用
what()
函数获取错误信息,获取到的错误信息为序号、字符串以及相应修改建议。如:
单元测试举例如下:
TEST_METHOD(TestMethod_OutRange)
{
string strin = "1
C 1 1 0
";
OutRangeException s(1, "C 1 1 0");
InputHandlerException std = s;
istringstream in(strin);
if (!in) {
Assert::AreEqual(132, 0);
}
CIntersect ins;
try {
ins.inputShapes(in);
Assert::AreEqual(132, 0);
}
catch (InputHandlerException e) {
Assert::AreEqual(true, strcmp(std.what(), e.what()) == 0);
}
}
-
ShapeRepeatedException
当读入某一图形,发现与已有图形完全相同时,抛出该异常。
该异常构造方式为,传入出现重复的图形序号和该行字符串,可以使用
what()
函数获取错误信息,获取到的错误信息为序号、字符串以及相应修改建议。如:
单元测试举例如下:
TEST_METHOD(TestMethod_ShapeRe)
{
string strin = "2
C 1 1 1
C 1 1 1
";
ShapeRepeatedException s(2, "C 1 1 1");
InputHandlerException std = s;
istringstream in(strin);
if (!in) {
Assert::AreEqual(132, 0);
}
CIntersect ins;
try {
ins.inputShapes(in);
Assert::AreEqual(132, 0);
}
catch (InputHandlerException e) {
Assert::AreEqual(true, strcmp(std.what(), e.what()) == 0);
}
}
-
IllegalLineException
当读入直线、线段、射线,且发现描述线类图形的两个点重合时,抛出该异常。
该异常构造方式为,传入端点重合的图形序号和该行字符串,可以使用
what()
函数获取错误信息,获取到的错误信息为序号、字符串以及相应修改建议。如:
单元测试举例如下:
TEST_METHOD(TestMethod_IllLine)
{
string strin = "2
L 1 1 1 2
L 0 1 0 1
";
IllegalLineException s(2, "L 0 1 0 1");
InputHandlerException std = s;
istringstream in(strin);
if (!in) {
Assert::AreEqual(132, 0);
}
CIntersect ins;
try {
ins.inputShapes(in);
Assert::AreEqual(132, 0);
}
catch (InputHandlerException e) {
Assert::AreEqual(true, strcmp(std.what(), e.what()) == 0);
}
}
-
ShapeCoverException
当读入直线、线段、射线等与已有线类图形重合(即有无限个交点)时,抛出该异常。
该异常构造方式为,传入该图形(指新读入的图形)序号、该图形字符串信息、与其重合的图形字符串信息,可以使用
what()
函数获取错误信息,获取到的错误信息为序号、该图形、与其重合的图形信息及相应修改建议。如:
单元测试举例如下:
TEST_METHOD(TestMethod_RS1)
{
string strin = "3
S 0 0 4 0
R 0 -2 0 -1
S 0 0 0 4";
ShapeCoverException s(3, "S 0 0 0 4", "R 0 -2 0 -1");
InputHandlerException std = s;
istringstream in(strin);
if (!in) {
Assert::AreEqual(132, 0);
}
CIntersect ins;
try {
ins.inputShapes(in);
Assert::AreEqual(132, 0);
}
catch (InputHandlerException e) {
Assert::AreEqual(true, strcmp(std.what(), e.what()) == 0);
}
}
九、界面模块
首先给出界面模块的整体外观
(一)界面模块设计
-
输入面板模块:提供添加、删除、清空功能
- 添加:提供文件导入和手动添加两种方式,添加后在列表框中显示,并绘制到绘图模块中
- 删除:在列表框中勾选,点击删除键即可删除选中图形,并自动在绘图模块中删除
- 清空:清空所有图形,并清空绘图面板
-
计算核心接口:添加图形后,点击
求解交点
调用计算核心模块,返回交点个数对话框,并在绘图面板中绘制交点,后文将详述接口函数的设计。
-
绘图面板模块:在添加图形时绘制图形,在计算交点后绘制交点。
-
错误反馈模块:反馈计算核心模块抛出的异常,例如
- 直线两点重合
-
产生无数交点
-
输入数据范围错误
(二)关键代码说明
本项目的GUI采用c++的Qt库实现,对比以前使用过的MFC,Qt有易上手,跨平台,界面美观等特点。对于很少写图形程序的我来说,采用Qt是一个很合适的选择。下面分别介绍各个模块的关键代码实现。
-
输入面板模块:充分利用Qt ui设计器提供的诸多功能强大的组件
-
整个输入面板置于可浮动拖出的Dock组建(窗体左侧部分),对于屏幕分辨率低的用户,可将输入面板分离出主窗口,让绘图面板占据全部主窗口
-
输入数据部分选用QComboBox和QSpinBox等组件
-
按钮利用Qt提供的QToolButton组件,方便定义样式和在工具栏重用
-
代码主要定义添加,删除,清空按钮的槽函数,以文件添加为例展示代码
-
void IntersectionGUI::on_actAddFile_triggered() {
if (lastPath == QString::fromLocal8Bit("")) { //记录上次打开的路径
lastPath = QDir::currentPath();//获取系统当前目录
}
//获取应用程序的路径
QString dlgTitle = QString::fromLocal8Bit("选择一个文件"); //对话框标题
QString filter = QString::fromLocal8Bit("文本文件(*.txt);;所有文件(*.*)"); //文件过滤器
QString aFileName = QFileDialog::getOpenFileName(this, dlgTitle, lastPath, filter);
if (!aFileName.isEmpty()) {
lastPath = aFileName;
// 读入文件数据,文件格式与需求定义相同
std::ifstream fin(aFileName.toLocal8Bit());
int N;
std::string line;
fin >> N;
std::getline(fin, line);
while (N--) {
std::getline(fin, line);
QString qline = QString::fromStdString(line);
if (!isShapeStrValid(qline)) { // 正则表达式简单判断输入格式
QString dlgTitle = QString::fromLocal8Bit("输入格式错误");
QMessageBox::critical(this, dlgTitle, qline);
break;
}
draw_shape_from_str(qline); //在绘图面板上绘图
// 图形列表中添加一行
QListWidgetItem * aItem = new QListWidgetItem(); //新建一个项
aItem->setText(qline); //设置文字标签
aItem->setCheckState(Qt::Unchecked); //设置为选中状态
aItem->setFlags(Qt::ItemIsSelectable | Qt::ItemIsUserCheckable | Qt::ItemIsEnabled);
ui.listWidget->addItem(aItem); //增加一个项
}
}
}
- 计算核心接口与异常处理模块:用户点击
求解交点
按钮后,调用槽函数on_actSolve_triggered
void IntersectionGUI::on_actSolve_triggered()
{
// 从图形列表中构造给核心接口的输入字符串
std::string input;
input += std::to_string(ui.listWidget->count()) + "
";
for (int i = 0; i < ui.listWidget->count(); i++)
{
QListWidgetItem *aItem = ui.listWidget->item(i);//获取一个项
QString str = aItem->text();
input += str.toStdString() + "
";
}
// 调用核心接口
std::vector<std::pair<double, double> > points;
int cnt = 0;
try {
cnt = guiProcess(&points, input); // 调用核心接口dll函数
}
catch (std::exception e) { // 反馈计算核心抛出的异常
QString dlgTitle = QString::fromLocal8Bit("计算出现错误");
QMessageBox::critical(this, dlgTitle, e.what());
return;
}
// 绘制交点
for (auto vit = points.begin(); vit != points.end(); ++vit) {
int w, h;
xy2wh((int)(vit->first), (int)(vit->second), w, h);
draw_point(w, h, Qt::red);
}
// 反馈交点总数
QString dlgTitle = QString::fromLocal8Bit("计算结果");
QString strInfo = QString::fromLocal8Bit("交点总数为:");
strInfo += strInfo.asprintf("%d", cnt);
QMessageBox::information(this, dlgTitle, strInfo,QMessageBox::Ok, QMessageBox::NoButton);
}
-
绘图面板模块:采用QLabel和QPixmap组件进行绘图,主要的绘图函数有以下几种
- 绘制直线、射线
- 绘制线段
- 绘制圆
- 绘制点
- 坐标轴初始化
最难写的函数就是绘制直线、射线,Qt自带的绘制直线函数
drawLine
实际上是给定两点绘制线段,所以想要达到绘制直线和线段的效果就必须求出与画板边界的交点。先根据方向算左右方向的边界,如果发现碰的不是左右边界,再算上下边界。
void IntersectionGUI::draw_ray(int x1, int y1, int x2, int y2, QColor const c, int const w) {
QPainter Painter(&curPixmap);
Painter.setRenderHint(QPainter::Antialiasing, true); //反走样
Painter.setPen(QPen(c, w));
if (x2 == x1) { // 竖直
if (y2 > y1) {
y2 = REAL_SIZE;
}
else {
y2 = -REAL_SIZE;
}
}
else if (y1 == y2) { // 水平
if (x2 > x1) {
x2 = REAL_SIZE;
}
else {
x2 = -REAL_SIZE;
}
}
else { // 向右上倾斜 先算左右边界交点,超范围就算上下交点
double k = (double)(y2 - y1) / (x2 - x1);
double b = y1 - k * x1;
if (x2 > x1) {
double y_ = REAL_SIZE * k + b;
if (y_ > REAL_SIZE) {
y2 = REAL_SIZE;
x2 = (y2 - b) / k;
}
else if (y_ < -REAL_SIZE) {
y2 = -REAL_SIZE;
x2 = (y2 - b) / k;
}
else {
x2 = REAL_SIZE;
y2 = y_;
}
}
else { ... } // 类似略
}
QPoint p1 = xy2whPoint(x1, y1);
QPoint p2 = xy2whPoint(x2, y2);
Painter.drawLine(p1, p2);
ui.canvas->setPixmap(curPixmap);
}
十、界面与核心的对接
(一)接口函数设计
为了设计松耦合,我们将核心部分设计了如下两个函数作为接口,命令行和GUI都可以通过这两个接口访问计算核心。
#ifdef IMPORT_DLL
#else
#define IMPORT_DLL extern "C" _declspec(dllimport) //指的是允许将其给外部调用
#endif
IMPORT_DLL int guiProcess(std::vector<std::pair<double,double>> *points, std::string msg);
IMPORT_DLL void cmdProcess(int argc, char *argv[]);
guiProcess
函数传入符合格式的字符串,交点集用指针返回,交点个数用返回值返回。cmdProcess
采用需求规定的命令行输入格式进行输入输出
注:这两个函数并不是分别针对gui和cmd,只是输入输出格式不同,都可以任意调用,保证松耦合。
(二)导出dll
通过此接口将计算核心封装成动态链接库calcInterface.dll
和库calcInterface.lib
(三)模块对接
- 命令行接口对接
int main(int argc, char *argv[])
{
typedef int (*pGui)(vector<pair<double,double>>* points, string a);
typedef void (*pCmd)(int argc, char* argv[]);
HMODULE hDLL = LoadLibrary(TEXT("calcInterface.dll"));
vector<pair<double, double>>points;
string tmp = "2
L 1 1 0 1
L 1 1 1 0
";
if (hDLL) {
pGui guiProcess = (pGui)GetProcAddress(hDLL, "guiProcess");
pCmd cmdProcess = (pCmd)GetProcAddress(hDLL, "cmdProcess");
try {
int ans1 = guiProcess(&points, tmp); // 测试接口函数1
for (int i = 0; i < points.size(); i++) {
cout << points[i].first << " " << points[i].second << endl;
}
cout << ans1 << endl;
}
catch (exception t) {
cout << t.what() << endl;
}
cmdProcess(argc, argv); //测试接口函数2
}
}
- GUI接口对接
#pragma comment(lib,"calcInterface.lib")
_declspec(dllexport) extern "C" int guiProcess(std::vector<std::pair<double, double>> *points, std::string msg);
_declspec(dllexport) extern "C" void cmdProcess(int argc, char *argv[]);
// 调用核心接口
std::vector<std::pair<double, double> > points;
int cnt = 0;
try {
cnt = guiProcess(&points, input); // 调用核心接口dll函数
}
catch (std::exception e) { // 反馈计算核心抛出的异常
QString dlgTitle = QString::fromLocal8Bit("计算出现错误");
QMessageBox::critical(this, dlgTitle, e.what());
return;
}
(四)【附加题】跨组对接调用其他组的计算核心
我们与另一个小组合作,互换了计算核心dll,测试结果如下,可以正常运行,git仓库链接
他们采用的gui接口函数格式与我们的完全相同,只需将对应的dll和lib替换即可直接运行。
IMPORT_DLL int guiProcess(std::vector<std::pair<double, double>>* points,std::string msg);
IMPORT_DLL void cmdProcess(int argc, char *argv[]);
-
命令行对接结果
-
GUI接口对接结果
十一、描述结对的过程
由于单个软件存在或多或少的问题,我们综合使用VS Live Share、腾讯会议以及github来进行远程结对编程。
- VS Live Share:可以更加真实的模拟两个人共同面对同一文件编程的效果,“领航员”也可以更方便的参与到代码的编写中,但VS Live Share无法让被邀请的人观看到程序的运行结果以及整个解决方案的结构。
- 腾讯会议: 我们辅以腾讯会议共享屏幕,来观看整个项目的架构和编译、运行等信息。
- github: 我们根据两个人擅长的不同部分,通过github进行代码同步,分别在不同的模块担任“驾驶员”和“领航员”的角色。
结对过程中,由于两人已经在许多课程作业中建立了深厚的合作基础和友谊,互相信任,因此,在遇到一些举棋不定的情况时,为了提高效率,在共同讨论各种方法的优劣之后,多采用断言和说服的方式确定思路。
以下是我们结对过程中部分截图:
十二、说明结对编程的优点和缺点。同时描述结对的每一个人的优点和缺点在哪里
(一)结对编程的优点:
- 更快的攻破技术难点:对实现的细节技术难点可以很方便地讨论,而且两人都熟悉代码,交流无鸿沟。例如在处理double值的hash函数时,我们经过讨论商榷,发现了c++自带的hash<double>不能直接在我们的代码中使用,故共同商量出用长整型的hash函数方式,解决了此技术难点。
- 加快对新知识的熟悉和学习:例如之前我们二人都没用用vs封装过dll,结对编程中就可以让领航者提前进行学习和搜集资料。待编程者完成代码编写后可以直接进行封装。
- 代码质量更高:结对编程时,有搭档盯着屏幕看,一些细小的粗心导致的错误,往往能够被立即发现,免得之后被粗心导致的bug卡住。
- 愉快的编程氛围:一个人编程通常比较枯燥无聊,特别是当发现bug的时候,而结对编程时,两个人一起工作的氛围要更轻松,如果遇到bug的时候,也不像一个人的时候那么绝望,两人可以利用知识互补,更容易定位bug,解决bug。
(二)结对编程的缺点:
- 时间协调:结对编程需要两人在同一时间做到电脑前,即使是远程开展,也要通过共享屏幕等方式。但是。两个人的习惯工作时间可能不同,比如我就喜欢晚上工作,队友喜欢早起工作,此时就需要协商解决,比如采用隔一天一换的策略。
- 远程结对编程交流不便:有一些细节小问题,比如某一行、某一个字母、某一个数字,如果在线下的话可以直接用手指出,或”夺下“键盘鼠标直接改掉。但是线上交流就不得不用用语言描述和定位,一些很简单的操作,由于着急或者其他原因一时间表达不清楚,就会浪费时间。如果是线下结对编程的话,应该不会存在该问题。
- 效率平衡:本来是两个生产力,硬性的只允许一个人写代码,虽然代码质量会变高,但是效率毕竟可能是减半的。如何维持效率平衡,需要在软件工程实践中不断探索。
(三)结对编程的每一个人的优缺点:
-
我搭档的优点
- acm大佬,写代码能力超强,能够梳理很复杂的程序结构,写了大部分复杂的条件分支判断代码。例如处理输入异常的函数。
- debug和测试更有耐心,对繁杂的版本控制和测试任务都能游刃有余地处理
- 对算法与代码实现的细节关注的更多一些。比如浮点数的hash值问题,他进行了详尽的测试,最终找到了解决方案。
-
我的优点
- 充满学习热情,能很快的学习新的语言和开发工具,如本次项目中的QT开发工具。
- 表达能力强。能够快速发现问题并描述出来,负责与其他队伍交流接口和对拍等事务。
- 对C++语言更熟练
-
我的缺点
- 对一些繁琐枯燥的工作不太认真
- 版本控制混乱,各种版本容易把自己绕晕
-
搭档的缺点
- OOP和c++基础不牢固
总结:逻辑清晰、代码规范、耐心细心、规划合理,我的搭档的是我见过最强的搭档。
十三、警告信息消除
使用默认的规则。可以看到已经没有任何错误和警告。
十四、思考
(一)接口数据类型如何设计?
在设计接口数据类型时,我们产生了一些异议,与其他小组讨论,意见也不是很一致。大体分为以下两种。
-
松式设计
所谓松式设计指采用万能数据类型,如字符串、整形等作为输入输出。
- 优点:任何人,不需要任何库或包就能使用,易于跨平台,移植性强。
- 缺点:数据自检错能力差,调用者可以传任何字符串到接口,易造成:
- 用户需要学习数据格式,构造特定格式的字符串,增加学习成本
- 易粗心大意造成格式错误或故意攻击接口,让核心程序得到非法的输入
- 接口程序必须严格对输入格式进行错误处理,防止核心程序崩溃
-
紧式设计
采用自定义数据类型作为接口,提供特定的构造函数,提供检错判断等。
- 优点:易学易用,防粗心,防攻击,使不构造出合法的数据无法通过编译,或抛出异常。
- 缺点:需要提供自定义数据类型的库,不易跨平台,移植性差,不方便对接,而且将class导出dll比函数麻烦。
讨论之后,我们最终采用了折中的办法,用c++STL中的vector<pair<double,double>>
这种数据类型返回点集,即不直接用纯粹字符串,也不使用自定义数据类型。较方便地解决了本次需求。
但是,如果有些情况下无法采用STL来描述某些接口的数据类型,又该采用什么样的方式设计呢?