BUAA OO 第一单元总结

BUAA OO 第一单元总结

〇.综述

第一单元以解析、展开、合并表达式为背景,渗透了许多面向对象的基本思想。在实现方法上,以递归下降法为核心进行解析与计算。此外,三次作业还以迭代的形式对代码的耦合性与可扩展性进行了约束。在具体的实现过程中也蕴含着许多小的技巧方法,需要熟练掌握与灵活运用。

一.架构分析

1.UML图

2.类的阐释

  • Input

读入函数,构建Function对象;读入字符串,构建Abb对象进行预处理;

  • Abb

对字符串进行五.实现细节1.字符串预处理中的操作,本质只是一个方法的集合;

  • Lexer与Parser

相互配合解析字符串,本质以前缀为分割标志,采用递归下降法解析;

  • Function与Sum

Function在Input被调用时构建对象,存储自定义函数的定义;Sum在字符串解析时建立,直接返回将i替换后的表达式因子;

  • Term

表示项,是最核心的类。包含了几乎所有基础的计算方法(项的乘法、加法、判断是否可合并等)。属性方面构建了两套数据结构,后面会有详细阐述;

  • Factor

    因子类的抽象接口,包含统一的cal()抽象方法;

    • Var

    常数/幂函数类。指数为0时表示常数;

    • Trigonometric

    三角函数类。其中的cal()与induce()方法均为优化性能设计。cal()可以通过调用factor的cal()对内部factor进行化简,并判断如果该factor是表达式因子且仅包含一项且该项仅包含一个因子时,将该因子取代表达式因子、作为三角函数内部的因子;

    • Expr

    表达式类。调用的各种方法实质都是对Term进行遍历、然后调用Term的方法;

    • FunCall

    自定义函数调用类。在Parser解析到自定义函数的前缀f/g/h时构建,构建过程中直接利用Function中存储的属性计算得到调用替换后的表达式结果,并将表达式结果以字符串的形式返回。

3.度量分析

各类的方法度量分析如下:

