命令行版:https://github.com/JerrryNie/Arithmetic/tree/cmd-app
GUI界面版:https://github.com/JerrryNie/Arithmetic/tree/gui-app
PSP | Personal Software Process Stages | 预估耗时(分钟) |
---|---|---|
Planning | 计划 | |
·Estimate | ·估计这个任务需要多长时间 | 5000 |
Development | 开发 | |
·Analysis | ·需求分析(包括学习新技术) | 500 |
·Design Spec | ·生成设计文档 | 400 |
·Design Review | ·设计复审(和同事审核设计文档) | 100 |
·Coding Standard | ·代码规范(为目前的开发制定合适的规范) | 40 |
·Design | ·具体设计 | 300 |
·Coding | ·具体编码 | 1800 |
·Code Review | ·代码复审 | 100 |
·Test | ·测试(自我测试,修改代码,提交修改) | 500 |
Reporting | 报告 | |
·Test Report | ·测试报告 | 100 |
·Size Measurement | ·计算工作量 | 60 |
·Postmortem & Process Improvement Plan | ·事后总结,并提出过程改进计划 | 200 |
合计 | 4100 |
题目要求至多生成一千道题目,并且要求不重复。关于判重,最初的想法是按照题目中的要求进行判断,但在实现过程中发现实现较为困难且复杂度较高,故放弃。为了满足不重复的要求,我们使任意两个表达式之间至少有一个数字不同,可以较为高效的实现表达式不重复的目标。
针对生成,最开始没有做特别多的限制,根据题目要求,每个表达式的至多有十个运算符,括号个数不限,因此对于每个操作数和操作符都采取了随机生成的操作,对于操作数,将其限制在了100以内。
关于分数,由于最开始没有自习看题目中的用例,因此操作数和操作符之间没有空格,因此将分数作为一个整数除以另一个整数进行计算,后来发现这样处理是有问题的,于是在生成操作数的时候又采取了一次随机,用来确定该次生成的操作数的类别。而且为了降低题目难度,将分数的分子限定在了10以内。
关于括号,采取随机数确定括号的数量和位置,并对不合法的括号予以删除(如一个数字两侧有括号;同一个表达式被两个括号包围等)。
这样生成的式子虽然满足要求,但是计算难度过大,根本无法在20s内完成计算,特别是甚至会出现溢出的情况,虽然已经将指数限制在3以下,并且避免了连续指数的情况(如2^3^3),但依旧会存在难以计算以及溢出的问题。为了解决这个问题,对题目生成又增加了诸多限制:
1)不会出现两个以上的数连续相乘除的情况。
2)不会出现乘方结果相乘除的情况。
3)当有分数参与运算时,参与运算的整数大小不超过10.
4)对溢出进行检测。利用c#内置的checked()函数进行溢出检测,若溢出则修改操作数避免溢出。
当增加了上述限制之后,题目难度相对来说在可接受范围内,不过仍旧有一定概率出现较难计算的题目,但概率已经比较小,且避免了溢出的情况。但以此种方法生成会导致乘方运算的个数较少,生成1000道题目仅有50~60个乘方运算符。
题目要求中要求解不超过十个运算符、支持加减乘除乘方操作及任意数量括号的算术表达式。针对这个问题首先想到的是将中缀表达式转为后缀表达式进行计算,转换规则如下:
1)如果遇到操作数,直接将其输出。
2)如果遇到栈为空或左括号,直接将其放入到栈中。
3)如果遇到一个右括号,则将栈元素弹出,将弹出的操作符输出直到遇到左括号为止。其中,左括号只弹出并不输出。
4)如果栈不为空,从栈中弹出操作符直到栈顶元素的优先级小于当前操作符的优先级。弹出完这些操作符后,才将遇到的操作符压入到栈中。对于左括号,只有遇到右括号才弹出,否则不弹出
5)如果读到了输入的末尾,则将栈中所有操作符依次弹出。
6)操作符优先级为:加减<乘除<乘方,其中,加减乘除结合性均为左结合,而乘方为右结合。具体表现在表达式转换上为对于加减乘除,要求栈顶元素优先级小于当前元素;而对于乘方,则改为小于等于。
将表达式改为后缀表达式之后,可以通过栈对表达式进行计算,计算规则如下:
1)若遇到操作数,则将操作数压入栈
2)若遇到操作符,根据操作符的运算目数(单目,双目,三目)取出相应的操作数并进行计算,并将计算的结果压栈。
3)遍历完后缀表达式即完成计算。此时栈中应只有一个操作数,即为结果,否则说明后缀表达式有误。
题目中要求要能够实现分数计算,而且计算后缀表达式时需要将操作数与操作符放在一个数组里边,因此定义了UniType类用来统一存储操作数与操作符
public class UniType
{
public int type = 0;
public char op = '+';
public int numerator = 0;
public int denominator = 1;
}
type用来区分表示的是操作数还是操作符,0表示操作数,1表示操作符。所有的数字统一用分数来表示,numerator表示分子,denominator表示分母,对于整数令分母为1即可。
题目中指定了两个功能,一个是生成指定数量的题目并输出到一个文件中;另外一个是生成题目并接受答案判断正误。因此设定了指定的参数格式来调用这两个功能。
调用第一个功能的参数格式为“-g num path powtype”,具体含义如下:
-g:表示要调用的功能为生成
num:表示生成题目的数量,要求为正整数
path:要保存的文件路径
powtype:乘方符号的形式,0采取“**”,1采取“^”
调用第二个功能的参数格式为“-s num powtype”,-s表示要调用的功能为做题求解,其他参数含义同上。
首先我们根据需求,建立如下的用例图:
基于对题目的分需求析,主要有三个类,一个题目生成类,一个题目求解类,以及控制类。同时为了增强可扩展性,题目设计了一个独立的类。类图如下
其中Control类为控制类,属性及方法含义如下:
generate_num:生成题目的个数
point:正确题目个数
GenerateToPrint():生成题目并输出的控制模块
GenerateToSolve():在命令行内做题的控制模块
Check():检查答案是否正确
ControlCore():核心控制模块,用来接收处理参数并根据调用功能调用相应的控制模块
ProblemSet类为题目集合,属性及方法含义如下:
problem_set:存放题目的集合
Generate():生成指定数量的题目
Get():返回题目集合
Problem类为单个题目,属性及方法含义如下:
exp:存放算式的字符串
GenerateSingle():生成当前题目的算式
Get():获取表达式
Solve类为求解类,属性及方法含义如下:
cur_problem:当前要求解的题目
Cal():计算当前题目
InfixToPostfix():将中缀表达式转为后缀表达式
类间调用关系如下:
单元测试结果如下
在进行类的设计时只考虑了主要的接口特别是供用户及GUI调用的接口,但在实现的过程中发现若将一个功能全部在一个函数内实现会导致该方法过于复杂,需要拆解于是实际实现的类远远比设计的要复杂,增加了许多成员变量及方法。但是由于这些成员变量和方法是对一个功能的拆解,对外部来说实际上是不可见的,因此许多属性并没有提供get/set方法,许多方法也都是void型的,难以进行测试。故单元测试主要对参数检查模块进行了测试;对于计算模块,由于无法单独测试各子模块,因此只进行了最终结果的测试。除此之外,对于gcd(最大公约数求解模块)等可测模块进行了单元测试。
代码覆盖率如上图所示,总体覆盖率有87%,并不是很理想。主要原因是对于Control模块,由于求解题目需要输入答案无法进行单元测试,因此覆盖率只有60%,Problem为了类的完整性及可扩展性设置了Set方法和默认构造函数,但这两个方法在项目实现过程中并没有被调用,因此覆盖率只有57%。但可以看到对于核心的ProblemSet类和Solve类,覆盖率都能达到90%以上,可以说对代码进行了较为全面的测试。
因为c#语言相对较为成熟,不存在不安全的函数等情况,因此本项目并没有警告,所以没有处理warning步骤。
public void InfixToPostfix()
{
int idx = 0;
Stack op_sign = new Stack();
while (idx < length)
{
//如果当前处理的为运算符
if (exp[idx] > '9' || exp[idx] < '0')
{
UniType cur = new UniType();
cur.type = 1;
cur.op = exp[idx];
while (true)
{
//乘方为右结合,需要单独处理
if (exp[idx] == '^')
{
//dic_pri为一个dictionary,存储了运算符的优先级
if (op_sign.Count == 0 ||
dic_pri[exp[idx]] >= dic_pri[op_sign.Peek().op])
{
op_sign.Push(cur);
break;
}
}
if (exp[idx] == ')')
{
while (op_sign.Peek().op != '(')
{
postfix_exp.Enqueue(op_sign.Peek());
op_sign.Pop();
}
op_sign.Pop();
break;
}
if (op_sign.Count == 0 ||
dic_pri[exp[idx]] > dic_pri[op_sign.Peek().op] ||
op_sign.Peek().op == '(' ||
exp[idx] == '(')
{
op_sign.Push(cur);
break;
}
else
{
postfix_exp.Enqueue(op_sign.Peek());
op_sign.Pop();
continue;
}
}
idx++;
}
//如果当前处理的为操作数
else
{
int sum = exp[idx++] - '0';
while (exp[idx] <= '9' && exp[idx] >= '0')
{
sum = sum * 10 + exp[idx++] - '0';
}
UniType cur = new UniType();
cur.type = 0;
cur.denominator = 1;
cur.numerator = sum;
postfix_exp.Enqueue(cur);
}
}
//将操作符栈的剩余内容输出
while (op_sign.Count != 0)
{
postfix_exp.Enqueue(op_sign.Peek());
op_sign.Pop();
}
}
上面代码实现了将中缀表达式转后缀表达式的功能,实现思路与设计部分的算法一致,主要思想方法是将直接入栈的情况分类讨论,如果满足就直接入栈,否则一直输出栈顶运算符并出栈。
public void CalPost()
{
Stack st_cal = new Stack();
try
{
while (postfix_exp.Count != 0)
{
//如果当前读取的是操作数直接入站
if (postfix_exp.Peek().type == 0)
{
st_cal.Push(postfix_exp.Peek());
}
//对于操作符,如果是负号(不是减法),直接运算
else if (postfix_exp.Peek().type == 1 && postfix_exp.Peek().op == 'm')
{
st_cal.Peek().numerator *= -1;
}
//否则按规则进行运算
else
{
UniType num2 = st_cal.Peek();
st_cal.Pop();
UniType num1 = st_cal.Peek();
st_cal.Pop();
if (postfix_exp.Peek().op == '^' && Math.Abs(num1.numerator) > 6)
{
validate = false;
}
st_cal.Push(Cal(num1, num2, postfix_exp.Peek().op));
}
postfix_exp.Dequeue();
}
//除零为不合法情况
if (st_cal.Peek().denominator == 0)
{
validate = false;
}
//根据最后结果的分母是否为1以及分子分母的正负性调整结果
if (Math.Abs(st_cal.Peek().denominator) == 1)
{
res = (st_cal.Peek().numerator * st_cal.Peek().denominator).ToString();
}
else
{
if (st_cal.Peek().denominator < 0)
{
st_cal.Peek().numerator *= -1;
st_cal.Peek().denominator *= -1;
}
res = st_cal.Peek().numerator.ToString() + "/" + st_cal.Peek().denominator.ToString();
}
st_cal.Pop();
}
catch
{
validate = false;
}
}
上述代码为计算后缀表达式的代码,实现思路与设计部分的算法一致,除此之外还增加了判断表达式是否合法的功能,用来在生成表达式时剔除不合法的表达式。
在命令模式下做题,输入答案后可以立即得到反馈。
如图所示,每次输入答案后会显示输入的答案并给出正误,如果回答错误还会给出正确答案。完成所有试题之后会给出总共的答题情况。
生成部分的性能分析如下图所示
生成+求解部分性能分析如下图所示。
可以看到, 生成1000道题目+求解(不包括输出)的时间只比生成1000道题目多20ms,说明求解的性能相对来说比较优秀。但是可以看到,同样是1000道题,生成部分的求解耗时远大于外部求解耗时。原因有一下几点:
1.生成部分调用求解是来判断在计算过程中是否出现了过大的数,虽然已经限制了操作数和操作符,但是仍旧存在一定的可能性导致有较大的中间操作数出现。计算这些庞大的操作数或较大的乘方操作会花费更多的时间。
2.因为对不合法的式子需要剔除,所以在生成部分实际求解的式子数量要大于1000,经过统计,平均每次生成部分实际求解的式子为1600个左右,计算更多的是自必然导致花费更多的时间。
从上图可以看到(独占样本),整个程序占用时间最多的分别是栈的peek操作,查重以及clr.dll的调用。其中clr.dll的调用为c#程序自动调用的;peek操作为计算表达式不可或缺的操作,这些都无法进行优化(没有实现自己实现栈,因为通常来说封装好的数据结构性能会优于自己实现的数据结构)。唯一可以优化的部分是查重,但是对于查重,我们也设想了各种方法,但要保证任意两个式子都不同,那么复杂度必然是O(),在这个基础上,通过比较运算数个数和出现情况基本上可以说是最优的方法了。因此可优化的空间非常小。
在本结对项目中,我们选择第1个扩展项目,即将程序变成一个Windows电脑图形程序,同时增加“倒计时”功能,每个题目必须在20秒钟完成。如果无法完成,则得0分并进入下一题。增加“历史记录”功能,把用户做题的成绩记录下来并可以展现历史记录。
为了对需求进行有效的提取,我们首先通过绘制用例图来对需求进行进一步的阐述。
用户所要进行的活动主要为3个(之后的界面也将围绕着这3个部分进行叙述):
登录:用户输入登录信息,系统对登录信息进行合法性检测,如果合法,才能进行做题
做题:用户在做题界面做题,并可以随时看到界面的倒计时信息。当用户提交答案后,可以看到对于提交答案的反馈信息。
查看历史记录:用户在历史记录的界面可以查看当前做题的历史记录。
本软件分为两个包:cal-cmd包和gui_app包,分别对应生成与计算部分的包以及控制器和UI界面展示数据的包。
本软件的设计依照“MVC”模型的设计方法,但因为控制器的设计比较简单,因而就将控制器与UI界面放在一个包(即gui_app包)中。
为了体现出包间关系,和包图中类的可见性,这里仅列出了包中所包含的类以及包间的关联。这里为了在GUII界面使用cal-cmd中的类,使用“import”关系来关联两个包。通过import,使得gui_app中的Controller类不仅可以获得来自cal-cmd中的类所提供的数据,并将其提供给各个UI界面,同时它也完成了将UI界面所接收的数据传向cal-cmd中界面的功能。
这种设计方案通过中间的Controller类,来实现模型(计算生成部分)与视图(UI部分)之间的数据传递,进一步解耦了两个模块之间的关系。
在下一部分,我们将主要展示增加UI界面后,此MVC模型的类间关系,并将以类图的形式加以说明。
GUI界面所有的类图设计如上,此处的主要部分分为三块:
如上图中的1号框所示。框内类间关系与每一个框所表示的类的作用已在前面叙述过了。所以这里主要说明1号运算与生成部分是如何与控制器Controller类进行关联的:
控制器内聚一个ProblemSet类的实例。当需要生成题目时,直接调用ProblemSet类内的Generate函数,实现题目的生成。同时,Controller可以动态生成Solver类的实例,用于具体题目的求解。
同时,控制器也与UI界面进行联系。主要通过UI界面的主窗口Form1的实例,给控制器发送生成题目的指令、从控制器接收题目、向控制器发送待求解的题目,控制器将题目的答案返回给主窗口等。
在GUI部分,Program类唯一生成一个MultiFormApplicationStart类,用以管理程序刚开始运行时生成的所有窗口。
MultiFormApplication类里生成了Form1(主界面)和Login(登录界面)的类的实例。
Form1中内聚了RecordSet类,用以组织所有的历史记录信息。并且,当用户需要查看历史记录时,Form1的实例会生成一个HistoryForm实例,用以展示历史记录信息。
RecordSet类里内聚了数个历史记录信息,每一条历史记录用Record类来进行封装,便于之后程序功能的扩展。
因为cal-cmd各个模块的设计在之前已有所阐述,因此在本部分仅具体说明关于gui_app包中类的设计。
这里的介绍顺序主要以程序运行时类的使用顺序为主。
Program类是程序的入口类,从Program类的Main函数中进入程序,并生成组织各类窗口的MultiFormApplicationStart类的实例。
MultiFormApplicationStart类是用于组织各类界面窗口的类。
其中的构造函数MultiFormApplicationStart()首先生成Login类(用于登录界面)和Form1类(答题主界面)的实例,并将二者加入管理中。当某一个窗口的实例关闭的时候,将调用onFormClosed方法。当所有的窗口都关闭之后,将关闭这个类的实例,并结束程序。
Login类主要用于登录界面的设计。
登录界面的概览如下(UI部分的使用流程会在后文中进行介绍):
此类中的属性部分主要是用于接收登录界面的一些控件的数据,这些数据最终通过调用Click事件来实现数据的接收。
Login类的构造函数Login(Form1 Form1Obj) 传入了一个主要答题界面的实例,这是为了在关闭当前类的实例之前,能够显示答题界面类(Form1 类)。
button3_Click函数是点击“Login”按钮的点击事件处理函数。此时会首先检查登录信息的合法性,即检测是否输入用户名及密码,并在出现异常的时候,弹窗反馈,并要求重新输入信息。当输入信息完全合法时,将登录的信息传递给主界面类的实例,显示主界面并关闭登录界面。
Form1类是主界面类,实现的功能较多,因而在此处主要阐述一些比较重要的功能。
主界面概览如下:
当点击“开始答题”按钮后,StartButton_Click按钮会开始起作用,在触发计时的时候,也会同时通过Controller类,从cal-cmd包中获取相应的题目。
当开始答题后,界面如下:
主界面中定义了两个timer类的实例,分别为timer1和timer2,这两个实例一个用于界面的倒计时,一个用于正计时,分别通过timer1_Tick函数和timer2_Tick函数实现。
ConfirmAnsButton_Click函数是在“确认答案”按键触发之后实现的点击事件处理函数,此时,这个函数将接收用户输入的答案,并进行合法性检测,如果没有问题,就会通过Send函数将答案发送给Controller,并最终反馈回答案的正误情况。
Send函数是Form1类与Controller类之间的接口之一,此函数将向Controller发送当前题目的答案,并将答案的正误反馈给Form1,最终显示给用户。
当每结束一道题的回答之后,Form1会使用它所聚合的RecordSet实例,同时它会在Send函数中,将当前的答题记录发送给RecordSet进行组织和整理。
GetProblem函数是Form1从Controller那里获取题目内容的函数。获取题目之后,此函数将负责将题目显示在主界面上。
FinishButton_Click函数是在当题目回答结束后,向用户展示答题历史记录的函数。当点击此按钮后,用户首先会看到一个弹窗,用来显示当前的答题总数和正确题目数。然后此函数还将隐藏主界面并动态生成一个新的历史记录窗口。
HistoryForm类是用于展示答题记录的界面类。
此界面概览如下:
此类的构造函数HistoryForm在加载该界面的时候,也将所有用户答题的历史记录信息传送到该界面上。
timeAtPresent函数用以显示当前的时间。
Record类记录的是每一条历史记录的信息。因为考虑到需求变更以及功能的扩展问题,将每一条记录用一个Record类的实例来表示。
ansDate字段是本条记录的答题时间
correctAns是本道题目的正确答案
id是本条记录的键
name是本道题目的答题者
quesContent是本道题目的具体内容
trueOrFalse是本道题目最终的回答情况
userAns是做题者的答案
Record构造函数将通过这些题目来进行构造
RecordToString将本道题目的答题信息以字符串的形式生成,便于最终的展示
RecordSet类是用于对Record实例进行有效的管理。
Records字段是用来组织所有包含在当前RecordSet中的Record实例
CreateNewRecord函数用以向这个Record的集合中添加新的历史记录信息
ShowRecords是用于最终的历史记录展示
Controller类是实现UI部分与逻辑部分之前衔接的类。
这里Controller类的设计使用Singleton模型,为的是避免重复生成导致UI与逻辑部分信息交换出现衔接的问题。
其中,
problem_set将从cal-cmd包中接收到的所有题目存放起来,以备UI部分展示需要。
GenerateProblemGUI2Controller是用来接收从UI部分发出的题目生成信息,并将次命令发送给cal-cmd包的中转站。
GetProblemController2GUI是将题目从cal-cmd中接收到,并将题目发送给GUI的一个接口函数。
SendAnsGUI2Controller是一个将用户答案从GUI中通过Controller发送给cal-cmd包的接口函数。
我们点击相应的可执行文件,进入如下的登录界面:
在此界面上,我们需要在1号文本框里输入用户名,在2号框中输入相应的密码,然后点击3号按键。
如果输入出现空缺的部分,此时当我们点击3号按键“Login”会出现如下的情况:
假设我们的输入如下:
并单击“Login”,此时我们将会进入答题界面:
在这个界面中,1号框用于显示倒计时(倒计时仅为20秒钟), 2号框显示答题已用时间。当点击3号按键之后,4号框会出现当前题目的序号,5号框会显示当前的题目内容,6号框用于用户输入具体的答案。此时,3号框会消失,即出现下面的界面:
当用户填写完成答案之后,点击7号按键,会将答案发送给系统,并将反馈答题的结果:
并接着进行下一道题。
如果输入的答案不合法:
同时,我们在作答每一道题的时候,倒计时会一直显示,当剩余5s的时候,倒计时会变成红色,用于提醒:
当倒计时结束之后,将会作如下显示:
并紧接着进入下一道题。答题的超时情况也将算作答案错误,记入最终的成绩中。
当我们点击“答题结束”按键之后,会出现如下信息,展示答题情况:
单击确定之后,会进入历史记录查看界面:
由于界面设计的效果与实际运行时存在一定的差异,在实际运行时,需要通过拉动滚动条来查看所有的答题信息。因而在这里展示一张答题的历史记录设计界面:
当关闭此界面后,程序会转回答题的界面:
此时点击“开始答题”,会开始新一轮的答题。
由于在基本写完博客之后,在GUI界面又新增了选择阶乘符号的功能,因此将此功能介绍如下:
如图画框部分所示。此处可以选择阶乘的种类:
此时我们就通过这个下拉框,将阶乘的格式改为了^。
PSP2.1 | Personal Software Process Stages | 实际耗时(分钟) |
---|---|---|
Planning | 计划 | |
·Estimate | ·估计这个任务需要多长时间 | 25 |
Development | 开发 | |
·Analysis | ·需求分析(包括学习新技术) | 130 |
·Design Spec | ·生成设计文档 | 50 |
·Design Review | ·设计复审(和同事审核设计文档) | / |
·Coding Standard | ·代码规范(为目前的开发制定合适的规范) | 40 |
·Design | ·具体设计 | 1560 |
·Coding | ·具体编码 | 1500 |
·Code Review | ·代码复审 | 70 |
·Test | ·测试(自我测试,修改代码,提交修改) | 900 |
Reporting | 报告 | |
·Test Report | ·测试报告 | 100 |
·Size Measurement | ·计算工作量 | 60 |
·Postmortem & Process Improvement Plan | ·事后总结,并提出过程改进计划 | 150 |
合计 | 4585 |
有了第一次个人项目的经验,在整个项目的实施流程上相比较于第一个项目更为顺利,但因为采取了较为陌生的c#语言进行设计,在新技术的学习上花费了一定的时间。
本次采用的设计方法为面向对象设计方法,关于uml建模之前只停留在了理论界面,当真正实践的时候发现有许多问题,通过这次的项目,也让我对面向对象建模的方法有了更进一步的掌握和理解。
由于这次需要和他人合作完成,因此分工合作变得十分重要,这主要体现在设计过程中的类间关系及接口设计。之前在另外一门课上,我所在的小组曾因设计存在缺陷导致项目出现很大的问题。这次我吸取了之前的教训,和搭档仔细商量了接口及类之间的调用关系,整个合作过程相对来说比较顺利。
本次项目实施的过程中依旧暴露出了一些问题,主要在于详细设计,即便已经设计好了算法和函数,但在真正实现时仍发现设计会存在一些问题,特别是一些针对代码坏味所需要做的修改和优化在设计的时候很难考虑到,如重复代码的提取等。这导致虽然设计的结构还算完整,但最后完成之后代码的结构较为混乱。这也提醒我需要提高自己的设计能力和代码重构能力。
最后,感谢在项目实施过程中老师的指导以及同组成员和其他同学的辛劳付出!