OO第一单元总结

面向对象编程第一单元总结

一、程序结构

1.第一次作业

类图

image

各个类的设计考虑

Main

主类,负责从标准输入中读取字符串并进行预处理。在Main中构造一个表达式Expression,Expression中用<BigInteger, Term> 的map存储各个项Term。Term是一项,有系数和指数两个属性。
【注:
BRANCH是number of branch statements;
CONTROL是number of control statements;
LOC是line of code;
CogC是Cognitive complexity;
下同】
image

Expression

表达式,负责表达式的求导和输出。一个Expression看做由若干Term相加组成,Expression的导数即为各个Term的导数之和。在Expression中利用map存储的优越性自动完成合并同类项的过程。

求导方法的模块设计复杂度较高,这个方法里既包含了求导部分,也包含了化简部分,应当把它们拆开。

image

Term

项,负责从代表一项的字符串中提炼出一个项,并且具备求导和输出的功能。Term实现了Comparable接口,自定义的比较函数会把系数大的项放在前边,保证正项不会在负项之后,起到优化的效果。
image

总体代码质量分析:

属性个数 方法个数 代码规模
Expression.java 2 2 55
Main.java 0 2 49
Term.java 2 8 73

image

优点:
能够完成题目要求,没有功能上的bug
缺点:(很多)
Main和Expression这两个类的复杂度都比较高。Main里包括了一部分对字符串预处理的内容,增加了复杂度。Expression内部设计不合理,也带来了复杂度。

在性能方面,我只考虑了合并同类项与正项提前,忽视了x**2可以优化为x*x。我错误的认为“最简”就是“最短”,可事实上,“最简”未必“最短”,“最短”也未必“最简”。
我在第一次作业中没有采用递归下降的方式处理输入,而是采用正则表达式的方式。在Main中把输入用加号分隔(指数中的加号用占位的方式特殊处理),之后把分隔号的字符串经过Expression传入Term,再提炼出项。

尽管一共只有三个类,但是却有远高于问题复杂程度的耦合性。主要体现在Main里对字符串进行了一定的处理,经过Expression传入Term再最终提炼出项和表达式,类与类功能不分明,这是加剧耦合性的关键,尽管因为这次作业比较简单,没有因为耦合性而导致bug,但仍然给第二次作业的重构埋下了伏笔。

总体来看第一次作业的架构设计并不合理。

2.第二次作业

由于第一次作业的架构设计很不合理。所以第二次作业我进行了重构。

另外,我采用了递归下降的办法解析输入字符串,这让代码的可扩展性大大增强。采用递归下降的好处是,只要按照递归下降的基本规则编写代码,细节上的实现仅仅是对题目要求的翻译,题目怎么说,我就怎么写。回避了字符串复杂性带来的各种可能的bug。人们常说“不要重复造轮子”,我想我们不仅要对前人的代码成果加以利用,更要对前人的思想成果加以利用,这样才能站在前人的肩膀上进步。而递归下降这种方法,正是文法分析领域一个宝贵的思想结晶,其正确性已由前人证明,我们只需学习即可。

遗憾的是,我采用了拆开表达式再求导的设计,而没有保留括号。在交作业的前一天,我突然发现保留括号实际上能够获得更好的性能,所以连忙又写了一份,相当于又进行了一次重构。

类图

image

类图非常复杂。原因是在交作业之前,我把我用两种方法做出的代码(拆括号或者不拆括号)放在了一起,用两种方法求出导数,输出较短的。实际上我完全可以只用不拆括号的版本,在里面添加拆括号化简的方法,但由于ddl迫在眉睫,没有时间把它们整合成一套体系。

各个类的设计考虑

Main

负责中枢控制,从标准输入中读取字符串,调用递归下降工厂生成表达式。再调用表达式求导并化简。最后把用两种方法(拆括号或者不拆括号)得到的导数进行比较,输出更短的。

image

NewExpressionFactory

用递归下降法从字符串中提炼出表达式(不拆括号)

