初学者重构梦的解析 - 2021 面向对象程序设计第一单元总结
第一单元的主题为表达式求导,并且由简单到困难分为 3 次作业,每次作业的要求基本相同,但每次都在上一次基础上加入了更多的语法,因此可以被认为是迭代开发。
首先第一次作业为多项式求导,没有很多特别的语法,也没有括号嵌套;第二次作业在第一次作业的基础上加入了表达式因子和三角函数以及括号嵌套;第三次作业则在第二次作业基础上加入了 Wrong Format
的格式错误检测,以及三角函数中改为可以填入因子。三次作业在保证正确的基础上要求应化简尽力化简,最后强测会有20%的优化分数占比。
由于我在 3.4 左右才开始学习 Java ,所以本单元的每次作业我重构了很多次,也重构失败了很多次,因此我会对每一次作业进行分析。
第一次作业
1. 程序结构
由于第一次作业的语法规则很少,可能出现的情况也不多,因此在第一次作业的时候我选择了很面向过程的程序写作方法 (虽然当时并不知道问题的严重性),即在 Main 中解决包括 类似于 “信号检测” 的解析项、合并同类项、以及 输出 的所有过程,并定义了一个构造项的类 Basic,类中有系数、幂次、和符号。
解析项:由于本人在第一次作业之前对正则表达式的各个语法格式也不是很熟悉,所以恶补了正则表达式的基本语法,并通过一个很长的正则表达捕获类 (?<op>[-+]?[-+*]?)((?<coe>[-+]?\d+)|x)|[*][*](?<exp>[-+]?\d+)
直接分成四个部分解析出符号、幂次、以及系数,并通过 if-else 逻辑组合判断将每一个项生成的 Basic 实例加入用来保存项的 Arraylist。
合并同类项:由于上述过程结束后,每一个独立项都带有求导的基本信息,因此可以进行排序。网络上关于 Arraylist 的元素排序基本上对象均为基础元素,亦或是实现了 Comparable 接口。由于对 Java 的学习时间过短,导致没有可以正确实现 Comparable 接口。后来,我粗略的学习了一下 Lambda 表达式,并在这里运用了一下,大致是这个样子:basicArrayList.sort((o2, o1) -> Integer.compare(o1.getExp().compareTo(o2.getExp()), 0))
。排序好了之后就可以进行合并同类项的操作了,使用 Try - catch
进行合并操作,可以认为 Arraylist 变为一个不可化简的、由项为元素组成的集合。
输出:根据 Arraylist 中每一个 Basic 的符号、系数和幂次直接进行输出,并在这个过程中进行细微优化。
2. 程序分析
可以想到在 Main 中实现如此面向过程的方法,是不符合面向对象课程的思想的。
程序结构过于简单,设计较为面向过程,所以类图显得极为简单。
整个程序虽然比较短,性能也还可以,但很明显 无法适应增量开发的需求,例如增加某一种语法规则,或增加语义等,都会让整个程序结构大改,甚至无法实现导致重写。
可以看到图中所示的方法的各项数据,无论是 方法复杂度、设计复杂度、还是 循环复杂度,Main 中的各个方法数值高高在上,程序 结构病态严重,循环和条件判断充斥在 Main 的各个角落,非常不好,但当时我没有意识到这个问题。
可以看到程序的 Main 循环复杂度非常高,搭配 if-else
控制起来也很吃力,只能算是 勉勉强强 完成了任务。
3. Bug 分析
第一次作业未能进入互测,原因在于临近 DDL 时没有看到要求的最后有写到数值可能超过 Long
整数类的情况,因此临时大改程序,将 Long
类型转换为 BigInteger
。由于程序中充满了 循环搭配if-else
的结构,这样一来导致需要将所有的加减法、正负判断等方法全部进行重写,耽误很多时间。重写好之后中测样例却一直过不去,发现在特定情况下会触发 Lambda 表达式 排序失效的情况,但很遗憾最后没有在 DDL 之前改完。另外强测中得分 78 分。
修复 bug 的时候,仅仅改正了 Lambda 表达式 的表述形式,就通过了全部强测。
4. 学习心得
站在第一单元的终结点反观我的第一次作业,那时候的自己 “五十步笑百步” 式的认为,自己不需要根据题目所述的那样完成表达式、项、因子等类的构建,也可以完成任务。殊不知这样的类似于 “信号检测” 的解析操作也给自己第二次作业没有通过中测埋下了病根。
第二次作业
1. 程序结构
第二次作业加入了三角函数、括号嵌套的语法。语法的增加导致第一次作业的做法基本是不可能的,因此进行了重构多次等,架构 大换血。
刚开始时设计程序结构时令我头大,由于不知递归下降的做法,导致自己创造了一个自下向上建立表达式树的策略。这一次的实现过程也很面向过程,按类别优先级自下而上完成表达式二叉树的建立。下面通过 类定义、解析项、表达树建立、以及 输出 等过程进行结构分析。
类定义:这次作业中,由于第一次作业 定义项为类 的方式已经不可用,因此我定义了一个 抽象父类 BNode
,并定义了各种子类继承这个父类,例如单项类 Sin
、Const
和运算类 MultiplyNode
、PmTreeNode
等,并将他们作为 结点 安插在表达式树的结点上。此外还定义了 ReplaceList
的类,为了实现多态容器形式表达式二叉树保存编号使用。最后实现了一个求导接口 diff(),用于完成求导。
解析项:这一次作业的解析过程我进行了重写。通过更多学到的正则表达式识别方法,将各个项的优先级进行划分: sin(x)、cos(x)、()、*、**、+-
,并按照这个优先级顺序创造不同的正则表达式对表达式进行递归识别。例如识别 ()
的时候会将括号内的表达式调用解析方法,即递归过程。
表达树建立:识别到的部分会根据类别产生一个对应的类,并放入一个 Arraylist 中,并将原表达式中匹配的部分替换成 “#i”
(i
为该匹配元素放入 Arraylist 中的角标)。因此最后形成了同构于自下而上建立表达式树的多态容器形式二叉树。
输出:这一次作业的输出靠 diff() 接口实现,由于程序健壮性很差,最后匆匆忙忙写完,且bug满天飞。ddl 前来不及进行优化,所以只是按照求导规则将对应类求导后直接打印。
2. 程序分析
这一次我依然是在 Main 中写了很长的拆分表达式的方法,但解析表达式的过程的确很面向过程,所以就把 Main 中不同的解析方法进行了封装。另外我定义了抽象父类 BNode
,和各个继承父类 BNode
的子类,以及定义了多态容器形式的二叉树,因此思想已经从 纯粹的面向过程求导,转变成了 面向过程解析 + 面向对象求导 ,并且也尝试实现了更多语法的正则表达式、接口等。
相较于第一次作业,第二次作业的类图显得很方正,因为类的功能分的较清楚。
整个程序代码量大幅增加,但由于所有的面向过程的解析过程全部存在于 Main 中,又存在括号递归,所以 Main 中的代码长度占据了总代码长的 \(\displaystyle\frac{1}{3}\),依然很臃肿。由于是正则表达式先后顺序匹配,因此如果增加语义很有可能导致正则表达式的重写,但程序本身具备一定的扩展性。
从图中可以看出,除了 Main 中有一个各项复杂度都很大的 Replace 方法不符合高内聚的要求,其余的方法内聚性很低。
由于是在 Main 中循环识别各种优先级的部分,因此循环复杂度特别高,但相对于第一次作业已经有了提升,并且架构和实现方法同时改变,让我只能暂且先把解析办法写入 Main,后续由于时间不够,来不及再进行拆分。
3. Bug 分析
第二次作业依然未能进入互测,原因在于学习更多的正则表达式语法、接口的实现、继承与多态等花费时间过多,最后匆匆忙忙完成了程序,但由于本身 bug 过多加上 15min 冷却时间,所以没能通过中测。
//括号嵌套时候,递归调用 Replace 方法,会引发递归中的因子识别问题.
private static String removeBrackets(String str, ReplaceList replaceList,
String pattern, Matcher m) {
while (m.find()) {
Replace(m.group(1), replaceList);
str = str.replaceFirst(pattern,
"#" + (replaceList.sizeOf() - 1));
}
return str;
}
这个程序的 bug 在于正则表达式的写作和循环控制,正则表达式在识别有前导符号的项或括号嵌套的时候可能可能会把运算符号和前导符号同时识别并被替换,这样就无法知道两项之间的运算符号到底是什么,另外由于会有前导符号,所以识别的时候需要更多的步骤,会继续增大 Main 中循环的复杂度。另外,diff()
接口没有返回任何数值,仅仅是将实现接口内的对应求导方法进行了打印,因此无法进行很好的优化。
最后修复公测 bug 的时候修复上述两个点就可以通过大部分点,但由于语法过于多样,程序健壮性也不是很好,所以我后来没有继续修改这个自创的 “容器类多态元素二叉树” 的方法,而是通过重新构建递归下降法(求导方法还导致了讨论区说到的 TLE)通过了除了会 TLE 的其余强测点。
4. 重构
本次作业我大大小小重构了至少 3 次,分别有不同的思路,这些思路我并不能判断是否一定可以实现,所以写程序之前对程序写作的可行性需要有更多的把握。
第 1 次我没有使用表达式树的结构,而是希望把每一个项建立成 Arraylist 的结构,类似于同一个结点上的每个元素都是一个项,如果有项就加入 Arraylist,如果有括号就就继续回调 Arraylist 的 构造函数 进行递归。在写的时候,我不确定这个想法最终是否可以写完这个程序,写的过程中也比较难思考,以及对 Java 继承和多态等概念理解不深和运用不熟练,所以中途放弃了这个做法。
第 2 次我想到了用表达式树的结构,但由于 Java 语言特性我不是很熟练,所以直接组建二叉树的时候遇到了问题,即无法像 C 语言一样使用指针等语言特性快速建立二叉树,后来我想破脑筋写出了一个通过 压栈弹栈组成的中缀表达式建树方法,不过由于搭配正则表达式解析项的是时候情况过于复杂,以及极难的控制,所以中途我又放弃了这个做法。
第 3 次我吸取了第 2 次的经验教训,直接希望可以通过正则表达式直接先处理括号嵌套,再进行后续工作,但这个想法在第一步实现就被阻挡了很久,由于语法和语义有很多种,我无法写出一个完全的正则表达式直接完成上述要求,于是又放弃了这个做法。这之后才产生了第二次作业中提到的方法。
5. 学习心得
这一次作业是让我呕心沥血的一次作业,从更多的正则表达式语法、继承与多态的实验、容器的选择练习、接口的实现方法等等都是我 4 天之内猛烈学习获得的,收获也不小,并且基本的架构已经定型。一周之内大大小小重构了 3 次有余,每一次当我心里有放弃本次作业的时候,我就想起我已经为了这次作业付出了很多、学习了那么多,绝对不可放弃。我知道总有一种适合我的方法可以写出本次的作业,虽然最后没能在时间规定之内完成,但是我很欣慰自己进步很大,这也为我第 3 次作业提供了很大的信心。
第三次作业
1. 程序结构
第三次作业在第二次作业的基础上加入了三角函数内可以嵌套各种因子、对错误格式检查的要求,因此再回到第二次作业中提到的有一定扩展性的结构上,可能会显得较为吃力。由于研讨课上有同学分享过使用 递归下降分析 的方法,因此我又去学习了递归下降分析的办法,最后使用递归下降 重构解析部分 完成了第三次作业。本次作业会通过 解析与格式判断、表达式树建立、优化、输出 的类别进行结构分析。
解析与格式判断: 我首先写了一个 C 语言版本的递归下降的 demo。照着这个自己验证过的模板,我在 Analyze 类中重构了解析表达式的部分,并进行了解析函数的扩展,通过识别各种不同的输入字符调用对应类的构造方法,具体实现就是经典的递归下降办法。对于格式判断,我定义了一个静态 flag
,外层采用了 “如果无对应 if - else
的构造分支,就将 flag
置为 false” 的方式。如果在进入构造分支后出现了格式不匹配,那么就进入异常,所以在递归程序的最开头将其用 try - catch
环绕,即可捕捉异常,并在异常分支将 flag
置为 false,个人认为这样会双保险,避免误判。
表达式树的建立:类中有一个静态 BNode
类 Temp1
,作用是在递归或正常程序逻辑的时候保存函数过程中的根结点,递归回调的时候再将递归中产生的子树根节点和程序入口处的结点相连。这样通过简单的控制,就可以完成表达式树的建立。
优化:由于第二次作业中已经定义好了抽象父类 BNode
,以及单项类 Sin
、Cos
以及运算类 MultiplyNode
、PmTreeNode
等,还有建立在抽象父类 BNode
的求导接口 diff()
,因此第三次作业还可以在这个基础上进行开发。我将 diff()
求导接口重新进行了定义,从原先的不返回任何对象,改成了返回求导之后的字符串,并在返回之前进行了简单的优化(括号添加方案、元素是否为 0,等等)。
输出:由于我写好了求导接口,因此输出的时候直接在顶层调用 diff()
接口就可以通过返回的字符串完成求导结果的打印。
2. 程序分析
本次作业中,我对继承和多态有了更多的理解,因此结构显得更加清晰。
我延续了第二次作业的基础架构,在这个架构上移除了用来保存二叉树节点的多态容器,增加了一个抽象类 Analyze
进行递归下降的表达式树生成过程,而不是将其全部堆在 Main 文件中。
可以看到,Analyze
中 的 check()
和 check2()
方法复杂度过高,但由于是需要递归下降的时候判断读入的字符属于什么类别,相关的识别函数已经进行了封装,所以可能无法避免。个别的 diff()
接口复杂度也较高,原因是我的优化中加入了大量的 if-else
进行特判优化,所以导致复杂度提升,但纵观所有的方法,方法之间的耦合度基本不是很高,因此模块之间的分工也较为明确。
这里由于上述的 Analyze
中有可以封装成类似于工厂类的循环构造判断,因此复杂度较高。基础类的复杂度稍微偏高是因为 diff()
求导接口优化占据了半壁江山,即不改变结构而进行 硬优化 的弊端。
3. Bug 分析
3.1 自测:
本次在强测中出现了 Wrong Format
的 判断失误 和 diff() 求导在遇到毒瘤数据会卡死的情况,前者是因为个人粗心,在三角函数内只能是一个因子,而我却调用了识别表达式的 check()
方法,导致了 bug 的产生。后者则是很多同学普遍遇到的问题。由于强测的字符串长度可以达到 70,所以我在强测结果没出来的时候就已经知道自己的程序肯定会至少有一个 TLE 的点产生。
下面是任意构造的数据卡在了diff()
求导接口的截图:
可以看到,递归下降建立表达式树的时候只用时 2ms
,而求导过程用了 64921ms
,这个数据比很多同学高,是因为我的硬优化,导致任何一层的关于字符串的判断都会调用底层的接口返回字符串,这样每判断一次都需要深度递归调用接口,速度慢也是理所当然的了。
我的改进办法:在父类 BNode 中加入两个类别字符串 Diff、NDiff,存储当前结点位置的求导结果,这样就可以做到在建树的时候就将当前的求导结果算出来,并赋值到当前函数位置的根节点上,这样就可以防止多层多类别的递归调用引发的 “栈内压入上百个函数入口” 的情况。改进后的效果:
可以看到,过程已经压缩到毫秒级,因此成功修复了超时的问题。
3.2 互测:
本次在互测中没有出现 bug,可能是因为互测中的各项数据限制和字符长度限制导致我的问题无法暴露。Hack 别人的 3 次,其中一个问题是连乘 (x**2-x)*(x**2-x)*…
时由于过度化简,导致不该化简的进行了化简。另外两次是我看代码的时候看到的对方 控制段代码 出现问题,造出来一个 项*(n-x)
的处理错误,直接抛出了异常,另外还发现了对方在优化的时一种情况会意外返回 null
导致的悲剧。但是上述过程中没有用到评测机,即使使用 Python Sympy 随机制造数据并进行求导正确性判断,也很难确保生成这样的样例。
4. 重构
第三次作业相比第二次作业,我重构了 1 次 解析方法 (正则表达式递归实现多态容器建树改成了递归下降分析直接建树),以及 2 次 求导接口 diff()
(第 1 次完成了返回字符串并进行优化,第 2 次优化了毒瘤数据会造成 TLE 的情况)。
总体来说,递归下降比较适合我,重写这些方法的时候我都觉得如鱼得水,很顺畅,这也可能归功于我第二次作业重构尝试写了各种方法的代码积攒的财富。
5. 学习心得
我想没有比前两次作业都没有进入互测,而在最后一次作业中冲进互测、刀了 3 个人,并在强测中有两个点 0 分的情况下还能拿到 84.8 分更欣慰的吧。最后一次作业终于让我打了个翻身仗,把欠下的稍微补了一些回来。
最后的最后
第一单元对于我来说是初学者重构的噩梦,而最终完成作业之后反观前几周的艰难历程,实在是当时的想法过于天真,让自己走了不少弯路。看着自己最后一次交上去的程序,依然有很多的部分优化没有做好,依然有很多的模块耦合度较高,自己依然在推敲着如何才可以进行 “硬优化”。但结束了就是结束了,已经是过去式了,下一单元多线程,希望还会有更多的收获吧!
希望以后我还可以有 “在重构 3 次失败的情况下仍然会选择重构” 这样的决心,这样的决心定会伴随我一路走下去。