Method CogC ev(G) iv(G) v(G)
Abb.Abb(String) 0 1 1 1
Abb.exprAbb() 0 1 1 1
Abb.functionAbb() 6 4 3 5
Expr.addMerge() 9 3 5 6
Expr.addTerm(Term) 0 1 1 1
Expr.cal() 6 1 4 4
Expr.delZero() 3 1 3 3
Expr.equals(Object) 4 3 3 5
Expr.Expr(long) 0 1 1 1
Expr.getIndex() 0 1 1 1
Expr.getTerms() 0 1 1 1
Expr.hashCode() 0 1 1 1
Expr.multi(Expr) 3 1 3 3
Expr.multiEx() 7 3 5 5
Expr.setIndex(long) 0 1 1 1
Expr.setTerms(ArrayList) 0 1 1 1
Expr.toString() 7 4 6 6
FunCall.cal() 0 1 1 1
FunCall.equals(Object) 3 3 2 4
FunCall.FunCall(Function, ArrayList, HashMap) 0 1 1 1
FunCall.getResult() 0 1 1 1
FunCall.hashCode() 0 1 1 1
FunCall.toString() 0 1 1 1
Function.call(ArrayList) 1 1 2 2
Function.Function(String, ArrayList, String) 0 1 1 1
Input.getFunctions() 0 1 1 1
Input.getStr() 0 1 1 1
Input.Input(ExprInput) 5 1 4 4
Lexer.getInput() 0 1 1 1
Lexer.getNumber() 2 1 3 3
Lexer.getPara() 7 6 6 6
Lexer.getPos() 0 1 1 1
Lexer.getSum() 18 3 9 12
Lexer.getTriVar() 7 3 4 5
Lexer.Lexer(String, HashMap) 0 1 1 1
Lexer.next() 3 2 2 3
Lexer.peek() 0 1 1 1
MainClass.main(String[]) 0 1 1 1
Parser.getExpr() 9 3 4 6
Parser.getFunc(String) 1 1 2 2
Parser.getPow() 4 2 4 5
Parser.getTrigonometric(int) 5 3 3 4
Parser.parseExpr() 9 1 5 7
Parser.parseFactor() 16 5 11 12
Parser.Parser(Lexer, HashMap) 0 1 1 1
Parser.parseStr() 4 1 3 3
Parser.parseTerm(int) 1 1 2 2
Sum.cal() 4 1 3 3
Sum.Sum(BigInteger, BigInteger, String) 0 1 1 1
Term.addFactor(Factor) 0 1 1 1
Term.cal() 10 6 6 6
Term.equals(Object) 4 3 6 8
Term.getCoeff() 0 1 1 1
Term.getCosMap() 0 1 1 1
Term.getFactors() 0 1 1 1
Term.getIndex() 0 1 1 1
Term.getSinMap() 0 1 1 1
Term.hashCode() 0 1 1 1
Term.isMergeable(Term) 17 7 7 12
Term.isMerged() 0 1 1 1
Term.multi(Term) 8 1 5 5
Term.multiMerge() 38 9 10 12
Term.setCoeff(BigInteger) 0 1 1 1
Term.setMerged(Boolean) 0 1 1 1
Term.Term(int) 0 1 1 1
Term.toStringOne(Boolean) 34 1 15 20
Trigonometric.cal() 34 1 16 18
Trigonometric.equals(Object) 4 3 4 6
Trigonometric.getFactor() 0 1 1 1
Trigonometric.getIndex() 0 1 1 1
Trigonometric.getType() 0 1 1 1
Trigonometric.hashCode() 0 1 1 1
Trigonometric.induce() 23 10 8 13
Trigonometric.toString() 3 1 3 3
Trigonometric.Trigonometric(Factor, long, int) 0 1 1 1
Var.cal() 0 1 1 1
Var.equals(Object) 4 3 3 5
Var.getCoeff() 0 1 1 1
Var.getIndex() 0 1 1 1
Var.hashCode() 0 1 1 1
Var.setCoeff(BigInteger) 0 1 1 1
Var.toString() 7 1 5 6
Var.Var(BigInteger, long) 0 1 1 1
Total 330 150 235 280
Average 3.975904 1.807229 2.831325 3.373494

其中,加粗部分为复杂度过高的方法。

经过分析发现,检测出复杂度过高的方法主要存在以下问题:

  • 耦合性过强。对于类中其它方法或其它类的方法依赖性强;
  • if-else嵌套过多。为优化性能对许多情况进行具体讨论;

综上,在今后的设计中应当更加注重类的划分,减少类与类之间的依赖;同时提高类的抽象层次,避免陷于过于细节的分类讨论。

二.核心方法

在笔者的三次作业中,最核心的方法是递归下降法

个人认为,结合三次作业,递归下降法具有以下特征:

  • 递归下降法适用于构建“嵌套式”数据结构。三次作业的题目背景——表达式,即是非常典型的嵌套结构。一方面,嵌套的对象类型众多,且会出现外层类型嵌套内层类型、内层类型又嵌套外层类型的结构(如因子中包含表达式因子,三角函数因子包含各类因子等);另一方面,嵌套的层数并没有限制,具有较大的“未知性”。因此使用常规的字符串解析/正则表达式匹配并不可取。而递归下降法适用于具有上述特征的数据形式。
  • 递归下降法适用于“嵌套式”调用方法。三次作业中,除了在解析时运用了递归下降法,还在各类因子、项的计算中运用了递归下降法。由递归下降法构造出的数据结构也具有嵌套的性质,在调用各种计算方法时需要考虑到构建的数据结构的嵌套性。因此在调用方法时仍需采取递归下降法进行调用。
  • 递归下降法要求对象必须具有较好的封装性与低耦合性。每次递归下降地进行下一层的解析或者调用下一层的方法时,一个基本要求便是不能对上一层的对象状态造成破坏。如果设计的对象没有较好的封装性与低耦合性,递归调用处理下一层时破坏了上一层的状态,当调用结束返回时,原有的方法面对错误的状态必然会导致错误的结果。
  • 递归下降法使得方法的构建不必牵累于类间的嵌套关系。当做好上一点所说的保证对象的封装性与低耦合性后,递归下降的优势也便凸显出来:方法的大部分设计只需考虑类本身的属性即可,嵌套的部分仅需一行调用被嵌套类相应方法的代码即可,非常简便。

