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

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

第一次作业

第一次作业没有格式判定,多项式中仅包含幂函数和常数,考虑不多(彻底丧失可扩展性),实际代码行数168。

UML图:

类分析:

本次作业仅包含了3个类

  • Main类:仅输入输出

  • Polynomial类:用于表达式的处理生成和求导,主要方法内容如下:

    1. 预处理
      • 直接去除空白字符,是replaceAll(), 不是replace()
      • 笔者并未将+--++-+等符号替换为一个符号,因为这样会对未来格式检查不利
    2. 正则提取
      • 笔者采取的是大正则的形式,并将表达式的符号直接算在项中
      • 先对项依次进行匹配,再对项中的因子进行依次匹配,分别加入对应的容器中
      • 此处内容均写入了一个方法中,方法规模较大,if else以及循环的出现次数较多,
    3. 同类项合并与化简
      • 将项或因子加入对应的容器中时根据幂次方进行同类项合并,方便求导
      • 求导后删除容器中无意义的项(经检验,本方法实际用处不大,反而增加了程序的时间开销)
    4. 求导
  • Term类

    • 作为表达式的元素进行存储,存储典型的a*x**b

Metrics度量分析:

  • 图上可以看出,init()方法的复杂度过高,原因是笔者在其中进行了表达式解析和生成的所有操作,先匹配表达式中的项,后进行项内的匹配没有进行相应的封装,显得过于臃肿。

输出优化:

  • 输出根据项的数量和特征考虑
  • x**2可以优化为x*x(笔者没考虑到)
  • 选择将正数项提前,如-x+1变为1-x,节约符号

优缺点分析:

  • 优点
    • 做法较为简洁
  • 缺点
    • 完全无扩展性
    • 部分方法如去除无效项较为无意义

测试:

采用python的xeger进行数据生成,sympy进行正确性判断,流程较为简单,第一次作业还不用考虑取点的问题


第二次作业

此次作业出现了sin(x)**kcos(x)**k,同时引入了表达式因子,需要考虑的因素更多。鉴于第一次作业着实考虑不周,第二次作业直接从头写,实际代码行数694。

架构基本思路:

以下基本的想法影响了我对类的架构,现留作记录,方便理解

  • 尝试构造Parser类处理字符串,返回值为最终的表达式或项
  • 采用递归下降的思路进行字符串的解析(由于出现了表达式->项->因子->表达式的循环结构,大型正则表达式相对来说难以适用)
  • 采用小正则匹配sin, cos, x, 1这类因子
  • 边匹配边进行合并,对于项或表达式的合并,只有在容器内完全相等时才合并
  • 构建表达式树,采取的是典型的表达式-项-因子的结构
  • 采用Derivative求导接口管理所有可求导的类
  • 采用Factor抽象类管理所有类型的因子(三角函数、幂函数、常数、表达式,不包括项)
  • 采用Connector接口来管理Expression类和Term类,前者本质上是+-连接符,后者本质上是*连接符
  • Term里的容器存储除常数以外的因子,常数由Term的变量coe进行存储
  • 在字符串末尾放一个永远不会出现的字符,这样就不需要每次判断是否越界了

UML:仅数据类

重构与类分析:

第二次作业的类图发生了巨大的变化,是一次明显的重构。发生重构的原因基本上是由于两次作业的需求差别有点大,笔者没有特别考虑程序的可扩展性。重构后的程序增加了相应的接口,具有抽象层面的因子和连接符的概念,这为程序带来了更多的可扩展性,可以根据需求增加更多的因子类型、因子内的嵌套、更多连接符及其直接的化简转换,未来的如果有新的需求增加应该可以改动较少。从第三次作业可以看出,程序基本的架构还在,除去新增的内容,笔者仍然改动了部分第二次作业的代码来使得程序能够更好地扩展。

关于数据存储的类,图上可以看出结构相对比较清晰,基本把握住了类与类之间的相互关系。Pow, Const, Sin, Cos, Expression这些都是因子的范畴,具有共同的Factor特点,而Term不是因子。同时ExpressionTerm又相当于一种连接符,可以实现一个新的接口Connector来管理这2个类。这些均具有可求导的特性,实现求导接口Derivative

Factor类的子类具有共同的特点:

  • 具有指数的相关需求(ExpressionConst需要进行相应的适配)

