面向对象设计与构造2022第一单元总结

面向对象设计与构造2022第一单元总结

一、架构设计的演进

​ 我这三次作业的一个共同的流程就是:解析——运算——化简。这里的运算指的是表达式的拆括号和合并过程(三角优化之前)带来的各种加减乘、乘方运算并得到结果。而化简主要指的是三角函数的优化化简。

​ 而我的设计主要是围绕运算来设计的。程序在解析的过程中,会递归地处理表达式——项——因子这样下降的结构,从整体到局部,但是从运算的角度看,我其实关心的是运算的结果。因此,我所设计的对象主要目的是表达运算的结果,以及实现其统一。而对于解析的过程我认为通过方法的递归已经隐式地实现了表达式树的遍历。这一点体现在代码上就是,我并没有单独构造ExprTerm这样的类,所有的解析运算完毕后均返回Result类。与此同时,三角化简也是基于运算结果的呈现形式来化简的。

​ 因此我接下来来说明这几次作业中类的构造思路。

第一次作业

​ 由于仅涉及多项式的展开,而且运算中间的幂次不超过8,因此我的运算结果Result类的存储形式非常的简单,就是一个ArrayList<BigInteger>实际上是一个仅有9个元素的数组。数组的下标是指数,对应的值是该幂次的系数。

​ 自然,加减乘、乘方就针对这个运算结果实现加法、取反和乘法方法既能实现。

​ 当然,为了实现递归下降法,我构造InputData类来底层处理最后一行的字符串,比如获取当前字符、读取一个带符号整数等基础操作,构造Analyzer类来进行表达式、项、因子的解析。

​ 这里将输出过程的相关方法写到了Result类中。

​ 因此加上主函数所在的主类,总共只有4个类。

​ 下面给出第一次作业的类图。

image-20220324220623326

第二次作业

​ 第一次作业时尚不清楚后面会增加怎样的需求,因此采取了一个拓展度几乎没有的架构。这就使得当次作业很容易做,但是下一次就得重构。

​ 第二次作业增加了三角函数、求和函数和自定义函数。从运算结果的角度来看,我的Result表示为HashMap<TriExpr,Polynomial>

​ 其中TriExpr是一个三角连乘式,这个类的数据结构为HashMap<TriSingle,BigiInteger>表示每个三角函数和对应的指数。而这个TriSingle类的数据结构又是HashMap<Boolean,Polynomial>前者简单用01表示sin/cos,后者表示三角函数内部的多项式。

​ 而多项式类的数据结构为HashMap<BigInteger,BigInteger>表示指数和对应的系数。

​ 再来说解析部分,针对新增的需求,Analyzer类新增了checkSin()checkCos()checkSum()checkDiy()四个方法。值得一提的是求和函数和自定义函数,其本身存在一些表达式需要运算结束后再返回最后一行待求表达式,因此,AnalyzerInputData对于他们自己的表达式/因子也是能够处理的。故我新增了Sum类和DiyFunc类来单独处理求和函数中i的代入和自定义函数中函数自变量的代入,形成全部代入完毕后的字符串再交给Analyzer类进行分析运算,把结果回传。当然,这里需要一个StringSaver类来按递归下降方式读取求和函数和自定义函数的表达式,并返回为字符串。这么做看似冗余,但实际上是为了避免无脑正则造成的bug。(但很不幸,这里也会有bug,后文分析)

​ 但这个Result的统一设计会造成一个问题,就是我有的结果是多项式,有的结果是三角连乘式。这些结果本身在运算中没有问题,但是输出上如果能识别出来就能简化输出长度(也符合正常的公式阅读习惯)。因此我定义了一些约定的常量和判定的方法。

​ 三角连乘式的单位1:表示成

\[1\cdot x^0 \cdot \cos^1(0\cdot x^0) \]

​ 于是,一个Result对象是纯多项式\(\iff\)这个Result不存在一个非单位1的且指数不为0的TriSingle

