BUAA-OO-Unit1 总结

BUAA-OO-Unit1总结

本单元作业的主题是: 表达式的括号展开与合并.

第一次作业

要求: 通过对表达式结构进行建模, 完成单变量多项式的括号展开.

1.1 程序结构分析

​ 在本次作业中, 笔者围绕不可变对象的主题来进行方法的构建, 依此即可规避掉由于深浅拷贝问题出现的莫名bug, 并且JVM内部的垃圾收集机制可以做到"随用完随清理", 不需担心爆掉内存的情况, 也不必手动清理内存.

下面给出本次作业中我的UML图(这里只列出了核心类, 解析类并没有列出):

在拿到本次作业指导书后, 通过分析得出程序需要具备四个较为重要的功能:解析、展开括号、合并同类项和打印. 大致流程如下:

graph LR; Input-->Parse-->Unfold-->Simplify-->Print

​ 对于解析方面, 笔者采用了递归下降的方法进行解析BNF, 原因是递归下降可扩展性极高, 对于后续作业中拓展的因子类型只需要填充相应因子的解析方法即可, 中心逻辑基本不需要改变. 另外, 对于正负号的处理, 笔者没有选择利用字符串替换的方式处理 (例如--替换为+等方式), 而是选择识别并将项的系数抽离作为属性来进行管理, 这样显得更加自然, 并且会规避字符串替换所带来的各种小坑, 采用以下写法:

public Function ParseExpr() {
    BigInteger sign = BigInteger.ONE;
    if (tokenizer.getTokenType == TokenType.SUB) {
        sign = sign.negate();
    }
    
    Function term = parseTerm().multiply(new Constant(sign));
}

​ 在建模表达式方面, 笔者经过再三考虑最终选择了单顶层抽象类Function的层次结构, 一方面是因为想通过顶层类来归一化管理各个因子, 另一方面是可将所有具体函数的化简和展开方法抽象出来作为顶层方法, 让各个子类去实现它, 这样就可以做到"各司其职", 达到解耦的目的.

​ 对于合并同类项方面, 笔者即采用深度合并, 通过较偏数学化的方式进行合并, 覆写了所有函数类的hachCode()equals()方法, 这一点的优势将在之后的迭代开发过程中有所体现, 其实就是可扩展性高, 再增添各种函数因子时合并同类项的部分不需要改动. 不过深度合并的劣势即在于递归搜索会造成开销较大, 不过这一点可以利用以下所述的"拆包"以及剪枝进行一定程度上的优化. 例如, 在加法合并时, 将每一个项的系数和内容(除系数外的函数, 例如2*x中2为系数, x为内容), 建立hashMap<Function, BigInteger>构建映射, 若在遍历时遇到内容相同的项, 则直接进行系数上的相加.

​ 处理乘法合并同类项的时候与上述逻辑相似, 将系数抽取出来统一管理, 在相同项内容的次数上作加法. 需要注意的是, 在采用上述合并同类项处理时, 需要提前进行"拆包"工作, 例如将形如((((1))))的表达式因子"拆包"为常数类. 同时, 可以在其他函数化简时做一些特判, 例如幂函数中:

public Function simplify() {
    if (exponent.equals(BigInteger.ZERO)) {
        return Constant.ZERO;
    } else if (exponent.equals(BigInteger.ONE)) {
        return base.simplify();
    } 
    
    return new Power(base.simplify(), exponent);
}

​ 对于展开括号的功能, 笔者在经过分析后得出了需要展开括号的四种情形:

  • \(A+(B+C)\ \ \rightarrow\ \ A+B+C\)

  • \(A*(B*C)\ \ \rightarrow\ \ A*B*C\)

  • \(A*(B+C)\ \ \rightarrow\ A*B+A*C\)

  • \((A+B)*(C+D)\ \ \rightarrow\ \ A*C+A*D+B*C+B*D\)

​ 对于前两种形式的展开括号, 笔者选择直接在项类和表达式类的simplify() 方法中进行判断处理掉这两种情况:

/*  Class Adder */
public Function simplify() {
    /* 		List<Function> newExpr = new ArrayList<>();			*/
    polynomial.forEach(term -> {
        if (term instanceof Adder) {
            newExpr.addAll((Adder) term.polynomial);
        }
    })
    ...
}

​ 对于后两者情况, 笔者在顶层Function类中定义了乘法multiply(Function function), 并通过覆写每个子类的乘法方法来处理这两种情况. 例如在MUltiplier类中的乘法方法大致如下, 表达式类的乘法方法类似:

/*		Class MUltiplier	*/
public Function multiply(Function function) {
    if (function instanceof Adder) {
        /* List<Function> newExpr = new ArrayList<>(); */
        (Adder) function.getPoly.forEach(term -> {
            newExpr.add(this.multiply(term));
        })
        
         return new Adder(newExpr);
    } else {
        return new Multiplier(this, function);
    }
}

​ 到此为止, 整个程序的主要流程部分就完成了.

​ 下图是我本次作业中的方法复杂度和类复杂度分析, 由于方法数较多, 就在这里放出一些复杂度相对较高的方法,

Method CogC ev(G) iv(G) v(G)
expression.Adder.simplify() 24.0 6.0 11.0 13.0
expression.Adder.optimize() 14.0 8.0 8.0 8.0
expression.Adder.toString() 11.0 5.0 7.0 7.0
expression.Multiplier.simplify() 22.0 12.0 13.0 16.0
parser.Parser.parseExpr() 12.0 1.0 9.0 9.0
parser.Parser.parseFactor() 7.0 6.0 6.0 6.0
... ... ... ... ...
Total 177.0 148.0 172.0 206.0
Average 2.391891891891892 2.0 2.324324324324324 2.7837837837837838

​ 可以看到, 对于表达式类Adder和项类Multipliersimplify()的方法复杂度普遍较高, 一方面是由于我将拆包、合并两个操作同时包含于这一方法中, 另一方面是由于第一次作业中我没有找到比较普适的方法去做合并, 导致于合并中包含很多instanceof, 属于偏面向过程的写法. 对于Adder.optimize()方法是用于最后的优化而言, 相对来说也比较面向过程. 对于字符串解析的方法Parser.parseFactor()复杂度较高, 是因为将整个字符串解析融在一起, 其实可以通过定义各个子函数解析方法来达到降低复杂度的目的. 并且在解析时需要边判断情况边解析, 其中包含的if-else语句较多, 圈复杂度较高.

Class OCavg OCmax WMC
expression.Adder 4.3 13.0 43.0
expression.Constant 1.25 2.0 15.0
expression.Multiplier 2.7333333333333334 14.0 41.0
expression.Power 2.0 5.0 22.0
expression.Var 1.25 2.0 10.0
parser.Parser 4.5 7.0 18.0
parser.Scan 1.5 2.0 3.0
parser.Token 3.6 10.0 18.0
parser.Tokenizer 2.1666666666666665 4.0 13.0
Total 184.0
Average 2.4864864864864864 6.0 15.333333333333334

1.2 bug分析

  • 己方bug分析

​ 笔者在本次作业的强测和互测中均未出现bug.

​ 但是在本地测试时, 发现项类和表达式类的合并同类项会出现问题, 例如(x+2)*(x+1) 会合并成(x+2)**2, 原因是在覆写常数类equals()方法和hashCode()方法时, 强制令常数类返回值相同, 并没有遵循覆写上述两方法时需要遵守的原则:

  1. 两个对象数学意义上相等, 则equals()方法返回true, hashCode相同.
  2. 两个对象数学意义上不相等, 则equals()方法返回false, hashCode尽量不要相等.
  • 互测时他人bug分析

​ 在互测时, 笔者共发现他人一个bug, 为符号输出格式错误, 原因则是在输出时对字符串做了大量if-else特判替换, 导致出现了最后会多出一个-0的情况无法处理. 可见字符串替换法处处藏有小坑.

1.3 测试手段

​ 在本次作业中, 笔者主要通过基于subprocess和sympy模块构建py评测机 + 理清逻辑复现代码两种方式进行本地调试. 虽然经过了两三天的测试, 但在周六中午给评测机加入因子池机制后, 意外的出现了上述bug. 经此反思, 评测机并不能保证程序完全正确, 我们程序的输入情况是远远不可穷举的, 评测机生成数据的随机性虽然能一定程度上遍历到大量情况, 但它的缺点即在于生成的数据可能绝大多数时候过于复杂, 却往往会忽略了相较简单却又难以随机生成的组合情况, 导致即使跑了很多测试点仍然有存在bug的潜在风险.

​ 在本次互测中, 笔者采用随机生成数据大量轰炸+阅读代码的方式进行hack, 同时阅读代码也可以学习到部分优秀同学的架构风格与写法.

第二次作业

2.1 程序结构分析

