成果展示与分析
UML图
在hw3的业务需求背景下,完整的执行流程大致为:
- Main函数为所有自定义函数生成Lexer,并交给各个FuncParser
- 为待解析表达式生成Lexer,交给主Parser
- 调用所有parser的setFunction方法,以所有FuncParser作为参数,为后续表达式树替换做准备
- 调用parser的parseExpr方法,解析表达式,并返回一个ast的根结点
- 调用ast根节点的getValue()和toString()方法,得到展开简化的表达式
- Main函数输出表达式
评价:
- 可扩展性:我认为这样的架构因为完全是基于文法的,因此在对于文法的扩展上可扩展性极高,只需遵循文法建立新的抽象层次class、书写新的解析方法parser和新的getValue方法(Poly的计算方法)即可;同时,对于自定义函数的处理,由于使用了表达式树移植方法也是高度可扩展的,因为继承自parser,其拥有与parser完全一样的解析能力,目前我的实现可以支持求导因子在自定义函数中出现、自定义函数的无序嵌套(比如先定义f后定义g,f依赖于g)
- 稳健性:其中涉及数值计算的Poly和Unit类被我设计为Immutable的,这一点可以很有效避免难以定位的Bug出现;由于Mode.DEBUGGING的设计,我可以很方便的获得程序出错的信息、定位程序出错的位置
- 可提升处:当前的实现是基于先建立AST,再扫描getValue()的流程实现的,但是为了更高的性能以及设计的简化其实可以边建树边求值(4,5步骤结合);目前我在Poly中使用HashMap<Unit, BigInteger>的结构存储多项式,但是由于将单项式信息分别部分存储在Unit和Poly中,这样的设计其实会带来Poly和Unit类的耦合,我进行多种思维实验和trade-off后选择该架构,并没有找到更好的方案(思维实验将在后文中介绍);
- 仍在思考:关于方法的权限层面,目前其实大部分方法都使用了public形式,这一点主要是在为了单元测式的方便考虑的;但是,我其实也在思考一个问题:到底什么是“单元”?一定是单纯一个方法吗?还是有时候是一些方法的组合构成一个业务功能的单元?自然有些时候为每一个方法都写单元测试有极高的稳健性,但是其实对一些功能极简单的子方法其实测试的价值很低,这是一个稳健性和开发效率的trade-off
代码规模分析
以下统计分析来自插件Statistics与ReloadedMetrics
总行数1620,源码1139,注释290,其中源码的占比为70%(以上统计数据含测试代码);不含测试的程序源码为899行
这一特性主要是由于我使用Test-first Programming方法,在方法实现前大量、详尽的书写specs,之后再进行实现。这样的方法虽然确实在前期准备阶段会花费大量的时间进行准备,可能花费半天的时间完成specs还没开始真正实现代码,但是其实“慢就是快”,在后期Debugging阶段会极大的节省时间,基本只要通过提前设计好的UnitTest就不会出现Bug,在所有测试阶段也是从未被hack成功。
OO度量
方法复杂度
ev(G) 基本复杂度是用来衡量程序非结构化程度的,非结构成分降低了程序的质量,增加了代码的维护难度,使程序难于理解。因此,基本复杂度高意味着非结构化程度高,难以模块化和维护。实际上,消除了一个错误有时会引起其他的错误。
iv(G) 模块设计复杂度是用来衡量模块判定结构,即模块和其他模块的调用关系。软件模块设计复杂度高意味模块耦合度高,这将导致模块难于隔离、维护和复用。模块设计复杂度是从模块流程图中移去那些不包含调用子模块的判定和循环结构后得出的圈复杂度,因此模块设计复杂度不能大于圈复杂度,通常是远小于圈复杂度。
v(G) 是用来衡量一个模块判定结构的复杂程度,数量上表现为独立路径的条数,即合理的预防错误所需测试的最少路径条数,圈复杂度大说明程序代码可能质量低且难于测试和维护,经验表明,程序的可能错误和高的圈复杂度有着很大关系。
方法复杂度较高部分:
从分析数据中不难发现,其实复杂度较高的部分主要集中在Poly类中,主要包含transform、calGcd、checkPolyType三个方法中,它们的职能分别为把Poly中的单项式转为字符串,计算多项式最大公因数,判断当前多项式是否为单项式、是否可化简;
其实这三个方法的复杂度较高是意料之中的,因为前面提到了由于权衡后最终选择将单项式信息分离存储在Poly和Unit类中,这会造成这两个类的耦合,是为了架构设计和性能而不得不做的牺牲
这三个也是为了性能优化而导致高度复杂的方法,唉,可见对于追求程序性能和程序本身可维护性之间的trade-off
还有一个来自lexer类的next方法,它的作用是解析输入字符串并输出Token流,这个复杂度高是因为内部存在着大量的if-else判断语句,针对这种情况,我能够想到的降低复杂度方法大致为判断-操作分离,将操作拆出来作为一个函数调用实现,私以为这个可以加入一种代码风格设计规范,也是一种为迭代增量开发设计的好习惯。(这也纳入了我在文末开源的我的OOP开发手册中)
所有方法的总复杂度与平均复杂度
架构设计体验 && 回顾反思
hw1 架构
值得注意或者说反思的是,在第一次的架构中,我使用单纯HashMap<BigInteger, BigInteger>的形式进行多项式的存储
为了实现对ast树的求值操作,我让所有ast节点皆继承了一个抽象类Polynomial,在此Polynomial类内部实现了众多对于HashMap存储形式多项式的算数操作,如polyAdd()
在反思的过程中,我认为这样的做法其实并不好,主要原因是:
- 没有考虑后续迭代的需求,可扩展性较差,这样实现对HashMap操作同样限制了我的可扩展性
- 传递一个可变引用对象是一件非常危险的事情,生存期的不确定会使得代码在难以预料的地方出现bug,这很不利于代码的维护
hw2 架构
不得不说的是,hw2是我进行最多思考、反复修改代码量最大的一次迭代,并形成了在开头展示的最终架构;同时,在本次迭代中,我也收获总结了最多的东西
总结主要在以下两个方面:
- 思维实验:多项式的存储形式
- Poly的方法设计与不可变对象思想
思维实验:多项式的存储形式
一个问题,Poly内部存成HashMap<Unit, BigInteger>的意义是什么?
答:我希望可以依赖Unit索引导coef,本质就是我希望对比Unit的varExp和Poly属性,在Add中看我能不能合成变新coef
但是显然这样做的效果很差,因为我把一个单项式的信息分离到了两个抽象层次Unit & Poly,以至于这两个抽象层次是高度耦合的
但是为什么不用ArrayList呢?因为它的get方法是基于索引的,而其实在业务逻辑中这个索引并不携带任何信息,那么如果我希望在这一堆Unit中找到可合并的项,相对于HashMap而言,它的速度会变慢非常多。
能不能把Unit里面存coef,varExp,Poly信息,同时在Poly里面再存一遍coef呢?这个方案甚至不如ArrayList,它直接威胁到了程序的安全性与稳健性,冗余存储要求的同步修改,稍有不慎就会出现难以追溯的bug,绝对不要!
但是,能不能结合出来一种两全其美的方案,使用HashSet来解决这个问题?
首先,对于这个Set而言,我们知道其内部的内容必然是不一样的,那么在merge的时候,要如何实现找到相同项呢?
在这一点上我们可以通过重写HashCode方法和equals方法来解决,在这两个方法中,我的HashCode和equals方法仅判断varExp和Poly是否一致
关于HashSet判断键值是否相等的内部实现,它会先根据HashCode来判断是否一致,再去判断Equals方法是否一致
这样可以把coef、exp(Poly), x^varExp 信息全部整合进入Unit,同时,我仍然是基于Hash值存取
-
public Poly grad(Poly src){
-
Poly ret = new Poly();
-
for(Unit i : src.units) {
-
ret = ret.add(i.grad());
-
}
-
return ret;
-
}
-
public Poly grad(Unit unit) {
-
// ...
-
}
这样的设想似乎非常美好!可是,对于Unit而言,如果其在HashCode层面上是不考虑Coef的
但是Poly的.equals()和.HashCode方法是基于对HashSet的HashCode调用的
此时会出一个问题,在判断可加性时会用到exp内部的Poly,但是对于Poly而言,其值是否相等的判断又是基于Unit是否相等的
由于在刚才的情况中,Unit是否相等不考虑coef,于是下述情况会被认为是可加的,这是绝对不合理的
-
x * exp(1*x)
-
x * exp(2*x)
-
// being addable
于是,在经过了复杂的取舍与思考后,我最终仍然以牺牲一定低耦合度为代价,使用HashMap<Unit, BigInteger>形式存储多项式
Poly的方法设计与不可变Immutable思想
在进行Poly中方法的设计实现的时候,我遇到了如下的问题:
- 修改输入参数,出现极其怪异bug,且极难以追溯Bug
- toString方法修改了变量状态,闹鬼奇观运行调试结果不一样
- 方法同时修改当前变量,又有返回值;调用方法后难以确定当前变量状态
对此,我阅读了MIT6.031关于ADT设计和Immutablility的slides,阅读了BigInteger实现的源码,总结出了方法设计的原则(代码规范)与思想,并写入了我的OOP代码设计规范,节取其中部分内容:
一个我们设计的类,或者说ADT(Abstract Data Type),
其对外部开放的方法包括四类(MIT 6.031),其功能与设计规范可被归纳为:
t means other args
T means target ADT
- creator : t* → T:从其余类型变量创建一个新ADT,大多用于Constructor
- producer : T+, t* → T:从已有ADT以及一些其余类型变量创建一个ADT,eg. BigInteger类的.add()方法
- observer : T+, t* → t:观测当前ADT状态的方法,不允许修改当前ADT不应有post-effect,eg. toString()
- mutator : T+, t* → void | t | T:修改当前ADT状态的方法,一般而言无返回值或返回值反应是否修改成功;返回值不应和Observer方法相同
在ADT方法设计层面,存在一个重要的理念:就是类本身的方法不要做过度复杂的操作,而是提供非常简单的方法功能即可,让用户将简单的原子方法进行组织来实现复杂功能
因此,在进行原子方法的设计时,只需要遵循提及的四类最基本的原子方法设计规范即可,对于方法组合实现复杂功能交给外部;当然也可以提供少量已经封装好的二级/复杂方法(由类内部调用原子方法实现)
使用mutable的危险主要在以下两个场景,会产生alias:
- Passing mutable values as params
- Return mutable variable
在ADT的设计时,为了保证满足我们代码工程的易于维护、可理解性、debug的便利,一定尽量将ADT设计为Immutable的。
hw3
hw3的迭代也就是最终的架构设计,其实相对于hw2而言基本没加什么东西,自定义函数我仅加了一行代码,加入求导因子和Poly、Unit内部的求导方法;仅仅引入了部分性能优化如提取公因数
可扩展性
情景1
自定义函数的顺序可以不存在依赖关系(如先定义f,f中含有函数g的调用,后定义g),且自定义函数中允许存在求导因子
该情景下,我的代码一行都不用改。
在我的实现中,自定义函数的处理是基于Parser的子类FuncParser实现的,而其中函数调用部分是依赖于setFunction方法为每个parser配置函数解析器,由于我的顶层实现如:
-
int funcNum = Integer.parseInt(Mode.SCANNER.nextLine());
-
HashMap<String, FuncParser> functionMap = new HashMap<>();
-
for (int i = 0; i < funcNum; i++) {
-
String funcString = Mode.SCANNER.nextLine();
-
Lexer funcLexer = new Lexer(funcString);
-
FuncParser function = new FuncParser(funcLexer);
-
function.setFunctions(functionMap);
-
functionMap.put(function.getSignal(), function);
-
}
也就是说FuncParser已经设置好了所有函数的映射表,遇到函数直接跳转到对应FuncParser解析即可,而函数映射表是共享的HashMap,因此我的自定义函数与顺序无关
情景2
引入新的计算因子sin()
我的代码需要扩展:
- Lexer加入解析Token
- 加入一个sin()的抽象层次,应用ast接口,重写getValue()方法
- Unit中加入sin(Poly)的存储项,以及相关的计算
Bug
Bug在经过UnitTest之后基本不存在过多,但是曾经准备过一个产生bug的原因,我称之为BUG LIST,以避免我下次再犯同质的错误
- 先判断存在性,再取出容器值!
- 在做乘法的时候,因为可能会出现自更新的问题,没有检查这里会不会报错
深拷贝与浅拷贝
- BigInteger这一类型的运算法则
- polyPow实现有误,快速幂写错了
- 以自定义类做HashMap索引需要重写HashCode和equals方法
- 重写toString方法时不能修改变量值或者属性,调用.toString后原对象的状态不应有任何改变
- 需要控制评测机生成数据复杂度
- 子类不能重写父类的private方法
- 在自调用部分出现了问题,这是因为源码中Token只能被解析一次,需要给lexer加入reset方法
- 自定义的put方法写错了
- 在parseDerivation时,没有特判断情况"dx + x" -> Expr
如何更好的设计避免bug和MIT6.031中safer from bugs的理念一致,我也总结了如何更好的避免bug出现的设计规范在OOP开发规范中。
一般而言,从经验总结:代码长度较高的方法、没有规范化、没有依赖内部调用降低复杂度的方法会更多地出现bug
互测
基本是依赖自己写的评测机,将所有人代码下载下来进行大对拍(但是三次作业两次全0房真的会谢)
手搓了少量边界条件数据,如特殊结果0,1,-1,0=0+0+0+...
性能测试:(x^8)^8...
优化
时间性能:
- 去除所有空白项、合并加减法
- 在递归过程及时remove掉coef=0的单项式
长度性能:
- exp内单项式提出系数
- exp(0)不显示,用特殊子类标记
- 简化系数为1,-1,0,指数为0,1的情况
- exp内部多项式提取最大公因数
心得体会
三周的OO课程,相对OOPre而言强度大了很多,但是也在这样高强度的训练中对于OO的理解更加深刻,对于一些曾经看到的知识点(当时没在意)有了更加深刻的理解与认识,例如Immutable对象、方法设计规范......同时,自己搭建评测机也锻炼了Python面向对象编程的能力(一门课高强度训练两种语言了属于是);当然,我在Unit1中对于设计模式的实践体会并没有很深,我觉得这一点是之后要深入学习强化的,以及,对于JDK文档的查阅和使用,我发现我还有提升空间,应该更多地利用造好的轮子优化代码
未来方向
感觉节奏和体验已经比较好了,就是第二次迭代开发的任务量略略有点大
Additional
经过Unit1的学习与实战经验总结,以及阅读MIT6.031的slides,我总结出了我的OOP代码设计规范(开发手册,会随着上课更新,但是中英混杂。。),目前已开源至github:
GitHub - nihaotian1/BUAA-OOP-SoftwareConstruction-Notebook: My summary after taking BUAA OOP course and reading slides in MIT 6.031
欢迎大家来找我一同讨论~
Reference
基于ReloadedMetric进行复杂度分析的文章
遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
原文链接:IDEA圈复杂度插件(MetricsReload)下载与使用-CSDN博客