​ 另一方面,为了避免单位1的幂次在Result相乘中增大,规定相乘中忽略单位1的相乘,最后再补上单位1.

​ 第二次作业新增的三角函数,可以运用sin(x)**2+cos(x)**2=1进行化简。因此我的策略是将前面所述的运算结果送入一个Simple的类专门进行化简。这个Simple类中的数据结构为HashMap<Pair<BigInteger, BigInteger>,HashMap<BigInteger, ArrayList<TriExpr>>>。其含义为单项式为\(a\cdot x^b\)的,且三角部分次幂和为某值的所有三角连乘项的集合。因为我注意到这一条化简的两个部分一定是三角次幂和相等的,把它归类可以减少匹配中枚举的复杂度。具体策略就是枚举两个多项式部分相同且三角部分次幂和相等的TriExpr,再尝试将其中一个的sin因子的次幂减2,将另一个的内部相等的cos因子的次幂减2,用equals方法判断剩下的部分是否相同,从而确定能否合为一。类似的思路也可以做1-sin(x)**21-cos(x)**2这样的化简。

第三次作业

​ 第三次作业的关键词就是嵌套。实际上,由于我的解析架构从一开始就是递归下降方法,因此多层括号的支持是天然的。我在这次作业中主要遇到的麻烦是三角函数里面是嵌套因子。前面所说运算中合并同类项的操作,但是三角函数嵌套因子的合并,在我的架构中依赖于hashcodeequals方法的重写来判定。上一次作业中,三角函数里面必然是多项式,判定较为明朗。但是这次作业三角函数里面显然是复合形式,用Result来统一就会出现循环定义的问题,这就使得我之前定义的常量单位1没法初始化出来。

​ 但是我发现,即便会出现“三角套三角”或者其他复杂的形式,最最里面的一层三角函数,它内部一定还是个多项式。所以我要利用好这个原子性。因此,本着一点点开闭原则的精神,我并没有抛弃第二次作业中的Polynomial和Result,而是定义了一个接口Factor,这个Factor本身是个空的,但我让多项式类、结果类和化简类全部implements这个Factor.目的就是让三角函数里面一般情况是Result(当然化简后改为Simple),最最里层是个Polynomial。这样以来,我的equals判定到最里层的多项式部分是明朗的,在外面的Result部分判定也是明朗的,如此递归下来,似乎就能很好得判定两个运算结果的实值相等了。注:合并同类项在实测中有效,但失效例子也不少。

​ 下面给出第三次作业的类图(建议在新标签页中打开图片)

image-20220324223043439

小结

​ 所以总体来看,我这三次作业类的设计上,Result类及其相关类的设计思路是计算效率依从Sum类和DiyFunc类是逻辑依从

​ 对象之间主要是组合层次,继承在这三次作业中并未实现,接口虽然在第三次作业中有用到,但也并非其典型用意。

​ 这三次作业的优点,我认为就是把运算和化简两个任务做到了较好的分离,而且对处理混合了多项式和三角连乘项的加减乘乘方运算能得到统一化的结果。

​ 这三次作业的缺点,在于输出和化简类的设计耦合度过高,逻辑复杂,有不少相近功能代码多处编写的问题。而且,为了处理好我上述的单位1,许多常量在对象与对象之间频繁传递,破坏了私有性,也增加了耦合度,这在设计上有违OOP的理念。

二、基于度量的程序结构分析

第一次作业

方法复杂度

image-20220323104224763

类复杂度

image-20220323104256349

代码统计

image-20220323104311812

​ 由此可见,第一次作业方法圈复杂度较高的地方主要在是Result类里面与输出有关的部分。输出部分由于考虑常数、\(x,x^2\)大多采用if-else结构处理和判断,造成圈复杂度过高。这次作业中类复杂度较高的地方在Analyzer模块,可能的原因是解析因子时总是需要特判指数,并频繁与InputData对象交互所致。

