代码改变世界

OO summary Unit 1

2022-03-24 20:55  BUAA_GreenDragon  阅读(62)  评论(0)    收藏  举报

Unit 1

第一单元的三次作业的任务主要是让我们实现一个表达式化简器。经过三次作业的迭代开发后,最终实现了一个能支持幂函数、常数、三角函数、sum求和函数、自定义函数以及它们的线性组合,乘法和相互嵌套的表达式化简去括号,输出只包含必要等号的等价表达式。

在这个过程中,逐步体会到基于需求的层次化设计和迭代开发可能是笔者最大的收获。

第一次作业

第一次作业的需求为:读入一个包含加、减、乘、乘方以及括号(其中括号的深度至多为 1 层)的单变量表达式,输出恒等变形展开所有括号后的表达式。

代码设计部分

非常感谢课程组在训练部分提供了基于递归下降思路的表达式解析程序,这对我之后的代码部分提供了莫大的帮助,后续作业的代码都是基于此方法开发的。我将第一次作业分为了三个层次:输入及解析、计算、合并同类项,每个部分只关心自己的功能,尽量做到上课所提到的“高内聚,低耦合”,因此第一单元的作业相较于另外两次的代码可读性更好,思路也更加清晰。

解析的时候我沿用了第一单元训练中的架构:表达式(Expr)由若干个项(Term)相加而成,项由若干个因子(Factor)相乘构成。有这个框架我们就可以把解析分成三层去做:顶层表达式解析,首先它会调用每一个项的解析方法;项的解析又会调用每个因子的解析方法,将每一步生成的结果抛回给上层,即可完成表达式的解析。其实我们得到了一个另类的表达式树,表达式节点挂的都是项,项挂的要么是表达式,要么是因子,而因子则作为最底层的叶节点。这样子建树我们保证了最底层的因子永远是最简单的幂函数Pow,则计算就是自底向上的过程了。

计算的过程我也采用了递归下降的思路:

  • 对于表达式一层级的计算:新建一个幂函数的ArrayList,该表达式计算结果即为该ArrayList中因子之和;对于里面每个项,调用项一层级的计算;返回该ArrayList
  • 对于项一层级的计算:对于第一个因子,新建一个ArrayList把该因子放进去,对于后面的每一个因子再新建一个arrayList存放,使arrayList每一项和原ArrayList里每一项相乘,更新原ArrayList;返回原ArrayList
  • 对于因子一层级的计算:这一层级就是遍历两个ArrayList中的幂函数,两层for循环即可

合并同类项中,直接采用Hashmap,利用指数作为键值就好,发现一个就系数相加。

第一次作业设计的UML类图如下(类中的属性应都为“private”,这里画图的时候笔者疏忽了):

可以看到,本次作业中的设计明显分为了三个模块,做到了模块间的低耦合,同时模块内部由于积极采用递归下降的方法,代码嵌套调用,码量少,也高内聚,符合设计标准。但是也值得注意的是接口Factor中为空,说明这个接口其实并没有起到作用,只是作为统一因子层级的“假老虎”,设计有待加强。

基于度量的代码分析

笔者利用IDEA插件MetricsReloaded插件进行代码的度量分析,但是由于度量结果太长,标红地方也少,就不进行贴图,采用文字描述代码的问题:

  • 在Methods metrics分析中,只有Merge(合并同类项)类中的toString方法在各种复杂度分析中都飘红,且数值远高于其他的方法。笔者认为这个方法是用于对建好的HashMap进行输出,涉及到一些优化的过程,譬如对系数为1或-1的特判,指数为0特判,情况比较复杂,达到了8个分支,因此各种复杂度都很高。

  • 在Class metrics分析中,发现Merge类和Parser(解析)类的OCavg达到了标红的4.00,平均圈复杂度最高,说明代码难以维护、更改。Merge类复杂度高的原因已经陈述。Parser类复杂的可能原因可能有两点:其一是在具体解析的过程中需要具体区分因子的种类,同样涉及到了4个分支,复杂度较高;其二是笔者事先没有对一些特定符号进行替换以降低解析难度,比如题干中指数的“**”和乘法的“*”,如果不进行替换,则在读到“*”时要特地多加分支特判,相当于增加了耦合度,笔者在后续的任务中也进行了更改。

像“*”这样的细节,在第一次作业内容较少时尚可容忍,但是在笔者迭代开发第二、三次作业时就被卡住,逻辑理不顺了,被迫改成了“^”。由此笔者也体会到了一些小技巧对于代码可维护性的保障。

