一、基于度量的程序结构分析
1.第一次作业
1.1 度量分析表
分析:
从WMC的值来看,代码复杂度方面控制得一般,Poly类(多项式类)、Term类(项类)和VarTerm类(变量类)的WMC值达到了10+,就第一次作业而言,我认为这个复杂度还是可以进一步降低的。
从LCOM来看,各类的内聚程度是可以接受的,其中Constant类(常量类)由于所承担的功能比较简单,方法之间实际不需要一定的实例相关,导致LCOM最大。此外,由于此次作业相对简单,从FININ与FINOUT值也可以看出,各类之间的交互很少,许多方法均由对应的类封装实现,因此耦合度的问题较小。
1.2 UML类图
设计思路:
为不同类型的对象(带符号整数、常量、变量、项及多项式)各自建立类,并让它们都实现同一个接口(即图中的Function),接口要求实现求导方法和转化为字符串的方法。利用正则表达式解析输入,将得到的不同对象存储在容器中,再调用各自的求导方法完成求导。输出时调用容器内各个对象的字符串转化方法。主类仅负责处理输入、调用方法完成求导和输出。FunctionRegex类仅用于保存正则表达式的字符串常量。
化简方面使用了HashMap容器,以指数为键、Term对象为值,作了简单的多项式合并。
评价:
初步运用了面向对象的思想,为不同对象建立各自的类并让它们实现统一的接口,方便了代码编写和维护。但是我在构建类的时候严格按照指导书的形式化定义去选择类的属性和方法,并没有根据问题本身的需要去进行抽象,从而又使得代码的逻辑复杂度提高,这也是我在上面提到的延续到后续作业中的一个思路上的问题。此外,我对正则表达式的使用也停留在大正则上,不具有进一步的适用性。
2.第二次作业
2.1 度量分析表
分析:
从LOC以及WMC来看,FuncUtils和Term类的复杂度是突出的。在第二次作业中我采用了工具类的思想构建了FuncUtils类,使之包含了许多方法(如字符串提取、求导、化简等),从而使得其LOC值和WMC值最高。Term类为了化简的需要,在字符串转化的方法中加入了较多的逻辑判断,同时也重写了如equals()、hashCode()等方法,使之LOC值和WMC值也较高。
从LCOM来看,第二次作业在内聚性方面表现尚可;其中FuncUtils因为涉及较多不同用途的方法,LCOM值较大是可以预见的; Term类重写了一些处于化简需要而未涉及其属性的方法,这导致Term类的方法之间无法做到理想的实例相关,导致该类LCOM值最大。
从FININ和FINOUT可以看出这次作业中模块之间的交互明显地频繁了。操作频度最高的自然是工具类(FuncUtils),其次是一些复杂的类,如Term类(项类)。SignedNum类(带符号整数类)是我在第一次作业保留下来的类,由于它涉及到指数、系数和常量,所以使用频度也高。其他的类的操作主要集中在FINOUT一列中,即调用工具类。总的来看,类与类之间的交互集中围绕着工具类展开,工具类会取得其他类的属性并执行相应方法;非工具类之间交互较少,基本不涉及对彼此属性的访问——因此站着分析耦合的角度来看,非工具类之间应该属于非直接耦合,工具类与其他类之间以数据耦合为主,没有存在比较严重的耦合情况。
本次作业我让两种三角函数类、幂函数类、常量、因子、项及多项式继承了类Function,这和第一次作业思路基本相同,从DIT也可以看出这个继承的关系。
2.2 UML类图
设计思路:
为不同对象建立各自的类,并让它们继承自一个抽象类Function,要求实现转化为字符串的方法;类中只提供对类属性的访问方法。对输入的处理思路同作业一,使用正则表达式提取和解析字符串。将处理输入的方法、求导与化简等方法都写在一个工具类(FuncUtils)中。将处理错误输入的方法写在格式检查类(FormatCheck)中。FuncRegex类仅用于保存正则表达式的字符串常量。WrongFormatException类继承了Exception类,定义了WF异常。在Main类中依次调用相关方法。
化简方面仅合并了同类项并处理了0和1,没有就三角函数性质等方面进行更深入的探索。
评价:
本次作业在对问题的抽象设计上几乎和作业一相同,其中的问题也相同,即过于依赖指导书的形式化定义,没有能去根据需要进行抽象设计各个类,这导致了方法的实现中多了许多判断逻辑。本次作业和作业一的最大不同在于创建了FuncUtils这个类,初衷是为了避免类与类之间存在直接的、繁琐的方法交互从而降低耦合度,同时也可以通过增加新的方法来方便扩展。但在具体实现时发现工具类中仍然避免不了冗杂的判断逻辑,其次是工具类中方法的实现十分依赖于操作的对象本身的属性,这两点使得工具类的适用性和扩展性打了很大的折扣,没有达到设计目的。总的来说,我认为本次作业尝试实践了面向对象的一些设计方法,但并没有能做好,原因主要在于两点:一是没有针对问题需求进行合适的抽象;二是对一些设计方法的认识停留在浅显的层面,比如把工具类简单看成是方法的集合。
3.第三次作业
3.1 度量分析表
分析:
从LOC来看,部分类的复杂度还是比较高的。本次作业我采用了表达式树,在TreeBuilder类中完成树的构建,其中的判断逻辑较多导致其行数很多。CheckUtils中定义了较多的正则表达式尝试覆盖WF的情况。SimplifyUtils中定义了一些逻辑繁琐的方法进行简化。
从WMC来看,复杂度集中在了TreeBuilder和SimplifyUtils两个类中,原因已如上所述。如下两个表格分别是TreeBuilder和SimplifyUtils两个类中的方法度量:
从LCOM来看,本次作业的内聚性基本都可以接受,唯一突出的是BasicFunction这个类。由于它是抽象类,非实例相关的方法占比大,故LCOM较高也可理解。
从FANIN和FANOUT来看,本次作业的耦合情况和作业二类似,类的交互集中在了一些方法类(如TreeBuilder建树,ExprDiff求导类,SimplifyUtils化简类)和一些基本模块类(如抽象父类BasicFunction,树结点类TreeNode)上,其他对象类之间的基本没有交互,总的来说仍然是以非直接耦合和数据耦合为主,在可接受的范围内。
DIT所表示的继承层次和作业一、二完全类似。
3.2 UML类图
设计思路:
为不同对象建立各自的类,让它们都继承自BasicFunction抽象类,并实现取指、转化为字符串等方法。建立TreeNode类即表达式树结点类,该结点保存的对象可以是操作符或BasicFunction类的对象。将建树、求导、化简的方法分别写在TreeBuilder类、ExprDiff类和SimplifyUtils中;将格式检查、输出的方法分别写在CheckUtils、TreePrinter中。WfException类定义WF异常,Regex枚举类定义一些基本对象的正则表达式。在Main2类中依次调用相关方法。
在化简方面,采用对表达式树进行深搜的方式合并了同类项及简单的三角函数平方式,未做进一步的探索。
评价:
本次作业的设计思路类似于作业二,两张UML图的结构在逻辑意义上是相同的。具体区别有两点:一是有意识地针对问题本身进行抽象设计,可以看到本次作业的函数对象类只有PowFunc、Constant、Cosine、Sine四个,并没有再去死板地遵循指导书的形式化定义;二是把不同功能的方法进行分类及分包而不是定义一个工具类,减少了方法设计的复杂度。总的来讲,本次作业把作业二中的设计思路中一些处理得不好的地方进行了改进,但没有进一步地运用新的设计方法,导致在代码的设计逻辑上几乎和作业二无异。
二、BUG分析
本次作业一、二在公测、互测中未被发现Bug;作业三在强测中被测出两处Bug,均是未判断出WF,此外在中测和互测中没有被发现Bug。
我在作业三中判断WF的思路是:枚举所有导致WF的字符组合,再使用正则表达式逐一匹配检查,把检查的过程交给专门的类处理。Bug发生的原因是我遗漏了一些情况。现在反思,对WF更合适的判断方式应该是在建树过程中。很显然我在考虑WF问题的时候,并没有把整体的设计考虑进去,而只是想把它从整体中孤立出来,最后只能徒增复杂度和错误。
下面我梳理一下关于自测过程中发现Bug的情况。
在对作业一进行自测的过程中,我发现Bug集中出现在字符串解析部分。在我的设计中,求导会产生新的项,而项的构造是通过解析字符串实现的,而不同项的解析方式不同,这就会容易考虑遗漏导致Bug。
在作业二中我有意避免了这种类与类之间的繁杂的交互,新建了工具类作为对象类之间交互的“桥”,同时把类的构造也交给了工具类统一规范,使得这种在类与类的交互过程中出现的Bug大大减少。但由于工具类的逻辑判断较多,导致Bug集中到了工具类中。多数Bug的原因是遗漏了逻辑分支。
在作业三中我把不同的方法分包,进一步降低了方法的复杂度,使得在建树、求导的过程中Bug出现较少。但对于作业三中复杂度陡增的格式判断方面,我没有从整体设计的角度考虑解决它的办法,导致Bug聚集在了这个部分。
总的来看,Bug的分布基本上是符合帕累托原则的,且通过一定的分析梳理,Bug的聚集位置也能够得到解释。对于Bug出现位置的一些特征也可提炼如下:
· 逻辑复杂度较高;
· 关联着多个用于不同模块间的交互但设计并不一致的接口,且没有做适配处理;
· 逻辑功能上与程序功能紧密相关,但实现上却有所脱离程序既有的整体架构。
三、互测策略
在第一、二次互测中都找出了3个不同质Bug,第三次互测中共找出4个不同质Bug。
第一次互测中我采用的是使用对拍器的方式,但发现除非是明显的设计缺陷,否则是很难高效率地查出Bug的。于是在第二、三次作业中我采用的是阅读并分析他人代码结构的方式。
首先,我通过工具(例如UML类图)了解他人代码的结构组织,选择其中方法集中的部分;然后大致了解其各个方法的功能后,选择一个代码逻辑相对复杂的模块仔细阅读,如果阅读困难可以借助IDE的调试功能;阅读过程中只要发现可能会出现的漏洞便构造相应的测试用例,并利用调试功能跟踪样例的执行逻辑。采用这样的方式一般能相对快速地把握代码的逻辑,在查找一些隐秘的逻辑错误时也比单纯使用对拍器效率会高不少。此外,阅读他人代码也能学到许多对自己有启发的的程序设计方法。
最后,我认为积累测试样例也是十分重要的,尤其是查出了一些隐秘的逻辑错误的样例。这些样例往往反应了一些共性的问题,可以有效提高我们互测的效率。
四、建造者模式
三次作业中我并没有应用任何创建模式,仅通过一个接口或抽象类以多态的方法去创建对象,但这样所需付出的代价就是需要在方法中穿插构建不同函数对象的逻辑,导致复杂度的提高。假设要重构,我选择使用建造者模式去构建不同的函数对象。
建造者模式(又称生成器模式),它可以把一个复杂对象的构建与其表示分离,可以由用户制定不同的构建过程从而创建出不同的对象。建造者模式包含以下组成部分:
· Builder:创建产品(Product)对象的抽象接口;
· ConcreteBuilder:具体的建造者对象,它实现Builder接口,创建并组装产品对象的各个部分,并且提供一个可供调用的接口取得产品对象;
· Director:负责管理产品顺序,一般是从参数中接受ConcreteBuilder对象,并按一定步骤使用ConcreteBuilder对象提供的方法完成产品创建;
· Product:待创建的对象,包含构成其自身的各个组件,可以定义组件的组装过程。
建造者模式的特点:
· 隐藏了产品装配细节,用户可以自定义产品的构建过程;
· 多个具体的建造者之间是相互独立的;
· 产品可以根据数据逐步构造,用户对最终产品可以有更多控制。
建造者模式和抽象工厂模式有着一定区别,前者的重点在于创建过程的每一步可以具体定义,且创建的次序可控;后者注重从“系列”的角度去创建产品对象,创建的过程相对固定。从这一点出发,我认为选择建造者模式创建各种形式不固定的函数对象更为合适。
仅针对不同函数对象的创建进行具体重构说明:
(1) 基本函数类保留,在Builder接口中声明创建不同基本函数的方法;
(2) ConcreteBuilder类主要通过调用基本函数类的构造方法实现Builder接口中的方法;
(3) 定义三类函数“产品”,分别是复合函数类、项类(由因子和乘号构成)、多项式类(由其他函数类和加减符号构成),三者内部均包含一个容器对象;
(4) 在Director类中设置三类函数的构建过程;
(5) 设置一个工具类,在其中定义解析输入的方法,该方法需要解析出第(3)步中定义的三类函数,并根据解析结果调用Director中的相应方法取得函数。
五、心得体会
通过Pre以及最近三次作业的练习,我认为面向对象相比于面向过程的一个“核心竞争力”就在于一种抽象地剖析需求和分解问题的思考角度。只有能充分地理解和运用抽象设计方法才可能发挥出面向对象真正的威力来。
然而在这三次作业中,我并没有能较好地去理解和运用抽象的思维方式。如前所述,我在第一、二次作业中都死板地按照指导书的形式化定义去设置各个函数对象类的方法和属性。这样导致的一个后果便是这些类的使用范围将相当有限——譬如幂函数求导后一般会变成项,不仅为了处理这一变化需要加入逻辑判断,而且这个幂函数对象几乎就没有什么继续使用的价值——它们仅能代表某些个体意义上的事物,无法代表一“类”事物。
这里我想谈谈我对抽象设计的两点浅显的看法:
首先,我们对于“类”应该这样去理解:一个类实际代表了一些具有相同属性的事物的抽象,这些事物的个体之间可能存在差异,但类并不关注这些差异。如果一个类是在这种理解上构建的,那么它就能描述更多的对象,使用范围也会更广。
其次,注意到作业要求中提到了扩展DIT指标,也随即提到了“抽象行为层次”。在计组理论的学习中曾反复地介绍过计算机的抽象层次结构。OS中也提到过文件系统的层次结构。从这些例子中应该总结出,抽象设计过程的一个重要步骤是对问题的抽象层次结构的分解——在不同的层次,应该忽略不同的差异,提炼不同的属性,关注不同的特征;而在同一层次上,不同的抽象之间应该具有相同的规模等级、细节等级以及复杂度等。对于任何一个复杂的问题或系统,我想我们必须做到这一点才能很好地去解决它。
我个人认为,抽象设计能力应该是在以后需要重点关注和训练的。
不打不相识,第一单元的练习帮助我初步了解了面向对象,也初步体会到了面向对象思维方式的优越性。学习过程中,无论是老师们精心设计的教学内容和课堂教学环节,还是讨论区中热心的同学和助教们的讨论交流,都给了我莫大的帮助和提升。再次衷心感谢和祝福所有辛勤付出的人!