BUAA-OO-Unit 1: 表达式展开

贺若芸-21379252 2024-03-23 18:57:19

BUAA-OO-Unit 1: 表达式展开


第一单元的主题是对表达式进行括号展开,主要训练目标是体会层次化设计的思想的应用和工程实现。本单元一共有三次作业,难度递增,从包含含加、减、乘、乘方以及括号的单变量多项式展开,依次加入指数函数、自定义函数和求导运算。以下将对这三次作业逐个进行分析并总结。

第一次作业

本次作业需要完成的任务为:读入一个包含加、减、乘、乘方以及括号(其中括号的深度至多为 1 )的

单变量表达式,输出恒等变形展开所有括号后的表达式。

UML类图

本次作业代码的UML类图如下:

在这里插入图片描述

从图中可以看出,所建的类大致可以分为解析类、储存类和运算类,具体每个类的设计考虑见下。

架构设计

在分析题目和参考训练题后,我将本次作业分为预处理、解析、储存、展开与优化等五个任务。

字符串预处理

由于输入的字符串中会含有空格、连续的加减号等,需要进行预处理以方便之后的操作。在Preprocessor类中:

  • 删除所有空格和制表符

  • 通过遍历合并连续的加减号

  • 将连续的加号替换为单个加号,并移除表达式开头的加号

最后输出一个字符串交给负责解析的类。

表达式解析:递归下降

根据递归下降的思想,我们要自上而下建立语法树,进行词法和语法的分析。

Lexer

这一部分是词法分析器,主要负责将经过预处理后的字符串分解成一系列有意义的单元,称为“token”。这个过程是解析器的第一步。在第一次作业中token 包含+, -, *, (, ), ^, x以及数字。

Lexer中的核心公共方法是next(),通过if-else语句识别数字和特定的单字符token。通过peek()方法可以访问当前的token。该方法使得外部代码能够在不改变Lexer状态的情况下检查当前token。

Parser

这一部分是语法分析器,使用Lexer实例来逐个读取输入字符串中的token,然后根据这些token构建表达式(Expr)、项(Term)和因子(Factor)对象。仿照训练题的代码,Parser类由以下三个主要方法组成:

  • parseExpr():解析整个表达式,包含一个或多个项。这个方法循环调用parseTerm(),直到遍历完所有的token或遇到右括号)为止,表示表达式的结束。加减号并没有在此处处理,而是在parseTerm中进行处理,作为项的正负号。
  • parseTerm():解析单个项,由一个或多个因子组成(通过乘号连接)。这个方法会处理加减号、识别乘号,并根据这些信息构建Term对象。通过循环调用parseFactor()方法,同时进行系数的相乘和指数的相加,最终在Exprlist中增加一个Term
  • parseFactor():解析单个因子,因子可能是数字、变量、带括号的表达式或变量的指数形式。这个方法根据当前token的类型决定如何构建因子对象,并处理指数。遇到带括号的表达式则调用parseExpr递归地解析。

表达式展开

经过以上处理后,得到的Expr中含有一个装有TermList, 而Term由两个List组成,分别装表达式因子和其他因子。其他因子在parseTerm部分以及进行计算,得到两个数字分别代表系数和x的指数,即:
$$
F(x) = \sum_{i} a_i x^{b_i}
$$
基于此,我新建了Operator类和MulExpr类进行处理。在MulExpr中我构建了两个公开方法mulExprmulTerm,可以读入两个表达式/项,返回其相乘的结果。在Operator类遍历ExprTerm列表,取出exprFactor列表不为空的项,对其调用表达式乘法和项乘法将括号展开,最终返回一个不带括号的表达式。

其中需要注意的是,为了提高程序的性能,处理更复杂的表达式展开,需要在mulExpr之后调用合并同类项的方法,即下面的优化。

结果优化与输出

为了缩短最终输出的字符串的长度,需要对展开后的表达式进行合并同类项,即将x的指数相同的项的系数相加。这里我用了HashMap来储存指数作为key,系数作为value,具体实现的代码如下:

HashMap<Integer, BigInteger> sites = new HashMap<Integer, BigInteger>();
        for (int i = 0; i < numTerm; i++) {
            Term term = input.getTerm(i);
            BigInteger coe = term.getCoe().getValue();
            int exp = term.getExp().getExp();

            if (sites.containsKey(exp)) {
                BigInteger newValue = sites.get(exp).add(coe);
                BigInteger value = sites.replace(exp, newValue);
            } else {
                sites.put(exp,coe);
;           }
        }

之后输出时需要依次重写 TermExprtoString方法,同时在其中需要进行一些特判以进一步缩短输出长度,包括:

  • 系数为0的项不输出,如果最后字符串为空则输出0;
  • 系数为-1或1,且指数不为0的项,省略1;
  • 指数为0则省略指数,只输出系数;
  • 将符号为正的项提前,以避免负号开头增加长度。

度量分析

在回顾作业时发现我的架构存在许多缺点,而度量分析将大部分都很好地反映了出来。

复杂度和大小度量分析

第一次作业中我总共构建了13个类,代码量416行(本文中所指代码行数均不包含注释行与空行)。其中类的属性个数、方法个数、每个方法规模、每个方法的控制分支数目、类总代码规模如下:

其中缩写含义为:

  • CSA: Class size(attributes) 类的属性个数

  • CSO: Class size(operations) 类的方法个数

  • NCLOC: Non-comment lines of code 类/方法的总代码规模

  • v(G): Cyclomatic Complexity 圈复杂度,数量上表现为独立路径的条数

可以看到所有类中Parser类的规模最大且所含方法最多,其次是Operator类。所有方法中Paser.parseTerm()的规模最大且控制分支数目最多,其次是Parser.parseFactor

在这里插入图片描述

其中缩写含义为:

  • WMC: Weighted Methods per Class 类的加权方法复杂度
  • OCavg: Average operation complexity 类的平均操作复杂度
  • OCmax: Maximum operation complexity 类的最大操作复杂度

可以看到Parser类是复杂度最高的,尤其是存在一个特别复杂的方法。Operator类的OCavg相对较高,可以考虑将复杂的操作分解为更小、更专一的方法,可以降低单个操作的复杂度。Preprocessor类在一个方法中承担了太多职责。

MethodCogCev(G)iv(G)
Expr.Expr()011
Expr.addTerm(Term)011
Expr.getTerm(Integer)011
Expr.getTermNum()011
Expr.toString()1116
ExprFactor.ExprFactor(Expr)011
ExprFactor.getExpr()011
Lexer.Lexer(String)011
Lexer.getNumber()213
Lexer.next()624
Lexer.peek()011
MainClass.main(String[])112
MulExpr.MulExpr()011
MulExpr.mulExpr(Expr, Expr)313
MulExpr.mulTerm(Term, Term)011
NumFactor.NumFactor(String)744
NumFactor.getValue()011
Operator.Operator()011
Operator.mergeTerms(Expr)514
Operator.remoBrackets(Expr)1015
Parser.Parser(Lexer)011
Parser.parseExpr()213
Parser.parseFactor()1268
Parser.parseTerm()19116
PowFactor.PowFactor(Expr, int)011
PowFactor.getBase()011
PowFactor.getExp()011
Preprocessor.preprocess(String)2117
Term.Term()011
Term.addExprFactor(ExprFactor)011
Term.addFactor(Factor)011
Term.getCoe()011
Term.getExp()011
Term.getExprFactor(Integer)011
Term.getExprFactorNum()011
Term.toString()1717
VarFactor.VarFactor(Integer)011
VarFactor.getExp()011

其中缩写含义为:

  • CogC: Cognitive complexity 认知复杂度
  • ev(G): Essential cyclomatic complexity 基本圈复杂度
  • iv(G): Design complexity 设计复杂度

其中Parser.parseTerm()Preprocessor.preprocess(String)的CogC值非常高,分别为19和21,高复杂度可能导致这些方法难以理解、测试和维护。这与先前类复杂度反映的信息一致。

OO度量分析

通过计算经典的OO度量,分析类的内聚和相互间的耦合情况。高内聚低耦合是面向对象设计的两个重要原则,有助于提高软件的可维护性、可扩展性和复用性。

在这里插入图片描述

