OO_第一单元总结
OO_第一单元总结
总体任务是实现表达式的求导,经过三次迭代开发后表达式扩充为常数、三角函数、幂函数、嵌套表达式之间乘积的线性组合
第一次作业
表达式为常数与幂函数乘积的线性组合
程序结构分析
- UML类图
设计之初的考虑是在Term
类中存储合并后的常数与幂函数因子的指数,并实现求导方法。在Polynome
类中实现对表达式的解析与对项的存储,在PolyCalculation
类中通过调用每个存储的Term
类的求导来实现对总体表达式的求导。
优缺点分析:缺点很明显,扩展性不好。优点则是比较便捷。
- 度量分析
ev(G) -> 基本复杂度
, 用来衡量程序非结构化程度。非结构化程度高意味着难以模块化和维护
iv(G) -> 模块设计复杂度
,衡量模块和其他模块的调用关系。模块设计复杂度高意味模块耦合度高,这将导致模块难于隔离、维护和复用。
v(G) -> 圈复杂度
, 圈复杂度大说明程序代码可能质量低且难于测试和维护。
这里摘出复杂度部分存在较高度量的方法
method |
ev(G) |
iv(G) |
v(G) |
---|---|---|---|
Polynome.constructPolyDict() |
3.0 | 10.0 | 13.0 |
Term.toString() |
3.0 | 6.0 | 8.0 |
constructPolyDict
方法负责构造Term
并存入HashMap
中,由于加入了对获取到正则中group
里特例的判断,导致程序复杂度较高。现在看来,就本架构而言,对group
特例情况更应向下交由Term
来处理,不仅可以减少内聚,还可以增加Term
类实现构造的鲁棒性。
toString
方法由于需要对输出进行优化而加入了许多if-else
特判,从而造成了较高的复杂度。
评测机
在本次作业期间,本人参考评论区同学分享的方法搭建了数据生成器与评测机。
-
对于数据的生成,个人实现了两种模式:手搓与自动。考虑到自动生成的数据尽管可能很长,看着唬人,但有可能大部分数据能够覆盖到的程序分支都是类似的,于是我也自行构造并搜集了一些类型的数据(例如包含相反系数的项、求导后可以爆
long
的系数指数等等),来提高找bug效率自动随机数据主要是根据表达式文法递归下降生成,这种方式比直接采用大正则生成略复杂一些,但具有更为优良的可扩展性,在生成后续更为复杂的表达式时,只需根据表达式文法稍作修改即可,并且根据文法生成思路十分清晰。同时也引入常量池等机制来生成具有一定层次的数据。
-
评测部分主要是根据
subprocess
库调用java -jar ...
命令获取jar
包结果,以及sympy
库的diff
与equals
方法对正确性判定。由于其无法对含有前导零的数据进行求导,于是本人在数据生成器的是含有两个相等数据的list
,一份含有前导零而另一份不含。
bug分析
本次作业没有在强测与互测中被发现bug。
- 找bug策略
由于本次作业比较简单,组合种类较少,因此对于自己的代码,本人采用的主要是黑盒测试,而没有进行一些单元测试(实际上当时也没有想到)。
刀别人时也是挂着评测机跑,在跑了十几万条仍未找到bug后放弃了。不过到结束互测时也没有人被发现bug。
思考
本次的大致思路是通过循环一个正则来提取因子,将因子的常数和指数存入项中合并,最后用HashMap
将项存储到表达式类中。
现在看来,这种解析方式的可扩展性无疑是十分差的。尽管在本次需求场景中编写耗时少且最后性能表现良好,可一旦需求变动,就面临需要重构的风险。
第二次作业
项加入了三角函数(不嵌套)和嵌套的表达式因子
程序结构分析
- UML类图
本次设计是用表达式二叉树来对表达式进行解析与存储。二叉树结点存储函数组合规则,叶节点存储因子。我们首先建立一个Factor
的抽象,从而延伸出三角因子、常量因子与幂函数因子等子类,并对每个因子实现求导方法。然后我们建立一个Node
抽象,将每一种函数组合规则(乘法、加减、嵌套)看作结点,建立类,那么对于每一种组合规则,它本身的导数就是左子树导数与右子树导数的的特定组合。以这种方式递归建树的叶节点就是因子。然后我们调用根节点的求导方法,就可以链式调用,进而获得整个表达式树的导数。
这种方法也是指导书中提到的方法,它的优点在于实现简便、思路简单(本人采用这种方法,大概半天的时间便编写完毕并成功通过中测),但缺点在于它的数据存储难以结构化和有序化,给输出优化带来了很大的困难,并且无法在建树过程中检查表达式的正确性,如果新增需求WrongFormat
,需要事先检查表达式的正确性,这样便会造成时间效率的低下。
- 度量分析
复杂度较高的方法如下:
method |
ev(G) |
iv(G) |
v(G) |
---|---|---|---|
TriangleFactor.toString() |
7.0 | 7.0 | 7.0 |
Expression.buildExpTree(String) |
6.0 | 17.0 | 19.0 |
FactorFactory.canCreate() |
5.0 | 1.0 | 5.0 |
FactorFactory.create() |
5.0 | 1.0 | 5.0 |
MultiNode.multiString(String,String) |
5.0 | 7.0 | 8.0 |
PowFactor.derivation() |
5.0 | 5.0 | 5.0 |
AddNodeType.derivation() |
4.0 | 1.0 | 4.0 |
Expression.createLeafNode(String) |
4.0 | 4.0 | 4.0 |
MultiNode.derivation() |
4.0 | 5.0 | 6.0 |
SubNodeType.derivation() |
4.0 | 1.0 | 4.0 |
复杂度较高的方法主要集中在derivation
方法和create
方法。前者是因为为了实现一些输出化简,需要进行较多的特判,而工厂模式的create
方法需要对因子类型进行逐个遍历,这也加深了方法复杂度与依赖度。而建二叉树寻找结点、插入结点的建树方法比较面向过程,其内部耦合度较高,因此也造成了较高的复杂度,应当对其进行进一步解耦。
评测机
- 数据生成
在第一版评测机的基础上增加了三角函数与递归的表达式因子。递归因子的实现思路:① 略显复杂,如果直接在生成表达式因子的函数中调用生成表达式的函数,那么尽管在单个表达式中有着最大递归层数限制,但一层层调用的层数却没有限制,导致爆栈。因此我们可以再加一个全局最大递归层数限制,将当前递归层数层层传递传下去。 ② 手动构造一些表达式因子,放在一个数组中取。这样更方便,并且造的数据也不弱。不想手造的话也可以先用评测机生成一些因子,直接拿过来用。
- 评测
做了一些修改,支持同时测评多个同学的程序,虽然仍然是单线程的,不过速度在当前需求场景下够用了。
bug分析
本次作业在强测中TLE了一个点,经分析查找,是因为没有对toString
方法的值预存,在同一个方法中两次调用一个相同的toString
方法,却会导致在递归调用过程中时间复杂度以指数形式上升。
- 找bug策略
由于本人低估了此次作业与第一次作业之间的难度差,给此次作业预留的时间比较少,导致完成后时间很仓促,没有在作业ddl前对评测机完成更新,从而没有对个人程序进行良好的覆盖性测试,大部分都是使用自己手搓的数据。
刀别人时,由于自动生成的数据很容易超过互测数据长度的限制,因此该数据只能大致定位bug的位置,然后逐步减小数据长度来更精确的寻找bug。除自动测试外,本人也根据被测程序代码设计进行了相应的hack。例如对于正则中没有进行特判就使用group
的,以及没有对string
进行特判就使用charAt
的,可以尝试构造使对应数据为空的数据情况。
在互测屋中找到别人的bug大致有如下几类:
- 括号嵌套层数多时会爆栈,应该是数据解析没有处理好,可能用了大正则。
- 对边界情况、特殊情况考虑不周,在有可能为空或
Null
的情况下使用charAt(i)
导致异常 - 超时,也是
toString()
方法没有处理好,没有预存,导致数据较大时有超时的可能
思考
个人感觉本次作业相对于第一次作业坡度很大,现在回想,难点主要在于架构的设计,表达式如何解析(主要是括号嵌套)?各层次数据如何存储?(二叉树、多叉树)。一旦想明白了这些,求导的实现其实很容易,代码的实现也很快。
第三次作业
项加入了三角函数(支持嵌套),增加了
Wrong Format
的判定
程序结构分析
- UML类图
本次架构采取递归下降的方式对字符串进行解析与存储。本人将Expression
类以及涉及expression
处理的类 (ParseExp, ExpCalculation
) 均放置在expression
包下,Term
类放置到expression.term
包下,Factor
类以及涉及到factor
处理的类(FactorFactory
)均放置在expression.term.factor
包下。
Expression
类中存储了一个Term
的List,各list元素之间是线性关系;Term
类中存储了一个Factor
的list,各list元素之间是相乘关系。对于三类普通因子以及表达式因子均继承于Factor
抽象类。对于Factor
求导得到Term
,对于Term
求导得到Expression
。
优缺点分析:将解析与存储进一步解耦,并且在解析中采用了递归下降的解析方式,使得程序具有了更好的扩展性。不过对于每个具体类,更好的方式是继承一个Derivable
接口,无论是Term
还是Factor
返回类型均为Derivable
类型,这样提取一层统一的抽象,实际上比目前Factor
与Term
求导返回类型不同要更好一些。
- 度量分析
method |
ev(G) |
iv(G) |
v(G) |
---|---|---|---|
expression.term.factor.TriangleFactor.toString() |
7.0 | 5.0 | 8.0 |
expression.ParseExp.getTriFactor() |
6.0 | 3.0 | 7.0 |
expression.term.Term.addTriFactor(Factor) |
6.0 | 6.0 | 6.0 |
expression.term.Term.toString() |
6.0 | 14.0 | 16.0 |
expression.Expression.addTerm(Term) |
4.0 | 5.0 | 6.0 |
expression.Expression.toString() |
4.0 | 5.0 | 7.0 |
expression.ParseExp.getFactor() |
4.0 | 4.0 | 6.0 |
expression.term.Term.derivation() |
4.0 | 4.0 | 4.0 |
expression.term.factor.FactorFactory.create() |
4.0 | 4.0 | 5.0 |
经过分析观察,这些方法都含有比较多的if-else
结构。toString
方法由于优化的原因需要进行许多特判;而addTriFactor
方法由于本人将Sin
和cos
合并到了一个类中,所以在添加TriFactor
时需要提取三角函数种类,因此需要加入一些判断语句。FactorFactory.create()
方法由于需要进行遍历匹配,因此也需要比较多的判断语句。
bug分析
本次作业未在强测与互测中被发现bug
- 找bug策略
在自测和互测时仍然采用自动数据+手动数据的方式。自测时逐一列举了许多Wrong Format
形式的数据,尽可能做到充分的WF
测试。
刀别人时,由于互测数据长度限制,本人对于数据生成器做了一些长度优化,不过看起来生成的数据变弱了,对于边界条件的测试不充分,而本周互测阶段有其他一些事比较忙,手动构造的数据不充分,互测时在有同学被同屋其他同学hack成功的情况下没有测出相应的bug。在互测结束后查看被hack到同学的数据,发现是该同学对于0*x
这类形式的优化出了问题,会忽略掉前面的0
。
思考
有了第二次作业的铺垫,感觉起来第三次作业的实现要比第二次作业顺畅得多。递归下降听起来很玄乎,但实现的原理其实很理所当然,即根据当前读入值判断分支走向,将需要递归的部分交由下层函数来解析它所对应的部分,与分治的思想有些类似,将大问题层层解析为更小的问题。
重构经历
在第一次作业时,没有考虑到需求的扩充方向,因此在第二次作业时进行了重构。三角函数和嵌套表达式因子的引入与第一次需求之间的跃迁比较大,对于架构是一个很大的挑战,咨询了几位几乎没重构的同学,大都是事先阅读过往届学长学姐的博客或是指导书,对于最终的成品有了一些了解,因此有意识地向后兼容。第二次的架构采取的是表达式二叉树,这种架构实现方便,但无法在解析过程中实现Wrong format
的判定。在第三次作业时,原意是在解析前通过递归下降解析来检查表达式正确性,但在实现起来深刻地感受到了递归下降解析对应的数据存储结构的优越性——无论是思维的顺畅、可扩展程度还是优化的便捷,都是要胜过二叉树的。因此因为思路顺畅,写着很舒服,索性再次重构。
重构前第二次作业:
class |
OCavg |
OCmax |
WMC |
---|---|---|---|
TriangleFactor |
3.8333333333333335 | 9.0 | 23.0 |
FactorFactory |
3.6666666666666665 | 5.0 | 11.0 |
Expression |
3.5 | 12.0 | 21.0 |
重构后第三次作业:
class |
OCavg |
OCmax |
WMC |
---|---|---|---|
expression.Expression |
3.6 | 7.0 | 18.0 |
可以看到重构前不少类的复杂度比较高,重构后的类进行了进一步解耦,由于Expression
作为顶层类的原因,复杂度比较高,但基本上实现了高内聚低耦合的原则。
几次重构的经历给我的周末带来了不小的负担,但我在亲身的踩坑试错过程中也对一些知识有了更深刻的领悟,例如一个良好架构的重要性,良好的数据存储方式自带十分强大的优化功能;以及面向对象的封装、解耦、提取抽象对于程序可扩展性的巨大帮助。
心得体会
- 架构设计是很重要的。
不论是可扩展性,还是后续的维护、优化,都需要一个良好的架构作为铺垫,否则很有可能需要大改甚至重构
- 注意提取抽象
抽象层的提取对于程序的规范、扩展具有重要的作用。例如,就本单元而言,derivable
抽象的提取可以统一许多函数的返回类型,从而使得程序在后续修改中容错能力大大增加。当然,抽象的提取要恰当,我们不能为了面向对象的形式而面向对象。封装和归一化类似军队制度建设,目标是搞出一个标准化、立体、多变、高效的指挥体系,从而获得打大战、打硬战的能力,而不是堆砌无用的所谓“设计模式”。
- 收获良多
第一单元虽然很磨人,但熬过来之后再回首,其实能感觉到对于能力的提升还是很大的。无论是面向对象的模块化思想、架构的设计、上机参看代码对个人潜移默化的影响、还是正确性外的各种优化技巧、评测机的搭建、数据的构造技巧等等,都增长了自身的知识和能力。
不过,个人感觉课程组还存在一些可以探索优化的地方。例如可以多给一些教学性质的代码(或许不是必须掌握的,但能帮助大家开阔眼界),在每两周的教学性质上机课上,个人感觉还是能学习借鉴到不少的,不过可能因为是考试的原因,所以整体代码设计比较简单,如果能在平时也分享一些值得钻研、设计优秀的代码来让大家学习借鉴,我相信大家的水平还是能提高不少的。
- 还要进一步探索尝试
对于本单元的作业,个人的输出优化方面做得并不充足,只做了基本的同类项合并,原本还有一些三角函数的基本化简,为求稳最后还是未将这部分放入。对于评测机,其实还能进一步探索——多线程评测、以及听完一位同学分享的花式数据构造方法后受到的启发:构造一些复杂的“原子”数据并随机调用,有些类似于常量池的原理。
希望第二单元顺利~