Connector接口:

  • 管理表达式或项
  • 方便表达式或项进行多态调用方法transfer()进行互相间的转换
  • 相互转换的过程及原因:
    • 当表达式作为项的因子时,如果表达式中只有一个元素,可以将其拿出,与项合并
      • x**2*(x)可以将其拿出进行合并为x**3
    • 当项中只有一个表达式因子的时候,可以将其拿出,与表达式合并
      • 1+x+(x**2+1)可以将其拿出合并为x**2+x+2
    • 笔者采取的是较为严格的结构,表达式中的容器只能存储项,项内的容器只能存储因子,这样方便管理,较为清晰,通过转化可以实现树的简化,避免产生一颗多层且无用的分支。

Parse类:

  • 进行递归下降分析并生成表达式,内部方法用于逐层parse返回对应的数据类型
  • 接收一个字符串->预处理->递归下降匹配合并->返回表达式

Main类:仅输入输出

Metrics:

  • 图上可以看出,基本复杂度较高的方法主要来自Expression类和Term,其中具有较多的循环
  • 循环的主要目的是在加入一个项或因子的时候,判断容器里是否有该元素的同类项(Hashsetcontains()方法,但是取不出来),在设计的过程中或许能够尝试使用写一个find()方法,来避免多次循环的出现。循环同时也用于求导、输出、Expression类和Term的相互转化等方法上。对于笔者的这种写法,HashSetArrayList几乎没有了区别,因此笔者还需要多加尝试。
  • parseBaseFactor复杂度非常高,因为笔者在其中解析了所有基础的表达式,采取了4个小正则来进行匹配,或许可以适当采用工厂的方法进行处理。
  • multiFactor用于合并项内的因子,其中进行了大量的判断过程,判断将被进行合并的Factor的类型,遍历进行判断是否合并,显得十分不优雅。

优缺点分析:

  • 优点
    • 类的架构个人感觉足够清晰,显示出明显的层级关系
    • 采用递归下降解析,没有任何回溯,扩展性强
    • 边读入边化简,实现了基本的合并简化
  • 缺点
    • 部分方法复杂度过高
    • 存在大量类似但有细微差别的代码,给人可以合并为一个方法但做不到的感觉,需要加强练习
    • Const类在架构中是一种折中的存在。Term中有单独的变量coe保存常量,需要有Const类来得到新的值,但其并不具有指数的特性,指数属于其多余的属性,它也不会被放入TermHashSet<Factor>中。这意味着架构可能并不太好,理论上应该将Factor拆成因子类及其子类带指数的因子类,来让类的继承符合基本逻辑。

BUG:

公测没有出现bug,也就放松了警惕,没有进行太多的测试。结果不出意外,互测被轮番hack,担起了Room三分之二的bug。

被hack到的bug:
  1. 表达式在作为因子时输出时忘记*号,如(1+x)(1+x),导致错误
  2. Term作为顶层时输出为空,如sin(x)**2+cos(x)**2求导得0没输出
没被hack到的bug:

在发现被hack后,笔者第一时间进行了测试,发现了这个bug,但room内并无人发现,以下为样例参考。

表达式输入:				(1+x**2)*(1+x*x)
表达式直接输出(不求导):	(1+x**2)

表达式输入:				(1+x**2)*(1+x**2)
表达式直接输出(不求导):	(1+x**2)*(1+x**2)

本次作业笔者采用hashset进行存储,本bug来自于对hashset内参与hashcode运算的元素直接进行修改。

hashset在存储元素的同时,会保存本元素的hashcode,方便使用。直接修改元素里参与hashcode计算的变量,会导致元素本身的hashcode改变,但hashset保存的hashcode并未改变。这导致了将hashset里的x改变为x**2时,调用contains(x**2)时会返回false,因为此时hashcode并不相等,尽管其依旧在容器中。

一个较为简单的解决办法是:将元素取出、进行改变再放回,这样可以刷新hashset保存的hashcode

本bug参考链接

本次作业出现bug的方法总体复杂度较高,也因此容易出现细节上或逻辑上的错误。

测试:

由于时间紧张,采取的老方法测试,但同中测和强侧一样,并未测出笔者的bug。笔者出现的这些bug可能均需要刻意构造才能够发现,随机性的生成算法往往在这时难以发挥作用。


第三次作业

本次作业仅增加了允许三角函数内出现因子、支持格式匹配这2项内容。由于第二次作业以及搭建好了架构,修改内容不会有太多便能完成作业。但为了保险起见,笔者将clone()方法全改为了深克隆,同时由于各项内容新增,对部分内容进行了调整。每个类增加内容不多,但总体增加了200行,达到了894行。