一些方法的复杂度比较高,但这是递归下降法本身带来的复杂度。

image

NewExpression

表达式类,管理各个项,包括构造,求导,化简等功能。

化简方法和toString()方法的复杂度都比较高,主要是为了性能的优化而进行了若干特判。

.equals()用于判断两个表达式是否相等,由于采用了list的数据结构,所以需要较大的复杂度。

image

NewTerm

项,管理各个因子,拥有构造,运算,求导,化简的功能

toString()和simplify()方法的复杂度都较高,也是为了优化而进行了若干特判导致。

canMerge()用于判断两个项能否合并,由于一项内存储了很多不同种类的因子,而且也没有规定顺序,所以这个方法也有一定的复杂度。

image

DerivativeAble

可求导接口,定义了求导的抽象方法。用于规范其他代码

Factor

因子。是各个因子的公共父类,继承了DerivativeAble接口,定义了化简和运算的抽象方法。

Cos

余弦因子,负责余弦函数的运算,求导,化简。

image

Sin

正弦因子,负责正弦函数的运算,求导,化简。

image

PowerFunction

幂函数,负责幂函数的运算,求导,化简。

image

OldExpressionFactory

用递归下降法从字符串中提炼出表达式(拆括号)

image

OldExpression

表达式,管理各个Term,包括构造,运算,求导,化简等功能。

复杂度比较高的方法也是为了优化性能而进行特判导致。

image

OldTerm

项,一项由系数,x指数,正弦指数,余弦指数四个属性。包括构造,运算,求导的功能

求导方法的复杂度比较高,原因是这一个类里综合处理了三角函数和幂函数的导数,没有分开求导。

image

MergeTermBySinCos

利用三角函数运算公式,对表达式进行化简

sinCosBackward判断能否利用1-sin(x)^2 = cos(x)^2进行化简。要进行能否化简和化简是否合算两方面的分析,所以复杂度比较高。

image

总体代码质量分析:

属性个数 方法个数 代码规模
Cos 1 8 56
DerivativeAble 0 1 3
Factor 0 6 9
Main 2 6 77
MergeTermBySinCos 14 6 84
NewExpression 1 13 176
NewExpressionFactory 2 15 229
NewTerm 2 13 208
OldExpression 2 13 189
OldExpressionFactory 2 16 184
OldTerm 3 21 193
PowerFunction 1 8 55
Sin 1 8 58

image
优点:
采用递归下降的方式解析字符串,效率高,可扩展性好。
第二次作业与第一次作业相比,每个类的功能更加的清晰,代码的可扩展性更强。
缺点:
有一些类仍然较为复杂,架构设计仍然有待提升。
求导部分涉及到不同类之间信息的传递,化简部分还涉及到不同类之间的类型转换,所以加大了耦合性。
拆括号求导和不拆括号求导的两部分代码没有综合到一起

3.第三次作业

第三次作业与第二次作业相比,主要加了三角函数可以嵌套因子这一规则。由于我的第二次作业采用了递归下降的办法解析输入字符串,所以字符串的解析过程并不需要较大改动。只需在生成三角函数的方法内加上调用读取因子的方法即可,为了判断输入是否合法,还需要进行指数绝对值的检查,以及最终字符串是否读取完毕的检查。

类图

image

在第二次作业的基础上,我删去了拆括号求导的那部分代码,在不拆括号求导的基础上,增加一个拆括号化简的方法,最终用拆括号的结果与不拆括号的结果比较,输出较短的。

与第二次作业相比,我提高了抽象的层次。一个表达式(Expression),含有若干项(Term),一个项,含有若干因子(Factor),Factor是一个抽象类,所有因子都是它的子类。这样做的好处是,我可以把求导的算法分散到各个因子上,而在Expression和Term里仅用到函数加减与函数乘除的求导法则。所有可以求导的东西,都实现接口DerivativeAble,这个接口主要定义两个方法:求导,返回另一个DerivativeAble;化简,返回另一个DerivativeAble。运用java的多态特性可以让代码更加的简洁。

