2024北航OO第一单元总结

0. 作业简介

对表达式进行化简,展开不必要的括号,并尽可能缩短表达式长度。作业迭代分三步走:
在这里插入图片描述
下面对笔者的架构进行分析。

1. 架构设计体验

1.1 第一次作业

注意到输入表达式的定义有三个特点:

  1. 具有严格的文法定义;
  2. 具有大量可能出现的空白项;
  3. 有可选项,如:Expression := [add/sub] Term | ...第一个Term含有可选的正负号。

基于第一个特点,以及指导书的提示,我果断采用递归下降法分析文法,这样在后续迭代时只需增加文法模块即可;而若使用正则表达式,相当于自己填堵了对括号嵌套的迭代空间。

基于第二个特点,我借鉴了编译技术中的词法分析,将输入分割为Tokens(词法单元)的列表。由于空白符是可选项,我的分割并不依靠它,而是利用正则表达式([+\\-*x^()=]|[0-9]+)逐步提取有效的tokens。在迭代时若出现新的文法,只需增加正则表达式的内容即可。

基于第三个特点,不同于大部分同学在预处理时“合并符号”,我采用了回溯式匹配,即先默认没有这个可选的符号,向下匹配,如果下层匹配失败则识别可选项,若仍失败,则向上报告。这样设计能够完全依据文法定义分析,如果有不合法的情况能够立即报错,增加健壮性。并且性能并没有明显影响。(本想使用异常报告失配,后来了解到这样开销很大,因此又修改成return null报告失配)

public void parseExpression{
        Term tmpTerm = parseTerm();
        if (tmpTerm == null) {
            //mismatching, thus tracing back
            
            //parsing sign
            if (tmpTerm == null) {
                //still mismatching
                return null;	//reporting mismatching
            }
            //detecting following terms
        }
}

第一次的架构可以用下图概括:
在这里插入图片描述

1.2 第二次作业

在自我思考和讨论中,发现第二次作业有三大焦点:

  1. 函数的展开:可以增加预处理模块,在字符串的意义上进行替换,类似于C语言的;也可以改变文法,增加对函数的分析;
  2. e指数的存储索引:合并同类项时,由于第一次大多以x的幂作为单项式的索引,因此需要改动。同学们各显神通,有的使用类似于Hash冲突的链表法解决同幂不同exp的情况,还有开发List<List<...>>的结构……
  3. 缩短长度。常见的策略有提取公因子、凑项等。

经研讨交流,本着“绝不重构,少量修改,可以新增”的原则(实际上是“开闭原则”),作出了如下解决方案:

  1. 采用类似展开“宏”的方式替换自定义函数。这样的好处是:自定义函数对于主体的处理程序是透明的;而如果修改文法,需要支持多变量,导致大量重构;
  2. 引入新的类MonoTag,作为Monomial的索引(相当于同类项的标识符),重写equals和compareTo方法,然后将之前的TreeMap<BigInteger, Monomial>直接替换为 TreeMap<MonoTag, Monomial>即可。这样可以使架构不必调整,减少重构风险。MonoTag内容如下:
    在这里插入图片描述

1.3 第三次作业

函数嵌套定义很简单:只需要复用展开函数的方法,对每个新函数的定义式都做一次展开即可。

而求导与表达式在解析和求值上都有很大共性:都含有子表达式,在运算时都要先对子表达式求值,唯一的区别就是DiffFactor在子表达式求值后还需求导。因此只需重写getSubPoly函数,在上层调用时利用多态性,自然地完成求导工作。这样不用修改上层逻辑,将上层不需要知道的细节隐藏。决定直接将求导因子继承于表达式因子,复用相关代码:
在这里插入图片描述

1.4 最终架构与迭代空间

1.4.1最终架构

架构修改路径由下图总结:
在这里插入图片描述

1.4.2 迭代的空间

  1. 如果需要实现积分,只需要继承ExprFactor即可(与求导类似);
  2. 可以实现非法表达式的检查。由下文的类图及代码可以发现,我预留了一个异常类IllegalExpressionException,(最初为了标记失配,后来改成了) 用于表示没有匹配的文法。上文提到,由于我是严格按照文法回溯解析的,对符号多余、括号失配、非法指数等异常都能做到检查。由于有异常处理框架,故只需完成对异常的逻辑检查即可。

2. 对最终程序结构的度量分析

2.1 代码量与方法规模统计

2.1.1 代码量

在这里插入图片描述

2.1.2 方法规模