其中缩写含义为:

  • LCOM: Lack of Cohesion in Methods 衡量一个类中方法之间相互关联的程度。LCOM较高表示类的内聚性较低,可能意味着类被赋予了过多不相关的职责。
  • CBO: Coupling Between Object classes 衡量一个类与其他类之间的耦合程度。CBO较高表示类与许多其他类紧密耦合,可能增加维护的复杂度。
  • RFC: Response For a Class 衡量类对外提供服务的能力,包括类中方法的数量加上这些方法调用的其他方法的数量。RFC较高可能表示类过于复杂,耦合度高。

从数据中可以得出,大多数类具有较高的内聚性,类中的方法紧密相关并集中于执行一组相近的功能。Expr, ExprFactor, Lexer, MainClass, MulExpr, NumFactor, Operator, Parser, Preprocessor, 和 VarFactor 都表现出较高的内聚性。
Parser, TermOperator等类的CBO值较高,这表明它们与许多其他类有交互。高耦合度往往意味着增加维护难度,它们也是在之后作业更改量最大的类。
Operator, Parser, Term的RFC值较高,意味着这些类承担了较多的职责,对外提供了较多的服务。Operator类在代码中被反复调用,因为我采用了一边存储一边计算的思路,这一点在之后被证明为是有诸多缺点的,会不利于化简,可拓展性较低。

优点

把握了此次作业表达式形式上的统一性,简洁地储存了系数和幂指数,最后化简和优化时也很方便。

Factor使用了继承的方法,便于管理。

架构具有较高的内聚性,类内部元素的联系紧密。

缺点

可扩展性较低,而且第一次作业没有实现多层括号的嵌套处理,只实现了一层的,导致第二次作业时检查了好久才修改了这一块。

边储存边计算的思路导致之后在Factor的类型增多后Term的存储就变得很复杂,导致在进行展开和优化的操作时很棘手。

第二次作业

本次作业中需要完成的任务为:读入一系列自定义函数的定义以及一个包含幂函数、指数函数、自定义函数调用的表达式,输出恒等变形展开所有括号后的表达式。

在第一次作业基础上,本次迭代作业增加了以下几点:

  • 本次作业支持嵌套多层括号。
  • 本次作业新增指数函数因子,指数函数括号内部包含任意因子。
  • 本次作业新增自定义函数因子,但自定义函数的函数表达式中不会调用其他自定义函数。

UML类图

本次作业代码的UML类图如下:

在这里插入图片描述

架构设计

第二次作业好像重构了又没重构……准确说是重写了。因为一直一开始没弄懂第一次作业的代码怎么改才能处理多层嵌套的括号,加上对一些细节不太满意,就从头又写了一遍,虽然改了一些细节(比如增加了一个enumcration来存储TokenType),但是整体解题的思路没变,还是一边往Term里面加Factor一边计算。

Factor从父类变成了接口,不过在这一次作业中感觉没有充分体会到继承和接口的区别。第一次作业中表达式因子和表达式是两个类,这一次中直接合二为一,让表达式直接去连接接口,变简洁了一点。

预处理部分和第一次作业中的一样,Lexer部分虽然变成了TokenTypeTokenTypeList来储存词法解析后的字符串,但整体和第一次作业没有本质变化,因此在这一部分主要介绍一下针对新增的几点的类和方法。

嵌套多层括号

经过仔细检查,发现需要在Operator的展开括号部分形成递归调用,大致代码如下:

``

