软件工程结对项目作业
1.简介
项目 | 内容 |
---|---|
这个作业属于哪个课程 | 班级博客 |
这个作业的要求在哪里 | 作业要求 |
我在这个课程的目标是 | 系统地提升软件工程能力 |
这个作业在哪个具体方面帮助我实现目标 | 掌握结对开发流程, 积累合作经验 |
教学班级 | 006 |
Github项目地址 | https://github.com/Eadral/SE_Pair_Project |
2.PSP
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | ||
· Estimate | · 估计这个任务需要多少时间 | 10 | 10 |
Development | 开发 | ||
· Analysis | · 需求分析 (包括学习新技术) | 30 | 20 |
· Design Spec | · 生成设计文档 | 5 | 15 |
· Design Review | · 设计复审 (和同事审核设计文档) | 30 | 30 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 10 | 70 |
· Design | · 具体设计 | 60 | 60 |
· Coding | · 具体编码 | 600 | 500 |
· Code Review | · 代码复审 | 60 | 70 |
· Test | · 测试(自我测试,修改代码,提交修改) | 120 | 150 |
Reporting | 报告 | ||
· Test Report | · 测试报告 | 30 | 30 |
· Size Measurement | · 计算工作量 | 10 | 10 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 60 | 90 |
合计 | 1045 | 1055 |
3.接口设计
Information Hiding
封装(Encapsulation)性是面向对象程序设计方法的一个重要特性。封装包含两层含义,一是将抽象得到的有关数据和操作代码相结合,形成一个有机的整体,对象之间相对独立,互不干扰。二是封装将对象封闭保护起来,对象中某些部分对外隐蔽,隐藏内部的实现细节,只留下一些接口接收外界的消息,与外界联系,这种方法称为信息隐蔽(Information Hiding)。
封装保证了类具有较好的独立性,防止外部程序破坏类的内部数据,使得程序维护修改比较容易。对应用程序的修改仅限于类的内部,因而可以将应用程序修改带来的影响减少到最低限度。
信息隐藏主要通过封装实现,防止外部改变类内的数据。
对于求交点的类,主要需要封装的是其内部的Line,Circle,Point这些数据,只能通过相应的Add, Get,Del方法来获取这些信息,这样实现了信息隐藏,有利于之后的维护。
Interface Design
接口应该清晰易懂,职责明确,易于维护。
编写文档
文档描述了接口的请求参数,返回参数,以及可能引发的异常。
迪米特法则(最小原则)
尽可能的减少接口的参数数量,不添加无用的参数。
同时接口保持单一职责,只完成一个工作,这样有利于维护。
对于这次作业,我们用更多数量的接口来代替复杂的接口,遵守了迪米特法则,使得维护和对接都更加简单。
Loose Coupling
一个松耦合的系统中的每一个组件对其他独立组件的定义所知甚少或一无所知。子范围包括类、接口、数据和服务之间的耦合。
松耦合系统中的组件能够被提供相同服务的替代实现所替换。松耦合系统中的组件不太受相同的平台、语言、操作系统或构建环境的约束。
松耦合的目标是最小化依赖,这样各个组件直接更容易替换,具有很高的扩展性和灵活性,同时也有助于提高可维护性。
在封装的基础上松耦合比较容易实现,但是与封装不同之处在于松耦合可能有着满足其他组件而非现有组件的需求,因此不能仅仅是隐藏信息,还要以良好封装的形式允许各种形式的增删改查。
为了实现松耦合,在这次项目中对所有的数据类(Line,Circle)都增加了Add,Del,Get方法,用于给各个模块提供功能。
4.计算模块接口的设计与实现过程
设计思路
这次新增的功能是支持射线和线段,可以把射线和线段当作线的特殊情况,只需要在求出交点后判断点是否在射线或线段上即可,整体的代码结构、类、函数几乎不变,只是增加了关于射线和线段的处理。
涉及的类有Line,Circle,Point,以及一个用于解题的Solver。
其中Line,Circle,Point都是不变对象,使用struct实现,其中Point涉及比较,因此会重载比较运算符,并且在这里需要进行精度判断。
Solver需要获取以上类的对象信息进行求解,这里选择Solver去组合Line,Circle,和Point。Solver还需要进行IO操作,提供Input和Output接口。
为了方便其他模块,提供了多种Input和Output接口:
Input(char *str)
: 读取纯文本(按照输入数据格式处理)AddLine(...)
,DelLine(...)
,GetLines(...)
: 对象接口(以Line为例,其他类似)GetIntersections(...)
,GetIntersectionNumber(...)
: 用于获取交点数据(结果)
Solve流程
- 从Input接口获取输入,构建Line和Circle数组。
- 求交点:遍历Line,Circle数据,两两求交点。交点插入到Point数组中。
- 这里会进行Ray和Segment的判断。通过分类讨论交点与线坐标大小的关系,判断交点是否在线段或射线上。
- 对Point数组进行排序去重。
- 从Output接口输出答案。
UML
6.性能改进
本次作业的计算流程和上次是完全一样的,在上次作业中尝试了数种方法并最终确定了这样的方案:两两对象求交点,将交点加入数组,最后对交点数组排序去重,得到答案。
这种计算流程可维护性高。并且通过测试发现,相比Hash表,链表等数据结构都有着性能优势。利用C++ vector的原地构造(emplace_back)和排序算法对缓存的利用,这种方案可以达到较好的性能,因此本次作业依然使用了这种方案。
这次的性能分析发现热点是求交点的部分,大量时间花费在了乘法运算上。
观察代码后发现存在一些重复计算,因此将部分计算在Line构造时就完成计算,并尽可能进行复用,减少重复计算。
Line(const int x1, const int y1, const int x2, const int y2) noexcept
: x1(x1), y1(y1), x2(x2), y2(y2)
{
dx = (long long)x1 - (long long)x2;
dy = (long long)y1 - (long long)y2;
x2y1 = (long long)x2 * (long long)y1;
x1y2 = (long long)x1 * (long long)y2;
x2y1_x1y2 = x2y1 - x1y2;
}
- 代码转换:
a.x2 * a.y1 - a.x1 * a.y2
=>a.x2y1_x1y2
40000条线数据,消耗最大函数是求交点
7.Design by Contract, Code Contract
契约式设计的主要目的是希望程序员能够在设计程序时明确地规定一个模块单元(具体到面向对象,就是一个类的实例)在调用某个操作前后应当属于何种状态。
契约式设计强调三个概念:前置条件,后置条件和不变式。前置条件发生在每个操作(方法,或者函数)的最开始,后置条件发生在每个操作的最后,不变式实际上是前置条件和后置条件的交集。违反这些操作会导致程序抛出异常。
在过去的课程中接触过契约式设计,我认为有以下好处:
- 能够提醒程序员去仔细思考这个模块应该做什么(核心的功能),不应该做什么(是否有副作用),以及相应的错误情况(减少bug)。
但是也存在着缺点, 严格的契约式设计很难实现:
- 不变式的描述很困难,使用逻辑表述的不变式本身很容易写错。
- 这些条件的自动化测试比较难实现。
今天大部分的API应该是通过注释来一定程度上的表示“契约”。
这次的结对作业中,我们主要是通过文档中的异常部分来描述:输入的错误情况(前置条件),计算中可能导致的异常(后置条件),不变式则主要靠单元测试维护。
8.单元测试
单元测试在上次作业的基础上进行扩展,按照粒度和功能分为以下几类:
1. 交点测试:测试Solver的Line-Line,Line-Circle,Circle-Circle求交点方法分别测试,断言交点数量。
交点测试用于验证交点计算函数的正确性。
TEST_METHOD(Cross)
{
stringstream sin;
stringstream sout;
Solver solver(sin, sout);
solver.LineLineIntersect(
Line(0, 0, 1, 1),
Line(0, 1, 1, 0)
);
Assert::AreEqual(solver.GetAns(), 1);
}
2. 错误测试:对错误输入,异常情况进行测试,断言输出的错误类型。
错误测试用于验证对错误的处理情况
TEST_METHOD(InvalidIdentifierTest)
{
stringstream sin;
stringstream sout;
Solver solver(sin, sout);
sin << R"(
1
X 0 0 1 1
)" << endl;
Assert::ExpectException<CoreException>([&] {solver.Solve();});
}
3. API测试:调用API并测试正确性。
API测试用于验证API是否正确实现和封装。
TEST_METHOD(APITest) {
Clear();
char buf[] = "2
L 0 0 1 1
L0 1 1 0
";
Input(buf);
Assert::AreEqual(1, GetIntersectionsSize());
double xs[5], ys[5];
GetIntersections(xs, ys, 1);
Assert::AreEqual(0.5, xs[0]);
Assert::AreEqual(0.5, ys[0]);
}
4. End-to-End测试:向Solver提供输入字符流,对输出字符流进行断言。
这类测试用于验证整个程序流程的正确性
TEST_METHOD(Test6)
{
stringstream sin;
stringstream sout;
Solver solver(sin, sout);
sin << R"(
4
C 3 3 3
S 2 4 3 2
L -1 4 5 2
R 2 5 -1 2
)" << endl;
solver.Solve();
int ans;
sout >> ans;
Assert::AreEqual(5, ans);
}
总共83个单元测试
覆盖率97.17%
9.异常处理说明
N值格式错误
第一行有且仅有一个整数,否则即报错
*#06#
L 0 0 1 1
N值非法
N<1时报错
0
几何对象标识符格式错误
无标识符,或包含非法字符/字符串,大小写敏感
1
X 0 0 1 1
几何对象描述错误
输入格式错误,包括行内输入过多/过少,数字输入非法
2
L 0 0 1 1
C 4 3 1 2
输入坐标分量超限
包括线的两点坐标分量和圆心坐标分量
2
L 0 0 1 100000
R 4 3 1 2
线的两点坐标重合
两点坐标不能相同
1
L 0 0 0 0
圆的半径非法
半径值必须为正,且在规定范围内
2
L 0 0 1 1
C 4 2 0
输入几何对象过少
少于N值
2
L 0 0 1 1
输入几何对象过多
多于N值
2
L 0 0 1 1
C 0 2 1
R 1 1 3 4
有无穷多交点
1
L 0 0 1 1
L 2 2 3 3
10.界面模块设计
界面部分是使用WPF实现的,通过dll调用核心功能。
主要功能
核心功能通过界面上的3个按钮进行操作:
- Import:导入纯文本输入文件
- Add:按照输入框指定的内容添加对象
- Remove:按照输入框指定的内容删除对象
绘制的处理逻辑是单向绑定,使用单向绑定是为了降低UI上的状态维护工作,把核心功能全部集中在核心模块中,这样有助于降低UI与核心的耦合,侧重于核心模块的灵活性,也有助于其他模块的扩展。
单项绑定的具体实现是在每次点击按钮后都会对画布和右侧列表进行刷新,具体流程如下:
- 描述界面的xml中绑定相应单机事件的函数。
<Button Click="ButtonAdd">Add</Button>
- 回调函数会调用API完成添加操作,并使用Draw()进行绘制。
private void ButtonAdd(object sender, RoutedEventArgs e) {
// ...
AddLine(x1, y1, x2, y2);
Draw();
}
- 从核心读取对象,并进行绘制
private void Draw() {
listView.Items.Clear();
DrawCircles();
DrawLines();
DrawRays();
DrawSections();
DrawIntersections();
}
GUI特性
- 列表选择:选择右侧列表中的对象可以即时更改下方输入框的内容,便于用户进行删除操作。
- 左侧的画布可以使用鼠标滚轮进行缩放,也可以拖拽移动。
11.界面模块与计算模块对接
界面模块与计算模块使用dll导出的API进行对接。
C++编写的核心导出API:
INTERSECT_API void Clear();
INTERSECT_API void Input(char *input);
INTERSECT_API void AddLine(int x1, int y1, int x2, int y2);
INTERSECT_API void RemoveLine(int x1, int y1, int x2, int y2);
// ...
INTERSECT_API int GetIntersectionsSize();
INTERSECT_API void GetIntersections(double* xs, double* ys, int size);
C#进行DLL调用:
[DllImport("intersect_core.dll")]
public static extern void Clear();
[DllImport("intersect_core.dll")]
public static extern void Input([MarshalAs(UnmanagedType.LPStr)]string input);
[DllImport("intersect_core.dll")]
public static extern void AddLine(int x1, int y1, int x2, int y2);
[DllImport("intersect_core.dll")]
public static extern void RemoveLine(int x1, int y1, int x2, int y2);
// ....
[DllImport("intersect_core.dll")]
public static extern int GetIntersectionsSize();
[DllImport("intersect_core.dll")]
public static extern void GetIntersections(double[] xs, double[] ys, int size);
按照上文的逻辑调用这些API,就完成了对接。
实现功能截图
12.结对过程
实时协作通过VS Live Share和腾讯会议完成,Live Share用于观看代码,通过腾讯会议进行语音。
零散的时间里通过微信和Git完成协助。
13. 结对总结
结对编程 | 我 | 17373072 | |
---|---|---|---|
优点 | 1. 有助于互相学习编程经验和好的代码实践 2. 互相监督, 互相纠错, 互相复审,有助于减少bug |
1. 有一定代码能力, 写代码效率较高 2. debug能力较好 |
1. 对需求分析仔细认真 2. 对细节考虑的比较周到 3. 编写代码认真,合作较好 |
缺点 | 1. 双方习惯上的差异可能导致合作上出现差错,导致效率降低 2. 频繁的交流成本较高,时间等因素难以满足结对的需求 |
1. 对需求分析的不够细致 | 1. 对bug的发现有些不足 |
松耦合
我们与(17373071, 17373078)组进行了模块互换。
最开始对接的时候有严重的问题,因为我们的设计思路完全不同。我们组的思路是UI只作为一个显示,其余的计算和状态维护等都由核心模块完成,而对方的思路是核心只有一个求交点的功能,其余的状态数据以及添加修改都由UI完成。
我认为一个系统复杂的部分特别是状态维护应该由单一模块完成,这样复杂度集中在了一个模块,其他模块就比较简单,很容易扩展,也比较方便维护。拿这次项目来说,核心应该完成尽可能多的内容,UI只需要进行显示,这样不管是桌面UI,Web UI,都只需要完成显示部分,也是只能由UI完成的部分。否则每个UI都维护一套状态,实际上就重复实现了逻辑,没有做到复用。
经过讨论后,对方组也使用了核心维护状态的方法。这里再次感谢该组进一步的将API向我们的靠拢,最后我们两组的API几乎完全一致了,可以很容易的实现对接。
交换dll后依然能够正常工作。
交换效果
代码检查
通过 Code Quality Analysis 的检查,没有警告。
总结
这次作业有两方面的收获:
-
熟悉了接口开发的流程
过去自己写个人项目较多,对接口考虑的较少,这次不但熟悉了dll的使用,同时也思考了应该如何去设计良好的接口。另外在互换模块的时候,我们开始因为API不同难以对接,看来在各种工程中都应该是接口先行,否则后果就是必须重构。
-
熟悉了结对开发
在合作中我主要到了代码复审的重要性。在一个人写代码的时候,对整个项目都是十分熟悉的,但是在多人开发时,如果不进行代码复审,就会导致对代码不熟悉,可能导致错误的理解以及引入bug。
本次项目中开始了简单的合作和对接,积累了一定的经验。期待之后的团队项目中收获更多。