一、基于度量的程序结构分析
第一次作业
1、设计思路:
第一次作业较为简单,整个多项式中只可能出现常数项或者变量项,且变量项只有常数✖幂函数这一种可能,因此多项式中的项可以归纳为 (c*x**p) 的形式,其中c为常数,p为指数,这个多项式为形如 (c*x**p) 的项的线性组合。首先,我们利用大正则的方式将整个多项式拆分成一个一个项进行处理。由于每一个项都只有c和p两个变量,因此我采用Map的集合形式对多项式的每一个项进行存储:将p作为Map集合的Key,当做寻找相同指数进行系数合并时的索引;将c作为value,看做每一个指数对应的属性。求导的过程也非常简单, (c*x**p) 的导数为 [(c*p)*x**(p-1)] 。在这次作业中,我使用了两个Map集合,一个存储读入多项式的内容,另一个存储求导后的多项式的结果,最后只需要使用遍历器遍历结果的Map集合,就能够将求导后的多项式进行输出。
2、UML类图:
3、度量分析:
4、 统计数据:
5、总结
这次作业是我在对面向对象编程的特点有了一定的了解之后写出的第一份代码。在代码中,我希望能够体现出一些面向对象的特征,因此对项中的系数建立了一个类,希望能够通过这个类描述幂函数的系数的属性。但是由于设计时思考不到位,以及这道题目本身较为简单,每一个幂函数的系数属性只需要一个 BigInteger 就可以实现,因此我所设计的 Term 类内部只有两个字段和一个构造方法,是一个无用的类。
此外,对于这次作业来说,由于求到的过程较为简单,因此代码量最大的位置在通过正则表达式读取多项式内的项。这里我也使用了工厂类的思想,将读取表达式的工作建立了一个专门的 Getnum 类进行处理。但是由于自己的代码风格不佳,整个利用正则表达式读取项并将项添加进Map的过程全部都在一个方法内部,使得 Getnum 类中的 getnum 方法非常臃肿复杂, Poly 类中的 toString 方法也是如此,从上方的度量分析中可以看出其复杂度相比其他方法高出许多。复杂度和代码长度增加之后,会导致bug和debug难度大幅提升,应该进一步将这些过大的方法细分为功能更专一的多个方法。
第二次作业
1、设计思路:
第二次作业相比于第一次增加了sin(x)和cos(x)的运算。此外,项不再是简单的 (c*x**p) 形式,而是增加了因子:常数、幂函数以及三角函数的概念,由多个因子相乘组成一个项,项的线性组合形成多项式。由于一个项的因子数不再确定,如何存储一个项的内容似乎变得十分困难。但经过仔细思考发现,第二次作业事实上只是第一次作业的简单延拓:尽管项的内容十分复杂,但是因子类型却只有四种,因此多项式的项可以归纳为 c*x**p*sin(x)**q*cos(x)**r 。所以我们可以利用上一次作业的思路,将Map中的key从一个 BigInteger 改为 这样的三元组,Map的value则仍然是c。求导时利用乘法求导的公式,将求导后得出的三元组存放在另一个Map中,利用相同的Key合并同类项,然后利用遍历器进行输出即可。
当然,这次作业与第一次作业有需要进一步思考的地方在于所得的求导后的多项式的化简。首先,若以三元组为
2、UML类图:
3、度量分析:
(由于方法过多,截取了复杂度较高的几个方法)
4、 统计数据:
5、总结
这次作业主要是在第一次作业的基础上进行部分方法的修改和新增功能的拓展,也间接证明了第二次作业是第一次作业的简单延拓。由于本次作业中,因子的种类较多,再加上吸收了第一次作业中将大量功能写在一个方法中导致方法臃肿且难以维护的教训,这次将 toString 方法和 Getnum 方法内的功能都进行了分解,使得单一的方法内的内容不再显得臃肿。但将单一方法的功能分散到多个子方法进行运行,不可避免地使得最顶层的方法调用其他方法的次数显著提升,度量分析中展示出了这样处理的后果。过多的调用会使该模块与其他模块之间的耦合度过高,一旦其中的某个模块发生修改,对其他模块的维护将十分艰难。因此,如何更好地平衡单一模块中的功能量和子方法的数量,还需要在设计阶段更好地分析安排。
在度量分析中,我们还可以看到有数个方法的基本复杂度很高。经过我的观察后发现,这些方法都使用了大量的if语句进行判断。过多的if语句可能导致方法内的逻辑混乱,其中产生的微小的bug很难去寻找。因此在实现方法功能的时候,不能简单的直接使用if进行所有的判断,认真梳理方法逻辑之后往往会发现整个实现事实上并不需要这么复杂。
度量分析中有一个名为 simplify 的方法在三个复杂度度量中都超标了。这个方法是我在中测最后阶段临时加上用于多项式的化简。由于多项式的化简本身较为复杂,再加上当时时间紧迫,因此这段代码显得十分冗长而且难以理解。在中测过后时间较为宽裕的时候,我回过头来仔细检查这部分代码,发现内部if语句的判断逻辑写反了。这个bug使得我处理所有能被化简的数据都出现了问题。这次的错误让我明白了代码逻辑设计的关键性。先仔细地设计代码功能的逻辑,再去进行代码实现,往往比脚踏西瓜皮式的写代码方式更有效率,并能极大地提高代码的易读性和正确率。
第三次作业
1、设计思路:
第三次作业在前几次的基础上增加了不少难度。首先,是增加了sin和cos内部嵌套因子,其次,是因子的概念增加了表达式因子。这样一来,读取多项式的方式、存取多项式的方式以及求导的方式都发生了变化,因此前两次作业的结构已经无法继续拓展在第三次作业中使用,我们需要对程序的结构进行重新设计。
从求导的功能要求上来看,无限嵌套的因子求导必然需要使用递归的方式进行处理,再加上对象构建时使用的继承逻辑与树形结构非常相似,因此使用之前学过的表达式树的方式存储和递归处理表达式的求导非常合适。在这次设计中,将多项式项作为表达式树的根节点,根据项的数量将表达式拆分成若干个线性相加的子节点,每个节点的具体正负符号由每个节点自身存储。项节点又可以根据因子数量拆分成以乘法相连的若干个因子子节点,其因子包含三角函数、幂函数、常数和表达式。由于三角函数内部可以嵌套一个因子的原因,三角函数内有且仅有一个包含三角函数或幂函数或常数或表达式的因子子节点。而常数和幂函数节点不再有子节点。
在读取多项式的方面,我们不能采用之前大正则的方式了,因为因子内部可以无限嵌套,而java中的正则表达式不支持递归读取。但是正则表达式这样强大的工具我们又不舍得放弃,因此在这次作业中采用预处理+部分正则的方式进行读取。先将表达式中最外层的“( )”转化为“[ ]”或是其他不会在表达式中出现的符号以防冲突。然后利用 sp + "\\[" + sp + "[^\\]]*" + sp + "\\]" 的正则表达式忽略第一层括号内的所有内容,这样就能够用与前两次作业相似的方式,以取表达式树中当前层的节点因子。然后再将第一层括号内部的内容再次以相同的方式解析,就能够获得表达式树下一层的节点因子。就能够将整个表达式读取并解析。
求导时,根据复合函数求导法则的思想,采用由内向外的方式,先遍历至表达式树最底层的节点并求导,然后向上递归。当回到根节点时,整个表达式的求导就完成了。
2、UML类图:
3、度量分析:
4、 统计数据:
5、总结
一开始看到第三次作业的时候,由于其所需要的结构与上两次作业相差较大,显得有些无从下手。但是经过指导书和老师课件的点拨,想到了能够利用对象创建模式和表达式树的思想构建整个程序的框架之后,思路开始逐渐明朗起来。因此可以可以看出,设计在代码编程过程中的重要性。
通过上面的度量分析可以看出,这次采用了对象创建模式,而不是使用之前的面向过程的编程思路之后,整个代码的复杂度都有了大幅度的降低。图中的3个 derivation 方法的复杂度较高是由于内部if-else的嵌套过多。而 Getfactor 方法的复杂度和耦合度过高,是由于在写代码的时候,由于对对象创建模式的工厂化方法尚不熟悉,导致我将创建实例的工厂和读取数据的模块合并在了一起,导致该方法中功能较为复杂,而且调用较多。在以后的代码编程过程中,还需要进一步简化每一个方法的内容,如果方法功能过于丰富,可以将其分成两个方法进行处理。像读取数据和创建实例这两个功能本身就是程序中代码量较大的部分,甚至可以独立分成两个类进行处理。
二、bug分析与debug策略
bug分析
在三次作业中,第一次作业和第三次作业在中测、强测以及互测中都没有找到bug。但是第二次作业中,存在重大的bug。
第二次作业中有三个bug。第一个bug出现在最后添加的 simplify 方法中。由于是在中测最后一天临时添加的,再加上需要的功能本身较为复杂,导致方法内部逻辑混乱,写完后也没有得到充分的测试。在这个bug中,我判断两项需要化简的多项式的前一项留下的因式是sin还是cos,以分别进行处理时,将if的判断逻辑写反了,导致sin的部分处理了cos的内容,导致几乎所有化简都会产生错误。这个错误主要是由于写代码时的习惯和风格不佳以及测试的不到位。
第二个bug是 Getterm 方法中正则表达式的问题。在正则表达式中,我由于错误地将多项式处理阶段已经处理过的符号再次放入项处理阶段进行处理,导致常数因子前面的符号有可能计算两次,使得形如 +-2*x**2 这样的多项式求导得出的答案为 4*x 是第二次作业强测出现大量问题的主要原因。
第三个bug是 toString 方法的bug。由于我在 toString 方法中使用了过多的if语句进行判断,导致逻辑判断十分复杂,内部的一个记录已经输出的项的数量的计数器在一个特定情况下无法正确计数,导致明明已经有输出的项,但是计数器却判断输出为空,因此在输出的多项式末尾多输出了0,形如 -x**-1 会输出 x**-20 。
当我们将这些bug与第二次作业的度量分析结合起来观察的时候,就能够发现,这三个bug所处的位置恰好是度量分析结果中复杂度最高的三个方法。这对我来说也是一个警示:方法内部过高的复杂度必然会增加内部bug的出现率,如 simplify 方法中的逻辑错误, toString 方法中的错误耦合等等。更糟糕的是,这样的bug往往在方法中隐藏的很深。因此以后在写代码的时候,尽量不在一个方法中使用过高复杂度的代码,如果必须要使用的话,必须进行充分的测试才能够使其正确性有所保障。
debug策略
在第一单元的互测以及自己寻找bug的过程中,我并没有像其他大佬那样自己构建评测机,自己随机制造数据进行测试,而是采用了最原始的手工测试的方式。但是手工测试不是盲目测试,它也需要有自己的测试范围和逻辑。在写代码之前,我会先对指导书中一些可能的坑点进行记录;在我写代码的时候,对自己编写每个部分的代码时的难度进行记录。然后写完后,对自己的程序以及之后互测中的程序,重点排查之前记录的那些坑点和难点。事实上,由于边界数据经常需要额外的代码对其进行处理,因此这些坑点和难点往往就是这次作业中的边界数据。比如前导0的处理数据 x**0000 ,多符号的数据 -+-3*x**2 ,内部嵌套时的合理空格数据 sin ( ( x ) ) ** 2 ,进行化简时可能忽略的首尾括号均有效的数据 (x)*(x) ,都是我在写代码时碰到的需要关注处理的内容。这些类似的数据后来在互测环节中,也取得了很好地效果。
三、对象创建模式的应用
对象创建模式能够为一个有大量的对象类型,且这些对象有着共同的特征的程序提供一个很好地设计框架。在第一单元的作业中,由于第三次作业增加了因子的嵌套,使得程序需要频繁的创建和管理有着共同特征的不同的因子,使用对象创建模式管理因子对象就显得得心应手。
对象创建模式首先要对程序中可能出现的各种对象进行枚举。在第三次作业中,可能出现的因子有常数因子、三角函数因子、幂函数因子和表达式因子四种,由于三角函数因子中sin和cos的行为也有所区别,因此三角函数因子再细分为正弦函数因子和余弦函数因子。这样一来,程序地对象结构就已经清晰了。首先构建因子的接口,具体的四种因子继承因子的接口,正弦函数和余弦函数因子再继承三角函数因子。
设计好因子结构之后,我们就可以采用工厂模式的方法,利用工厂类创建对象实例,利用实例内部的方法管理实例本身。
在第三次作业中,由于我对工厂模式的概念不够清晰,所以我将工厂类和数据读取类合并为一个类,但依旧包含了简单工厂方法的思想,下面的代码是截取的工厂模式部分
while (matcher.find()) { if (matcher.group("powFun") != null && !"".equals(matcher.group("powFun"))) { Factor powfactor = new Powfactor(matcher.group("powFun")); arrayList.add(powfactor); } else if (matcher.group("sin") != null && !"".equals(matcher.group("sin"))) { Factor sinfactor = new Sinfactor(matcher.group("sin")); arrayList.add(sinfactor); } else if (matcher.group("cos") != null && !"".equals(matcher.group("cos"))) { Factor cosfactor = new Cosfactor(matcher.group("cos")); arrayList.add(cosfactor); } else if (matcher.group("coef") != null && !"".equals(matcher.group("coef"))) { Factor coeffactor = new Coeffactor(matcher.group("coef")); arrayList.add(coeffactor); } else if (matcher.group("polyfactor") != null && !"".equals(matcher.group("polyfactor"))) { Factor poly = new Polyfactor(matcher.group("polyfactor").trim()); arrayList.add(poly); } else { System.out.println("WRONG FORMAT!"); System.exit(0); } }
四、心得体会
这次第一单元的作业是我第一次系统地进行面向对象编程的训练。在pre的作业中,我的思想还基本停留在面向过程编程中。在完成了第一单元的作业之后,我将这些作业与之前pre阶段的代码进行对比,对面向对象编程的思路有了进一步的了解。
第一单元的作业让我体会最深的有两点。第一点是编程前设计框架的重要性。在之前面向过程编程的学习过程中,由于所需要构建的代码功能较为简单,再加上面向过程编程本身就有一种按过程思考的思考方式,想到哪里写到哪里的脚踏西瓜皮式的写法似乎也没遇到什么问题。但是面向对象编程就不一样了,由于代码中重点要关注的是对象之间的具体和抽象关系以及对象之间的功能分配,如果没有在写代码之前就做好构思,那么写下的代码往往面临着耦合度过高,逻辑混乱的问题,往往需要多次重构才能达到所需要的功能。因此以后在面向对象编程的过程中,要重视框架构思的过程,绝对不能因为贪图省时间而忽略了这一点,最后捡了芝麻丢了西瓜。
第二点是代码测试的重要性。由于这个学期面向对象编程所需要达到的程序的功能远比之前面向过程变成所学的内容要复杂,因此在完成代码之后,在进行自我测试并寻找程序的bug上花费的时间不应该比写代码本身的时间要少。在第二次作业中,我就是忽略了测试寻找bug的重要性,反而转去寻求代码功能的优化。最后几个bug连起来,差点连互测都进入不了。