北航软工结对项目
结对项目
项目 | 内容 |
---|---|
本作业属于北航 2020 年春软件工程 | 博客园班级连接 |
所属教学班 | 006 |
本作业是本课程结对项目作业 | 作业要求 |
我在这个课程的目标是 | 收获团队项目开发经验,提高自己的软件开发水平 |
这个作业在哪个具体方面帮助我实现目标 | 体验结对编程 |
项目代码 | Github 仓库 |
需求分析
与上一次的个人项目作业相类似,本次的任务关键在于交点的求解。至于新需求“交点绘制”,是简单扩展,实际上是将每个交点的坐标反馈到用户。
本次任务增加了两种图形:线段与射线,它们都是特殊的直线。为什么是特殊的“直线”呢?因为本质上,这两种图形都是将无限长直线进行截断而形成的。
由于这种性质,我们不难发现,整体的求解过程几乎没有改变。上一次的求解划分方法抄录如下
- 直线与直线
- 平行:交点个数 0
- 同一条直线:交点个数无限
- 相交:交点个数 1
- 直线与圆
- 相离:交点个数 0
- 相切:交点个数 1
- 相交:交点个数 2
- 圆与圆
- 相离:交点个数 0
- 相切:交点个数 1
- 相交:交点个数 2
- 内含:交点个数 0
但是,射线与线段毕竟不是直线,因此我们需要考虑因截断带来的影响,即所求得的交点是否在图形上。而求解交点的过程,依旧是参见 Paul Bourke 先生的文章。
除此以外,我们还需要考虑射线与线段之间的新的交点,即端点的重合。
根据上述分析过程,不难得到所需的实体,UML 图如下(使用 StarUML 生成)。
接口设计
考虑到我们所要编写的库需要面对的环境是未知的,即不清楚会被什么语言以什么形式调用,因此我们选择了最广泛的 C 形式。我们希望,调用语言只要能够获取变量的地址,就能顺利地调用我们的库。具体的接口如下
// 以下的 CORE_API 均是 __declspec(dllexport),声明将要在 dll 中导出
extern "C" CORE_API GraphManager * create_graph_manager();
extern "C" CORE_API int add_line(GraphManager*, char*, Type, INTTYPE, INTTYPE, INTTYPE, INTTYPE);
extern "C" CORE_API int add_circle(GraphManager*, char*, Type, INTTYPE, INTTYPE, INTTYPE);
extern "C" CORE_API void remove_graph(GraphManager*, int);
extern "C" CORE_API int calculate_intersect(GraphManager*, char*, INTTYPE);
extern "C" CORE_API int fetch_intersect(GraphManager*, char*, FLOATTYPE*, FLOATTYPE*);
extern "C" CORE_API void clear_manager(GraphManager*);
extern "C" CORE_API void dispose_graph_manager(GraphManager*);
各个接口分别的作用是:
- 创建 GraphManager
- 向指定的 GraphManager 添加一条线(可能是直线、线段或射线,由 Type 指定)
- 向指定的 GraphManager 添加一个圆
- 从指定的 GraphManager 中删除一个图形
- 计算指定的 GraphManager 中所管理的图形的交点
- 从指定的 GraphManager 中获取交点信息
- 清空指定的 GraphManager
- 销毁指定的 GraphManager
这个 GraphManager 是我们为了不暴露具体的实现,额外设计的类,通过这个类的指针来与调用方进行互动,这样就不需要调用方有任何的定义结构体的能力。
这种设计其实是对 infomation hiding 原则和 loose coupling 原则的实践,调用方不必对被调用方的实现做任何假设,被调用方也不必对调用方的实现做任何假设,两者之间的连接只有基本类型。
关于 Design by Contract,这其实是我们曾经接触过的话题。在大二的面向对象课程中,曾使用 JML(Java Modeling Language,一种用于描述程序规格的语言)对程序的行为进行描述,并使用相关的工具链自动生成测试。Contract 其实是非常实用的,它是代码无关的,可以在使用代码实现程序之前对程序进行约束,并在实现完成后生成相应的测试。Contract 曾一度作为提案加入到 C++20 标准中,但由于提出比较晚,还需要时间进行考察调研,暂时脱离 C++20。
在设计接口的核心功能时,其实我们并没有过多地考虑 Contract,因为这一步在做需求分析时已经完成,我们已经能够确认程序的每个部分应有的功能以及相应的约束,某种程度上,需求分析的结果也是一种 Contract。
异常处理
这一部分是接口设计的后续内容。
异常处理是必要的,因为我们无法假设调用方的调用方式,否则调用方与被调用方将存在一定的非必要的耦合关系。
以下面这个接口为例子
extern "C" CORE_API int add_line(GraphManager* gm, char* msg, Type type, INTTYPE x1, INTTYPE y1, INTTYPE x2, INTTYPE y2);
调用方在尝试调用这个接口之前,对内部实现没有了解,不清楚直线各个参数的限制(当然,库文档一般会提供,我们假设文档中没有注明),那么可能会错误地提供了两个相同的点,或者超过限制范围的坐标值等等,这些都是需要反馈的。
那么,问题就在于如何反馈异常。首先,不可能抛异常(尽管我们的库是使用 C++编写的,具有抛异常能力),不同的语言与环境的异常模型未必相同,调用方不一定能够完成异常的处理。我们依旧需要一个通用的做法,选择返回错误信息的长度与错误信息的指针(对于本接口,返回值即错误信息长度,msg 用于存储错误信息的指针),如果错误信息的长度为 0,意味着没有异常发生,这里同样只有基本类型的参与。
根据需求,我们设计了以下几种异常类型:
- 直线两点重合
- 输入参数的大小不在限定范围内
- 圆半径为负
- 在计算交点之前就尝试获取交点信息
- 无交点却尝试获取交点信息
- 输入数据中具有重合或重复的图形
对于最后一种情况,即会产生“无限多交点”的情况,我们容许它的发生,会以警告的形式反馈给用户,但是会将发生重合的图形连接成为一个图形进行计算。
单元测试
先展示覆盖情况(使用 OpenCPPCoverage 生成)
测试的框架如下
static int main_ret = 0;
static int test_count = 0;
static int test_pass = 0;
#define EXPECT_EQ_BASE(equality, expect, actual) \
do {\
test_count++;\
if (equality)\
test_pass++;\
else {\
std::cerr << __FILE__ << ":" << __LINE__ << ": expect: " << expect << " actual: " << actual << "\n"; \
main_ret = 1;\
}\
} while(0)
#define EXPECT_EQ(expect, actual) EXPECT_EQ_BASE((expect) == (actual), expect, actual)
#define EXPECT_TRUE(actual) EXPECT_EQ_BASE((actual) != 0, "true", "false")
#define EXPECT_FALSE(actual) EXPECT_EQ_BASE((actual) == 0, "false", "true")
void gm_test()
{
auto gm = create_graph_manager();
... // 测试项目
dispose_graph_manager(gm);
}
int main()
{
gm_test();
printf("%d/%d (%3.2f%%) passed\n", test_pass, test_count, test_pass * 100.0 / test_count);
return main_ret;
}
测试项目有功能性测试和异常测试。
首先是功能性测试,以测试两圆外切情况为例
clear_manager(gm); // 首先清空 GraghManager
add_circle(gm, nullptr, Type::circle, 0, 0, 1); // 加入一个圆,用空指针接收错误信息可以及时发现异常的发生
add_circle(gm, nullptr, Type::circle, 2, 0, 1); // 加入另一个圆
EXPECT_EQ(1, calculate_intersect(gm)); // 计算交点数并进行比较
测试数据的构造会考虑正负零以及在数据范围附近的情况(-100000, 100000),分类的依据可见需求分析。
然后是异常测试,大致如下
// 两点重合
clear_manager(gm);
EXPECT_TRUE(add_line(gm, msg, Type::line_segment, 0, 0, 0, 0) > 0);
// 超出数据范围
clear_manager(gm);
EXPECT_TRUE(add_line(gm, msg, Type::line_segment, 10000000, 0, 0, 0) > 0);
// 超出数据范围
clear_manager(gm);
EXPECT_TRUE(add_line(gm, msg, Type::line_segment, 0, 0, 0, -10000000) > 0);
// 超出数据范围
clear_manager(gm);
EXPECT_TRUE(add_circle(gm, msg, Type::circle, 0, 0, -10000000) > 0);
// 半径为负
clear_manager(gm);
EXPECT_TRUE(add_circle(gm, msg, Type::circle, 0, 0, -1000) > 0);
// 计算交点前获取交点信息
clear_manager(gm);
EXPECT_TRUE(fetch_intersect(gm, msg, &x, &y) > 0);
// 无交点获取交点信息
calculate_intersect(gm, msg, &point_num);
EXPECT_TRUE(fetch_intersect(gm, msg, &x, &y) > 0);
// 输入数据中具有重合或重复的图形
add_circle(gm, nullptr, Type::circle, 0, 0, 3);
add_circle(gm, nullptr, Type::circle, 0, 0, 3);
EXPECT_TRUE(calculate_intersect(gm, msg, &point_num) > 0);
界面模块设计
界面模块我们选取的开发框架是 WPF,开发语言为 C#。由于项目给出的界面模块需求为几何对象的文件导入、增删、绘制与交点求解,这些需求都可以通过按钮点击事件完成,故界面模块的总体设计为:上方用画板展示绘制内容,下方排布按钮进行控制。完成后的初始界面如下:
画布设计
画布设计又分为坐标网格的绘制与几何图形的绘制,下面解释二者的实现细节。
坐标轴的绘制比较简单,只需在画布中央画两条直线,其中 y 轴的两端为画布上下边界的中点,x 轴的两端为画布左右边界的中点。而坐标网格的绘制其实也只是坐标轴绘制的一个加强版,横纵线交错即为网格;坐标的绘制通过TextBlock
实现。下面是展示沿 X 轴方向的坐标网格和坐标的绘制,y 轴同理翻转即可。
几何对象绘制设计
几何对象的绘制分为线绘制和圆形绘制,其中线绘制又分为无限长直线、射线和线段。
线绘制最为麻烦,因为 WPF 框架中给出的现有方法只支持绘制线段,不支持无限长直线。我们对于无限长直线和射线的实现办法为:添加边界点。边界点的求法为:令横坐标 x 为 INF_X(这里取 INF_X 为画布宽度的一半,也就是把画布放到二维坐标系的中心时的边界值),利用直线方程求出 y。需要考虑平行于坐标轴的情况,下方代码不再赘述:
// input: x1, y1, x2, y2
double A = y2 - y1, B = x1 - x2, C = x2 * y1 - x1 * y2;
double edge_x1, edge_x2, edge_y1, edge_y2; // 1为边界起点,2为边界终点
double INFX = frame_width / 2;
if (x2 < x1)
{
edge_x1 = INFX;
edge_x2 = -INFX;
}
else
{
edge_x1 = -INFX;
edge_x2 = INFX;
}
edge_y1 = (-C - A * edge_x1) / B;
edge_y2 = (-C - A * edge_x2) / B;
之后即可根据线对象的类型进行绘制。需要注意的是,之前在二维坐标系中进行计算,最后绘制时应将点坐标转换到画布的坐标系(也就是二维坐标系沿 x 轴翻转后取第四象限)中去:
Point start;
Point end;
switch (type)
{
case Type.infinite_line: // 无限长直线
start = convert_point(edge_x1, edge_y1);
end = convert_point(edge_x2, edge_y2);
break;
case Type.line_segment: // 射线
start = convert_point(x1, y1);
end = convert_point(edge_x2, edge_y2);
break;
case Type.segment: default: // 线段
start = convert_point(x1, y1);
end = convert_point(x2, y2);
break;
}
LineGeometry line = new LineGeometry();
line.StartPoint = start;
line.EndPoint = end;
Path path = new Path();
path.Stroke = Brushes.Black;
path.StrokeThickness = 1;
path.Data = line;
mainPanel.Children.Add(path);
convert_point
的代码如下:
private Point convert_point(double x, double y)
{
return new Point(x * SCALE + x_offset, -y * SCALE + y_offset);
}
圆形与坐标点的绘制可以使用 WPF 提供的EllipseGeometry
(椭圆)类。二者区别只有空心实心和半径大小,下面给出坐标点的绘制方法:
// input: x, y
Point p = convert_point(x, y);
EllipseGeometry el = new EllipseGeometry();
int pointR = 2;
el.RadiusX = pointR;
el.RadiusY = pointR;
el.Center = p;
Path path = new Path();
path.Stroke = Brushes.Black;
path.StrokeThickness = 2;
path.Fill = Brushes.Black;
path.Data = el;
mainPanel.Children.Add(path);
intersections.Add(path);
按钮设计
按钮的功能设计如下:
Files
:从文件导入几何对象的描述并绘制(不独立设置“绘制”按钮是为了更直观地展示输入的几何对象,避免用户进行重复冗余的点击操作,下面的增添/删除按钮也贯彻了这一理念)。Intersect
:求解现有几何对象的交点并绘制。Delete
:选择某一个几何对象,将其从画布上删除,并删除画布中的所有交点。Add
:根据选择的类型及输入增添一个几何对象,并在画布上画出。Clear
:清空画布上所有的几何对象及交点Scale+/-
:放大/缩小画布的坐标系,将会删除图上所有的几何对象和交点。
下面展示这些按钮触发事件的实现细节。
按钮事件函数的写法为:
-
Files
var dialog = new Microsoft.Win32.OpenFileDialog { Filter = ".txt|*.txt" }; if (dialog.ShowDialog(this) == false) return; string fileName = dialog.FileName; Console.WriteLine(fileName); drawer.clearAll(); // 清除画布 drawer.DrawXY(); // 重新绘制坐标轴 drawer.ReadGraphFromFile(fileName);
其中
read_graph_from_file()
方法的实现流程为:- 读文件第一行的数字
- 根据数字按行读取并 parse
- 将 parse 后获得的几何对象信息输入到
core
中的计算模块进行添加 - 调用线绘制方法进行绘制
其中还涉及若干错误判断,使用
MessageBox.show()
报告错误 -
Intersect
该事件主要调用
core
中的计算模块,并使用core
中实现的取交点函数逐一获取所有交点。public void calc_and_draw_intersects() { // 计算交点 int r = NativeMethods.calculate_intersect(core_graph_manager, msg, ref n); if (r > 0) { MessageBox.Show(msg.ToString()); } for (long i = 0; i < n; i++) { // 从core中取一个交点坐标 r = NativeMethods.fetch_intersect(core_graph_manager, msg, ref x, ref y); if (r > 0) // 如果有错误信息 { MessageBox.Show(msg.ToString()); break; } else { drawIntersectPoint(x, y); } } }
-
Delete
该事件弹出新窗口(项目中为
GraphsWindow
),获取当前所有几何对象的信息,并使用ListBox
陈列,用户确认选择后分别删除其在ListBox
中的描述、画布中的图形以及在core
中的对象,并删除所有交点。画布中删除某一图形的逻辑如下:
// input: graph id // remove graoh on Canvas mainPanel.Children.Remove(graphs[id]); // remove info graphs.Remove(graphs[id]); // remove points on graph Path[] points = pointsOnGraph[id]; foreach (Path point in points) { mainPanel.Children.Remove(point); } pointsOnGraph.Remove(points); // remove intersections(all) foreach (Path intersect in intersections) { mainPanel.Children.Remove(intersect); } intersections.Clear(); // remove data in core remove_graph(core_graph_manager, id);
-
Add
弹出新窗口进行几何对象的添加。实现方法是使用
ComboBox
实现下拉栏选择四种几何对象类型的其中一个,并根据类型的不同给出不同数量的参数输入框(TextBox
)。提交输入时进行正确性检测。最后将正确的输入整合为一行,利用按行处理文件输入的方法进行添加处理。string wrongMsg = ""; verify_TextInput(tbox1, tblock1, ref wrongMsg); verify_TextInput(tbox2, tblock2, ref wrongMsg); verify_TextInput(tbox3, tblock3, ref wrongMsg); if (combo.SelectedIndex != (int)ComboItem.C) // 圆形没有第四个参数输入框,只有x, y, r { verify_TextInput(tbox4, tblock4, ref wrongMsg); } if (wrongMsg.Length != 0) { MessageBox.Show(wrongMsg); // 输入栏的错误提醒 } else { string info = combo.Text + " " + tbox1.Text + " " + tbox2.Text + " " + tbox3.Text; if (combo.SelectedIndex != (int)ComboItem.C) { info += " " + tbox4.Text; } try { drawer.AddGraphFromLine(info); // 添加该行对应的几何对象信息 } catch (FormatException) { MessageBox.Show("Wrong Format!"); // 在执行添加逻辑时的错误报告 } }
-
Clear
该事件包括清除画布内容、几何对象所有信息,并重新绘画坐标网格。清除的细节如下:
mainPanel.Children.Clear(); // 清除画布内容 graphs.Clear(); // 清除几何对象绘制信息 intersections.Clear(); // 清除交点绘制信息 pointsOnGraph.Clear(); // 清除几何对象上的点(起点、终点、圆心)的绘制信息 graphsInfo.Clear(); // 清除用于生成ListBox的几何对象信息 core_graph_manager = IntPtr.Zero; // 清除core中的manager
-
Scale + / -
该事件由调整画布尺寸参数
SCALE
实现。坐标网格绘制中,在绘制垂直 x 轴的坐标网格的循环语句中,网格密度是根据SCALE
来调整的,由此实现画布的尺寸增减:for (int i = SCALE; i < frame_width / 2; i+=SCALE)
在实现中,用户每点击一次按钮 Scale+/-,首先清除画布,
SCALE
增加/减少5
,并进行设定边界,避免无限放大,然后再重新绘制坐标轴。如下是实现的大致逻辑
if (SCALE >= 100) { MessageBox.Show("Reach Max Scale", "Note"); return; } clear_all(); SCALE += 5; drawXY();
界面模块与计算模块的对接
计算模块生成动态链接库提供,由于界面模块使用的是 WPF 框架及 C# 进行开发,可以通过DLLImport
语句可以导入 dll 中的接口进行使用。
在界面模块中导入接口函数的语句如下:
[DllImport("core.dll")]
internal static extern IntPtr create_graph_manager();
[DllImport("core.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi, BestFitMapping = false, ThrowOnUnmappableChar = true)]
internal static extern int add_line(IntPtr gm, StringBuilder msg, int type, long x1, long y1, long x2, long y2);
[DllImport("core.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi, BestFitMapping = false, ThrowOnUnmappableChar = true)]
internal static extern int add_circle(IntPtr gm, StringBuilder msg, int type, long x, long y, long r);
[DllImport("core.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi, BestFitMapping = false, ThrowOnUnmappableChar = true)]
internal static extern int calculate_intersect(IntPtr gm, StringBuilder msg, ref long res);
[DllImport("core.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi, BestFitMapping = false, ThrowOnUnmappableChar = true)]
internal static extern int fetch_intersect(IntPtr gm, StringBuilder msg, ref double x, ref double y);
[DllImport("core.dll", CallingConvention = CallingConvention.Cdecl)]
internal static extern void remove_graph(IntPtr gm, long id);
[DllImport("core.dll", CallingConvention = CallingConvention.Cdecl)]
internal static extern void dispose_graph_manager(IntPtr gm);
为了达到松耦合的条件,界面模块中不涉及计算模块的类操作。通过 GraphManager 指针对接各个接口。初始时需创建新的 GraphManager 指针:
IntPtr graph_manager = create_graph_manager();
之后在添加一条直线,根据输入得到的类型及坐标,即可如下添加一条直线:
add_Line(graph_manager, msg, type, x1, y1, x2, y2);
其中 msg 用于获取计算模块的错误信息,通过MessageBox
报告给用户。
实现的功能如下:
- 从文件中读取几何图形信息并画出(Files 按钮)
- 求交点并画出(Intersect 按钮)
- 删除一个几何图形(Delete 按钮)
- 增添一个几何图形(Add 按钮)
- 清空画布(Clear 按钮)
- 画布尺寸增减(将会删除图形)
- Scale+按钮
- Scale-按钮
- Scale+按钮
代码质量分析
截图如下
结对过程
由于疫情,我们采用 Live Share 和语音聊天的方式来进行结对编程。说实话,效果一般,我们甚至还不太了解对方。但是效果还是有的,譬如说,描述坐标的变量都是 x,y 之类的,我常因为不够细心而弄错变量名,如果是我单独编程,这些问题很可能会推迟到单元测试时才会被发现,幸亏我的队友比较细心,堪比 clang-tidy,及时提醒我修正。
这其实就是结对编程的一个好处,两双眼睛同时 review 代码。同时,由于有其他人的介入,会迫使自己尽可能地写出富有意义的代码,也就是说,能让队友轻易地看懂代码的意图并了解执行的流程。
这次的任务有图形化编程的需求,这是我不太擅长的,而队友在这方面对我有很大的帮助,在看代码的时候也大致地了解了 WPF 开发的方式。
PSP 表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | ||
· Estimate | · 估计这个任务需要多少时间 | 5 | 15 |
Development | 开发 | ||
· Analysis | · 需求分析 (包括学习新技术) | 60 | 120 |
· Design Spec | · 生成设计文档 | 30 | 30 |
· Design Review | · 设计复审 (和同事审核设计文档) | 30 | 30 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 0 | 0 |
· Design | · 具体设计 | 30 | 90 |
· Coding | · 具体编码 | 60 | 90 |
· Code Review | · 代码复审 | 30 | 60 |
· Test | · 测试(自我测试,修改代码,提交修改) | 60 | 90 |
Reporting | 报告 | ||
· Test Report | · 测试报告 | 10 | 10 |
· Size Measurement | · 计算工作量 | 10 | 10 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 30 | 30 |
合计 | 355 | 575 |