OO-第一单元-总结

董鑫-21374113 学生 2024-03-21 16:06:36

OO第一单元总结

程序类图分析

总体结构

注:类图中标注为蓝色的类为第二次作业中与第一次作业差别加大或新引入的类。绿色的类为第三次作业引入。

如上类图,程序主要有Parser, Lexer, Node, Polynomial几个部分,Lexer将外部文本转换为Token,Parser将Token转换为AST,Node负责AST的遍历,并生成Polynomial,Polynomial负责表达式的计算与输出。

其中,语法分析器采用SLR分析法,利用手动或外部工具,构建出SLR语法分析表,通过LrTableBuilder将表解析为可运行的状态机,实现语法分析。

分软件包类图

Token方面,定义了接口Token,有方法getType获得Token所属类型,用于语法分析。其实现类,保存类型及内容,而getNumber, getIdentity获取具体内容的方法,封装于内部,不对外暴露。这样实现,好处是,在Lexer.next()的过程中,一并将Token的类型和内容保存到Lexer内部的属性curToken,方便peek的实现,获取内容时,也只需要将Token转为对应类型,通过对应的get方法,获得内容。坏处时,Token子类与Token转基础Node的类AstNodeBuilder耦合较深,不得不完全了解这些子类,才能获取其中的内容。

Parser 采用一个状态机的形式,State作为Action的集合,作用是用于获取当前状态下,当前输入对应的操作(shift,reduce,goto,accept),Action的四个子类,代表了移入,规约,错误和接受,用于操作Parser的符号栈和状态栈,其中,规约操作起到构建起整个表达式树的作用。这里采用的表达式解析完全使用了课程组提供的形式化表述,拆分了所有可选项与加减号,最终使用了34条表达式作为分析。所以,我并未对原始字符串做任何预处理操作,空白符在词法分析中被跳过以减少可能的状态数与表达式规约数。

Identity包从第二次作业引入。

Identity包,用于管理标识符。其中,Index封装了一个int,对应了某个标识符。在IdentityManager中,会使用Index来检索到对应标识符。IdentityManager采用了单例模式,作为全局的标识符管理器。通过这样一个Manager,实现了从标识符其字符串到实际的某个变量的映射,这为后续函数中的变量替换区分形参与实参,以及表达式计算时,保证不同引用实际上指向同一内容。

Node是语法树的组成,也是Parser内部符号栈的组成。Node类通过getType()向Parser传递自己在语法树中的类型。AstNodeBuilder提供了一个从Token到Node的转换,用于直接从Token得到最基础的叶子Node。ExpressionNode则是AST到表达式的桥梁,其虚方法flattenToExpr(),递归地将当前节点转换为表达式。其中,UnaryOpNode和BinaryOpNode是ExpressionNode的子类,分别对应一元和二元运算符。它们内部使用一个Function<PolyNomial, Polynomial>以及BiFunction<...>,利用lambda表达式(或其他函数式接口)表示运算符运算的规则。FunctionCall是函数调用类型的节点,通过IdentityManager获取对应函数,以将实参转化为Polynomial。

Function包内容从第二次作业引入。

该类图中的Function包的类,都是为了表达函数定义的AST节点。前文提到的表达式Node,表达式相关Node,并没有使用作业中的形式化定义来进行层次化表达,而是一元或二元运算符(加减乘,乘方)构成的表达式树,(形式化表达的类型位于Node的getType)自然的构建出来的。每个节点的子节点均是ExpressionNode,通过继承实现子节点的多态。而此处的函数定义的AST节点,则完全按照形式化表达构建。节点的子节点的类型是完全确定的(从类图的组合关系即可看出)。

在函数定义被Accept后,即可通过FunctionDefine的register()方法,将函数定义(即)注册到IdentityManager的表中。

FunctionLib用于管理一些已知函数,如Exp,和Dx(这两个都被我看作函数,而简化了前端解析表达式的部分),通过类似f,g,h自定义的过程,也可以将这些函数注册进IdentityManager中,从而被调用。

sym包中的类是表达式计算的主体,在第二次作业中,从虚线框的结构转变为蓝色框的结构。以应对一项有多个变量相乘的情况。在重构过程中,处了Polynomial类的变动不大,就相当于重写了一遍sym包内部的类。相较于AST中用运算符构建的表达式树,sym包中用类构建的表达式树,更加符合形式化表述的情况,但是,认为这些(可计算的)表达式均是展开后的形态,因此,形式化表述中因子实际对应IVarible的子类,也不存在表达式为因子的情况。