两次作业较为相似,以下仅谈不同之处。

新增思路:

  • SinCos继承自TriFunc三角函数抽象类,方便对内部因子的管理。二者内部仅复写求导、化简、clone()toString()等方法。

  • 对空白字符的处理:

    • 由于要检查格式匹配,因此不能在一开始将其去除

    • 对形式化表达式进行分析,发现空白项其实可以看作一种特殊的内容,出现在各个位置

    • 采用eatBlank()方法,在递归下降的过程中会出现空白项的地方,调用该方法

      例:表达式 -> 空白项 [+-] 项 空白项 +- 空白项 项
      
      匹配流程:
      eatBlank()
      匹配+-
      匹配项
      eatBlank()
      匹配+-
      ...
      
  • 格式异常判断:

    • 对于需要判定当前字符的地方,如判断+-*()等,如果不是该符号,直接抛出异常
    • 如果最终返回了顶层,对于存在格式错误的字符串,只需判断递归下降匹配的index是否到达了结束位置

优化总结:

第二次和第三次作业较为相似,优化也一并说。

  • 读入时进行化简(在递归过程能够保证每一层都被化简)

    • 因子平级:
      • sin(x)**0, cos(x)**0, x**0, cos(0)**k-> 1
      • sin(0)**k->0
    • 括号降级
      • 表达式(x)**k->项/因子x**k,表达式(x**a*sin(x)**b)**k->项x**ak*sin(x)**bk
      • a*(x+1)->表达式ax+a
    • 三角函数化简(可选)
      • cos((-x))->cos(x)
      • sin((-x))->sin(x)
  • 化简后进行合并(直接equals()toString().equals()

    • 项内因子合并:
      • x*x->x**2
      • (1+x)*(1+x)->(1+x)**2(此步为了方便求导,注意改变最终输出形式)
    • 表达式内项合并:
      • 1+x+1+x+x*sin(cos(x))+x*sin(cos(x))->2+2*x+2*x*sin(cos(x))
  • 求导过程加入新的表达式的过程也自动进行了上述合并过程

  • 输出过程的化简也基本遵循上述规则进行输出化简

UML:

类分析:

TriFunc类:

  • 这次作业SinCos的共性较大,有必要继承自同一个类,因其具有内部变量innerFactor,笔者认为,如果幂函数下也允许表达式,本架构的改动不会很大,只需要把TriFunc内的内容提升到Factor抽象类中。

Metrics度量分析:

  • 在方法部分,依旧有许多的方法飘红,部分方法复杂度依旧过高,尽管用了HashSet,依旧用了大量的循环来达到目的。
  • ExpressionTerm类耦合度较高,二者存在着相互转化且互为对方容器元素的关系,独立性较差。
  • Parse类的parseBaseFactor在第三次作业得到了较大的改善,逻辑变得更加清晰,复杂度降了下来

优缺点分析:

  • 优点
    • 递归下降解析,方法足够层次化,能够很好地创建出新的因子
    • 有基本的同类相合并,并尝试了部分优化的方式,时间开销并没有明显提升
    • 除了ExpressionTerm两个冗长的类,其他类基本具有高内聚低耦合的特征
  • 缺点
    • 部分方法复杂度依旧过高
    • ExpressionTerm类耦合度较高,接口没能很好地管理二者的相似方法

BUG:

被hack的bug:

非常不幸,共出现了两个bug,笔者着实不太细心。这些bug方法代码行数都不多,主要是细节上的问题。

  1. 忘记复写常量类的equals()方法,导致出现了任意常数相等的惨状
  2. 想把x**2转换为x*x来节省长度,却忘记x*x作为表达式是不能直接出现在sin()cos()里的,导致强测错了两个点,非常不值得。
hack的bug:

笔者也hack到了别人2个bug,主要都是细节上的问题(忘记考虑末尾的空格、符号归属紊乱),不具有特殊的参考意义。

笔者并没有具体结合被测程序的代码设计,根据基本的输入输出情况基本可以判定程序不存在较大的问题,主要问题还是处在细节上。

测试:

  • 数据生成:
    • 互测过程中的限制条件较多,考虑到个人自动生成的数据过于随机,没有什么参考价值,决定手动构造数据来进行测试。
  • 数据比较:
    • python的sympy类能够很好地进行求导和化简,可以作为数据比较的参考。其中一部分能够化简成一样的结果,直接输出0即可;对于未能化简为一致的数据(如2*(1+x)2+2*x),采取输出到文件备份的方式手动核准。
    • 对于输出结果的格式检查,最为简单粗暴的方式就是复用之前所写的递归下降解析表达式的方法,从文件中读取输出的表达式,只要解析的类中不采用完全静态的变量,多次解析应该不成问题。
    • 取点比较是一个很好的方式,免去了手动比较的麻烦,但笔者时间有限,没有采用。

对比与心得体会

  1. 三次作业感受

    • 表达式求导在本质上还是对数据读入和存储的一次思维训练,求导和输出是其中相对较为简单的内容
    • 在搭建架构时,应该思考以下可扩展性,尝试做出合理的预测。在面对新增的需求时,应该能够做出较小的改动便能完成程序的设计,在这方面我还需要多加训练。
    • 三次作业,可以看到梯度的猛然增加。作业1和3之间的落差让作业2显得很尴尬,延续作业1的架构会让作业3显得很痛苦,在作业2完成重构为作业3铺路同样会有很大的工作量,希望未来能有所调整,显得更加平滑一些。
  2. 收获

    • 封装

      • 封装是面向对象的一个重要概念,让一个类管理自己范畴内数据,是一种较为安全的方式。
      • checkstyle里强制要求变量为private。在其他修饰符下,包内其他类均可直接访问该变量,这不利于封装概念的体现。
    • 容器的选择

      本次作业容器的选择主要应用于合并操作,从使用者的角度来看

      • Arraylist:

        • 允许元素的重复
        • 具有indexof()get()
        • 合并操作需要遍历,与每个元素判断equals(),直到找到同类项或结束才停止
      • Hashset:

        • Hashset是没有value的hashmap,保存的是hashmap的key,不允许重复
        • 具有contains()方法,能够快速判断是否存在
        • 比较2个hashset是否相等时,可先比较hashcode是否相等,再比较大小是否相等,这时2个容器不等的概率其实已经很小了,之后再进行equals()比较,可以减少equals()的比较次数。
      • Hashmap:

        由于笔者的实现方式,hashmap显得难以使用,这里就介绍下笔者的感受。以Term类为例,项具有系数和其他因子,在其他因子相等时,需要将系数相加,这时hashmap的value保存的是系数

        • 如果系数仅保存为value而不保存在Term中:

          这对Term的toString()并不利,因为系数本身属于Term

        • 如果系数在valueTerm中都有保存

          因其可能为变量,可能导致数据存储的不一致性,不太安全

        鉴于上述问题,笔者找不到很好的解决方式,为避免风险,笔者没有采用,需要继续学习。

    • 高内聚低耦合

      • 各个类应该具有一定的独立性,在保证发生变化时能够很好的进行适应。这或许需要多加理解。
    • 特判

      • 特判在某些时候并不是一种优雅的选择,用特判将某段程序从具有共同特点的程序中独立出来,这更多的可能是一种设计上的失误
      • 为程序增加很多的特判,会减少代码的易读性,也会在遇到考虑不周的情况下很容易出现bug
      • 正如将x**2转换为x*x来减小长度的操作,此类做法在真正的表达式求导中并不应该出现
    • 批量输入输出

      • Main类的main函数的输入可能只有一次,在不改变Main类的前提下,Test类可以采用如下写法:

        public class Test {
        	public static void main() {
        		for (str) {
        			System.setIn(new ByteArrayInputStream(str.getBytes()));
                    Main.main(args);
        		}
        	}
        }
        

        System.setIn()可以用于重定向System.in,同理System.setOut()。采用这种方式可以在Test类中从文件中输入多行,或直接输入多行,可以用于自己构造数据的测试。

  3. 互测感想

    互测整体上是一个学习提升的地方,学习别人的架构或写法,思考与自己所写相对于他人的优势和劣势。

    • 性能分
      • 本单元作业均根据数据的长度来决定性能分
      • 根据实际情况,其实只有第一次作业才有争取拿满的需要。后两次作业若要进行规则过于复杂的表达式化简可能得不偿失,只适合走极限流派的大佬。
      • 但一定程度的化简依旧是有必要的,这既是对能力的一种训练,也能让最后输出的结果能给人看
    • 包的使用
      • 笔者在互测阶段发现大部分同学都没有将程序放入包中
      • 合理的包的使用可以让程序结构变得清晰,所有类直接放在src文件夹下,带来的可能是一种混乱
      • 笔者显然也没有对包进行合理的使用,需要加强
posted @ 2021-03-26 20:18  NoNameExists  阅读(96)  评论(1)    收藏  举报