一、写在前面
OO第一单元结束了,在这单元中我初步接触了面向对象和层次化设计的思想,本单元主要任务是读取表达式,并进行括号展开,化简输出。下面是我对着三次作业的总结和感想。
二、基于度量来分析程序结构
本部分借助工具对三次作业的程序结构进行分析,衡量指标主要包括:
代码量:利用Statistics插件对整个项目进行分析,其会显示每个类的代码行数和总行数。
UML类图:表现出代码架构,便于说明和理解思路。
类复杂度:利用MetricsReloaded插件进行分析,具体有以下指标:
OCavg指类的平均操作复杂度。
OCmax指类的最高操作复杂度。
WMC代表类的加权方法复杂度。
方法复杂度: 利用MetricsReloaded插件进行分析,具体有以下指标:
CogC指方法的认知复杂度。
ev(G)指方法的基本圈复杂度,用来衡量程序非结构化程度。
iv(G)指方法的设计复杂度,衡量模块和其他模块之间的调用关系。
v(G)指方法的圈复杂度,数量上为独立路径的条数。
复杂度过高意味着架构设计不合理,某个部分可能可以“再分”成若干部份。好的架构有助于我们更流畅地debug和迭代开发。
2.1 Homework1
2.1.1 代码量
总体来看,代码量较多的是Parser和Term。
其中Parser职责是对输入的表达式进行解析,因此占比较大是可以理解的。
但Term行数过高的原因是我在这个类里进行了对表达式展开并输出的具体实现,这其实超出了这个类本身应有的职责,是不合理的。
我当时一心想实现功能完成作业,导致我没有静下心来思考架构,这也导致了我接下来的重构。
2.1.2 UML类图
在第一次作业中,我采用递归下降的方法解析表达式。
表达式Expr由若干个项Term组成,项Term由若干个因子Factor组成,因子Factor包括了三种:变量Variable,常数Number,表达式Expr。由此形成了组合层次结构和递归层次结构。
解析输入时调用解析表达式的方法,解析表达式时多次调用解析项的方法直到结束,解析项时多次调用解析因子的方法直到结束,解析因子时判断是哪一种因子并调用解析对应因子的方法。
输出表达式时多次调用输出项的方法;输出项时从第一项往后遍历,遇到表达式因子则利用乘法分配律派出多个遍历分支分别进行输出,遍历到尾部时将此次遍历的所有项进行输出。这样就完成了展开括号的要求。
架构的优点:思路直观、简单。
架构的缺点:未进行展开后表达式的合并,性能低下,且可扩展性低,亟待重构。
2.1.3 复杂度分析
类复杂度:
由以上表格图可以看出我的Term类(被包含在了Expr类中)和Parser类复杂度超标,尤其是Term类,复杂度的主要来源是我将字符串的展开和输出方法放在了Term中,这是不合理的,应该另开一个类来实现。
方法复杂度:
上面的表格图与我前面对于类复杂度分析的表述相呼应,可以看出Parser类和Term类的复杂度的主要来源,解析因子的方法和展开并输出项的方法,一种解决方式是对其进行解构,分成多种方法,必要时另开一个类。
2.2 Homework2
2.2.1 代码量
可以看到第二次作业的代码量相比于前一次显著增加,这主要是由于功能的增加(自定义函数的带入,三角函数的引入)。
2.2.2 UML类图
如上图,我采用了与第一次作业截然不同的思路,我利用了后缀表达式的思路对表达式进行读取和计算。为了便于检查,我建了一个后缀表达式树。运算符为分支节点,叶节点恒为表达式。建好后用后序遍历的方式对其进行计算和化简,简洁直观且不易出bug。同时我建了两个静态方法类来进行自定义函数的带入和各种运算。
架构的优点:1.用一个统一的表达式节点包括了各种各样的因子、项等,然后在其内部实现各种各样的计算,外部看来统一为表达式。2.采用后缀表达式进行计算,顺带化简,正确且高效。3.复杂的方法用静态方法类进行实现,分散了复杂度,架构更合理。
架构的缺点:虽然有意分散了复杂度,但由于短时间内从新写起,时间较紧,有些类的复杂度还是有点高。
2.2.3 复杂度分析
类复杂度分析:
方法复杂度分析(截取部分,其余复杂度较低,可忽略):
相比于第一次,这次作业的复杂度的最大值更低,实现的功能更复杂,计算和化简思路也更简单,debug也更容易,这离不开架构和化简方法的改进。
2.3 Homework3
2.3.1 代码量
因为前一次架构可扩展性较强,故这次作业实现较为轻松,加的代码占比也较少。举个例子,在我原来的架构上实现这次作业,就像是在一个已经实现add和addi的CPU数据通路上增加addu和addiu等功能,并没有质的改变,只需在几个细节处增加操作选择的分支,并实现相应操作即可。
2.3.2 UML类图
可以看到相比于上一次作业,我只是在表达式类Expr、项类Term、变量类Variable和三角函数类Trif中依次实现了求导方法。
由于这次时间比较充裕,我便新建了一个类对结果进行化简优化,但只实现了平方和的化简。
其余基本无变动。
架构的优点:1.架构可扩展性强。2.计算、展开和化简较方便,基本不需要考虑特殊情况。
架构的缺点:在上一次的基础上没有进一步分散复杂度,部分类的复杂度仍较高,同时对结果的优化不足,比如没有实现二倍角公式,只实现了简单的优化。
2.3.3 复杂度分析
类复杂度:
方法复杂度(截取部分,其余复杂度较低,可忽略):
由于上次的架构的缺陷对此次功能迭代的影响并不明显,因此我没有对其进行进一步改进,复杂度与上一次作业大致相同。
三、架构设计体验
这一部分我会结合功能实现过程,详细阐述我的架构设计思路。
3.1 Homework1
这次作业主要分为读取和输出,其中输出这一过程间接实现了表达式展开,但没有进行合并,导致这次作业性能很低。同时因为我利用了括号最多一层的条件,架构的可扩展性很差。
3.1.1 读取
读取的类包括了Lexer和Parser,具体过程我在第一次作业的UML类图下面已经基本阐述清楚了。这样的读取过程是建立的层次结构决定的,即Expr下分出多个Term,Term下分出多个Factor,Factor有多种不同的具体实现。
3.1.2 输出
输出时我们首先调用输出表达式的方法,输出时依次调用输出每一个项的方法。每一个项的输出十分类似于第二次上机实验的思路,即从第一个因子向后推进,穷举所有可能的组合并输出,以此实现括号展开。
3.2 Homework2
这次作业利用后缀表达式的思路进行压栈读取,最后建立一个后缀表达式树,然后利用后序遍历进行计算,途中顺便进行合并,而括号的消去在压栈的时候就已经实现了。
压栈时操作符的优先级如下:
项目 | Value |
---|---|
± | 1 |
* | 2 |
neg/pos | 3 |
** | 4 |
sin/cos | 5 |
dx/dy/dz | 5 |
( | 6 |
其中neg/pos是每一项前面自带的符号,注意与±号代表的加减操作区分。
值得注意的是这个优先级利用了题干给出的限定条件如sin/cos以及dx/dy/dz后边必跟一个括号,我们可以推断出sin要入栈时栈顶一定不可能是dx,还有很多类似情况。如果要更强的泛用性,需要对上述优先级以及出栈规则适当修改,但在本次作业中上述优先级和我们在大一数据结构课中学到的后缀表达式出栈规则已经能够保证正确性。
一个建立好的后缀表达式树的例子如下:
±(dx(–sin(x*(x-1))**8+1))**2
(上面一并展示了dx操作,在第三次中才出现,但加上去并不难,体现了这种架构良好的可扩展性)
因此我们建立一个结点接口,用操作符和表达式来实现它,表达式内部是项的数组,项由常数*x^exp*y^exp*z^exp*若干三角函数^exp组成。这样的架构很好地实现了各种类的统一化访问(比如节点类包括了操作符和表达式,叶子节点也仅由表达式组成而不是项和各种因子),后序遍历时不需要进行复杂的特判。
最后将根节点返回的表达式输出时调用各个项的输出,各个项输出时再调用各个因子的输出,其中三角函数因子再递归调用表达式的输出即可。
这里我的优化比较简单:
每个项要根据自己是否为1来判断是否要将常数1舍去。
在输出三角函数时要判断其包含的表达式是否可以看作单个因子,否则加括号。
最后返回表达式字符串时判断一下一开始是不是+,是则去掉。
3.3 Homework3
这次作业的架构变化不大,主要实现了求导操作,具体实现已经在上面的示例图中展示了,这次作业把重点放在了支持自定义函数调用和三角函数优化上。
3.3.1 支持自定义函数调用
每次读取时用已经实现的计算方式和解析好的自定义函数来解析新的自定义函数,得到的字符串作为这次读取的自定义函数即可。
3.3.2 三角函数优化
主要体现在利用sin,cos的函数性质将-sin((-x))转化为sin(x),将cos((-x))转化为cos(x),以及将sin(x)**2+cos(x)**2 转化为1。
3.4 重构体验
第二周的重构比较紧张,但效果很好,有了前一次的经验,在进行第二次的重构时我考虑了很久才开始写代码,写起来更加流畅,性能也更好。两次对比,我发现:1. 重构后的类更多,类内部的属性和方法安排也更合理,类之间的关系也更清晰,易于维护。2.类的最高复杂度有所降低,也利于维护和迭代。
经历了这次重构后我的一个心得是,当进行迭代时如果实在无从下手,一定要果断进行重构;但我们不能抱着每次都要重构的想法而不认真考虑每次的架构,每次重构都是对我们的一次历练和提醒,我们应当深思熟虑后再动手,多考虑接下来可能进行怎样的迭代开发,尽量减少重构。这样我们才能更好地学习面向对象这种新的思想。
四、优化策略
本单元我进行的优化主要有:
- 合并同类项,注意要考虑三角函数的相同。
- sin(0)=0,cos(0)=1
- 三角函数表达式(能看做非表达式因子时)内能去括号就去括号,而不是无脑加括号。
- -sin((-x))=sin(x),cos((-x))=cos(x)
- sin(x)**2+cos(x)**2=1
五、分析自己程序的bug
三次作业中仅在第一次作业中出现了一个bug,错在自己对于java函数调用的性质不熟悉:java函数调用传的是引用值(地址),在另一个调用函数内对传入引用对应对象进行修改时,此影响在函数结束并不会消失而是一直保留。我们一定要充分理解java中对一切对象的操作都是用其引用进行的这一思想。
六、分析自己发现别人程序bug所采用的策略
1.基本功能测试
另外一种发现bug的方式是遍历各种可能的组合进行判断,比如在中测中可能只进行了dx的测试,我们也要进行dy和dz的测试等,因为这样的粗心导致失分是很可惜的。简单来说就是各种基本功能要实现全。用自己构造的评测机来进行这种组合穷举工作非常合适。
2.特殊情况测试
评测机往往有一定局限性,因为随机构造很难有一些边界情况(好比在一个1x1各自内选择一个点,落在内部的可能性为1,落在边上的可能性为0一样)。除非你人工添加了一些特殊情况,但这又跟手动构造很像了。
三次作业我都采用构造一些边界数据进行hack的思路,比如sin(0)**0等,在我看来,需要进行特判的一些数据就是容易出错的数据,而采用越少特判的架构就是更好的架构,因为这样的架构的泛用性更强。
七、心得体会
由于我上学期没有学习先导课程,寒假又没有进行预习,这三周适应的新事物对我来说比较多。总的来说,这三周我的心情跌宕起伏、收获也颇丰。虽然过程略显不易,但经历了上学期计组课程的磨练,适应起来并不难。我深知这只是开始,要真正掌握面向对象编程思想仍道阻且长,我会调整心态继续向前进。
总结的心得体会主要如下:
1.从新开始写作业或者进行迭代前一定要充分思考再动手,动手之前整个架构应该已经在心中比较清晰了,这样会让自己思考更有深度,也更有收获,而不是只拿掉每次的分数就行。
2.不要闭门造车,多看讨论区,多学习别人的架构和思路。有时别人架构的一个小细节就能解决你半天想不出来的一个难题,同样的,你的架构也可能在不经意间给别人带来启发。
3.虽然本门课程不直接考察测试数据的构建,但有很多侧面考察的环节(比如充分测试自己的程序保证其过强测,构造数据hack别人的程序而拿分等等),我在这方面有所偷懒,下个单元开始应当重视这部分。
4.优化的同时要兼具正确性,如果不能保证正确性应当放弃这个优化,否则测试点出错会导致得不偿失。