Ivarible是为了应对Term的因子种类可能比较多,创建的接口,提高了扩展性。需要子类实现toString用于输出,实现getName用于获取用来合并的名称,实现mul用于因子之间合并(相乘)。

在重构过程中,虽然第一次作业中的结构较为简单,只有两个类,但由于Polynomial类中只用到了Variable类的相乘的情况,因此,在重构过程中,使用Term替换原本的Variable,并实现Term的乘法,使得Polynomial的改动并不多。只需着重实现Term和IVariable及其子类,同时对外暴露mul和一些接口即可。在重构过程中,我体会到,面向接口的编程,确实能够一定程度上的去耦合。

visitor包从第三次作业引入。

Visitor等相关类,是借鉴了Antlr4的Visitor模式设计(实际上没有实现Antlr4那么完整的Visitor模式)。通过某种意义上的访问者模式,来递归地访问Polynomial等表达式类。通过BaseVisitor类的父类调用子类方法,在父类中决定调用什么类的visit方法,实现了动态多态的一种解析,简化了其子类的编写。部分代码如下:

public T visit(IVariable var) {
    if (var instanceof Variable) {
        return visit((Variable) var);
    } else if (var instanceof Exp) {
        return visit((Exp) var);
    }
    return null;
}

而visitor类的方法,通过lambda的包装,也可以成为AbstractFunction,从而实现统一的函数作用于表达式。

Visitor这样设计,实际上是为了非侵入式地实现在表达式类中的递归调用。正如这次的求导,Dx(Poly) = Dx(Term) + Dx(Term)。原始的递归调用,需要分别在Poly类和Term类中写上求导的代码。而通过这种模式,这些函数能集中在一个类中实现,同时具备一定的多态能力(当然相比于Antlr4的Visitor模式,这个实现方式比较简陋)。

像作业二中的函数实参表达式替换,我就是在Poly,Term等类中,分别实现了eval()函数,递归地调用eval()函数,实现函数实参的替换,实际上也能通过这种模式实现。

这种模式的好处是:递归的函数集中在一个类中编写,方便阅读、理解。同时,在需要添加新的函数时,只需要继承BaseVisitor类,编写新的visit方法,更加灵活,同时不会造成表达式类的代码冗余。

缺点是:许多需求都需要表达式内部的信息,如Poly类中的Term集合,原先的设计具备一定封装性,将Term集合的信息对外界屏蔽,但是这种在外部实现函数的需求,又不得不访问到这些信息,从而不得不提供protected的getter折中的实现这一功能。

耦合与内聚分析

通过上述类图,我分析自己的各个类的耦合和内聚性如下:

  1. Token包内部的各类,除Lexer以外,只是对于不同类型Token的简易的数据封装,而Lexer直接面向原始字符串的Token分割,不得不直接使用到这些类的细节,因此Lexer和其他Token子类,耦合性较高。

  2. Identity包中的各类只是用于创建唯一标识符编号的类,各类的功能比较简单,也只是普通组合。

  3. Parser.Ast包中的AstNodeBuilder类,需要从Token构建出基础的Node,需要Token子类的细节,与Token子类的耦合性较高。而parser.ast包中的其他类,由于都是节点,同时广泛使用接口继承,各种节点的子节点均是抽象类/接口,因此这些类之间耦合性较低,同时每个类的内聚做的较好。parser.ast.function包中的类却不一样,子节点的类型较为固定,耦合程度较高。

  4. sym包内的各类,都是用于表达式计算的类,虽然各类的相同功能的方法都做到了统一命名,却没能想到很好的方法,设计接口,使各个类的接口统一。Polynomial类和Term类的耦合程度较高,但Variable和Exp部分,却使用了接口屏蔽了细节,耦合的程度不深。

  5. visitor包的各类,(包括DxVisitor),虽然采用了在外部实现表达式操作的一些方法。但是却不得不依赖于表达式本身的一些细节。其中DxVisitor与sym包中的表达式耦合程度较高。

总的来说,各个包之间的耦合程度不深,但是各个包内部的类由于为了实现相似的功能,或是直接操作部分数据,因此部分采用了protected来对包外的类屏蔽细节,对包内的内开放,使得包内的内耦合程度较高。

基于度量的分析

圈复杂度分析:(按CogC排序,取前十个)

方法CogCev(G)iv(G)v(G)
sym.Polynomial.toString()182910
sym.Exp.toString()126811
parser.LrTableBuilder.buildTable()9166
parser.tokens.Lexer.next()6245
sym.DxVisitor.visit(Term)6144
sym.Polynomial.pow(BigInteger)6434
sym.Term.mul(Term)4133
sym.Term.toString()4234
sym.Variable.toString()3123
parser.ast.AstNodeBuilder.fromToken(Token)2333