本地测试自己bug的策略

  • 利用中测及指导书中提到的数据点进行评测,发现了一些诸如解析因子后下标忘记后移导致解析异常等问题
  • 针对优化部分的输出以及零指数进行特别测试,构造如0**0、-11*x*1之类的测试点
  • 特别考虑大数问题,对于系数进行爆int的大数强测

笔者曾在解析带指数的项时,本地屡次遇到异常,后来debug发现是没有在解析完指数后将Read(读取表达式)光标后移,导致下一步的符号判别出错。仔细分析发现笔者在写指数项时是仿照乘法的逻辑写的,但是在解析乘法时调用了因子的解析,而笔者漏掉的read.next();是就是在因子解析后完成的,在项一层级并没有体现。由此可见采用递归下降的解析手段需要对去全局的逻辑有比较清晰的认识,否则很容易在细节问题出锅。得益于第一次作业还算完备的Parser解析类设计,笔者在后续的迭代开发中只是增加功能,并未遇到什么bug。

互测hack他人的策略

由于课程组对于“一层括号”和“指数不超过8”的限制,并不能构造出很强的数据点。

  • 考虑大数,专门构造爆int的常数运算进行hack。
  • 针对化简优化部分进行hack,这部分也是最容易出bug的地方,部分同学为了代码简便通常会漏掉一些特殊情况。譬如在判定系数时暴力将“1*x”替换成“x”的就是没有考虑多位系数末位为1的情况,用“11*x”hack等等。

第二次作业

第二次作业的需求与第一次作业相比增加了三角函数sin和cos、自定义函数以及求和函数sum,并要求支持多括号嵌套展开。

代码设计部分

依旧还是分为了三大部分处理,包括输入解析、计算、合并同类项并输出三部分。笔者苦于三角函数的处理,想了两天才开始动笔。

输入解析部分:其中由于在第一次作业中采用了递归下降的解析办法,可以完美解决多层括号处理的问题,因此只需要在对于新加的特定因子进行解析即可。对于自定义函数以及求和函数的识别解析其实本质上就是字符串替换的过程。其中在具体实现中笔者并没有采用小伙伴们的“暴力识别替换成一长串再解析”的方法,原因是若是考虑后续的迭代开发,f函数里面再套一个g,就必须采用正则表达式反复匹配,不仅逻辑容易乱,代码也不好看。因此我将第一次作业的解析进行了延伸,当识别到函数因子,则将后续括号内的东西打包丢给对应的类,在对应类内部进行解析,最后返回一个对应的对象。以求和函数为例:
如此一来,相当于将第一次作业的整体作为一个子方法被特殊函数selfSum调用,将sum括号内部当成了一个表达式解析了,最后生成的表达式对象也作为sum内部的属性保存起来。如此一来,当遇到嵌套调用,譬如sin(),或是f(sum(i,-1,1,x))这种输入时,最外层括号内的内容都会被当做表达式处理,若是还是遇到了自定义的函数,则又到了下一层表达式的解析,不管怎样返回的都是一个表达式对象,可以完美解决。笔者也因此在第三次作业周相当轻松,就只需要考虑优化问题。

但是这种方法也有弊端:笔者相当于对于因子一层级进行了细化,解析时常数、幂函数、三角函数、sum、自定义函数都是被单独解析的,非常割裂,这就表明每次解析得到的因子没有“系数”这一说法,只有“指数”,这就意味着统领因子的接口“Factor”有名无实,只是挂名,内部没有任何东西,在乘法计算的时候也没办法用getCoeff(<Factor>)的方法,相当于降低了模块间的联系。

计算部分:计算部分可谓是让笔者大费脑筋,因为三角函数的引入,使得原来将幂函数认定为基元的做法失效。三角函数作为数学上特别的存在,无法包括在幂函数内,这意味着要将计算部分的算法重构。由于笔者一直没有想好该怎样简便地储存、处理数据,就一直没有动笔。后来经过同学点拨,如醍醐灌顶:既然幂函数和三角函数的计算不能归结为“因子”一层级,那么将储存计算结果的基元上升到“”一层级即可。只需在讲前文提到的算法中“放因子”的部分加一步“先将因子放入新建的一个Term”,再把Term放进去。