各个类的设计考虑

Main

控制其他类完成工作。从标准输入中读入字符串,调用递归下降工厂解析得到字符串,进行异常处理。调用表达式类进行求导,最终输出导数。

image

Factory

用递归下降法从字符串中提炼出表达式,并反馈可能出现的异常。

递归下降法本身就有一定的复杂度,我在编程时也没有进行特殊的改造,而是直接按照递归下降的套路进行编程,所以会残留一些复杂度。

image

Expression

表达式,管理各个项。拥有构造,运算,求导,化简等功能

化简和输出时都为了性能而进行了若干特判,提高了复杂度。

.equals()方法用于判断两个表达式是否相等,由于我采用list存储各个Term,所以要进行遍历性的查询,复杂度较高。然而时间开销并没有多太多。因为尽管遍历一个表达式里的所有项是线性时间复杂度,劣于map的对数时间复杂度,但是本次作业的输入字符串比较短,不会有太多项,而且list采用线性表存储,访问内存时的局部性更好。

image

Term

项,管理各个因子。提供构造,运算,求导,化简的方法。

与Expression类似,toString()方法和simplify()方法都为了性能而进行了复杂的处理,提高了复杂度。simplify()方法需要递归性的调用其他模块的simplify()方法,所以耦合性比较高。

canMerge方法由于Term里的各个Factor是用list存储,所以要进行遍历查找,有较高的复杂度。

image

DerivativeAble

可求导接口。声明求导和化简的方法,用于规范其他代码

Factor

所有因子的公共父类,实现了DerivativeAble接口。声明了运算和求导的方法。

我觉得DerivativeAble接口和Factor抽象类是降低代码复杂度,减小编程难度的关键。在表达式Expression里,求导的过程只是对各个Term的导数求和。在项Term里,求导是运用乘法的导数规则,对因子和因子的导数进行运算,而不关注各个因子本身的求导法则。对于不同的因子,有着不同的求导规则,这被封装在了各个Factor里。我认为这样层次化的设计比在Term里直接完成所有的求导任务更好。代码的设计与数学上求导的过程相对应:上层类的求导方法与导数乘积和导数加减的法则匹配,底层类的求导方法与因子的求导公式匹配。便于修改,检查和抵bug。而且可以通过形式验证的方法证明正确性。

ConstantNumber

常数因子。负责项中常数的运算,求导,化简。

image

Cos

余弦因子。负责项中的余弦的运算,求导,化简。

化简方法比较复杂, 因为要考虑指数为0,1,其他等多种情况,也要考虑内部因子的化简。

image

Sin

正弦因子。负责项中正弦的运算,求导,化简。

与预先一样,化简方法比较复杂, 因为要考虑指数为0,1,其他等多种情况,也要考虑内部因子的化简。

image

PowerFunction

幂函数因子。负责项中幂函数的运算,求导,化简。

image

ExpressionFactor

表达式因子,管理一个表达式。

化简比较复杂,因为我为了性能而进行了复杂的处理。

image

总体代码质量分析

属性个数 方法个数 总代码规模
Term 1 13 260
ConstantNumber.java 1 13 69
Cos.java 2 10 97
DerivativeAble.java 0 2 4
Expression.java 2 15 213
ExpressionFactor.java 1 11 64
Factor.java 0 5 10
Factory.java 2 16 265
Main.java 0 2 31
PowerFunction.java 1 11 72
Sin.java 2 10 96

image

普通的因子类与其他类的耦合程度不大。

另外,为了给化简留出充分的时间,我对toString()方法进行了重新设计。新增一个StringBuilder参数,把字符串向StringBuilder中添加。这样做能够减小字符串在内存中复制的次数,能够节约一定的时间。

优点:
第三次作业的功能和性能均优于第二次作业,但总体复杂度小于第二次作业。