分支数最多的若干类

方法数分支数行数
Polynomial3546121
Exp102652
Term132248
Lexer92232
Variable131426
LrTableBuilder31498
DxVisitor51030

本次作业中,复杂度最高的是Polynomial和Exp类的toString方法,这两个方法较为复杂的原因是,我将表达式化简的任务合并在toString中,有许多对子表达式的判断。Exp中主要要根据其指数表达式的类型,来决定是否要输出括号和提取指数。而Polynomial中输出项的时候,要根据系数的符号控制输出项的符号。因此复杂度较高。

类复杂度如下:

ClassOCavgOCmaxWMC
parser.LrTableBuilder4.569
parser.ast.AstNodeBuilder333
symvisitor.BaseVisitor2.535
sym.DxVisitor2.2549
sym.Exp2.1921
Main222
parser.tokens.Lexer1.78516
sym.Term1.67420
sym.Polynomial1.54843

可以发现,LrTableBuilder和AstNodeBuilder的平均复杂度最高,这两个类的方法数最少,但是内部又有许多的分支判断,因此复杂度最高。

基于身边统计学,通过比较互测中的同学的代码的部分数据,数据如下:

序号文件数总行数文件平均行数总方法数总分支数
59250042328(234)188
1147578566194
2169757469348
3178055488252
4779812887260
51793664101272
6159877378286
714111396126354

通过比较,可以发现,我的代码行数远多于其他人,类(1文件约等于1类)数远多于其他人,文件平均行数较低,总方法数远多于其他人,总分支数却较低。

方法数有部分是因为枚举类实现接口导致产生大量方法,而另一部分在于SLR分析时,规约操作使用了大量的lambda表达式,被计入方法数,如果使用递归下降,这些方法数可能可以被看做分支。但即使去掉这些方法数(括号内),我的方法数仍然远多于其他人。

相应的,由于我个人的一些想法,即学习借鉴部分编译原理的内容,使用了语法树与表达式分离的结构,有大量的语法树的节点的代码使得代码膨胀。同时,parser中的状态机模式,Token类型的枚举拆分成多个类,使得类的数量大幅增长,但是一些类的实现上,如AstNodeBuilder采用了表驱动的方法,即Hashmap代替if-else判断,简化了一些分支。而只看表达式计算等类,我和其他人使用的类的数量较为接近。

其他方面,由于我对函数式编程思想的倾向,使我更广泛使用lambda表达式和容器的foreach方法代替for,比如表达式取反this.powCoeMap.forEach((term, coe) -> polynomial.powCoeMap.put(term, coe.negate()));,或是写出这样的遍历排序后的表达式(用于输出化简)

for (Object e:
    this.powCoeMap.entrySet().stream().sorted(
        (lhs, rhs) -> rhs.getValue().compareTo(lhs.getValue())).toArray())

这使我的分支数相较于其他人较少,也可能引入了更多的方法。

架构设计体验

迭代过程

三次迭代过程中,我的架构设计始终保持总类图中表述的结构,在此结构上进行扩展,不对内部的各种类做出大量的修改。绝大部分修改在第二次作业中完成,除了修改了sym包内的表达式结构,还引入了新的Token枚举类型,Ast中的函数定义与调用节点,标识符管理器IdentityManager类。第三次作业中是一种实现求导,使用的是非侵入的Visitor。具体修改在上述类图中已经说明。

关于重构,主要只重构了表达式计算部分的代码,引入了单项式Term表示变量/函数相乘。并且引入了IVariable接口,使得函数/变量能同一的存储与Term中,提高了可扩展性。

新迭代情景

比如,需要加入Sin,Cos函数支持,可以继承Func接口,比如

public class Sin implements Func {
    private Polynomial expr;

    private BigInteger power;

    public Sin(Polynomial expr) {
        this.expr = expr;
        this.power = BigInteger.ONE;
    }

    public Sin(Polynomial expr, BigInteger power) {
        this.expr = expr;
        this.power = power;
    }

    public String getName() {
        return "__SIN__" + expr.toString();
    }

    public IVariable mul(IVariable rhs) {
        if (!(rhs instanceof Sin)) {
            throw new RuntimeException("Cannot multiply "
                + this.getName() + "and " + rhs.getName());
        }
        Sin sin = (Sin) rhs;
        return new Sin(expr, this.power.add(sin.power));
    }

    public boolean equalsToOne() {
        return this.power.equals(BigInteger.ZERO);
    }

    public Polynomial eval(HashMap<IdentityIndex, Polynomial> vars) {
        return new Polynomial(new Term(new Sin(expr.eval(vars), power)));
    }