合并同类项部分:在前一部分的计算过程中,我们只是将表达式分成了若干小项,现在要将项都相加起来并且合并同类项。非常自然地想到像第一次作业一样利用HashMap记录,以系数作为value。那么就要将系数后的x^<num>\*sin(<Expr>)^<num>\*cos(<Expr>)^<num>的项作为键值,因而自然想到要约定一个顺序。将所有的项并入HashMap后根据系数是否为0进行输出即可。

第二次作业的类图如下:

可见与第一次作业相比,依旧是三大模块,比较清晰;但是模块内部逻辑有些混乱,当时写完也没有进行整理,像Merge类,方法写了一大堆,但是其实是不宜阅读的,逻辑比较混乱。这也导致了后面的复杂度分析中多处变红。

基于度量的代码分析

可以看到还是在Merge类和Parser类中复杂度标红了,在Merge类中的toString方法中需要根据系数进行输出,分支多,写的比较冗长;在triString方法中设计对三角函数列表的排序,写了一堆if else,情有可原;在Parser类中的parseFactor中需要对具体方法进行特判,也写了很多if else。可见写代码的时候逻辑比较混乱,写的也很吃力。

本地测试自己bug的策略

  • 特判sum中出现负数、循环0次、出现大数的情况
  • 特别构造自定义函数中不按顺序的形参,如:f(y,z,x)
  • 构造指数较大的情况

由于考虑了两天,周六才写完过了中测,因此本地的测试并不充分。程序对于表达式结果为0得到空串的特判逻辑有问题,使得对任意一个结果为0的表达式(不管是三角函数内还是外部的大表达式)输出为空串,也因此收到了hack。可见本地的充分测试非常重要,应该先满足一些特殊情况。

互测hack他人的策略

  • 针对大指数进行hack
  • 针对三角函数内大数进行hack,防止属性设置为int

由于Const代价算法限制了提交数据的规模,使得笔者在hack的时候有些有心无力。笔者发现有同学用长为500的数组对表达式展开的结果进行保存,展开超过500个项就抛出了异常,但是由于代价刚好卡到了1000,hack不了,笔者心累……另外由于本次hack不允许用sum,导致一些sum的bug也没能hack。

第三次作业

本次作业中需要完成的任务为:读入一系列自定义函数的定义以及一个包含幂函数、三角函数、自定义函数调用以及求和函数的表达式,输出恒等变形展开所有括号后的表达式。

代码设计部分

与整体逻辑与第二次相同,没做更改。但是做了部分优化:判断三角函数内部是否为简单因子决定是否加括号、对sin(0)/cos(0)的特判。

前者笔者在对应三角函数进行初始化解析的时候就进行判定,简单因子的判定逻辑:1 & (2 | 3)

  1. 系数map的size为1,说明表达式仅有一个项
  2. 项内系数为1,且:仅有幂函数/仅有一个三角函数
  3. 项内系数不为1,且仅有幂函数,且指数为0

后者非常简单,不赘述。

第三次作业的UML图及方法复杂度分析类似于第二次作业,并没有做出太大改动,在此略过。

本地测试自己bug的策略

由于题目要求与第二次作业变化不大,因此笔者有时间针对优化部分手撸了一些数据在本地进行了充分测试,在互测内也没有收到hack

  • 针对自定义函数嵌套调用的检查,如3 f(x,y,z) = sin(y)*g(y,x,z) g(x,y,z)=h(x,z,y)+sum(i,-1,1,i**2) h(x,y,z)=x*y+z f(1,2,3),利用形参位置变动检查bug
  • 针对sum的大数运算检查
  • 针对优化进行检查,特判sin((0)), sin(x**2), sin((2)), sin((cos((<Expr>))))之类的化简

互测hack他人的策略

  • 针对sum的大数进行hack,发现有一位同学被hack到
  • 针对优化部分进行hack,特判sin^2 + cos^2 = 1, sin((-x+1)) + sin((x-1))之类的化简,没有hack到

小结

总体来说第一单元的作业虽有磕磕绊绊,但也是顺利完成了。在这过程中逐步体会到java中层次化、模块化的重要性,也逐渐理解老师讲的“高内聚,低耦合”的代码风格要求,这对我本身的代码编辑及维护善莫大焉;再者就是体会到递归下降的逻辑魅力,有点类似于分治,将不同的活丢给下一阶级干,只关心自己手头的工作,理解这种思路并运用在代码中也为我构造算法提供了很大的帮助。

这一单元笔者并没有学习编程测评机,准备在下一单元中进行学习,以更好课下本地强测(和hack别人)。

最后,勉励一下自己:“天道酬勤”,希望自己在今后的单元学习中也能顺利完成任务。