此外,正如上面提及的,在三次作业中,表达式的解析计算都运用了递归下降法。

三.设计思想

1.面向对象设计方法

由于在构建对象的过程中,直接将现实中的表达式、项、各类因子等构建了相应的类。因此在构造展开、化简的方法时,很自然的可以将现实中表达式的展开、化简过程映射到方法的构造中。这也正是面向对象构造与设计的一大优势:在建立位于“解空间”的机器模型和位于“问题空间”的实际待解问题模型的映射时代价较低,仅需对实际待解问题模型中的对象进行合理的划分与抽象即可。

2.面向方法构建数据结构

个人认为,不同的数据结构方便于不同的方法。在解决实际问题中往往需要构造许多方法。因此,当构建的数据结构不便于方法的实现时,应当适时的对现有的数据结构进行转换或重新构建,以适用于不同的方法。

如在本单元的作业中,我们需要对表达式进行解析与计算。利用递归下降法建立的“嵌套式”数据结构便于解析却不便于计算,因此需要构建一套新的数据结构便于表达式计算方法的实现。

对于新数据结构的构建,既可以构建一个新的类,也可以在原有的类增添属性、在同一类中构建两套数据结构。个人在三次作业中采用了后面的方案,因为原类的数据都已经得到较好的封装,如果另建一个新类还需要相应地构造在类间传递数据的get()/set()方法,较为繁琐。

基于以上思想,自然衍生出以下三个问题:

  • 构建一种与原数据结构包含信息完全等价的新数据结构
  • 构造利用新数据结构进行表达式计算的计算方法
  • 构造由原数据结构向新数据结构的转换方法

对于三个问题具体解决过程的实例,可以参照:

讨论:关于数据结构构建的一点思考 - 第二次作业 - 2022面向对象设计与构造 | 面向对象设计与构造 (buaa.edu.cn)

3.使用接口提高抽象层次

使用接口,可以极大的提高类的抽象层次,有利于提高可扩展性。

在三次作业中,笔者使用了Factor接口,各类型的因子作为Factor的实现。这样使得在高一层的类中观察各因子时,无需纠结于它们具体是哪一类具体的因子,只需在更高抽象层次上的Factor对它们进行构建与调用。此外,当有新的具体的因子类加入时,只需将其作为Factor的又一实现,而无需改变高一层类中的处理过程,提高了可扩展性。

4.自结果向问题设计

在三次作业的题目叙述中,概念较多且细节复杂,从问题入手很容易一上来就拘泥于各种细节而忽视整体。但对于表达式展开化简后的结果是十分清晰的。

因此,笔者采用了所谓“自结果向问题”的设计思路,即:先假定自己已经对输入的字符串作好了解析,得到了Expr->Term->Factor的嵌套数据结构,然后直接基于假定得到的数据结构进行转化、计算,最后再写字符串解析的相关代码,真正实现之前的假设,完整地从头到尾串联起来。

其实除了这种大方向上的假设,在构造具体的方法时也可以采取这样的思路,如:在构建Term的multi()方法时,假定已经得到了Term的系数、指数、三角函数的HashMap等,基于假设写出multi()方法,然后再设法实现假设;在构建合并同类项的方法时,先假定自己已经构建好判断同类项的方法,基于假设写出可合并状态下系数相加、重新加入ArrayList等过程,最后再设法实现假设……确定好每一个步骤的dependencytarget,可以避免自己过早地陷入细节,可以使自己对全局的设计始终保持清晰的认识与把控。

四.迭代历程