    public String toString() {
        if (this.expr.equalToZero()) {
            return "0";
        }
        if (this.equalsToOne()) {
            return "1";
        }
        if (this.expr.isFactorLike()) {
            return "sin(" + expr.toString() + ")"
                + (power.equals(BigInteger.ONE) ? "" : "^" + power);
        }
        return "sin((" + expr.toString() + "))"
            + (power.equals(BigInteger.ONE) ? "" : "^" + power);
    }
}

并将其注册进FunctionLib

private static void registerSin() {
    ArrayList<IdentityIndex> sinParams = new ArrayList<>();
    IdentityIndex sinParam = IdentityManager.getGlobalInstance()
        .getOrRegisterIdentity("Sin_1_param", AstType.VARIABLE);
    Polynomial sin = new Polynomial(new Term(new Sin(
        new Polynomial(new Term(new Variable(sinParam))))));
    sinParams.add(sinParam);
    IdentityIndex sinIndex = IdentityManager.getGlobalInstance()
        .getOrRegisterIdentity("sin", AstType.FUNCTION);
    EvalFunction expFunc = new EvalFunction(sinIndex, sinParams, sin);
    IdentityManager.getGlobalInstance().setFunction(sinIndex, expFunc);
}

即可实现Sin函数,效果如下:

0
sin((x))*5*sin((x))+2*sin((x))*exp((x))
5*sin(x)^2+2*exp(x)*sin(x)

当然,如果需要实现求导,还需在Visitor类加入对Sin的求导操作。

程序bug

幂函数通过int传参

出现问题在于多项式的幂的实现,本身的变量已经设计了可以用大整数作为指数,但是在实现幂次的时候,为了快速幂实现的方便,将对应函数的形参设计为了int,使用时还需要.intvalue取得int的幂。

如下

public Polynomial pow(int k) {
    int p = k;
    Polynomial ans = new Polynomial();
    Polynomial clone = (Polynomial) this.clone();
    ans.powCoeMap.put(new Term(), BigInteger.ONE);
    while (p > 0) {
        if ((p & 1) == 1) {
            ans = ans.mul(clone);
            if (p == 1) {
                break;
            }
        }
        clone = clone.mul(clone);
        p >>= 1;
    }
    return ans;
}

修复:将int改为BigInteger,并将运算符改为对应的函数即可。

出问题的主要原因在于个人的失误,对于快速幂的过分熟悉,没有采用BigInterger作为参数,甚至使用时,还需要额外.intvalue()来调用。设计时应当更加注意传参,调用的类型的影响,不应该盲目转型来强行调用,而需要思考转型的必要性。

指数可能为负数

根据课程组提供的形式化表述,所有的指数都应是非负的,然而在对Exp表达式化简的过程中,对于有多个负数项的情况下,考虑到了指数只提取abs(gcd()),但是忽视了只有一个因子的情况,盲目的强行提取其系数。将if (expr.size() == 1)改为if (expr.size() == 1 && expr.gcd().signum() == 1)即可。

出现这种问题的主要原因是,Exp.toString的分支数太多,设计时没有考虑周全,同时也没有做出详细的测试,导致出现一些问题。

避免方法,设计更加全面的单元测试,同时应该想办法将Exp.toString的分支数减少,以减少出错的几率。

找他人错误

  1. 根据其特判的边缘情况,设计相应的样例,如果出现特判,则可能在一些边界情况上出现问题,在我看来一个更加通用的处理出现问题的可能性反而更低。

  2. 设计随机测试(广泛提交评测机)

  3. 构建Cost限制以内的,输出最长或可能的运行时间最长的样例。我认为,程序的运行时间,也是程序性能的一部分,对他人程序性能的检测,也是非常有必要的。在性能上的要求,也是为了同学们正确使用一些Java内置库,或是一些程序结构。

程序优化

表达式的化简

首先,在sym包内的表达式,计算时,始终保持其是最简的,对多项式设计了equalsToZero, isFactorLike等谓词,为Exp内部的化简提供了基础。同时,对单项式,也设计了equalToOne,对Ivariable接口的子类,也必须实现equalToOne,方便单项式中的化简。在多项式内,有cleanZero的方法,调用运算函数时,首先清理掉右式的等于0的单项式,返回时也清理掉结果中的等于0的项,对于单项式,也有清理等于1的项的函数。通过这样的方式,保证运算过程中的表达式的最简。

一些性能和程序上的优化