public void openBrackets(Expression input) {
    int exprSize = input.getTermNum();
    for (int j = 0; j < exprSize; j++) {
        Term term = input.getTerm(j);
        Expression expr = term.getExpr();
        if (expr != null) {
            openBrackets(expr);
        } else ...

指数函数因子

类似于幂函数,由exp(<因子>)、指数符号^和指数组成

首先要新建Exponent类,包含两个属性来表示<因子>和指数,需要注意的是baseFactor的类型应为Expression这样才能通过parseExpr的方法将其没有错误地解析存储起来;指数在第一次作业中可以用int类型,但是在第二次作业中可能会超出范围,需要用BigInteger来储存。

同时在Term类中新建一个List来储存Exponent

指数函数的化简是一件非常棘手的事情,存在许多边缘情况。由于Term部分存储得有些混乱,以及第二次作业整体难度较大,最终我只采用了和第一次作业优化时类似的方法,将一个项中<因子>相同的指数函数的指数相加。最终的性能分很低。

指数函数的括号部分包含一个“坑”,在公测时因为这个费了很久才发现。关于必要括号的保留,需要注意exp(<因子>)这里只能是因子,如果是表达式需要加括号变成表达式因子;容易忽略的是表达式只含一个项的情况,这里仍然需要加括号。

自定义函数因子

  • 自定义函数的定义形如 f(x, y, z) = 函数表达式 ,比如 f(y) = y^2g(x, y) = exp(x)*exp(y^2)h(x, y, z) = x + y + z

  • 自定义函数的调用形如 f(因子, 因子, 因子) ,比如 f(x^2)g(exp(x^2), exp(x))h(1, 0, -1)

首先,我们需要进行自定义函数的读入。在main类里将输入的函数以=分割为两部分储存进HashMap。依据此我们需要得到其函数名、形参和函数表达式这三部分,并按创建三个List将其存储,以便之后调用。

``

int count = Integer.parseInt(scanner.nextLine());
HashMap<String, String> definition = new HashMap<>();
for (int i = 0; i < count; i++) {
    String input = scanner.nextLine();
    String[] parts = input.split("=");
    definition.put(parts[0].trim(), Preprocessor.preprocess(parts[1].trim()));
}

然后,在Parser部分,单独创建parseFunc方法对其进行解析,在得到函数名后,借助其形参的数量得到其实参,然后进行字符串替换。类似ExponentFunction的实参因子也要设为Expression类型,调用parseExpr进行解析。

``

public Expression substituteVariables(String funcExpr, Map<String, Expression> varMapping) {
    String input = funcExpr;
    // 对于每个变量和它的替换值
    for (Map.Entry<String, Expression> entry : varMapping.entrySet()) {
        // 生成一个匹配整个单词的正则表达式
        String regex = "\\b" + entry.getKey() + "\\b";
        Pattern pattern = Pattern.compile(regex);
        Matcher matcher = pattern.matcher(input);
        Expression factor = entry.getValue();
        // 替换所有匹配项
        input = matcher.replaceAll(Matcher.quoteReplacement("(" + factor.toString() + ")"));
    }
    ...
    return expr;
}

这里需要注意的是,由于exp也含有x,所以需要在读入时将形参前面加_以防误替换,或者也有将exp改为e等多种处理方式。

度量分析

复杂度和大小度量分析

第二次作业中我总共构建了13个类,代码量759行。其中类的属性个数、方法个数、每个方法规模、每个方法的控制分支数目、类总代码规模如下:

可以看到所有类中Operator是规模最大的,其次是TermParser

在方法中,Parser.parseFactor()规模最大且分支最多,其次是Lexer.Lexer(String)Term.toString()

在这里插入图片描述

相比于第一次作业,TermOperator类的复杂度发生了较显著的变化。

Term的OCavg(2.54), OCmax(13) 和 WMC(33)均增高,且WMC为所有类中最高的,表明Term类不仅包含复杂的方法,而且方法数量较多。这个类可能需要重构,以减少方法复杂度和方法数量。

Operator的OCavg(2.9)降低,但它的OCmax(6)和WMC(29)增高,表明它包含多个复杂度不一的方法,且承担了相对较多的职责。可能需要将一些职责分离到其他类中。

MethodCogCev(G)iv(G)
Exponent.Exponent(Expression, BigInteger)011
Exponent.getBaseFactor()011
Exponent.getExpOfExp()011
Exponent.toString()624
Expression.Expression()011
Expression.addTerm(Term)011
Expression.getExpOfExpr()011
Expression.getTerm(Integer)011
Expression.getTermNum()011
Expression.setExpOfExpr(BigInteger)011
Expression.toString()1116
Function.Function(String, List)112
Function.getDefinition(HashMap<String, String>)815
Function.getFormVars(String)011
Function.getFuncDefin(String)011
Function.getName()011
Function.getVarMapping()011
Function.substituteVariables(String, Map<String, Expression>)112
Lexer.Lexer(String)151313
Lexer.getNumber()846
Lexer.getToken()011
Lexer.getTypes()011
Lexer.hasNext()011
Lexer.move()011
Main.main(String[])213
Number.Number(String)011
Number.getValue()011
Number.toString()011
Operator.add(Term)011
Operator.getSites()011
Operator.mergeExp(Term)734
Operator.mergeTerms(Expression)112
Operator.mulExpr(Expression, Expression)1316
Operator.mulTerm(Term, Term)313
Operator.openBrackets(Expression)1415
Operator.putSites(BigInteger, BigInteger)011
Operator.remoBrackets(Expression)714
Operator.replaceSites(BigInteger, BigInteger)011
Parser.Parser(Lexer)011
Parser.parseExp()213
Parser.parseExpr()314
Parser.parseFactor()301123
Parser.parseFunc()313
Parser.parseTerm()818
Power.Power(BigInteger)011
Power.getExp()011
Power.toString()222
Preprocessor.preprocess(String)2117
Term.Term()011
Term.addFactor(Factor)555
Term.getCoe()011
Term.getExponents()011
Term.getExpr()011
Term.getExprs()011
Term.getPower()011
Term.remoExpOfExpr(Expression)915
Term.setCoe(BigInteger)011
Term.setExponents(ArrayList)011
Term.setExpr(Expression)011
Term.setPower(BigInteger)011
Term.toString()28112

根据方法的复杂度数据,Parser.parseFactor(), Term.toString(), Preprocessor.preprocess(String)最复杂。

相比第一次作业,由于因子的形式进一步多样,对其进行解析、转为字符串输出的操作也变得更加复杂。无论是构造还是修复bug时相关方法也是最容易出错的。

OO度量分析

以下为第二次作业的类内聚度量和类间耦合度量:

在这里插入图片描述

大多数类展示了较高的内聚性(低LCOM值),但Function的LCOM值较高,原因是它同时承担了分解并存储输入进来的自定义函数和对表达式中的函数进行字符串替换两个功能,应该将这两个功能分开以实现高内聚。

ParserFunction等类展示了较高的耦合(高CBO值)。Parser需要多次调用其他类来构建因子,项和表达式,这是无可避免的。Function由于承担两个功能,与LexerParser等多个类产生联系,将其功能分开应该能够降低耦合,同时字符串替换的方法可以返回一个字符串交给parser.Factor(),在Parser类中再进行从字符串到Expression的构建。

点评

优点在于对于自定义函数的处理,字符串替换的方法可以处理在定义中调用其他函数的函数;大多数类的内聚性都较高。

但是第一次作业中边存储边计算的缺点依然保留,导致指数函数的化简非常棘手以致于摆烂,而且由于开始时没有命令-查询分离原则导致出现bug修复了很久;同时Function函数包含了两个功能,没有实现高内聚低耦合的目标。

第三次作业

本次作业中需要完成的任务为:读入一系列自定义函数的定义以及一个包含幂函数、指数函数、自定义函数调用、求导算子的表达式,输出恒等变形展开所有括号后的表达式。

在第一次作业基础上,本次迭代作业增加了以下几点:

  • 本次作业支持求导操作,新增求导算子 。
    • 求导因子可以出现在很多位置,包括函数调用实参,指数函数内部等,注意考虑周全。
    • 为了限制难度,在输入中,求导算子不会在自定义函数中出现。
  • 本次作业函数表达式中支持调用其他“已定义的”函数

第三次作业新增了求导算子和调用其他“已定义的”函数的自定义函数,其中后者在第二次作业中已经实现,前者乍一看复杂但其实也不难。

UML类图

本次作业代码的UML类图如下:

在这里插入图片描述

架构设计

这次作业基于第二次作业的修改量很小,主要在Term类中新增方法derivative()对求导算子进行处理。返回一个Expression;其次LexerParser中解析即可。

求导过程中较为复杂的是链式法则和乘法法则。链式法则表达为:
$$
(f(g(x)))' = f'(g(x)) \cdot g'(x)
$$
而乘法法则则表达为:
$$
(f(x) \cdot g(x))' = f'(x) \cdot g(x) + f(x) \cdot g'(x)
$$
需要考虑到数字因子,幂因子,指数因子和表达式因子的多种组合,具体实现的代码如下:

public Expression derivative() {
    Expression result = new Expression();
    BigInteger tempCoe = coe;
    BigInteger tempPower = power;
    if (tempPower.equals(BigInteger.ZERO) && exponents.isEmpty() && expr == null) {
        //数字求导为0
        ...
        return result;
    }
    Term deFormer = new Term(); //前导(ax^b)乘后
    deFormer.setCoe(tempCoe.multiply(tempPower));
    ...
    result.addTerm(deFormer);
    Term deLatter = new Term(); //后导乘前
    deLatter.setCoe(tempCoe);
    deLatter.setPower(tempPower);
    if (exponents.isEmpty()) {
        ...
    } else {
        if (expr == null) {
            ...
            result.addTerm(deLatter);
        } else {
            ...
            result.addTerm(deLatter); //exponent的导乘expr
            ...
            result.addTerm(deLatter); //expr的导乘exponent
        }
    }
    return result;
}

度量分析

由于大部分代码没有变化,因此该部分仅对新增方法Term.derivative()的复杂度进行分析。

MethodCogCev(G)iv(G)v(G)
Term.derivative()1821111

该方法的CogC, iv(G), v(G)均偏高,说明这个方法在逻辑上相对复杂,包含多个控制流结构和它们的嵌套。这对于阅读和理解代码,以及维护和修改它,都是一个挑战。同时,圈复杂度的值11进一步证实了这一点,说明方法中存在多个独立的执行路径,需要依次判断求导的对象是否为数字因子、是否含指数函数因子、表达式因子等。

相比之下,实验题中展现的求导思路更为精妙,依次构造了不同因子的求导方法后,只需要在遇到求导因子时调用这些方法,根据普遍的链式法则(常数与幂因子相乘的求导也符合这一法则)对其进行处理,从而使单个方法的复杂度降低、可维护性和可拓展性均增高。

Bug分析

很幸运在第一单元中没有在强测和互测中被找出bug,但是前期的中测和评测机(感谢分享自动评测机的大佬)还是修改了不少bug的。

出现bug最多的方法是parseFactor(),和Term类,Operator类中的方法。parseFactor()的代码行数和圈复杂度都很高。Term类和Operator类同样也是复杂度很高的类,另一方面也是我没有将计算与存储分离、将命令与查询分离的原因。

在互测中找别人bug的策略,一开始尝试通过去看别人的代码来寻找,但发现费时费力而且看不出来;之后主要依赖自动评测机,在找出bug后逐步对输入的数据进行拆分测试,找到真正出问题的数据;遇到自动评测机报超时的情况(主要在第三次作业中运用该策略),如果正确输出的数据没有非常长(比如500字符以上),则通过计算输入限制的代价来找到能够成功hack的数据;最后也会通过输入一些容易忽略的数据来测试,比如指数函数的括号,0的0次方是1等(但是没有成功过)。

可扩展性

如果继续增添因子类型,比如三角函数因子等,可以在Factor下新建类,在Term中新建List存储,但是最后的化简依然是一个难题。所以建议重构为其他同学大多数采用的unitpoly的形式,在解析的时候只存储,之后再进行计算。

老师在最后一节课提出可以采用面向对象的设计模式来优化代码结构,在之后的作业中尝试一下~

心得体会

总的来说通过第一单元的作业学到了很多,逐步对Java这一语言有了认识。对于没上过OOP的我来说,第一次作业是最痛苦的,非常感谢wxm给予的耐心指导:pray:;第二次作业难度较大,第三次作业终于喘了一口气。

由于摆烂和懒惰架构的问题,在第二三次作业的性能分很低,尤其是第三次作业,下次一定不!

未来方向

OO课程的设计非常精妙:thumbsup:,如果能考虑到没上过OOP的同学就更好了~可以介绍一些关于Java的语法知识,或者推荐资料去阅读学习。

注:本文引用均来自于三次的作业指导书


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

301

社区成员

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

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