类名公有方法数私有方法数
func.function40
func.funcManager53
grammar.ConstFactor20
grammar.DiffFactor20
grammar.EExponentFactor30
grammar.Expression30
grammar.ExprFactor40
grammar.Factor60
grammar.Monomial255
grammar.MonoTag30
grammar.Parser711
grammar.Term50
grammar.VariableFactor10
InputHandler51
Lexer30
Main11

部分方法的访问属性还能再优化。

2.2 方法复杂度分析(含分支)与改进方案

在这里插入图片描述
主要关注ev指标(基本圈复杂度),它表示真正增加程序路径数目的条件分支。
由于绝大部分方法基本圈复杂度都很低(控制流路径不超过2),故上图只展示ev大于2的。下面对ev最高的parseFactor提出优化方案。原方法的逻辑如下:

 private Factor parseFactor() throws IllegalExpressionException {
 		//...
 		if (firstToken.equals("x") {
 			//parsing variable
            
        } else if (firstToken.equals("(")) {
            //parsing expression
        } else if (firstToken.equals("dx")) {
            // parsing derivative
        } else if (firstToken.equals("exp")) {
            //parsing exp
        } else {
            //must be a number, or mismatching
            BigInteger constValue = parseSignedIntegerAndMove(false);
            if (constValue == null) {
                return null;
            }
            //parsing
        }
		//...

这一段有两个问题:

  1. 使用大量的串行分支判断,实际上是除了对整数的处理外,在逻辑上可以并行的;
  2. 违背SOLID中的单一职责原则。完成了判断+处理两项任务。

通过对C语言函数指针的理解,我设想制造一种基于“查找表”的优化。经学习,了解到Java中的函数式接口(只封装一个方法):

    private interface FactorFactory {
        Factor createFactor(String firstToken) throws IllegalExpressionException;
    }

只需要将这个接口作为Map的第二元,将标志字符串作为索引即可实现。然后将解析逻辑封装成适配于上述接口的函数,即可完成“打表”:

        Map<String, FactorFactory> factorMap = new HashMap<>();
        factorMap.put("x", this::createVariable);
        factorMap.put("(", this::createExpr);
        factorMap.put("dx", this::createDiff);
        factorMap.put("exp", this::createExp);

最终解析业务的代码简化为很简洁的形式,并且基本圈复杂度由9降至1

String firstToken = tokens.get(currentIndex);
        Factor factor;
        FactorFactory factory = factorMap.get(firstToken);
        if (factory != null) {
            factor = factory.createFactor(firstToken);
        } else {
            factor = createConstFactor(firstToken);
        }

2.3 内聚与耦合度量与分析

在这里插入图片描述
我们关心如下两个指标:

  1. CBO:一个类与其他类之间的依赖关系数量,包括其与其他类的关联、聚合、继承等关系;
  2. LCOM:用于评估类的内聚性的度量指标。它衡量了一个类中的方法之间的关联程度。

Parser的CBO最高,是因为其调用了几乎所有的文法类,用于解析表达式。这导致类较为冗长。

Function的LCOM最高,是因为类内部的方法没有共享数据成员。如图所示,reverseSign,setExponent等各自改变相应的成员变量,而没有形成共性的目标。在这里插入图片描述

2.4 类图与类设计分析

2.4.1 类图

经三次迭代,最终类图如下:
在这里插入图片描述

2.4.2 设计理念简析

定义了两个包裹:grammar和func。func内部的类主要负责自定义函数展开的业务,grammar完成文法解析与化简的逻辑。两个包均只有一个类与Main有依赖关系,达到解耦目的。

为降低主类复杂度,输入解析单独封装为InputHandler类;Main负责实现各类的调度流程;为达到复用的效果,Lexer类的业务被封装为一个静态方法,减少调用的复杂度。

    public static ArrayList<String> tokenize(String rawStr) {
        Lexer lexer = new Lexer(rawStr);
        return lexer.toTokens();
    }

在func包中,Function类维护函数信息,包括名称、参数表、定义等,为FuncManager所管理,FuncManager完成替换逻辑。

grammar包的设计原因与历史渊源,在“架构设计体验”已经详细分析,不再赘述。

3. bug分析

本单元三次迭代在中测和强测以及互测中均一次性通过,故分析两个在调试中出现的bug。

3.1 共享内存修改导致出错

hw1中,在计算Term时,有以下语句:

public void addToPolynomial(Map<Integer, Monomial> polynomial) {
	//...
	for (Map.Entry<Integer, Monomial> entry: subMap.entrySet()) {
        entry.getValue().multiplyAndUpdate(tmpMonomial);
        Monomial.addToMap(polynomial, entry.getValue());
    }
}

我在计算单项式相乘时,选用的是更新当前单项式。到第二次作业时,由于需要将幂与exp内部表达式相乘,导致原本两个引用共享的对象被修改,从而出错。这是由于设计复杂,将运算与修改混合到了一起导致bug。因此第二次对Monomial类的运算做出了大量修改。逻辑是:
将Monomial只有在构建时(增添系数、幂指数)允许修改;一旦建成,后续任何方法(Monomial相乘、合并同类项等)都不允许修改原操作数,而是返回新的对象。

许多同学提出“无脑深拷贝”的策略,我之前也写出过这种多余的代码:

    res.power = new BigInteger(m1.power.toString());

后来发现,问题的本质是可变对象与不可变对象的区别。大胆将属性声明为final,即使有共享的内存,也没必要担心它被修改。例如,MonoTag中的属性:

    private final BigInteger power;
    private final Map<MonoTag, Monomial> expOfE;

这个类就没必要深拷贝。

而只有对于可变对象,才要递归地拷贝,直到遇到不可变对象为止。

3.2 输出逻辑复杂导致出错

hw1的输出是直接使用的大量判断,认知复杂度为9,基本圈复杂度达到3:
在这里插入图片描述

public String printWithOnlyNegSign() {
        if (coefficient.equals(BigInteger.ZERO)) {
            return "";
        } else if (power == 0) {
            return coefficient.toString();
        } else {
            String coeStr;
            if (coefficient.equals(BigInteger.ONE)) {
                coeStr = "";
            } else if (coefficient.equals(new BigInteger("-1"))) {
                coeStr = "-";
            } else {
                coeStr = coefficient.toString() + "*";
            }
            String varStr = power == 1 ? "x" : "x^" + String.valueOf(power);
            return coeStr + varStr;
        }
    }

在hw2中,仍沿用这种逻辑,导致一些没想到的情况出现bug。例如:
coefficient = -2, power = 0, exp=x,输出成了-2exp(x)。这是因为幂为0就没加*。
解决方案:拆分逻辑,分为打印系数、幂、e指数三大部分,每个子方法只需要知道还有没有后续内容即可:

    public String printWithOnlyNegSign() {
        if (coefficient.equals(BigInteger.ZERO)) {
            return "";
        } else {
            return printCoe(!power.equals(BigInteger.ZERO) || !exp.isEmpty()) +
                    printPowFunc(!exp.isEmpty()) + printExp();
        }
    }

代码由19行降至了7行,认知复杂度由9降至3,基本圈复杂度由3降至2,可见复杂度和稳定性的关联。而拆分、下发业务,是降低复杂度的途径。

4. 发现他人bug

策略:数据生成器和人工数据结合。人工方面,除了测试理解题意的exp((-x))和性能的dx(exp(exp(...,还有针对代码架构的测试:

例如,评测机发经常报解析异常的错误,于是阅读代码,发现这位同学直接在输出表达式的方法中调用println函数,而非交给主类输出。猜测递归打印exp内部表达式时会多输出换行符,于是制造exp((x)),输出exp((x\n))\n,hack成功。

5. 优化

除了基本的合并同类项、简化表达等策略,第三次还加入了合并同类项,使用gcd方法求公因子在放到exp外面即可。
提取公因子时,我再次犯了修改Monomial不可变成员的错误:

        for (Monomial mono: exp.values()) {
            mono.coefficient = mono.coefficient.divide(gcd);
        }

经过分析,不应该修改coefficient和power,能改的只有exp(容器类),改为:

        for (Monomial mono: exp.values()) {
            Monomial newMono = new Monomial(mono);
            newMono.coefficient = mono.coefficient.divide(gcd);
            newExp.put(newMono.getTag(), newMono);
        }
        exp = newExp;

教训:应该严守约定,不要投机取巧。否则破坏代码风格和逻辑结构,导致出错且很难查出。

6. 心得体会

  1. 迭代时,切忌打补丁,一旦发现逻辑不清晰的地方立即修正,否则只会为将来遗留更大的麻烦!
  2. 充分利用研讨课和网上资源,了解更清晰、高级的实现,闭门造车不可取。
  3. 做充分测试,手工边缘条件+数据生成器随机测试,过中测并不是终点。

7. 未来方向

  1. 建议在总结博客周,对一些公认的架构优美、表达清晰的代码进行分析讲解,方便大家学习。
  2. 建议适当对数据生成和判定提供一些指导。
  • 24
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值