要求: 通过对表达式结构进行建模,完成多项式的括号展开与函数调用、化简.

​ 首先给出我本次作业中的UML类图:

​ 在本次作业中, 添加了三角函数、求和函数和自定义函数的功能. 对于三角函数, 可以直接通过建立相关类来继承顶层抽象类, 将所有方法覆写后, 即可进行合并同类项和相关化简. 对于sin(0) -> 0 的优化, 笔者采用以下写法, 而不是最后输出时的字符串替换(x) :

/*		class Sine 		*/
public Function simplify() {
    if (inner.equals(Constant.ZERO)) {
        return Constant.ZERO;
    } 
    ...
    return new Sine(inner.simplify());
}

​ 对于求和函数和自定义函数的处理无疑是本次作业的难点, 经过仔细的分析, 发现这两类函数需要一个共同的功能: 即代入操作. 此时不禁联想到了C语言预处理过程中宏的替换(也就是字符串替换法), 或者对表达式建模, 采用换树的做法进行代入. 由于字符串替换的方法实在太容易出一些意想不到的小错, 笔者最终选择了换树的做法, 即在顶层类Function中添加substitute(HashMap)方法来供各个子函数覆写, 即可确保覆盖到每一种组合情况. 最终只需在DefinedFunction类中的simplify()方法中完成代入操作 :

/*		DefinedFunction		*/
public Function simplify() {
    Function unfoldedFunction = function.unfold();

    Function substitutedFunction = unfoldedFunction.substitute(map);

    return substitutedFunction.simplify();
}

​ 可以看到, 在本次作业比较核心的地方逻辑很简洁, 这里可以先将内部表达式展开后在代入, 会省掉不少时间和空间开销. 同理, 求和函数的处理也类似于此.

​ 在本次作业中, 对MultiplierAddersimplify()方法进行了解耦, 将拆包的工作抽取出来, 并将一部分拆括号的操作融入到了构造方法中, 使得方法的耦合度和复杂度都降了下来, 下面列出本次作业中复杂度相对较高的方法:

Method CogC ev(G) iv(G) v(G)
parser.Tokenizer.extractTokens() 8.0 4.0 10.0 10.0
parser.Parser.parseFactor() 21.0 12.0 16.0 18.0
parser.Parser.parseExpr() 12.0 1.0 9.0 9.0
expression.Sine.simplify() 4.0 4.0 4.0 4.0
expression.Cosine.simplify() 4.0 4.0 4.0 4.0
expression.Var.toString() 4.0 4.0 1.0 4.0
expression.Multiplier.simplify() 12.0 6.0 6.0 7.0
expression.Adder.simplify() 14.0 3.0 6.0 8.0
... ... ... ... ...
Total 206.0 211.0 234.0 296.0
Average 1.7758620689655173 1.8189655172413792 2.0172413793103448 2.5517241379310347

​ 可以看到, 解析类的方法还是一如既往的高, 原因是在上一次作业的基础上又添加了新的解析自定义函数和求和函数的操作. 而sinecosine类中的simplify()方法是由于本次作业中做了简单诱导公式的优化, 其中if-else的语句较多, 导致ev值过高. 而对于AdderMultiplier类中的simplify()方法的复杂度和耦合度仍然相对较高, 风格仍然偏面向过程.

Class OCavg OCmax WMC
expression.Adder 3.5454545454545454 8.0 39.0
expression.Constant 1.25 2.0 15.0
expression.Cosine 1.6666666666666667 4.0 15.0
expression.DefinedFunction 1.1111111111111112 2.0 10.0
expression.FunctionManager 1.5 2.0 3.0
expression.Multiplier 2.25 7.0 36.0
expression.Power 2.0 5.0 24.0
expression.Sine 1.6666666666666667 4.0 15.0
expression.SumFunction 1.2857142857142858 3.0 9.0
expression.Var 1.6666666666666667 4.0 15.0
parser.Parser 4.142857142857143 12.0 29.0
parser.Scan 1.5 2.0 3.0
parser.Token 5.8 21.0 29.0
parser.Tokenizer 2.0 4.0 10.0
Total 254.0
Average 2.189655172413793 5.466666666666667 14.941176470588236

2.2 bug分析

  • 己方bug分析

​ 在本次作业的强测和互测中均未出现bug.

​ 在本地测试之中, 曾经尝试过 \(sin^{2}+cos^{2}=1\) 的优化, 但复杂度过高, 且由于因子类型较多, 无法保证得出最优结果. 最终在权衡之下, 笔者在正确性和性能之间选择了正确性.

  • 互测时他人bug分析

