本文UML图省略了绝大多数方法,包括一些起辅助作用的方法(不是主要功能)和toString方法。
第一次作业
只需要实现+-连接的幂函数的表达式的求导
本次用ArrayList承载每个项Term,Term间的+-号连接视为Term系数的正负。
class OCavg WMC
MianClass | 4.666666666666667 | 14.0 |
Term | 2.7 | 27.0 |
Total | 41.0 | |
Average | 3.1538461538461537 | 20.5 |
method ev(G) iv(G) v(G)
MianClass.main(String[]) | 1.0 | 10.0 | 10.0 |
MianClass.Parse(String) | 3.0 | 2.0 | 3.0 |
MianClass.toDiff(ArrayList) | 1.0 | 3.0 | 3.0 |
Term.canMerge(Term) | 1.0 | 1.0 | 1.0 |
Term.compareTo(Term) | 5.0 | 1.0 | 5.0 |
Term.Diff() | 3.0 | 1.0 | 3.0 |
Term.isNeg() | 1.0 | 1.0 | 1.0 |
Term.isZero() | 1.0 | 1.0 | 1.0 |
Term.merge(Term) | 1.0 | 1.0 | 1.0 |
Term.Term(BigInteger,BigInteger) | 1.0 | 1.0 | 1.0 |
Term.Term(String) | 1.0 | 10.0 | 11.0 |
Term.Term(String,String) | 1.0 | 1.0 | 1.0 |
Term.toString() | 4.0 | 2.0 | 4.0 |
Total | 24.0 | 35.0 | 45.0 |
Average | 1.8461538461538463 | 2.6923076923076925 | 3.4615384615384617 |
解析输入是最复杂的,分支也最多,所以Term.Term(String)的圈复杂度比较高。化简的部分不是很复杂,判断分支不算多,所以toString圈复杂度不是很高。我我认为这次的Term.Term(String)虽然设计复杂度高,这是因为解析方法不得不与很多方法联系紧密,难以避免。但是它的基本复杂度不高,所以最后也没出什么问题。我认为虽然我的设计不是很好,但这次也勉强可用,不过这种只能完成本次任务的思维局限了后续扩展,导致后面的重构。
由于这次作业任务相对清晰且准确,我没有被测出bug。
我在阅读代码后,发现有人在输入0指数时就把该项舍去。于是我构造了只有0指数的表达式,他就没有输出。
第二次作业
要实现+-连接的由*连接的幂函数、sin和cos函数
图中的Function类和Atom的子类都有以String为参数的构造方法,是解析方法,此处省略未画
由于我在完成本次作业时已经知道第三次作业将会添加表达式嵌套的要求,因此我在设计本次作业时是按照下次作业考虑的。所以这一次对上一次进行了重构,把表达式分为了三个“层次”:最外层是由+-连接的Term组成的Function,中间层是由*连接的Atom组成的Term,最底层是Atom。
显然,Function求导得Function。由乘法求导规则,Term求导得Function。因为三角函数求导会变为sin()**n * cos()**m,因此Atom求导得Term。
在代码中,Sin与Cos部分有大量重复代码,我当时因为只有两个类有重复代码就没重新设计。现在想来,应该再设计一个公共的“三角函数”父类,实现相同的方法,再在子类调用父类的方法并加上自己的一点不同,可以避免重复。
class OCavg WMC
Atom | 1.0 | 13.0 |
Cos | 2.0 | 12.0 |
Function | 4.8 | 24.0 |
MianClass | 1.0 | 1.0 |
Power | 2.1666666666666665 | 13.0 |
Sin | 2.0 | 12.0 |
Term | 2.142857142857143 | 15.0 |
Total | 90.0 | |
Average | 2.0454545454545454 | 12.857142857142858 |
先看类的统计。显然Function类的平均圈复杂度和总圈复杂度最大,因为最复杂的“解析输入”这一功能是通过Function的构造函数实现的,且这一功能会产生很多分支。Function作为唯一与主方法有关系的类,需要把结果彻底得出后再传回主类,因此Function的多数方法都涉及别的类的方法(主要是Term),会多一些判断。这个问题遗留到了第三次作业,导致我在分支愈加复杂的Function构造函数中出了问题。
method ev(G) iv(G) v(G)
Atom.Atom() | 1.0 | 1.0 | 1.0 |
Atom.Atom(Term,BigInteger) | 1.0 | 1.0 | 1.0 |
Atom.bigIndex() | 1.0 | 1.0 | 1.0 |
Atom.getCoef() | 1.0 | 1.0 | 1.0 |
Atom.getIndex() | 1.0 | 1.0 | 1.0 |
Atom.getVar() | 1.0 | 1.0 | 1.0 |
Atom.is0pow() | 1.0 | 1.0 | 1.0 |
Atom.is1pow() | 1.0 | 1.0 | 1.0 |
Atom.isVarIsTerm() | 1.0 | 1.0 | 1.0 |
Atom.isZero() | 1.0 | 1.0 | 1.0 |
Atom.setCoef(BigInteger) | 1.0 | 1.0 | 1.0 |
Atom.setIndex(BigInteger) | 1.0 | 1.0 | 1.0 |
Atom.setVarIsTerm(Boolean) | 1.0 | 1.0 | 1.0 |
Cos.clone() | 1.0 | 1.0 | 1.0 |
Cos.Cos() | 1.0 | 1.0 | 1.0 |
Cos.Cos(BigInteger,BigInteger) | 1.0 | 1.0 | 1.0 |
Cos.Cos(String) | 1.0 | 3.0 | 3.0 |
Cos.Diff() | 1.0 | 2.0 | 2.0 |
Cos.toString() | 4.0 | 3.0 | 4.0 |
Function.addTerm(Term) | 1.0 | 1.0 | 1.0 |
Function.Diff() | 1.0 | 2.0 | 2.0 |
Function.Function() | 1.0 | 1.0 | 1.0 |
Function.Function(String) | 12.0 | 17.0 | 20.0 |
Function.toString() | 2.0 | 2.0 | 3.0 |
MianClass.main(String[]) | 1.0 | 2.0 | 2.0 |
Power.clone() | 1.0 | 1.0 | 1.0 |
Power.Diff() | 2.0 | 2.0 | 3.0 |
Power.Power() | 1.0 | 1.0 | 1.0 |
Power.Power(BigInteger,BigInteger) | 1.0 | 1.0 | 1.0 |
Power.Power(String) | 1.0 | 3.0 | 3.0 |
Power.toString() | 4.0 | 3.0 | 4.0 |
Sin.clone() | 1.0 | 1.0 | 1.0 |
Sin.Diff() | 1.0 | 2.0 | 2.0 |
Sin.Sin() | 1.0 | 1.0 | 1.0 |
Sin.Sin(BigInteger,BigInteger) | 1.0 | 1.0 | 1.0 |
Sin.Sin(String) | 1.0 | 3.0 | 3.0 |
Sin.toString() | 4.0 | 3.0 | 4.0 |
Term.addAtom(Atom) | 1.0 | 1.0 | 1.0 |
Term.clone() | 1.0 | 2.0 | 2.0 |
Term.Diff() | 2.0 | 2.0 | 3.0 |
Term.isNeg() | 1.0 | 1.0 | 1.0 |
Term.isZero() | 1.0 | 1.0 | 1.0 |
Term.setCoef(BigInteger) | 1.0 | 1.0 | 1.0 |
Term.toString() | 4.0 | 5.0 | 7.0 |
Total | 70.0 | 84.0 | 95.0 |
Average | 1.5909090909090908 | 1.9090909090909092 | 2.159090909090909 |
此外是几个toString函数基本复杂度较高。因为涉及化简,有一些分支,导致圈复杂度高。我应该定一个化简方法的接口再实现,而是直接在toString里分别实现。通过比较基本复杂度,可以看出我的Function由String的构造函数是最不好的。分析原因,可以看到它的设计复杂度高,因为这个功能是解析输入并构造出对应的表达式,导致其关联很多。我在解析时让它形式地构造Term,传递字符串来构造每一个Atom的子类,导致它与这些类的方法关联很多。这部分是因为我无法更清晰地拆分功能,分不出别的方法。如果一定要分,可能只是单纯提出一些只用一次的函数,类似面向过程时的处理。Function类确实是非常关键,绝大多数耦合都发生在这里,难以拆分,导致三个复杂度都很高。
我自己这次没有被测到bug,主要是我化简比较谨慎,没有利用三角函数公式化简。
我为了先测出来一些错误,就构造了包含我认为所有在处理上的难点的一个表达式(含1、2、3个连续+-号;含与不含指数;结果的系数为0与否,指数为1与否),成功测出有问题的代码。此外,我测试别人的策略是寻找哪些部分的代码很长,一般一个方法代码长就容易出错。别人的代码在化简部分非常复杂,因此我就挑选了一些含有0、1作为系数和指数的结果来测,发现果然有在结果为0时无输出的人。
第三次作业
增加了表达式因子和以因子作为自变量的sin和cos函数;增加了空白符位置错误的识别。
本次相对第2次修改不算很大。
此次比上一次作业添加了Atom对Function的关联:Atom的自变量可以为Function。
Function.indexOfEnd属性是一个只有在解析的时候有用的量,我设计它是用来在递归调用Function构造方法时记录字符串读取到的位置,这样可以不用静态变量,更加安全。
class OCavg WMC
Atom | 1.0 | 14.0 |
Cos | 2.375 | 19.0 |
Function | 4.9 | 49.0 |
MianClass | 2.0 | 2.0 |
Power | 2.2857142857142856 | 16.0 |
Sin | 2.375 | 19.0 |
Term | 2.0 | 16.0 |
Total | 135.0 | |
Average | 2.4107142857142856 | 19.285714285714285 |
method ev(G) iv(G) v(G)
Atom.Atom() | 1.0 | 1.0 | 1.0 |
Atom.Atom(Term,BigInteger) | 1.0 | 1.0 | 1.0 |
Atom.bigIndex() | 1.0 | 1.0 | 1.0 |
Atom.getCoef() | 1.0 | 1.0 | 1.0 |
Atom.getIndex() | 1.0 | 1.0 | 1.0 |
Atom.getVar() | 1.0 | 1.0 | 1.0 |
Atom.is0pow() | 1.0 | 1.0 | 1.0 |
Atom.is1pow() | 1.0 | 1.0 | 1.0 |
Atom.isVarIsTerm() | 1.0 | 1.0 | 1.0 |
Atom.isZero() | 1.0 | 1.0 | 1.0 |
Atom.setCoef(BigInteger) | 1.0 | 1.0 | 1.0 |
Atom.setIndex(BigInteger) | 1.0 | 1.0 | 1.0 |
Atom.setVar(Function) | 1.0 | 1.0 | 1.0 |
Atom.setVarIsTerm(Boolean) | 1.0 | 1.0 | 1.0 |
Cos.clone() | 1.0 | 2.0 | 2.0 |
Cos.Cos() | 1.0 | 1.0 | 1.0 |
Cos.Cos(BigInteger,BigInteger) | 1.0 | 1.0 | 1.0 |
Cos.Cos(Function,BigInteger,BigInteger) | 1.0 | 1.0 | 1.0 |
Cos.Cos(Function,String) | 1.0 | 2.0 | 2.0 |
Cos.Cos(String) | 1.0 | 3.0 | 3.0 |
Cos.Diff() | 1.0 | 3.0 | 3.0 |
Cos.toString() | 6.0 | 5.0 | 6.0 |
Function.addTerm(Term) | 1.0 | 1.0 | 1.0 |
Function.clone() | 1.0 | 2.0 | 2.0 |
Function.Diff() | 1.0 | 2.0 | 2.0 |
Function.Function() | 1.0 | 1.0 | 1.0 |
Function.Function(String) | 27.0 | 40.0 | 46.0 |
Function.getFunc() | 1.0 | 1.0 | 1.0 |
Function.getIndexOfEnd() | 1.0 | 1.0 | 1.0 |
Function.nxtCh(String) | 1.0 | 1.0 | 1.0 |
Function.tkBlk(String) | 1.0 | 1.0 | 1.0 |
Function.toString() | 2.0 | 2.0 | 3.0 |
MianClass.main(String[]) | 1.0 | 3.0 | 3.0 |
Power.clone() | 1.0 | 2.0 | 2.0 |
Power.Diff() | 1.0 | 3.0 | 3.0 |
Power.Power() | 1.0 | 1.0 | 1.0 |
Power.Power(BigInteger,BigInteger) | 1.0 | 1.0 | 1.0 |
Power.Power(Function) | 1.0 | 1.0 | 1.0 |
Power.Power(String) | 1.0 | 3.0 | 3.0 |
Power.toString() | 5.0 | 4.0 | 5.0 |
Sin.clone() | 1.0 | 2.0 | 2.0 |
Sin.Diff() | 1.0 | 3.0 | 3.0 |
Sin.Sin() | 1.0 | 1.0 | 1.0 |
Sin.Sin(BigInteger,BigInteger) | 1.0 | 1.0 | 1.0 |
Sin.Sin(Function,BigInteger,BigInteger) | 1.0 | 1.0 | 1.0 |
Sin.Sin(Function,String) | 1.0 | 2.0 | 2.0 |
Sin.Sin(String) | 1.0 | 3.0 | 3.0 |
Sin.toString() | 6.0 | 5.0 | 6.0 |
Term.addAtom(Atom) | 1.0 | 1.0 | 1.0 |
Term.clone() | 1.0 | 2.0 | 2.0 |
Term.Diff() | 2.0 | 2.0 | 3.0 |
Term.getAtoms() | 1.0 | 1.0 | 1.0 |
Term.isNeg() | 1.0 | 1.0 | 1.0 |
Term.isZero() | 1.0 | 1.0 | 1.0 |
Term.setCoef(BigInteger) | 1.0 | 1.0 | 1.0 |
Term.toString() | 4.0 | 5.0 | 7.0 |
Total | 101.0 | 134.0 | 147.0 |
Average | 1.8035714285714286 | 2.392857142857143 | 2.625 |
我的Function由String的构造函数是最不好的,它的圈复杂度高。除了因为第二次作业说过的原因外,也因为我在手工解析输入时实现了判定递归入\出口、Term开始\结束、构造哪个Atom等功能,分支必然很多。这些相关代码在构造时花费了最长的时间,最终也出了问题,确实证明这圈复杂度高导致分支多导致我有点情况没测到。对此的一个解决方案在重构部分的第一个方案给出了。
我的bug不出所料地出在了Function构造函数解析输入的部分。我手工判定具体某个Atom的构造时,对sin和cos括号里的起始符号出现判定有遗漏,且自己没测到。如果我能把这部分功能分担一下,可能可以避免考虑不全的问题。毕竟把这个任务分到别的类和方法,就会在被分到的地方详细考虑这一小部分,可以避免在一个很大的解析输入的方法里依然同时考虑很多细节。
我测试别人的策略是寻找哪些部分的代码很长,一般一个方法代码长就容易出错。别人的代码在化简部分非常复杂,因此我就挑选了一些含有0、1作为系数和指数的结果来测,发现没有问题。我发现有人的合并同类项处理非常多,就试了结果合并同类项后不符合题目格式的例子,果然测出了在合并多个表达式因子时候的问题。
对象创建模式及重构
这一部分以第三次作业为基础讨论。
我认为在处理输入的时候可以采用更简洁的切分,然后把截取字段交给构造Atom的AtomFactory工厂来构造,这样判断构造Sin、Cos还是Power的任务就会移交给AtomFactory工厂完成。但是这也有一个问题,就是当出现表达式因子时,Function的构造函数会和AtomFactory返回Atom的函数互相调用,形成两个函数的递归。我之前是为了避免这种情况才没有使用工厂方法的。从事后的角度看,即使是两个方法间的递归也不会产生很高的圈复杂度(因为本质上判定递归入\出口,无论在一个还是两个函数里,条件是不变,不涉及更多判断),但能大大降低耦合(切断了Function和Atom的关联),是值得的。
更彻底的重构方案:取消我设置的Function、Term和Atom三个层次,而把+、-、*、**、sin、cos一视同仁,都作为“运算类”处理,将之视为0、1或2个自变量间的运算。即建立表达式树。用一个顶层Factory判别并生成不同的“运算类”。这样求导的时候就不会产生我本次作业中的“某个‘层次’求导的返回值属于别的‘层次’”的问题,因为这种重构取消了我以符号分“层次”的思想。但这种方案在解析输入时会遇到更多的判定分支(因为每个部分的构建都是递归的,需要小心判定+和*的出口,会使if-else层次更深),我的原设计则从思路上类似构建“链表的链表”,比建树和合并树的分支更直接。
心得体会
我在这单元收获很大。第一次作业显然还是面向过程的思路:即把程序拆成解析、构造、求导和化简四部分,只是把和Term最相关的放在了Term类,剩下的放在了主类。我当时认为面向对象就是把以某个对象作为参数的一些函数写成这个对象的方法,这个在后面有更新。
在第二次,求导时我需要克隆一些实例,就按需要给类写了clone方法(其实compareTo和toString方法也是类似后面描述的,只是我对clone印象最深)。这里面我认识到当一个类由别的类组成时,它只需要调用组成成分类的clone方法,而不用管它们是如何实现的。这一点上我认识到,面向对象本质还是一种抽象方法,目的是使“高层”一些的类不用管“低层”类的细节,节省了我思考的负担,不容易出错。
第三次中,我即使知道我的Function构造方法极其复杂,但仍然没有拆分,主要是考虑到这个方法涉及递归调用,如果把一部分的构造方法分到别的类,一是不利于字符串的线性读取并解析,更重要的是提高了Function类和别的类的耦合,加之涉及递归调用,就需要处理更多的类的方法之间相互递归,这是我不愿意面对的。我基于对“高内聚、低耦合”的理解做出了上面的决定,但仍然出现了个人思考顾及不到而产生的错误。我觉得这很难取舍,因为我有一个如下假设:当我设定好一些必要的类后,这些类之间本身就有逻辑上和整体功能中固有的联系,提高内聚降低耦合只是可以翻译成“某个类自己的事情不要麻烦别的类”(这不是否定工厂模式,工厂模式只是一个辅助的类,不影响必须存在的那些类),但是这些“事情”或“功能”的总量是不会减少的,高内聚的代价必然是类变得冗长,也可能超出人力顾及范围。也许后面我再多了解一些设计模式可以平衡这些问题(工厂模式就相当于拆分出类的构造功能变成新的类,降低了类的长度,产生了影响不太大的耦合)。
我总是在一些设计方案上反复纠结,开始写代码过晚。我认为可以尝试TDD,这样也许能有效减少bug,但是重构开销又会很大,也不好权衡。