第二、三次作业

由于第三次作业主要是在第二次作业基础上重新实现了化简类,故不再单独放第二次作业的UML了。

方法复杂度

image-20220323105430785

​ 这张图没有列全,已经足以说明问题了。排在前面的方法非结构化程度太高,但其实这些方法都是化简类,倒是与运算中间过程没有关联,相对独立。为什么化简类的圈复杂度如此之高?我认为很大的原因在于我的化简类主要数据结构相当于三层HashMap嵌套,因此遍历需要一层一层打开键值对考虑,每一层的都会伴随着一些 键值是否存在 诸如此类的判断,在编码和实际运行上复杂度都很高。这说明一味地追求形式的统一性虽然降低了理解的难度,但是不利于程序的维护。

类复杂度

image-20220323110327360

​ 与解析相关和与化简相关的类非结构化程度较高。

代码统计

image-20220323110406601

​ 三角优化代码量是大头。

​ 总代码量上其实是有些过多了。这里的部分原因是,做三角优化前在Result类中实现了一套输出方法,后来进行三角优化后,数据的存储形式发生了变化,因此在Simple类中又实现了一套输出方法。而由于最终的输出效果是"多项式*三角函数连乘"的基本形式,所以在PolynomialTriSingle中也有各自的一套输出方法。这些输出方法,一方面有些重复实现了相同的功能,比如printA方法我在画UML图时发现它在好几个类里都出现了,而且实现上大同小异;另一方面随着三角优化的实现,原先ResultPolynomial中的一些方法被废弃了。这有点“堆屎山”的倾向,但我当时考虑如果关闭优化应当保留适当可调用的部分,所以也没有特意删除。综上几个因素,导致代码量过多。

​ 但细究这个问题,还是设计上没有把输出和其他的任务相对分离。输出的逻辑的确和最终数据的存储结构有关,但是像这里存储结构稍稍变了,就得复写一系列相似的输出方法,我觉得这与OOP的精神是相悖的。

三、自己程序的bug

第一次作业强测和互测均没有问题。

第二次作业强测没有问题,互测中出现一类bug被刀了四次。

典型错误数据如下

0
sin(-2)**2

我的程序对此直接把里面的负号拿到外面作为多项式的-1,却没考虑到平方以后应该是正的。所以解决方法就是遇到这样需要提负号的情形,拿出来放到对应的多项式中的一定是-1进行若干次幂后的结果。

该处bug属于Result.setTri()方法,其圈复杂度未显著高于其他方法。

第三次作业中强测WA了两个点,互测也中了两刀。

总体来说是三类bug

第一类bug是当自定义函数里面调用多元自定义函数时,我的StringSaver在递归下降处理被调用的自定义函数转string时忘了把逗号加上,导致里面解析出错。

代码局部示意:

	while (!str.reachEnd() && str.getNowchar() == ',') {
            ret = ret + ",";   //This operation missed!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
            str.moveTonext();
            ret = ret + readFactor();
        }

该处bug属于StringSaver.readDiy()方法,该方法圈复杂度并未显著高于其他方法。

第二类bug是求和函数里采用将循环量替换i的手段进行过运算,但遇到类似i**4这样的字段,遇到负数直接替换成-3**4,其实应该是(-3)**4得81,会误计算成-81.解决方案是替换内容外面加上()

该处bug属于DiyFunc.calc()方法,该方法圈复杂度并未显著高于其他方法。

第三类bug是化简类里面有一处equals笔误,导致该条件判断语句判断两个不同类的对象,导致该条件判断失效。

代码局部如下