在程序的设计上,为了能够更加有效的进行表达式的化简,在合并同类项的过程中,使用了HashMap来快速查找可合并的对象。对于容器中对象的多引用问题,我没有采取深拷贝的方法来解决。我将表达式的各类,均设计为“不可变”对象,对外暴露的public方法,均无法改变表达式本身,只提供了返回新对象的方法。同时对内的private方法,可以改变表达式本身的方法,严格限制这些方法仅用于构造新的对象。以此实现了对表达式的不可变。比如

public Polynomial add(Polynomial rhs) {
    Polynomial polynomial = this.clone();
    rhs.cleanZero();
    rhs.powCoeMap.forEach(polynomial::addTerm); // addTerm用于将项加入表达式
    polynomial.cleanZero();
    return polynomial;
}

有了不可变性,在容器内的多引用等问题便不是问题,此时clone也只是需要一个相同内容的副本,而这个副本的内的项也无法被改变,因此只需要实现浅拷贝,就可以代替原本的深拷贝,降低了一些时间上的复杂度。

心得体会

在本次OO实验中,通过实践一个可以解析字符串表达式,并且将其化简的程序,在迭代过程中,不断扩展我的程序,我了解、学习了一些关于编译原理,符号计算的内容。但更重要的是,通过实践,我学习,了解了一些关于面向对象的设计思想。尤其是对象之间互相的方法调用形成的递归,非常契合表达式计算的运算过程。

在本次实验中,其实相比面向对象的内容,我更多的学习,参考了很多关于编译原理的内容。首先,我学习了一些关于语法分析的内容,思考了一种SLR分析器的设计。其次,我参考、借鉴了一些关于抽象语法树的内容,利用语法分析器,并非直接构造出表达式,而是先创建出抽象语法树,再构建表达式。再者,我学习了一些关于Antlr4的内容,对其Visitor模式的语法树访问有了一定了解,促使我写出了我代码中的Visitor类用于求导。

同时,虽然我的代码中并没有引用,直接的使用各种开源项目,但是编译相关的开源项目对我的帮助相当大。本次实验中,我广泛地使用了Antlr4,用于语法分析的学习,样例的Cost计算与合法性判断。同时还有两个的开源项目,语法分析表构建器,和基于Antlr4语法的随机测试生成器,在我构建语法分析器,和构建随机测试生成器的过程中,都给我提供了很大的帮助。

最后,对于我的程序的结构设计。虽然我在AST的节点设计中,大量使用了接口与继承的内容,使得语法树的构建更加灵活,在SLR分析器的设计中,同样使用了这种方法。但从自我的感受以及我对互测代码的阅读,本次作业的结构其实非常的清晰,导致本次作业中,广泛使用组合的方式,而不是继承。但是使用组合的过程中,也不是面向接口的编程,导致使用组合的时候,多个类之间的耦合性其实非常高,每次为了实现一些新的内容或是修改表达式的部分内容,都必须在多个类之间,进行修改,导致最终的扩展性并不高。同时,有时为了判断一些情况,或是遍历表达式的内容,都不得不暴露表达式各类的成员,破坏了原本的封装性。我也没有想到有什么好的办法,可能还需要在后续的课程学习中,不断精进。

一些建议

在OS的学习中,我进一步的学习了git相关工具的学习,在OO的二三次实验中,我使用git管理分支,修复bug,增添功能,感到了git在管理分支上的重要作用与强大能力。既然OO先导课中也强调了关于git使用的相关内容,能否在OO的课程中,对bug修复、甚至新功能的添加,都要求一些关于git分支上的管理,以让同学们对git工具链的使用,和分支上的管理有更进一步的了解。

另外,互测中的Cost限制及其不合理,在应该限制的部分限制不严,不应该限制过多的地方又限制过度。下面是一些例子:

  1. 两式相乘的Cost不合理,由定义一个长度为1的常数,如9,或是x的幂,x^8,其Cost都为1,那么x^8*x^8*x^8...的Cost都是1。为了避免一些常数带来的Cost过高,使用9*9*9等形式,只增加了一些长度,却不会真假Cost。

  2. dx的Cost直接是指数级别的,这直接导致了连对一个普通的多项式求导都会超过Cost限制。可以想象,dx的Cost是为了限制exp嵌套导致的输出长度膨胀,但是,对exp嵌套的求导,其展开长度也并不比幂函数大。而且更重要的是,exp的Cost似乎太少了,exp嵌套的Cost太低才是问题。

因此,我认为,对这个Cost的计算应该有所调整。

...全文
86 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

301

社区成员

发帖
与我相关
我的任务
社区描述
2023年北航面向对象设计与构造
学习 高校
社区管理员
  • YannaZhang
  • CajZella
  • C_ecelia
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