OO第一单元总结
OO第一单元总结
一、程序代码分析
-
第一次作业
主要任务是对由幂函数线性组合成的简单多项式函数进行求导
-
类图及架构分析
-
类图
-
架构&思路简析
DervationMain类为主类,承担所有工作;Item为自定义的类,存储每一项。
思路大体如下:
- 输入(getAllItems):在表达式前加一个加号(同一化处理),之后匹配输入表达式的
[+-]项
的形式的子串,分析这些子串,得到一个个的Item,包含系数coefficient,指数Index,存入ArrayList中。 - 求导(derive):遍历ArrayList,使系数变为\(coefficient\times(Index-1)\) ,指数自减一,并抛弃0项(导致bug)
- 合并同类项(combineSimilar):再次遍历ArrayList,使指数相等的项的系数相加。
- 除掉零项(clearZeroItem,debug阶段才添加进来的):再次遍历ArrayList,系数为0则除掉。
- 输出(outAnsExp):若数组为空,则输出0,否则正常输出每一项。(同时对表达式进行适当简化)
- 输入(getAllItems):在表达式前加一个加号(同一化处理),之后匹配输入表达式的
-
优缺点以及评价
-
优点
- 架构简单(
没有优点)
- 架构简单(
-
缺点
-
几乎完全是按照面向过程的思路去完成,而没有用到面向对象的封装等思想。主要体现在以下几点:
- 主类中通过封装几个private static修饰的方法(不如叫函数更为贴切)分别完成输入、求导、合并同类项以及输出的任务(后来为了Debug还加了一个消除零项的方法)
- 输出结果表达式要进行输出项的工作,而这一数据高内聚的操作也被一个Main类中的一个方法完成
-
滥用正则表达式,导致表达式非常长
-
-
评价
- 简单分析类图就可以看到,Main类中的方法,不论是getAllItems(分析输入)方法、或是后面的derive(求导)方法、combineSimilar(合并同类项方法)等,都是以 ArrayList
- (实际上就是多项式类型)作为参数或返回值,这其实就是数据与函数紧密聚合的体现,应当是面向对象建模的经典类型。
因此,这部分代码其实可以直观地通过面向对象思想进行重构——构建一个以ArrayList- 型变量为主要数据域的Poly类,类中有求导、合并同类项等方法,而构造方法是分析输入方法。这样,相比于原来的"一main到底",可以大幅度提升模块的内聚性,降低耦合性,以后做功能扩展也会更加便利。
- 简单分析类图就可以看到,Main类中的方法,不论是getAllItems(分析输入)方法、或是后面的derive(求导)方法、combineSimilar(合并同类项方法)等,都是以 ArrayList
-
-
-
度量分析
-
代码规模分析
-
仍然反映出了"一main到底"的代码特点,main类过于臃肿
-
-
方法复杂度分析
-
- 由于if-else分支语句、for循环语句以及循环控制语句的大量使用,导致输入函数和输出方法的代码可读性(CogC)严重下降,圈复杂度(v(G))较高,这些都是我之后需要非常注意的问题,稍不留神就会成为潜藏bug的"绊脚石"
- 不过反倒是因为类很少,代码的耦合(iv(G))并不明显
-
-
类复杂度分析
-
- 同样是"一main到底"导致了main类的平均圈复杂度(OCavg)很高
-
-
-
-
Bug分析
-
被发现的Bug:
- 由于合并同类项操作放在了求导操作之后,导致有一些可以相互抵消的项先被求了导,之后才被合并为零项。而最初只在求导时消除零项,因此输出部分并未考虑零项的存在,导致0被不正确地输出,进而产生bug。
- 特征其实是由于考虑不周全,逻辑设计时没有考虑所有的输入形式,进而出现这种bug,这当然也与我没有采用OO的思想有关,导致很难通过code review 进行debug。
-
发现别人的bug:
- 本次互测我仅发现了别人的一处bug,特征正是if嵌套过多,导致代码分析难度高,进而在输出阶段的一个if分支埋藏了一个bug(把"**"写成了"+")
- 通过简单的逻辑分析与测试用例的构造,即可让这部分if-else语句正好执行出bug的分支,进而成功hack
-
-
第二次作业
主要任务是对包含简单幂函数和简单正余弦函数的组合函数求导
相比第一次作业,添加了三角函数和表达式嵌套的形式,本次作业我完全抛弃了第一次作业的架构,进行了完全的重构,也是非常紧张地完成了任务
-
类图及架构分析
- 类图
-
架构&思路简析
-
图中方法与类稍多,主要关注上半部分接口与类的继承关系
主要架构参考了助教在Hint中提示的"化整为零"的思路-
可求导接口(Derivable):包含求导方法和字符串转换方法(这个没有必要加入,但是为了提醒自己重写这个方法,加上了)
-
元素类(抽象类Factor,为便于管理添加此类,其实无用):即运算所作用的操作数,子类包括
- 常整数(Const——命名不规范, const为java预留字,容易冲突),保存常量因子
- 三角函数因子(Trigon),保存三角函数以及其幂次
- 幂函数因子(Power),保存幂函数
-
一种是运算类(抽象类Operation,亦为便于管理添加此类,其实也无用):即运算的类型,子类包括
- 加法(Add):加法,包括两个实现了Derivable的操作数(可能是运算,也可能是元素)
- 乘法(Multiple):乘法运算,包括两个实现了Derivable的操作数
- 嵌套(Compound):函数的复合类型,包括两个实现了Derivable的操作数(第二次作业没有用到)
-
-
-
-
由于加入了表达式因子,而Java正则表达式不支持嵌套,因此我斟酌了半天,最终决定完全放弃第一次作业的架构
(面向过程有什么架构[苦笑]),转而参考Hint中提示以及课上提到的递归下降分析法,通过构造表达式树解决问题 -
具体执行过程如下:
-
输入与处理:(步骤较少,Main类中就解决了)输入多项式,进行简化——去除空格(没有考虑WF,这也导致我第三次作业疯狂吃瘪),以及乘方号的转换等操作
-
递归下降分析:(WordMatcher中的静态方法完成)解构输入字符串。首先消除题目文法中的左递归,提出新的递归文法
表达式 -> [+-]? 项 表达式’
表达式’ -> [+-] 项 表达式’ | ε | )
项 -> [+-] 因子 项’
项’ -> * 项 | ε
因子 -> 符号整数 | x [^整数]? | cos(x) [^整数]? | 表达式
整数 -> [+-] [0-9]+按照这一文法通过递归下降解构输入字符串,获得表达式树
-
求导:对表达式树的头节点进行求导,而这一求导方法会递归调用子节点的求导方法,进而对整个表达式树求导,完成求导操作
-
输出:通过重写的toString方法进行输出,注意加减括号以及\(0\times\)、\(0+\)、\(1\times\)的简化。
-
-
一些处理技巧:
- 处理字符串时,我类比了C语言中输入流的概念,建立了一个InputStream类,包括输入的字符串以及当前字符指针。通过提供移动/固定指针读取字符的方法,解决了”右括号谁来读取“之类的尴尬的问题
- toString方法中,为了简化包含\(0\times\)、\(0+\)、\(1\times\)的表达式,我采取了特判。如若当前节点为乘类(Multiple),则判断左右子节点的toString方法返回值,若至少一个为”0“,则当前节点的toString返回”0"。来降低输出表达式的复杂度。
-
-
优缺点
-
优点
- 通过求导接口、运算类与元素类的抽象,还有递归下降分析法和表达式树的建模,出色地完成了既定任务,中测几乎可以是一次过的
- 避免了第一次作业那样以超长的正则表达式解析输入字符,提高了代码的可读性
- 小技巧运用巧妙,降低了代码规模
-
缺点
- 建模思路是助教给的,这里以我自己的能力可能还是很难完成这样的建模任务
- 没有采用统一的方法(如通过表达式转换等)构造表达式树,导致表达式树非常不平衡(不过在输入严格限制的情况下,不平衡导致的性能损失很小)
- 读取输入后无脑去除空白符,导致了我第三次作业的WF判定出现各种问题(详见下一次作业)
- 没有采用工厂模式等来管理类,尽管在作业中没有负面影响,但是若要真的作为一个迭代项目,需求再扩大,则可能代码就会变得一团糟
-
-
度量分析
-
代码规模分析
- 可以看出,除了WordMatcher类中代码行数爆表外,其他类的规模并不很大。
- 主要是因为WordMatcher类中仅包含一些用来递归下降分析的和构造表达式树的静态方法,而这些方法中其实有一些重复的可重构为方法的代码块,如获得带符号整数。而我当时没有进行即时的方法重构导致代码量较大(时间紧急也是原因之一)
-
方法复杂度分析
- 首先可以直观地看出,方法总数变多了,而除了WordMatcher中的部分方法外,其他代码的认知复杂度(Cogc),非结构化程度(ev(G)),模块设计复杂度(iv(G)),以及模块的圈复杂度(v(G))都比较低,这显然是得益于建模得当,模块方法的内聚性高,耦合度低,以及分支语句未滥用。
- 但是WordMatcher就问题很大了,仍然是前述的没有合理重构方法,导致了模块认知复杂度高(if语句多,大多都是重复的,可以被重构的),同时模块的非结构化程度、也很高。
- 不过其中的模块设计复杂度高却是架构性的问题——递归下降分析中要在分析字符串之后,递归调用项、因子等的构造,导致了模块耦合性高,相比之下,其他模块就没有这样的问题。
-
类复杂度分析
- 同样的问题——WordMatcher中的类方法的圈复杂度都高,导致平均复杂度也高
- 不过三角函数类Trigon中的方法圈复杂度高是因为构造方法等方法中要判定是正弦还是余弦函数,所以也算是结构性的问题
-
-
Bug分析
-
公测/自测Bug
- 中测:几乎是一次过,没有测出bug
- 强测:TLE,互测出的Bug也是相同问题。
- Bug:在表达式树的toString方法中为了优化表达式调用了过多次的子树的toString方法,导致在输出时的分支结构的条件语句中反复调用toString,时间过长引起TLE。
- 解决:解决问题只用了不到10min——既然是性能bug,那就要进行性能分析。通过简单的时间测试可以发现,在整个程序运行过程中,在最深的嵌套层数输入下(约10层),输入、求导时间均为ms级别,而输出所用时间直接到了s级别。这样就定位了bug,之后利用IDEA的debug工具,发现toString方法太耗费时间,进而才发现是条件语句反复调用了toString导致的。这样的话,只要提前调用一次toString,以(少量)空间换(大量)时间,既保证了输出表达式的简洁,又提高了性能,成功AC。
-
互测:
- 被测:同上
- hack:hack思路和第一次作业相同,通过分析输入/输出模块中的圈复杂度(当时不知道这个概念,只知道要找大量分支嵌套的语句),寻找逻辑上的bug,进而成功hack一次。
-
-
第三次作业
在第二次作业的基础上添加了WF判定和三角函数函数内部复合。
由于第二次作业架构合理,第三次作业并没有浪费太多的时间去重构,但是却由于之前犯的一些懒,导致这次作业也是差点没过去
-
类图以及架构分析
-
类图(仅展示相比第二次作业新增的类)
-
架构分析
-
以第二次作业为基础,三角函数的复合非常容易解决,仅需在WordMatcher类中识别三角函数时再识别一次因子即可。
-
WF判断就比较棘手了:
- 新增一个自定义Exception类——FormatException,出现读取到不想要的字符或是在非预期情况下读取到了字符串尾则抛出,main中catch到之后直接输出WF。理想情况下应当能够满足需求
- 由于第二次作业中直接抛弃空白符,因此要继续采用第二次作业的架构就要对输入进行空白符检测,否则要进行架构的部分重构,我这里采用了前者,避免因为重构出现其他的不可预料的问题
(谁都想偷点懒嘛==),不过却导致了bug - 右括号和字符串尾对于字符串分析器来说作用相同,所以要进行括号匹配的特判(不过好像没有针对这个点的检测)
-
-
优缺点
-
优点
- 工作量相对较小,开始增添代码时可以从容地完成任务
-
缺点
- 没有重构,生硬地进行空格检测,导致递归下降分析法的格式检测优势没能很好地发挥出来,还导致了bug
-
-
-
度量分析
-
代码规模分析(HW3 vs HW2)
- 上图为第三次作业的规模图,下图为第二次。可以看出除了添加了两个类,实际并没有做太多重构
-
方法复杂度分析(参数从左往右依次为CogC,ev(G), iv(G), v(G))
- 上图为新增类InputExaminer中的方法复杂度,可以看出由于前面没有重构,新增的空白检测方法whiteCharCheck的圈复杂度与非结构化程度都很高,主要就是因为它是一个检测器,所以都是条件分支在判断。当然,也正因如此,这个方法里也埋藏了一个bug。
- 上图为第三次作业中的WordMatcher类中的各种方法,再次对比第二次作业:
- 可以看出添加了复合函数后,由于需要获得因子以及新表达式,三角函数构造器trigonMatcher等的圈复杂度以及非结构化程度、模块耦合程度都又上了一个台阶。
-
类复杂度分析
- 仍然反映了前述问题,WordMatcher中的方法以及新增类InputExaminer中的方法的圈复杂度普遍很高
-
-
Bug分析
-
公测/自测:
- 中测:由于一处笔误,中测前我非常非常煎熬,不论从哪里搜刮来的数据都测出没有问题,但总是无法通过中测。最后心如死灰的我重新测试了一次题中给出的示例,结果发现了我的输出中会出现sin(2*x),但是cos函数却能正确套上括号,一查才发现是在嵌套类中的toString方法中有一处把"right"写成了"left"。
- 强测:出现了一个bug,是WF考虑不周到,没有想到sin中的常数也是常数因子,作为一整个带符号整体,导致WF判定失败,不过其实质上是由于前面说的没有重构,导致空白符判定比较难以实现,进而出现了”漏网之鱼“。
-
互测
- 被测:又出现了一个bug,而之前一直是我hack别人圈复杂度高的代码块,这次轮到我因为圈复杂度太高而被hack了——因为在乘法类的toString方法中,判断0与1处的if嵌套不正确,导致括号不能正确地加上,输出了bug
-
-
二、心得体会
- 今后要刻意地以OO的思想对程序进行行为级、结构级建模——第一次作业的教训
第一次作业的完全面向过程的编程思想虽然一时爽,但是后续需求扩大重构起来真的会出大问题的。而且学习、利用OO思想也是这门课的核心之一,所以一定要学好、用好
- 对程序的输入输出要有把握,也要善用工具——第二次作业debug的经验
第二次作业尽管出现了TLE,但我把握了我的程序以及输入限制,做出了合理的推断——问题不可能出在表达式树上,尽管它是递归的——输入限制150,表达式树最多200层,若按照最不平衡的二叉树来算也不可能因为树的遍历导致TLE,而事实证明这是对的。
此外,还要善用工具——如Date类测程序时间等。
- 合理重构,而不要心存畏惧——第三次作业的教训
前面提到,由于开始没有重构,导致后续WF判断需要专门写一个类来完成,事实上,若进行了合理重构,仅凭递归下降分析法即可完成绝大部分WF判定任务,根本没有必要再去写新类,而且BUG也更少。
不过,重构要保证时间充裕,所以一定要尽早完成,而且要提前构思好,不能边想边重构,否则很可能连完工都完不了。
- 胆大也要心细,尽量减少typo导致的bug——第三次作业的另一个教训
总之,希望接下来的作业中我能吸取教训,高效高质量地完成任务!