for (Map.Entry<BigInteger, HashMap<TriExpr, BigInteger>> x : input.entrySet()) {
            HashMap<TriExpr, Boolean> used = new HashMap<>();
            for (Map.Entry<TriExpr, BigInteger> y : x.getValue().entrySet()) {
                if (y.getKey().equals(TriExpr.getZeroExpr()) || used.get(y.getKey()) != null) {
                    continue;
                }
                for (Map.Entry<TriExpr, BigInteger> z : x.getValue().entrySet()) {
                    if (z.equals(y) || z.getKey().equals(TriExpr.getZeroExpr())
                            || used.get(z.getKey()) != null || !y.getValue().equals(z.getValue())) {
                        continue;
                    }  //第一个条件我当时误写成了z.equals(x)
                    ...

触发bug的数据

0
cos((cos(x)**2+sin(x)**2-1))

正确的执行中,该HashMap<TriExpr,BigInteger>应该只有一个对象,因此可以continue掉。但错误执行中无法continue导致后面的错误合并。

该处bug属于MoreSimple.merge3()方法,该方法和所属类的圈复杂度较高

四、发现别人程序bug采取的策略

第一次作业我写了数据生成器来构造数据,查出两人的bug,其问题是出在读取字符串到行末时有空格或换行符仍不能停止,导致字符串越界。

第二次和第三次作业主要通过BNF描述的方法手动构造各类样例,包括

  • 不同类型的变量因子和表达式因子
  • 边界数据
  • 嵌套展开
  • 等等

并对房间内所有人的程序进行本机测试。

第二次作业查出两人的bug,主要是处理sin()**0时错误输出为0。

第三次作业查出一人的bug,其问题是化简sin(x)**2+cos(x)**2错误化简为0.

五、心得体会

​ 首先这个过程确实是我第一次写千行级别的程序,这对整体的设计要求和以前的程序是不可同日而语的。总的来说还是先确定架构,再考虑实际编码。这三次作业过程中没有出现中途停下来思考或修改设计的情况,所以真正编码所用的时间并不长。

​ 第二次作业提交前我是最焦虑的,原因就是当时完成了所写的优化距离提交仅有数个小时,而一直聚焦于局部的实现,让我当时对整体的设计有所淡忘,心里没底。所以我静下心来认真阅读了所有的代码,用纸简要记录了脑中的一些约定和每个类的重要策略。如此梳理之后,便有了大概把握。第二次作业后来也平稳度过了。

​ 然而这三次作业出现的bug还是挺多的。而且尤其令人惋惜的是,这些bug本身并非是设计、逻辑上出的问题,恰恰是细节和编码上的疏忽。两次bug修复,第一次就改了一行,第二次就改了三行。我觉得由此能看出有事没事,多做测试,应该是一个必须的意识。手动构造数据再多,往往还是受制于自己作为编写者的思维,所以自动化的数据生成器真的是一定要写。夫祸患常积於忽微。

​ 此外,三角优化虽然花了不少心思去研究,但是编码上复杂度较高(无论是编写时的感受还是事后的度量分析),实际表现也不尽人意。这启示我在作业的架构设计上应该更主动地和其他同学进行讨论,也包括认真阅读讨论区的帖子,这样有利于把思路打开,或许能让那些自己认为不好实现的点迎刃而解。

​ 这门课叫做“面向对象设计与构造”。就第一单元的三次作业而言,我觉得自己的设计还不够OOP。分析问题的时候,确实把“运算”当成一个抓手去勾勒出解决问题的思路,但是,这样的思路直接去创建类,去创建方法,真的是在面向对象吗?还是仅仅把一个又一个类作为面向过程的“合理、好用、可嵌套”的数据结构呢?比如说不能因为checkstyle要求属性是private,在使用上却肆无忌惮弄一堆get方法让这些private名存实亡。我觉得从普遍较高的圈复杂度中还是好好反思。我要做的不仅仅是学会解决作业提出的任务,像以前的题目一样求一个AC,而且要锻炼自己用OOP的思想(比如说设计模式),以应对好任务的可迭代性等偏向软件工程的需求,去整体设计,去构造类和对象。

posted @ 2022-03-25 11:20  LaiAng8086  阅读(147)  评论(2编辑  收藏  举报