​ 在互测时发现了同一个人的三个非同质bug, 分别是:

  1. 在三角函数内出现负常数因子的优化错误, 会出现sin(-1)**2 -> -sin(1)**2的情况;
  2. 对于零次幂的特判有误, 会出现sin(0)**0 -> 0 的bug;
  3. 某些情况下输出会少字符, 例如2*11*cos(x) -> 2*1cos(x) 输出格式错误.

2.3 测试手段

​ 在本次作业中, 笔者重构了第一次作业的评测机, 加入了对自定义函数和求和函数的生成机制, 并采用“字符串替换”的方法得出扩展后的正确答案, 与本地输出进行对比. 缺点则是测试生成数据过于"死板": 每次测试只能生成三个自定义函数, 每个自定义函数都只能有三个形参, 并且顺序固定. 由于评测机并没有覆盖到所有可能出现的情况, 对于其它情况笔者采用了手动构造测试数据+和朋友分享测试数据的方式进行对拍.

​ 在本次互测中, 由于明确限制不能有求和函数. 于是在第一次作业的评测机的基础上添加了生成三角函数的功能用以评测. 此外, 笔者还通过手动构造一些通过三角函数诱导公式可化简的数据, 针对性打击优化错误.

第三次作业

要求: 通过对表达式结构进行建模,完成多层嵌套表达式和函数调用的括号展开与化简

3.1 程序结构分析

​ 与上次作业结构基本相同, 故而不在此列出UML图和复杂度图. 唯一的区别在于笔者于本次作业中做了以下的优化:

\[Coe\times sin^nF\times cos^nF \rightarrow (Coe\ divide\ 2^n)\times sin^{n}(2\times F)\ \ \ \ \ \ if\ \ 2^n\ |\ Coe \]

和三角函数奇偶性, 主要目的是统一内部因子便于合并同类项,

\[sin(-F)\rightarrow -sin(F),\ cos(-F)\rightarrow cos(F)\\ eg.\ sin(-x-1) \rightarrow -sin(x+1) \]

​ 方法复杂度主要是优化方法较高, 因为的确没想出什么比较优雅的写法.....其余基本与上次作业的一致, 此处就不贴出了.

3.2 bug分析

  • 己方bug分析

​ 在本次作业的强测和互测中均未出现bug.

​ 但在本地的测试中, 发现了由于做了三角函数奇偶性的优化出现的问题, 例如会出现sin((-x))**3 -> --sin(x)**3的错误, 原因即在于在展开幂函数的括号时, 并没有让内部因子达到最简状态, 故而最后会多出一层Multiplier的负号出来. 通过本bug也进行了一定的反思, 在对已有架构进行功能填充的时候, 经常会忘记自己原先设计某些方法的细节, 而只是当作API一样拿来直接用, 故而即便觉得逻辑正确, 可能也会因为一些细节设计上的不兼容导致bug出现. 在增添某些功能的时候, 我们或许应该仔细思考清楚本程序中的核心方法是什么, 以及此时此刻添加的这些功能是否会对这些核心方法产生影响. 说的具体一点, 在本单元所设计的程序之中, 最为重要的两个过程一定是括号展开和合并同类项, 故而在做某些优化的时候应该思考清楚此时的优化与这些核心过程是怎样的逻辑, 比如流程中出现的先后顺序问题、相互的调用关系等问题, 若只局限于优化方法内部逻辑的正确性, 通常容易忽视掉在多模块协同合作方面由于接口不兼容出现的种种难以预料到的问题.

  • 互测时他人bug分析

​ 在本次互测中共找到了房内其他七人共13个bug, 列举如下:

  1. 对于三角函数内部无法嵌套求和函数, 即sin(sum(i,1,0,0))就会抛出异常.
  2. 对于输出格式错误, sin((-x)) -> sin(-x)
  3. 对于求和函数上下限爆int的情况无法处理, sum(i,9999999998, 9999999999, i)即会抛出异常.
  4. 对于sum函数内上下限涉及到负数时会出现莫名bug, sum(i,-1,3,i**2) -> 13
  5. 深度合并出现问题, sin(1)+sin(sin(1)) -> 2*sin(1)
  6. 正负号解析出现问题(与问题2很像, 但问题2却hack不到他....), sin((-+4))**2 -> -sin(4)**2