基于三.设计思想的内容,三次作业的迭代过程,实质上是三次数据结构的迭代与重构过程。每次作业因子的类别越来越多,化简后得到的最终形式也越来越复杂,因此需要相应的调整现有的数据结构,相应的方法也随之变动。

下面介绍笔者三次作业的面向计算方法的数据结构,并简述相应方法的设计。

1.hw1

  • Expr

    private BigInteger[] coefficient;
    
  • Term

    private BigInteger coefficient;
    private int index;
    

由于hw1中只出现了幂函数,所以对于表达式,其最终结果一定为系数0+系数1*x+系数2*x**2+...+系数8*x**8的形式,因此仅需构建一个大小为9的数组:下标表次数,值表系数。相应的,项的合并方法也十分简便,仅需在次数对应下标的值内加项的系数即可。

2.hw2

  • Term

    private BigInteger coeff;	//coefficient
    private long index;			//index
    
    //////////
    
    private long[] sinIndex; 
    private long[] cosIndex;
    //index stands for x's index, value stands for sin's index
    
    //////////
    
    private HashMap<BigInteger, Long> sinCoeff;
    private HashMap<BigInteger, Long> cosCoeff;
    //key stands for const, value stands for index
    

下面依次阐释

  • 对于系数*x**指数的形式,系数和指数是必须记录信息。对于其中的x,由于作业限制只会出现x一种自变量,相当于自变量只有x一种状态对于只有一种状态的变量无需记录,否则会造成信息冗余

  • 继承上面的思路,对于sin(x)**指数1*sin(x**2)**指数2*...*sin(x**8)**指数8,由于作业限制sin内的自变量只可能是x,x**2...x**8,所以可以只记录指数1~指数8八个会变化状态的信息。若有sin缺失则给对应指数值赋值为0即可。

    e.g. sin(x**2)**3*sin(x**5)**2 ->

  • cos同理。

  • 对于sin(常数1)**指数1*sin(常数2)**指数2*...,各项常数指数均不相同且有对应关系。使用HashMap的键存储常数,值存储指数。由于HashMap按键值有序排列。这样存储既保证了常数与指数的对应关系,还方便了后续判断两个项是否为同类项。

  • cos同理

3.hw3

  • Term

    private BigInteger coeff;
    private long index;
        
    private HashMap<Factor, Long> sinMap;
    private HashMap<Factor, Long> cosMap;
    

与hw2相比,最大的不同在于sin与cos内部可能为各类因子。因此此时无法以幂函数的指数作为HashMap的key,而是直接以Factor本身作为HashMap的key。注意此时需要对应的重写hashcode()与.equals()方法。关于这点在五.实现细节中还会有详细阐述。

4.小结

从上面三次作业的迭代过程中不难看出,倘若一开始设计的数据结构具有较好的普遍性与可扩展性,则迭代开发也会变得比较轻松。但更具普遍性的数据结构,其相应的方法也更难构建。个人认为这是一个需要斟酌权衡的地方。

而在笔者的迭代过程中,过于注重减少信息冗余以实现方法构建的简便性,因此在每次迭代的过程中进行了较多的重构。这是笔者在以后的作业中应当继续磨练避免的事情。

五.实现细节

1.字符串预处理

为了便于进一步的解析,可以将读入的字符串进行如下预处理:

  • 去掉所有空格/Tab
  • 将所有连着的符号合并。如 +++ -> +, +-+ -> -
  • ** -> ^
  • sin -> s
  • cos -> c
  • sum -> a

2.Lexer与Parser

Lexer与Parser的具体实现方法,在训练栏目中已经给出了很好的示例。个人的理解:Lexer是以字符单元为单位进行处理,便于Parser构建逻辑上的数据结构联系。

此外,Parser判别当前解析的是表达式/项/各类因子的依据,实际是以各自字符串的前缀为划分依据的。以Factor为例,如下图所示:

可以看到,在完成五.1中的预处理之后,六种因子的前缀分别为+/-/数字xs/cf/g/ha(,各不相同,可作为划分依据。

3.判断同类项

判断不同的项是否为同类项,即需比较x的指数、sin各项、cos各项。注意在比较sin/cos时既需比较factor也需比较指数。

在比较factor相等时,需要重写hashcode()与.equals()方法。此时使用IDEA自带的重写方法、选中关心的属性作为相等的依据即可。

Java基础常见知识&面试题总结(上) | JavaGuide

4.优化细节

个人认为比较容易实现的性能优化的点

  • x**2 -> x*x(注意三角函数中自变量须保持x**2)
  • 正项提前
  • 系数指数“1”省略
  • 利用三角函数奇偶性去掉自变量符号,同时注意外部幂次的奇偶性
  • sin(0) -> 0 ,cos(0) -> 1
  • sin/cos中若为表达式因子且仅包含一项且该项仅包含一个因子时,将该因子取代表达式因子、作为三角函数内部的因子

此外还有三角函数的平方和公式、二倍角公式等。但三角函数水太深了,还是应当在保证正确性的前提下追求性能。

六.常见bug

以下是笔者三次作业时遇到的一些bug:

  • 遍历容器时修改容器;
  • 误将浅拷贝用作深拷贝;

以上两点是在乘法展开时遇到的问题。笔者采用的解决方案是建立一个新的容器,将新的元素或无需删除的元素加入新容器,将需要删除的元素不加入新容器,最后再将原容器变量名指向新容器即可。

  • 判断同类项时只比较了sin/cos内部的factor,未比较外部的指数;
  • 判断字符串相等时误用==,应用.equals()
  • BigInteger调用add()方法后没有再赋值给自己;

后三个问题较为细节,也有自己掌握不熟练的原因,发现后更正即可。

七.hack策略

先贴一张笔者hw3的hack“成果”(

平心而论,hack的确有很大的运气成分在里面。不过也有一些通用的策略:

  • 全面性测试。首先针对所有的代码进行全面性测试,观察输出行为是否存在有关的化简优化;
  • 针对不同的输出行为,构造特定样例。如对于进行了三角函数正负号化简的代码,可以测试sin/cos奇偶次幂的不同样例,观察结果是否正确;
  • 阅读代码。对于将字符串简单匹配、替换的行为着重关注,观察是否考察全面;
  • 边界数据构造。如超int范围大数字、多层函数嵌套等。

此外,在hack过程中阅读他人的代码也可以学习到他人的一些巧妙设计,可以适当地借鉴学习。

八.真情实感的心得体会

真情实感地吐槽一句真的太肝了呀。对于Java零基础的笔者,从菜鸟教程的基础语法学起勉强搞完了两个pre;还没太消化完全又是一周一迭代的堪比数据结构大作业的每周作业。很多东西现查现学现用,一直被任务push。一周七天都有各自的安排,感觉每天都在coding...

首先非常感谢老师和助教们设计的“训练栏目”,给三次作业的实现起到了很好的启发式作用。自己在迭代任务的push之下也确实真正体会到一些面向对象设计的思想。也非常感谢每天一起coding的三位hxd,相互交流学习共同进步。

但与此同时,笔者深感自己对于面向对象的理解依然过于浅显。在Thinking in Java一书中,作者认为:

每个对象看起来都有点像一台微型计算机——它具有状态,还具有操作,用户可以要求对象执行这些操作...

读到这里,隐约感觉作者对于对象的描述与上学期计组课程设计的mips微系统很像:它们都具有状态与操作,并且可以根据自身的状态与外部的请求执行特定的操作。可惜在第一单元的实现过程中,笔者并未在实践过程中体会到内部状态对于行为操作的影响。

此外,类的抽象、各种设计原则、容器的灵活使用……依然需要后续大量的训练去逐步体会琢磨。希望自己能在老师、助教和同学的帮助下继续进步~

posted @ 2022-03-25 16:42  Lingo30  阅读(93)  评论(1编辑  收藏  举报