缺点:
Term和Expression以及Factory这三个类与其他类的耦合程度较大。Term和Expression是因为它们作为项和表达式,要对因子和项进行综合管理;Factory是递归下降工厂,与各种因子类、项类、表达式类都有联系。

二、自己程序bug分析

功能方面,三次作业在强测和互测中均未发现bug。

性能方面,第一次作业忘记考虑了x*x优于x**2,被扣了性能分。后两次作业在性能和正确性的综合考量下,放弃了一部分性能分。

三、别人程序的bug分析

采取的策略是用测评机随机生成大量测试用例,进行测试。非常有效,三次互测均成功的找出了同房间同学的bug,而且没有无效hack,“弹无虚发”。

每帮对方改一个bug,就拿测评机重新跑一次,保证一个bug只hack一次,这样不影响他的心情,也不耽误我的时间。

结合了被测程序的代码设计结构来设计测试用例。一些代码处理输入的过程比较繁琐和冗长,容易出bug,于是构造结构比较复杂的表达式(例如包括三个连续减号的表达式)来hack。

image

四、重构经历总结

我一共进行了三次重构。

第一次是开始做第二次作业的时候,发现第一次作业的架构较差,难以扩展,所以重构。

第二次是第二次作业做了一半的时候,发现采用了拆开表达式再求导的错误策略,于是重构。

第三次是在做第三次作业的时候,感到第二次作业的代码可以进一步提升抽象层次,所以重构(当然,这次重构的改动比前两次小得多)。

前两次重构主要是为了实现新的功能而另起炉灶,第三次重构却是为了代码更优,所以可以进行重构前后的代码对比。

重构前的第二次作业:

image

重构后的第三次作业:

image

第三次作业比第二次作业功能更强,性能更优,但复杂度更低,代码行数更少。这种脱胎换骨的效果,正是重构换来的。

五、心得体会

1.先动脑,后动手;先总体设计,再考虑细节

在第二次作业中,我之所以进行了两次重构,是因为一开始采取了错误的“拆开表达式再求导”的策略。事实上,我应当在动手编程之前分析到底是拆开表达式求导性能更优,还是不拆表达式求导性能更优,之后再动手编程。这样总工作量更小,工作效果也更好。

2.磨刀不误砍柴工,学习用高阶方法解决问题

在做第二次作业时,我用了一整天的时间来学习递归下降文法分析。但是学成后的收获的确很大。用递归下降法解析字符串,正确性,可扩展性和可移植性都要远远强于用正则表达式解析字符串。所以我即使在第二次作业提交的前一天才开始重构,也仅仅用一个下午就完成了工作。在第三次作业中,我几乎没有在解析字符串上太费脑筋,仅仅在第二次作业的基础上加了几行提炼内部因子的代码以及错误格式特判的语句,最终也没有bug。

3.好的架构很重要

好的架构意味着高可扩展性,高可移植性,能够大大降低增量开发的难度。在第二次作业的重构中,我的架构比第一次作业提升了很多,所以在做第三次作业的时候并没有费太多力气。

4.算法没有最好的,只有最合适的

在完成第三次作业的时候,我意识到表达式类里对各个项的存储,以及项里对各个因子的存储,不一定非要使用map,也可以采用线性表。事实上,尽管采用线性表会有线性时间复杂度,劣于map的对数时间复杂度,然而本次作业中限制输入字符串的总长度不能超过70,所以一个表达式里不可能有太多项,一个项里不可能有太多因子,所以差距不会太大。另外,如果用map来存储,由于一个项有若干不同的特征,map的key还要单独设计,不仅会增大编程难度,而且会带来运行时实例化key类的开销;如果采用多层嵌套的map,那么就要忍受实例化若干map带来的开销。况且线性表在内存中是连续存储,时间开销会小一些。所以总体来看,采用线性表也未尝不是一种好的选择。第三次作业中我采用这种方式组织数据,强测的结果显示,每个测试点的用时均小于时间限制的十分之一。

posted @ 2021-03-26 15:20  邢云鹏19231177  阅读(181)  评论(0编辑  收藏  举报