OO第一单元总结-表达式求导

艰难地完成了前三次作业,应当借此机会好好反思。

前三方面基于每次作业分析

  • 程序结构
  • 程序bug
  • 发现程序bug的策略

最后两个部分进行单元总体分析

  • 重构经历总结
  • 心得体会

第一次作业

1.1 基于度量分析程序结构

复杂度分析

方法复杂度

image

类复杂度

image

Term类实现过于复杂。除了Poly的输出部分,其他复杂的方法集中在Term类中。

总体上看,即使作业较为简单,但设计框架仍然不太好。虽然拆分出了两大类,但是PolyTerm之间存在着一定的耦合,功能之间的界限不够清晰,特别是在输出部分。

原因分析

Poly输出部分。由于采用了循环中加入if-else的判断来控制符号问题,使得整体逻辑变得复杂。

Term中需要负责解析各类可能的幂函数形式,具体实现是通过很多的小正则来匹配,虽然使得每个正则易于理解,但是缺点是引入了很多的条件控制语句,导致控制流的复杂。(这一部分也直接导致了后续bug的产生)

类图

程序结构如下图:

image

由于第一次作业相对比较简单,所以更多地是对java容器和一些正则的使用。

设计考虑

类名 功能
Parse 字符串简单处理,提取出相应对象
Term 将传入信息解析
Poly 管理各个Term,并进行合并、化简

实际上用一个Term类几乎可以解决问题,但是出于练习的目的,把合并和整体上的管理抽象出来,新增了一个Poly类,其中利用map来管理的数据。利用map,将指数存为key,底数作为value,通过检测是否有key值实现化简。

优缺点分析

优点

  • 实现手段简单
  • 正则匹配时易于实现

缺点

  • 输入和输出部分有过多控制语句,逻辑复杂,容易产生错误。
  • 不具有可扩展性,导致在第二次作业时重构。
  • 关于面向对象的思想应用得不够好,模块之间存在耦合,模块自身内聚也不够。
    • 例如输出部分,可以将其单独成为一个类,便于扩展,后续重构时可以保留。
  • 构造器逻辑复杂。

反思要如何改善呢?

  • 对简单的问题也应当充分的训练,建立合理的面向对象架构。笔者对于第一次作业实现过于简单化,在Term中的解析几乎不能移植到第二次作业,没有应用太多面向对象的构造,而导致后期作业负担较大。

    一些架构良好的同学在第一次就建立了完整的架构,整个单元都不需要经历重构。所以要笔者自认为的“简单”,实际上是给自己挖坑。面向对象思维,还需要多多练习。

  • 对于构造器逻辑,通过查阅资料以及课程下发的阿里巴巴Java开发手册,推荐的写法是提取出一个init方法,而不是将控制逻辑全部写在构造器中。

image

1.2 分析程序的bug

公测

本次公测未出现错误,但化简部分没有做到极致,例如未将x**2化简为x*x

互测

正如上文所说,Term类的过于复杂导致bug产生。在Term带参数构造方法中,因为有太多if-else判断,所以疏漏了其中的某种形式都会导致出错。

互测的bug是由于表达式第一项的识别出错,导致产生异常。

有bug的代码复杂度

image

正如预想中的一样,复杂的代码会产生更隐蔽的bug。产生bug的位置恰恰是最复杂的代码段。代码行数55,显著高于其他方法。圈复杂度12,同样显著高于平均的2.95。

1.3 发现程序bug的策略

主要从常规测试、边界测试两个方面考虑。由于第一次作业相对简单,所以可以结合代码来构造。由于笔者没有想到第一项的特殊性,在其他的测试测试样例中未发现同组的bug。后续在结果中发现大多数被hack的都是类似于这一点。

第二次作业

2.1 基于度量分析程序结构

复杂度分析

方法复杂度

image

类复杂度

image

这一次作业就完成得非常差。首先是设计结构上,只有一点是非常明确的,就是现在是重构的好时机。作业一过于简单的架构不能适用于第二次作业。

一开始试图采用递归下降的方法来解析表达式,但是经过了比较长时间的学习,因为笔者不开窍,所以仍然没有学会。一看时间,原来到周日了。没办法,只能带着学到的一点点递归下降知识去解析了。

这样做的后果是什么呢?正如图中所展示的,由于设计不规范而带来的复杂度以及非结构化的爆炸!

原因分析

方法部分。关于结构化程度的判断主要飘红的是find...,这主要来自于解析部分,在解析时也出现了一些互相的调用,有较多的耦合。

似乎是借鉴了递归下降的思想?先用各种小正则表达式匹配,大的因子可以通过小正则的组合匹配,写起来相对是比较容易理解了。但在实际的实现时,这一部分if-else判断语句也过多,直接导致复杂度过高。

部分。首先是PowNodeTriNode,由于其中也存在着一部分递归下降的判断,以及输出的格式。所以复杂度较高。

