BUAA OO Unit1 单元总结
概述
第一单元的主题是表达式分析与化简,通过对文法的形式化表述,建立表达式的层次化结构,并运用递归下降的方法解析表达式,在这个过程中逐步体会面向对象的思想。
HW3 uml图

HW1
要求:HW1 的要求是对表达式进行恒等变形,直至没有任何的括号出现,其中括号的深度至多为 1 。
递归下降:涉及 Lexer 和 Parser 类。其中 Lexer 类作为词法分析器,同时囊括了扫描字符串的行为,根据当前扫描到的字符进行符号分析。而 Parser 类为文法分析器,其严格根据文法的形式化表述和 lexer 所返回的的当前 token 来提取表达式的各个部分,并层次化地存储到 expr 中。
Mono-Poly 架构:我所采用的这一架构最重要的点在于字符串方法和基于此的排序之上。其中 Mono 存储了一个单项式(对应一个项)所有的因子参数,包括 coef, expX, expY, expZ, Trigonos 。Poly 则存储了若干个 mono 。同类项的合并会在计算的同时完成,主要由 Poly 类中 addPoly 和 mulPoly 方法实现,于是所有的数据管理类都有一个 toPoly 方法,通过转换成 poly 来对数据进行处理。Poly 类也承担了最后的输出职责,最终输出是将 expr 转化为 poly 再调用 toPoly 方法。
想法:在第一次作业开始的时候,我对递归下降法只有很少的理解,再加上对 Java 语法并不熟练,导致我第一次作业没有很好地完成。在实验课以及认真钻研递归下降的实例之后,我才找到完成这次作业的方法。在 HW1 中,我对代码的架构作了一些考量,接下来的两次作业之中,我也基本延续了这个架构。(如图所示,我的 Factor 接口只实现了 Mono 和 Poly 类)我的想法是,表达式总能表示成一个多项式,相应地,项总是一个单项式,在这种观点下,事实上破坏了原有的层次化表述,实际中在解析时,遇到数字或者变量时会返回 Mono ,遇到表达式因子时会返回 Poly 。在完成 HW1 时,我对动态多态性并不熟练,所以基本就是从这一数学角度分析,这一架构的问题在 HW3 中会出现,尽管解决方法并不困难,但是并不优雅,也不契合面向对象的设计思想。
问题与解决:我在阅读文法的过程中进行了很多的权衡,其中对于符号进行了长久的思考,在字符串预处理中将连续符号去除之后,最终还是认为在解析输入时,Term 类在装载 factor 的容器之外应该包含一个符号。而在输出时,由于输出的是 poly,只需对每一个 mono 进行正确的输出即可。
Bug:HW1 中我没有遇到解析上的错误,主要碰到的都是在优化上,如涉及平方项直接替换等于 2 的指数(x**21),以及消除等于 1 的系数(231*x+1*y)的情况,这当中既有没有充分测试也有不熟悉 Java 语法的原因。
复杂度:
class | OCavg | OCmax | WMC |
Expr | 1.50 | 3 | 6 |
Lexer | 2.00 | 4 | 8 |
Main | 1.67 | 3 | 5 |
Mono | 1.64 | 4 | 23 |
Parser | 3.50 | 7 | 14 |
Poly | 1.42 | 3 | 17 |
Term | 1.67 | 3 | 5 |
Average | 1.77 | 3.86 | 11.14 |
在方法中,仅有 Parser.parseFactor() 的四个数值都较高,第一次作业结构较为简单,整体来看复杂度并不高
HW2
要求:HW2 中附加了三个新特性:多层括号、内含因子的三角函数、以及自定义函数。
想法:
对于多层括号,递归下降法完全支持,问题转向后两个特性。
出现了三角函数,增加一个 Trigono 类,其中包括 type,factor,和 exp 三个属性,同时 Mono 的属性也需要改变,必须增加一个存储 trigono 的容器,在文法解析中,出现三角函数时我依然会将其直接转换成 mono 返回。引入新类型同时产生了对三角函数合并同类项的要求,而合并之前必须判断相等,这固然可以通过遍历容器判断存在性的方式来完成,但可能有些死板而缺乏新意,通过在研讨课中得出的巧妙思想,我最终采用的是使用字符串比较来判断相等(这一方法不仅对容器是适用的,对于任何的类也是一个可能的比较方法,当然 Hashcode 可能也是一个方法),而需要对存储 trigono 的容器进行字符串比较,则必须首先要进行统一的排序,于是在 Trigono 类中加入 compareTo 方法(compareTo 方法必须要考虑到所有的 trigono 属性,绝不能出现返回 0 的情况!),在这一方法的实现中,必须比较 Trigono 的 factor 属性,这就要求对 factor 可能包含的多个 mono 进行排序,于是在 Mono 类中也要加入 compareTo 方法,最终 Poly 中的 Mono 容器和 Mono 中的 Trigono 容器都是经过排序的,这样就能够保证基本的同类项的合并。对形如 sin(0) 或类似的优化问题,可以通过判断三角函数内 factor.toString() 的结果在解析时就转化为 0 或 1,注意对幂次的特判应是优先于其他的特判的。
处理自定义函数,我的方法是在预处理时进行替换,这一过程发生在 Lexer 的构造器中。首先在读入函数的时候要将 x、y、z 替换成如 %1 的参数,防止重复替换带来的问题,然后在 Lexer 构造函数中调用一个字符串替换的递归函数 callFunc ,最终返回一个不含自定义函数的字符串作为输入进行解析。
Bug:在自行测试时,我出现过一个 Bug 是在递归替换自定义函数时的错误,没有考虑到在参数因子中还会出现包含函数的运算的情况,错误原因是在扫描到函数末尾后会直接进行替换,由于我采用的是对括号数进行计数来判断函数是否终止,于是甚至可能会导致之后的全部字符串被忽略。其他的 Bug 都是在优化时出现的,但并没有全部被 Hack 出来,一个是对于形如 sin(-x)^n 的优化,我的方法是在添入三角函数因子时就将其转化为 -sin(x)^n 的形式,但在最初的设计中我没有考虑 n 次幂的不同情况而出现了错误;另一个是对于三角函数内部是表达式因子时的优化,如 sin((0)),或内部表达式因子能够化简为一个常数、变量、或三角函数因子的情况,这时事实上并不需要第二层括号。由于我实际存储的因子都是 Mono 或者 Poly 类型,对于是否需要添加括号必须添加判断,我最初是用正则表达式来完成这项工作的,但在遇到如 sin((sin(...)*cos(...))) 时又遇到了问题,最终还是抛弃了正则表达式,使用了最基本的分析属性的方式。
复杂度:
class | OCavg | OCmax | WMC |
Expr | 1.50 | 3 | 6 |
Lexer | 2.86 | 6 | 20 |
Main | 2.25 | 4 | 9 |
Mono | 1.53 | 4 | 29 |
Parser | 4.80 | 9 | 24 |
Poly | 1.83 | 7 | 22 |
Term | 1.67 | 3 | 5 |
Trigono | 2.20 | 7 | 22 |
Average | 2.14 | 5.38 | 17.12 |
method | CogC | ev(G) | iv(G) | v(G) |
Lexer.getFunc(String) | 10 | 4 | 6 | 7 |
Parser.parseFactor() | 16 | 5 | 12 | 12 |
Parser.parseTrigono() | 14 | 6 | 6 | 10 |
Poly.mulPoly(Poly) | 20 | 6 | 8 | 8 |
Trigono.toString() | 20 | 1 | 7 | 7 |
Total | 145 | 96 | 144 | 157 |
Average | 2.27 | 1.50 | 2.25 | 2.45 |
以上是复杂度较高的方法,parseFactor() 由于涉及多种情形,复杂度较高,另外的方法都是通用的计算方法或是字符串方法。
HW3
要求: HW3 中增加了两个特性:求导因子和函数定义时可引用可求导。
想法:
求导并不是一个困难的问题,但由于我的架构原因,实现起来并不那么理想。如前文所说,在解析表达式后我返回的是 Poly 类对象,poly 是由若干个 mono 组成的,当中可以看作用加法连接,于是只要考虑单个 mono 的求导运算。对于单个 mono 而言,求导运算应该具体到不同的因子类型,而 Mono 类集合了常数、变量、三角函数三种因子,于是我的做法是将 mono 的属性进行分割,重新拆分为这仅包含单个因子属性的 mono 填入容器,然后遍历容器针对每个分割出的“因子”进行求导,虽然实现的代码量也很小,但却有违层次化设计的初衷,直至这时我才发现我的架构可能的不足之处。
在样例中明确指出自定义函数会先进行求导再代入实参,于是我认为在读入自定义函数进行预处理时,首先要将引用的函数进行替换,然后处理求导因子,这样能够保证在分析表达式时先进行求导再代入实参。于是新增了 Func 类用于存储自定义函数,同时将 callFunc 函数移植进去,并添加了 callDerive 函数,用于在函数体包含求导因子时,对其进行一次整体的表达式解析从而去除。
Bug: 第三次作业较为容易,我在一次通过之后也没有进行更多测试,于是就留下了一个愚蠢至极的错误:在自定义函数中有求导因子时,我会先对其进行解析随后输出字符串,然而却忘记对这个字符串进行预处理。于是出现的乘方符号 ** 就会导致后续无法解析,这个错误也使得我在圣杯战争中被刀得片甲不留。
复杂度:
class | OCavg | OCmax | WMC |
Expr | 1.67 | 3 | 5 |
Func | 3.75 | 6 | 16 |
Lexer | 2.67 | 6 | 16 |
Main | 1.67 | 4 | 5 |
Mono | 1.73 | 4 | 38 |
Parser | 5.00 | 9 | 25 |
Poly | 1.77 | 7 | 23 |
Term | 1.67 | 3 | 5 |
Trigono | 1.92 | 6 | 23 |
Average | 2.18 | 5.22 | 17.22 |
method | CogC | ev(G) | iv(G) | v(G) |
Func.getFunc(String) | 10 | 4 | 6 | 7 |
Parser.parseFactor() | 17 | 6 | 13 | 13 |
Parser.parseTrigono() | 14 | 6 | 6 | 10 |
Poly.mulPoly(Poly) | 20 | 6 | 8 | 8 |
Trigono.toString() | 15 | 1 | 6 | 6 |
Total | 159 | 111 | 160 | 175 |
Average | 2.24 | 1.56 | 2.25 | 2.46 |
总体来说,复杂度被控制得较好,唯有 Parser 类因其牵涉到各个类,复杂度较高。
不足之处
如上所说,我所延续的这一架构可能对层次化设计的初衷有所违背,Poly 同时是计算类和输出类,而 Poly 和 Mono 类也许承担了过多的任务,至少应当引入 Number 和 Variable 两个因子类,这样在对 Mono 的处理过程中,一些特判也能够通过检查类型来解决。然而,由于我的化简过程极大地依赖于字符串比较,如果引入新的类,需要在 toString 方法上作细致的改动,当然另一种更加简便的方法是在调用新类的 toString 方法时直接转换为 Mono 再输出字符串,类比于将 Expr 转换为 Poly 再输出,这样的话,能够在确保字符串正确性同时确保合并同类项正确性的基础上保留因子的类型,做到了更好的多态性。
另一点是容器的使用,直到 HW3 我依旧主要采用的是 Arraylist 和 TreeSet 为主,将 TreeSet 改换成 TreeMap 能够为多项式计算带来方便,但需要注意应对自定义类重写 Hashcode 和 equals 方法。
心得体会
三次作业有很强的关联性,一个好的架构对于完成后续任务的复杂程度有决定性的影响,所以建立一个好的架构是十分重要的。在这三次作业中,我总体的实现思路没有大的改变,但随着对 Java 语法和多态性的逐步理解,我在后两次作业中都对代码进行了抽象和精简,虽然具体功能没有改变,但这个过程才是让我学到了最多的。此外,从这三周的作业来看,每周的 OO 练习还是越早开始越好,这样可以留出更多的测试和精简代码的时间。在第一单元,我经常是在已经强测之后才开始对上一周代码的优化,这也许不是最好的做法。在代码设计方面,我还需要多多学习,期待在之后的实践和交流中对于更好的架构设计、各种设计模式、以及 Java 特性能有更深刻的理解,非常期待下一周即将开始的新一单元。