OO第一单元(表达式求导)总结
OO第一单元总结
第一单元是对表达式求导,并且分为三次增量开发。第一次是仅有简单幂函数,第二次是增加三角函数和表达式因子,第三次是允许三角嵌套因子并支持格式检查。
一、程序结构分析
涉及的度量指标
基础度量
Class Metrics
- CSA(class size (attributes)):类的属性个数。
- LOC(line of code):类的代码行数(规模)。
- CSO(class size(operations)):类的方法个数,包含继承的非静态方法。附上NOAC(number of operations added)、NOOC(number of operations overridden)来更容易分析类中新增和重写的方法的数量。
Method Metrics
- LOC(line of code):方法的代码行数(规模)。
- CONTROL(number of control statement):控制分支数目。
类内聚耦合相关
- LCOM(lack of cohesion of methods):基于相关联(共享变量或调用)的方法的个数表征类的内聚。值为1表示内聚度极高,值越大则内聚程度下降。
- CBO(coupling between objects):计算相互耦合的类的个数(不含因继承产生的依赖)。附上每个类依赖的类的个数Dcy,及其被依赖的类的个数Dpt。Cyclic是指由双向依赖的类的个数。
圈复杂度
-
v(G)(cyclomatic complexity):方法的圈复杂度,衡量判断模块的复杂度。数值越高说明独立路径越多,测试完备的难度越大。
-
ev(G)(essential cyclomatic complexity):方法的基本圈复杂度,衡量程序非结构化程度的。
-
OCavg:类中方法的平均圈复杂度。
第一次作业
类图分析
整体思路:
架构上,由类图(右侧)可以看到表达式的结构是“表达式-->项-->因子”的组合。因子为抽象类,由常数和幂函数两个子类。ExpressionParser进行表达式的解析和生成。
重要类的设计:
- Factor(因子):抽象父类,记录因子的类型(Type),并由子类实现具体的求导(getDerivative)、合并因子(combineFactor,乘积上的合并)。
- Term(项):使用ArrayList存储若干因子。采用系数与因子分离,以便更好判断常数项,加速求导。类的主要方法是求导、合并因子(addFactor中进行)、合并同类项相关(判断因子是否相同,并提供因子深克隆)。
- Expression(表达式):使用ArrayList存储若干项。实现求导和同类项合并(addTerm中进行)。
- ExpressionParser:使用正则解析。先循环匹配项,再循环匹配因子。
度量分析
Class Metrics:
Method Metrics:
由于第一次作业比较简单,类和方法的规模都不算很大,方法的控制语句数量也不高。相对比较复杂的方法是在解析表达式ExpressionParser当中,以及合并同类项(Term.combineTerm),也可以接受。
内聚与耦合:
大部分类的内聚程度较好(Factor属于抽象类,图中评价方式不太合理,不过也启示有些行为上的共性可以使用接口)。至于类之间的耦合程度,Dcy的值比较接近架构中各个类的组合情况,其中ExpressionParser因为要生产对象而Dcy数值较大。而Dpt较高的Constant其实是因为项化简得到常数后会直接生成一个新的常数对象(三次作业中都视为不可变对象处理)。
整体评价
优点:
第一次作业的架构设计可扩展性还是比较好的。类的聚合、耦合情况也还可以,基本实现解析与数据存储的分离。
缺点:
在一些优化设计上时间复杂度高,主要因为合并因子/同类项都要需要遍历list。同时在解析表达式的时候面临大正则(主要是匹配一整个项的正则)的问题。(这一部分将在重构中分析)
第二次作业
类图分析
整体思路:
经过重构,表达式的结构整体上仍然是“表达式-->项因子-->因子”的组合,但内部组织形式有所改变,如下图所示。同时由于因子类型增多,Factor这一抽象类增加了变量X,三角、表达式共四个子类。Parser改为递归下降解析。
重要(更改)类的设计:
- X(变量):作为变量因子嵌套求导的终止符。
- Sin/Cos(三角因子):支持嵌套因子。
- PowerFunction(幂函数):支持内部因子嵌套,也算是为第三次作业的嵌套因子提前试验。这里的设计是为了更好合并一个项内的相同因子,所以将其存储为幂函数形式。输出细节需要根据因子类型而定。
- Term(项):使用HashMap存储若干幂函数,方便合并相同因子。如果因子时只有一个因子的表达式,会在addPower中调用优化,递归将因子不断放出(可以视为连乘当中的拆括号)。
- Expression(表达式):因为表达式因子的存在,所以也视为抽象类Factor的子类。同时存储方式改为HashMap,并增加优化simplifyAmongTerm——若表达式内的项只有一个表达式因子的一次方,就把它分解成若干项加入表达式中,以得到进一步的合并(就是连加中的拆括号)。
- Parser:使用递归下降的方式解析表达式。
度量分析
Class Metrics:
Method Metrics:
相比第一次作业,类的规模、方法的规模明显增多。其中规模较大的依然是Parser和Term.addPower(加因子并合并相同因子)。除了优化之外,因为视为不变对象,多处需要深克隆,也增加了方法的数量。
内聚与耦合:
第二次作业的内聚情况和第一次差不多,甚至略好。虽然耦合程度有所增加,但是感觉这更多是因为新增的类都属于因子这一层,而因子就存在较多组合关系。
整体评价
优点:
第二次作业的架构设计由于允许嵌套因子的存在,可扩展性进一步提高。类的聚合、耦合情况维持解析与数据存储的分离。
HashMap存储模式更有利于优化。同时递归下降解析也修改了大正则的问题。(这一部分将在重构中分析)
缺点:
在addTerm、addPower方法中是边加边优化,因此存在许多复杂判断(与之相关的方法CONTROL较高),以及特判,代码比较臃肿,可读性不好。ParseFactor相当于一个简单工厂,也比较臃肿。
第三次作业
类图分析
整体思路:
在第二次作业的基础上仅增加了三角嵌套因子的解析和格式检查,没有重构。
重要(更改)类的设计:
-
WrongFormatException:格式错误异常。
-
Parser:基于递归下降解析,增加了三角嵌套因子的解析和格式检查。
-
Expression(项):增加了作为嵌套因子时的优化。即如果表达式作为嵌套因子且只有一个单因子项,则可以将因子降级放出(可以视为嵌套因子的拆括号)。
度量分析
Class Metrics:
Method Metrics:
由于修改不多,这次类的规模、方法的规模增长明显没有第二次作业那么多。增长部分在解析parseFactor以及三角嵌套表达式因子的降级优化(优化由Expression类实现,所以该类的规模也增加了)。
内聚与耦合:
第三次作业的内聚情况和第二次差不多。耦合程度再次增加,但应该是因为嵌套因子。
整体评价
优点:
维持了较好的扩展性,以及解析和数据管理的解耦。
缺点:
在个别优化方法中,可能存在改进空间,使方法更为简洁。比如我主要进行的三种优化:连加的拆括号、连乘的拆括号、作为嵌套因子的拆括号。其能否优化的条件是逐层递进的,能否设计成一个方法?
二、自己程序bug分析
三次作业中仅在第一次作业的互测中被发现bug。这个bug是为了优化,系数为-1时仅输出负号,但是后面多了一个*,如-*x。仅修改了Term类的toString方法的一个细节,调整并多加了一个判断。代码行没变,只是因为判断增加了而复杂度稍有增加。
三、他人程序bug分析
我采取的测试方案是手动构造样例和评测机(python实现,结构如下图)随机样例。手动构造主要考虑的是一些易错点(0、多重嵌套等等),评测机则进行大量测试。事实证明评测机相比之下更有效,也成功地在第一次和第二次互测中找到了bug。每次找到bug后如果比较方便修改,会修改bug后再次评测,避免hack同质bug。
本单元的测试没有根据程序设计结构来设计测试样例。但现在觉得其实这也是一个不错的测试思路(也可以用于自测),比如若解析表达式的部分设计复杂,可以设计复杂嵌套、多个连续加减号、多空格等的用例测试,以便找到bug。
四、重构经历总结
我只在第二次作业中进行了重构。其实是受到研讨课同学分享的启发,以及第二次作业表达式因子、嵌套因子优化的需求,就进行了重构。下图展示了重构前后j架构的变化(注:常数因子是作为系数单独存储的)。除去第二次作业因子种类增多之外,主要的变化有两点:一是表达式Expression也继承抽象类Factor,从而支持表达式因子;二是项Term视为因子(Factor)的组合变成若干幂函数PowerFunction的组合,这个极大有利于项内相同因子在乘法上的合并。同时为了方便合并,Expression和Term中的存储容器均改为HashMap。
下图是圈复杂度的变化。对于类而言,因为架构的改变出现了双向依赖的类。但是重点关注变化的Term和Expression,其方法的平均圈复杂度下降了。原因是HashMap更方便优化,而优化方法往往在每个类中是最复杂的。
对于方法而言,可以看到由于因子变得复杂,有些方法的圈复杂度显著提高了。它们大部分是本来就比较复杂的因子解析(parseFactor)、支持嵌套因子的幂函数(PowerFunction)、和优化相关的(Term.addFactor)、以及有优化的toString。当然也包括一些新增的优化方法,其本身复杂度也比较高。但是平均值并不算有太大的提升,说明重构比较合理,实现了更多新功能而没有使程序控制过于复杂,也可以说明不同功能的模块之间低耦合。
五、心得体会
1、好的架构是成功的一半
其实第一次作业的时候就花费了不少时间思考表达式、项、因子之间的结构,在抽象层次就对这三者做了相对还可以的组合。这里自认为的“相对还可以的组合”是这种组合具有一般性,方便拓展。而第二次也仅仅是修改了一部分的架构进行重构。因此实现之前做好架构设计十分重要。在思考结构的时候,如果考虑可扩展性,可以尝试从抽象一点的层次去思考,比较好找到思路;当然个人也认为不必为了可扩展性而在第一次设计时就考虑过于复杂的结构,这样效率比较低。
2、面向对象设计的初体验
这次是第一次使用面向对象的设计方法完成有一定规模的程序。其中感悟最深的就是把表达式等看作一个对象,让它自己管理内部的状态(加减项/因子、求导、优化)。同时抽象父类的存在使得所有因子的求导得以统一,方便归一化调用方法(其实也可以用接口)。因此这让我初步感受到面向对象设计的单一职责原则、多态特点。同时在架构设计中也在不断思考如何才能更好地做到开闭原则,提高其可扩展性。相信经过接下来的学习会有更深入的理解。