Parser类即解析的主要类,其中有各种解析方法,并且没有太好的形式化组合。带来的后果可以看到——方法复杂度远高于其他类。

类图

image

这一次的作业跨度对笔者来说较大,恰恰又是承上启下的关键作业。所以关于本次作业的设计经过了长时间的考虑,最终采取了指导书中推荐的表达式树的方案,具体实现时保留了最基本的因子项以管理数据。

设计考虑

核心在于建立表达式树,化整为零。如图中所展示的,考虑到重用性以及灵活性,做出如下设计:

  • 将嵌套视为运算符
  • 表达式树基本单位是树节点TreeNode,所有因子、运算符继承TreeNode
  • 上述成分均实现求导接口
类名 功能
CstNode 带符号整数
PowNode 幂函数
TriNode 三角函数
AddNode 加运算符
SubNode 减运算符
MultNode 乘运算符
NestedNode 嵌套运算符

优缺点分析

优点

  • 可以解析较复杂的表达式,以及多重嵌套
  • 逻辑上较为清晰
  • 具有一定的可扩展性

缺点

  • 不利于表达式化简
  • 笔者在解析部分实现不够好
    • 复杂度高
    • 结构化差,对于某种解析式太过于细节,实际上可以更加抽象化,减小最初解析的工作量。
  • debug时面对较长的表达式难以定位。

反思缺点

关于化简这部分,在树形表达式阶段似乎比较困难。

  • 一种做法是需要重新读入表达式,并用map结构存储,通过重写hashcode等方法来进行判别合并,这样基本能解决大部分的化简需求,好处是便于操作。
  • 第二种方法是先进行展开,并按照一定顺序例如x-sin(x)-cos(x)排列,之后再进行比较。这个方法看似可以深度化简,但是实际上遇到多重括号嵌套会遇到比较大的困难。

解析部分。这种杂交的写法并不好,既有正则又有字符匹配,导致整体逻辑复杂。实际上如果时间来不及,也不一定要强求递归下降,通过正则以及形式化语言的组合同样也可以实现解析。

2.2 分析程序的bug

公测

强测中出现了非常多的bug。导致这次作业几乎相当于没做。主要问题出现在运算符优先级的判断上,举例来说,例如AddNode在笔者的实现是(左孩子求导 + 右孩子求导),没错,这层括号直接改变了运算的优先级,导致最后的结果出错。

image

这也是互测中被hack的所有点所具有的bug。所在位置是SubNodeAddNode中的deri()方法,由于括号问题提升了两者的运算优先级。

互测

主要bug是运算优先级问题。

有bug的代码复杂度分析

image

问题出现在AddNodeSubNode其中deri()的具体实现部分。比起未出现bug的部分,这一部分代码行、圈复杂度均非常低。虽然对比起来这部分的控制简单不少,但是由于一开始的疏忽也导致强测和互测出现了严重的错误。

2.3 发现程序bug的策略

在这次作业中,同屋的bug数量普遍比较多。笔者在构造测试样例时也主要是从常规测试、边界测试、多层表达式因子嵌套测试等方面去构造。但由于时间比较紧张,且本次的代码复杂度普遍较高。所以并没有进行完整阅读他人代码的白盒测试,均是直接检验输出,加上看一部分解析代码。利用几个典型样例,可以推测出大约3人的表达式因子嵌套是用了几个正则去表示的,这样导致在括号嵌套层数较多时出现解析错误。

一般发现同屋的bug样例主要可能有以下几点

  • 表达式因子嵌套导致程序错误
  • 输出的符号错误问题
  • 减号+表达式因子的多层组合

第三次作业

3.1 基于度量分析程序结构

复杂度分析

方法复杂度

image

类复杂度

image
由于第三次作业和第二次作业的架构类似,所以在复杂度上也基本相同。但是前一次作业中并没有实现完整的递归下降,所以在解析过程中抛异常并不全面,所以如果不重构,就被迫需要新增的类用于检查格式,其中也有一边识别一边判断的类似行为,所以用到了较多的正则表达式和判断,整体复杂度高。

类图

image

设计考虑

总体与第二次作业的架构类似,主要是完善了嵌套部分的求导实现,由NestedNode管理数据,举例来说函数f(g(x)),左孩子是外层模式函数f(g),右孩子是内层因子g(x),求导时只需要按照规则实现对应外层求导乘内层函数。以及新增了InputChecker用于判断错误的格式。

优缺点分析

优点

  • 嵌套组合实现逻辑简单
  • 有一定可扩展性。如果能将某种运算抽象为组合规则,就可以创建这种结点,根据对应规则完成求导。

缺点

  • 需要仔细考虑运算的优先级
  • 依然是解析部分逻辑复杂
  • 格式检查复杂
  • 需要单独实现化简部分