​ 经此分析, 笔者在互测中所发现的bug多数来源于细节上的处理, 例如没有意识到要求输出的格式、没有解析全面合法数据格式和数据限制等问题. 因此, 仔细阅读指导书并复现到自己的代码显得尤为重要.

3.3 测试手段

​ 在本次作业中, 笔者选择再次重构评测机, 利用py构建出了支持函数嵌套调用的数据生成器, 沿用上次作业的思路继续使用基于递归解析的字符串替换法得到完全替换后的表达式. 并通过多参数的设计将生成的数据的强度控制在了一定范围, 便于自动化时提高测试效率, 否则常常出现爆掉Java内存的操作. 关于正确性评定, 本人首先通过simplify(exp1 - exp2 == 0)进行判断正确性, 但后续发现在多层三角函数的嵌套下验证效率会较低, 甚至有时会出现输出None的情形. 于是采取在定义域内线性随机取点的策略, 将偏差控制在一定精度之下即为正确, 这样测试效率较高.

​ 此外, 由于笔者实现了二倍角和三角函数的一些优化, 因而又在上述评测机的基础上继续构建了完全基于给定因子池的数据生成器, 专门构建针对各种系数组合、正负号组合、三角嵌套组合的表达式来进行对优化功能的测试. 所给定的因子包含但不限于:

factor_pool = ["(-2*x*sin(x)*cos(x))", "(-(sin((-x)))**2)", "(-cos((-x))**3)**3",
             "(-(cos(x)**2))", "(-sin((-x)))",
             "(sin(sin((cos(x)+x**2))))", "sin((-sin((-cos(x)-x**2))))", "cos((-x))**3", 
               ...]

​ 此外, 可构造一些特殊的数据来针对优化而测试.

​ 在互测的时候, 笔者主要采取随机大量轰炸 + 手动构造带坑数据来进行测试, 从效果上来看是不错的, 但由于一直在弄其它的事情, 没有仔细阅读其它同学的代码.

整体架构分析

​ 对于这三次作业的迭代开发而言, 我沿用了第一次作业时的架构. 但由于层次结构没有划分的十分清晰, 导致在某些方法的实现上还是偏面向过程, 偏繁琐. 对于没有将BNF中的层次结构进一步细化是我本次作业中比较遗憾的一点. 因此代码中出现了部分类耦合度和复杂度相对较高, 特别是在AdderMultiplier类代码行数较多, 相较于其它类显得稍显臃肿, 现在看来, 指导书中指出将加法和乘法抽象出来进行设计会更合理一些, 这样即可将Function类中的multiply()方法和Adder/Multiplier中的sunplify()方法提取出来作为一种运算, 会有效的减少AdderMultiplier的码量, 也会有效地解耦联. 同时还可将计算功能和结构存储部分完全分离, 相信测试的时候也会更加明了.

​ 在本次作业中, 我体会到了层次结构设计的重要性, 根据所给任务进行剖析, 将其划分成不同的小任务分派给各个类, 并将某些共性抽象出来, 构造成统一的接口或抽象类去归一化的管理, 形成特有的层次结构进行设计, 这样在增添一些新功能的时候业务逻辑基本不需要大改, 代入到此次作业中其实就是加法和乘法两种运算, 只要选择到正确的顶层类去管理各个因子, 这两种运算的逻辑和代码基本不需要修改. 但通常这一层的设计是思维量较大的、较难的、也是较容易被思维定势所束缚的, 但却是决定架构好坏的关键因素. 此外, 我还体会到了设计逻辑自然的重要性, 在第一次作业时, 本可通过二元组的形式进行建模, 但其应用之处是比较局限的, 在后续作业的迭代后添加了新的因子和嵌套括号, 其灵活性就变得很差了. 因而笔者在第一次作业的权衡之下, 选择了深度合并的方式, 逻辑更加贴近代数处理, 在后续开发的过程中没有基本没有做修改即可完成迭代.

心得与收获

​ OO的第一单元一转眼就度过, 过程虽艰难但学到了很多知识, 包括但不局限于:

  • Java编程能力的提升, 学会了很多API的使用.
  • 学习了python中subprocess/sympy模块的使用, 和本地设计评测机的心得.
  • 构造数据的一些心得.
  • 面向对象思想的初尝试, 学习到了层次设计的手段与思想.
  • 体验到了迭代开发的过程中可维护性和可扩展性的重要.
  • ...

posted @ 2022-03-25 13:36  Wang_zm  阅读(73)  评论(0编辑  收藏  举报