反思

  • 设计的过于简化导致框架结构的简陋,框架不好导致这次格式判别不能在解析的过程中完成。(本次作业的bug就在于的格式的正确性判断)再深入分析,发现格式检查与解析部分有着极其相似的地方,而这样的代码段带来的是不可维护性。一个可能的优化方案是把这两部分的匹配字符串的部分抽象出来成为一个private方法,便于维护,甚至还可以在化简部分使用。
  • 关于表达式化简问题,在自己进行测试时出现了很多bug,因为这次的输出比较复杂,删括号或者合并时容易出现括号不匹配的问题,由于存储方案的限制,利用hashcode进行合并也相对比较困难。总体来看应该可以多抽象出表达式、其他因子对象,利用形式化语言来进行解析,这或许才是题目的正解。

3.2 分析程序的bug

公测

公测中的bug均是对于wrong format的识别错误。那么问题所在的类自然就是Inputchecker,在该类的构造方法中。

  • 指数超过50的判别。这里笔者使用了parseInt,实际上格式错误很有可能出现远大于int的数据,这里直接导致抛出异常,然而笔者并没有捕捉该异常,导致出错。
  • 嵌套的识别问题。例如sin(+ 123),由于笔者的解析逻辑分散在各个地方,给自己挖了不少的坑。在识别时外层的常数因子和内层均无问题,然而在判别WF时却没有修改内层因子的判断逻辑。导致很多不合法的内容在外层可以被识别,在内层却没法识别。

互测

互测中由于不能输入WF数据,未被测出其他bug。

有bug的代码复杂度分析

果不其然,bug出现在了复杂度飘红的构造方法中。正如上文所说,笔者将业务逻辑写入了构造方法中,干出了及其不推荐的事情。导致构造方法中调用其他解析函数,相比较于其他没bug的方法,该方法复杂ev(G)和v(G)两个指标均爆红,圈复杂度11,代码行数45也较多。

image
如何改进

首先还是要遵循规范手册,将复杂业务逻辑写入init,其次在进行识别时可以将其抽象为内部方法,内层的因子不用特殊处理,视为和外部一样的即可,维护代码一致性。

3.3 发现程序bug的策略

由于整体功能与第二次类似,所以构造策略也类似。先划分等价类,多的部分主要是格式检查、嵌套。因为互测数据要求正确格式,所以只需要构造一些嵌套的样例即可。由于本次代码更加复杂,只进行了黑盒测试。在评测时第二次作业的样例都能顺利通过,只发现在嵌套上出现的问题。

在发现的bug中,有同学在化简时将嵌套内部的因子打开而导致错误。例如sin(x**2)化简成了sin(x*x)。这样的化简导致内部将幂函数变成表达式因子,缺少了括号导致格式错误。

重构经历总结

笔者在第二次作业时进行了一次重构。

重构前:

image
重构后:

image
对比程序结构,不难发现进行重构的主要原因是第一次作业的架构过于简单,也就导致只能解决简单问题而没有可扩展性,功能太单一,对于复杂表达式没有办法解析。重构前的架构只保留部分关于幂函数的解析部分,重构后增加了继承和接口,使逻辑更加清晰,各个模块之间可以独立工作且代码结构简单,有利于第三次作业的扩展。

但也有需要改进的地方。重构后,各个子模块实际上变得简化。但是由于解析部分没有完全理解递归下降,存在其他的递归调用,导致解析的复杂度高,也存在与其他部分的耦合。

可能的解决方案,一是学会递归下降,利用这个方法解析。二是利用正则表达式和形式化表述的结合。而由于笔者并没有完全学会递归下降,所以在第二次作业中的架构兼有二者,导致递归深度可能很深,不利于调试和维护。

心得体会

经过本单元的学习,能真切地感受到OO带来的压力。为了完成第二次作业,查了很多资料,也和进度快的同学交流,由于先前准备使用递归下降的架构,也尝试了很多,但是学习进展比较缓慢。快到ddl了,但笔者的知识并不支持解析复杂表达式。只能放弃该方法,顶着巨大的压力,利用一些正则来弥补无法解析的问题,经过几天凌晨和早起奋战,总算截止的前十几分钟勉强过了。

面向对象的思维培养。从第一次作业,到第二次作业之间的重构,虽然不够彻底,但是足以使人体会到面向对象的强大之处。

工程能力。在此前编写最长的程序不过一百行左右,而OO作业带来的复杂工程能很好地锻炼这方面的能力。在这样的复杂问题中,一个小问题可能带来整体功能的错误。还有迭代开发以及debug能力,每次作业的推进都建立在对前一次作业的良好理解基础上,强测互测的bug修复也是全新的体验。

技术交流。在讨论区能看到精品的讨论,对作业有很大启发。不过这方面我做的还不够好,讨论需要建立在良好思考之上,但是自己对于一些自学的内容理解起来进度比较慢,需要加强这方面自学的能力。

总体上,本单元的三次作业完成得非常差,借着博客周静下心来好好反思是很有必要的,虽然作业很困难,但是能得到的收获也很多。

posted @ 2021-03-29 13:38  Jareth  阅读(161)  评论(1)    收藏  举报