OO Unit1 Summary
Unit1
第一单元的任务主要是让我们实现一个求导器,经过迭代开发之后能支持幂函数,常数,三角函数以及它们的线性组合,乘法和嵌套之后形成的函数的求导。
第一次作业
可能是最累的一周,求导器和评测机都要从零开发,bug多多。
求导器部分
第一次作业的需求是实现常数函数和幂函数经过加减,乘法这两种组合规则而形成的函数的导数。根据指导书中层次化的描述以及一些经验,我直接考虑了这样的一个架构:
表达式由项经过加法和减法规则构成,项由基础的函数因子(本次是常数函数和幂函数)通过乘法这种组合规则形成每个函数因子的求导规则是固定的,它们相乘之后的求导遵从链式求导法则,线性组合求导遵从线性的求导法则,可以把求导分成三层去做:顶层表达式求导,他会调用每一项的求导方法,而每个项呢,又会遵从链式法则,进而会调用函数因子的求导方法,这样下去就实现了对整个表达式的求导。对于化简,本次作业我单独设计了一个化简类Simplifier
,化简的主要思路是合并项内乘积,进而合并幂次相同的项,这些化简是通过Poly
和Monomial
这两个工具类完成的(感谢第一次实验)。对于解析表达式,由于第一次作业比较简单,所以我直接使用了比较熟悉的正则表达式提取想要的内容,利用工厂创建出想要的对象。
第一次作业的设计的UML图如下(使用ProcessOn在线工具绘制):
可以看到,如果只考虑当前需求的话,看起来还是可以的。但是,它和依赖倒转原则格格不入,所有的类都是具体类,这种基于具体类而非抽象建立起的架构意味着发生某些变化时可能会导致整个架构发生很大的变动。
评测机部分
第一次作业我写了一个简单的本地评测机,使用了python
的sympy, regex, xeger, subprocess
等模块,实现根据正则生成数据,创建子进程自动运行待测程序,符号运算比对求导结果,错误数据写入回归测试数据文件。在搭建评测机的过程中,笔者通过读文档学习相关模块相关方法的使用,遇到了进程安全,正则写错等各种各样的问题,最终才完成一个较为有效的评测机。该评测机帮助笔者通过中测,并且在互测中找到了同房同学的bug。
基于度量的代码分析
由于分析结果太长,想着重突出的地方太分散,放图观感太差,所以笔者使用文字描述暴露出的问题:
所有方法中,仅有Monomial
类的toString()
方法的基本复杂度,模块设计复杂度以及圈复杂度同时飘红且数值大得离谱,加权方法数也最多。经过分析,原因是该部分toString()
方法考虑了\(ax^b\)的各种可能情况并分别做出了优化,要分8种情况进行讨论,所以三种复杂度都很高。
所有类中,输入类OCavg(平均复杂度)最高,原因是其中存储了大量正则表达式,并且匹配时我是按照项匹配的,所以两项之间的符号还需要手动遍历处理,所以平均复杂度相对其他类最高。
分析自己bug的策略
- 搭建评测机进行评测,找到
hack
数据之后粗略定位bug位置,然后构造同质简单数据去debug。 - 对本身逻辑就比较复杂的方法或者类在提交之前进行单元测试,尽可能地做到路径覆盖和分支覆盖。
- 和同学讨论可能的易错点,然后构造相关数据验证自己的程序是否能正常处理相关情况。
在中测中,笔者曾经在中测因为符号问题卡在某个测试点,分析之后发现是笔者错误的对两个项之间的符号进行了识别。该部分比较复杂,很多行代码都是在处理一些细节,因为疏忽被找到bug是解释的通的。
在强测和互测中,笔者均未被找到bug。
寻找别人bug的策略
- 由于第一次作业房内大部分同学的大部分逻辑在主类完成,所以不太好看,笔者直接评测机随机数据狂轰滥炸,先确定哪些程序一定有bug。
- 根据随机数据
hack
情况,读别人的代码,调试,然后构造相对简单的数据进行hack
。 - 使用曾经能
hack
自己的数据去尝试能不能hack
别人。 - 如果同房同学代码一看就辣眼睛,那就直接上评测机,否则就读一读代码学习一下。
在互测中,笔者使用此法找到了同房同学的bug,分析之后发现和笔者在中测遇到的bug类似,也是没处理好符号问题。
第二次作业
想了两个晚上表达式解析,遇到一大堆细节问题。
求导器部分
本次增加了三角函数,并增加了新的组合规则:嵌套(指导书中说的是表达式因子,但是想一下就知道其实就是嵌套),比起往年前移了嵌套规则的需求,个人认为虽然这使得第二次作业难度有了肉眼可见的增加(主要体现在面向过程几乎不可做 + 即使想到了用树形结构也不一定会用合适的方法解析和创建结构这两点上),但很大程度上减少了那种为了追求三角函数的极限化简而在架构上开倒车的不理智行为。
\(upd:\)在做了第二单元作业之后,发现今年的作业设计确实在引导同学们进行合理的架构设计以及使用设计模式和设计原则方面做出了努力,太赞了!
本次作业主流的架构是表达式树,但是在实现上可能会出现二叉和多叉两种流派,造成该分类的原因可能是一部分同学受到数据结构课程的影响,习惯性建立二叉表达式树,而另一部分同学可能从层次化的角度思考,发现表达式层和项层其实就是多元加法和多元乘法,不一定非要组织成二叉树。
笔者在本次作业中使用的是带有分层思想的多叉树结构,且充分引入了抽象(这部分应该算是重构经历了):
- 在上次的基础上增加三角函数类和表达式因子类,其中表达式因子类有两个属性,一个是外层函数,一个是内层函数,在本次作业中外层函数可以看成是\(y = x\)。
- 为了抽象出树的结点类型,笔者设计了
Derivable
接口,其中有求导方法和化简方法,方法的返回值类型都是Derivable
类型,让表达式,项,各种因子都实现该接口,这样就可以让我们不必关心到底是调用哪种类型的对象的求导方法和化简方法。 - 对于化简,笔者由于最开始没有使用抽象,所以导致代码混乱,很大部分时间在修改代码上,导致后期时间比较紧张,只实现了基本的诸如忽略0项,忽略1因子,合并常数和幂函数合并等基础优化。本次化简方法是放到各个类本身里面进行的,弃用了上次设计中的化简类。
引入抽象之前的UML图是这样的:
可见,类与类之间存在大量的依赖关系甚至循环依赖。在引入表达式因子的时候,所有的因子,项,甚至表达式类都要对返回值类型以及对返回值的使用做出修改。之所以发生这样的连锁反应,是因为依赖关系是会传递的,改变一环,就会导致依赖它的所有类发生调整,进而影响会继续蔓延。得亏这项目小,如果项目大一点,不知道要改多少地方,改动的时候又不知道会引入多少bug。
引入抽象之后的UML图如下:
这个设计其实正好反映了设计原则中的依赖倒转原则,因为我所有的类中使用的都是Derivable
引用,并不关心到底是这几个具体类中的哪个,所以其实各种类之间并没有“互相依赖”,而是同时依赖于接口Derivable
,这意味着,我们修改任何一个实现该接口的类,都不会影响其他类对该类对象的使用,因为所有的引用都是抽象的,我只关心接口强迫实现的方法而不关心到底是谁实现了该接口。这样设计,相当于封装了对一元函数因子类型以及幂指函数等组合规则类型的变化,具有不错的可扩展性。
由于解析表达式也是本次作业的难点之一,所以下面简要介绍一下几种可以参考的方法(在此之前,建议用四种方法写一下表达式计算,这四种方法对完成本次作业表达式的解析都非常有用):
-
最好的方法当然是经受过很多试炼的递归下降分析法了,在给定文法的情况下,该法在实现时非常有套路,但是第一次写难免会遇到一些困难。
-
考虑到我们建立结构时可以先不管嵌套,所以可以把嵌套结构中的内容做一个替换,替换成特殊字符,这样便消除了递归,接下来使用稍作修改之后的正则表达式即可完成树的构建。
-
数据结构课上,建立表达式树可以依托栈来做,这里同样也可以。
-
(有局限性但可以快速实现的权宜之计)如果上面的都不太会,可以利用本次作业中不出现
Wrong Format
数据的特点,先去掉所有的空白符,替换相邻加减号,把乘方**
替换为其他字符,然后采用类似于\(O(n^2)\)计算中缀表达式值的思路去建立二叉表达式树,具体来讲:- 扫描预处理的表达式,找找看有没有不被任何括号包围且不是指数和常数自带的
+
或者-
,如果有,假如有满足要求的加法,那就创建一个加法结点,然后分别对左右子串递归创建左右子树 - 如果没有上述
+-
,那就扫描表达式,看看有没有不被任何括号包围的乘号*
(由于已经预处理了乘方,所以不必考虑识别错误的问题),如果找到了,建立乘法结点,分别递归左右子串,建立左右子树。 - 如果没有上述
*
,那在本次作业中只可能是下面几种情况了:- 空串,这种情况本次作业中可以直接创建一个0结点
- 表达式因子,这种情况要创建一个嵌套结点,在本次作业中外层函数必然是\(y=x\),所以可以让左儿子是\(y=x\),右子树则需要递归去掉最外层括号之后的串
- 常数,幂函数,三角函数因子,这时直接创建相应结点就完事儿了
伪代码大概是这样:
NodeType createNode(String expression) { if (找到满足条件的加减法) { NodeType left = createNode(左边的子串); NodeType right = createNode(右边的子串); NodeType now = new AddType(left, right); // 也可能是SubType,看找到的是加号还是减号了 return now; } else if (找到满足条件的乘法) { 类似上面的过程 } else { if (是表达式因子) { NodeType left = 幂函数x; NodeType right = createNode(括号里的子串); NodeType now = new Compound(left, right); return now; } else if (普通因子) { 这个你应该会写了; } else if (空串) { 这个你也应该会写了; } else { 应该是出bug了; } } }
据调研(私信),至少有5位同学依照该方法顺利完成第二次作业,其中一位同学强测通过了所有数据点,可见这个方法的正确性还是有一定保证的。
- 扫描预处理的表达式,找找看有没有不被任何括号包围且不是指数和常数自带的
笔者本次作业的解析部分是实现了一个不太严格的递归下降分析器,需要对空白符做预处理,存在已知bug,会在输入不合法的情况下发作(所以本次作业可以用),但在后续升级评测机的过程中笔者修改成了严格的递归下降。
评测机部分
本次作业在输入上出现了递归结构,所以难以再使用正则表达式生成数据,所以笔者重写了generator
,使用了类似递归下降的思路去生成随机数据。由于嵌套结构过深或者表达式太复杂会导致sympy
求导和化简超时,所以对于随机数据需要手动设置嵌套最大深度和表达式项数。由于在中测中有的同学出现化简得到的输出格式不正确的问题,而sympy
是可以处理指导书中的类似---sin(x)
这样的数据的,所以笔者想要在原来的基础上增加检验输出是否合法的模块validator
,使用递归下降的方式实现,可惜的是在作业ddl之前笔者并没有开发完成该部分,所以对强测还有互测还是有点心虚的。
基于度量的代码分析
由于笔者第二次作业和第三次作业架构差不多,且几乎只修改了读入解析模块,所以该部分就省略了,直接看第三次作业的即可。
分析自己bug的策略
同上一次作业。
在这次作业中,笔者再次因为符号问题浪费了几次评测机会,经过分析发现笔者实现的弱化版递归下降并不能处理某种情况。递归下降虽然很套路,但是它需要严格按照文法去进行解析,所以在文法复杂时某些方法圈复杂度也会比较大,就容易出现bug。为了简化处理,笔者选择使用去除空白符,合并多余加减号的方式规避掉这一点(所以下次作业还是得重写读入解析)。
在强测和互测中,笔者均未被找到bug。
寻找别人bug的策略
同上一次作业。
很遗憾,这次作业评测机并没有帮我找到同房同学的bug,最终,整个房间仅有一个同学被hack
。通过观察hack
记录,发现该同学将一个字符串转成整数时抛出了java.lang.NumberFormatException
,应该是解析提取错误或者没做好归一化访问导致。
第三次作业
较为轻松的一周。
求导器部分
增加的新需求是三角函数的嵌套以及输入鲁棒性(非法输入处理)。由于第二次作业已经基本搭好整个架构了,所以这次作业天生就可以支持三角函数嵌套求导,求导部分几乎不需要做改动(唯一的改动是输出时某些地方的toString()
方法需要添加更多的括号。),至于输入的鲁棒性,由于上次作业我使用的是基于替换字符的弱化版递归下降,所以这次需要重写解析模块,改成严格版递归下降就好了。作业的核心部分的类图和上次作业完全一样,故不再重复贴图。
对于递归下降的实现,我把它完全放到了Parser
类中,对外提供parseExpression
方法进行解析表达式,内部提供了解析项以及各种因子的私有辅助方法,这也导致Parser
中虽然单个方法规模都可以接受(20-50行),但是整个类的规模达到了300行以上,在互测时参考了同房同学的代码之后,我认为在自己的设计中可以把各种token
抽象成类,并且把词法分析和语法分析分开去做,这样更加符合单一职责的原则。
评测机部分
这次我的评测机在课下自测的时候做了一定的修改,使之能够有一定的概率生成一些Wrong Format
数据,并且正确性判定从程序vs
现有轮子变成了我的程序vs
小伙伴的程序(变成了写算法题时用的对拍机):
- 如果两人都没有输出
WF
,那么就利用符号运算比较结果。 - 如果其中有一个人输出
WF
,则暂停对拍机,把数据写入回归测试的数据文件中,交由我自己人工判断。 - 如果两个人都输出
WF
,则不进行符号运算,直接输入下一组数据。
在互测中,把评测机生成WF
数据的逻辑注释掉(其实应该设计成一个方便的开关),就可以生成符合互测要求的数据了。
基于度量的代码分析
由于我的作业中主要分为读入处理和数据处理两大部分,所以对两部分分别进行度量分析
读入解析部分
使用IDEA
的Metrics
插件,可以得到分析结果,此处截取的一部分中包含了我们比较关心的求导和化简方法,三列度量结果分别为基本复杂度,模块设计复杂度以及圈复杂度。
可以看到,对于各种因子的解析方法(parseSin, parseCos, parsePowerFunc, parseExpressionFactor, parseFactor
)基本复杂度都比较高,模块设计复杂度和圈复杂度也不低(虽然没飘红)。分析原因,对于parseFactor
方法,其复杂是因为该方法需要判断具体是哪种Factor
,这是通过尝试调用各种解析具体因子的方法实现的;对于其他几个方法,其复杂一方面是合法的因子可能会省略指数,所以解析指数时需要考虑两种情况,另一方面是像三角和表达式因子这种需要递归括号内的部分,由于之前使用形如sin\\((.*)?\\)
的正则匹配出现过匹配错误,所以我括号前,括号中和括号后的部分是分了三步进行解析的,这就导致解析过程较为复杂。个人认为解析过程中的一些逻辑还可以提取成为辅助方法供高层的解析方法调用,这样应该可以降低方法的复杂度。
另外,整个读入解析模块有三百多行,明显是一个重类,可以考虑前面所说的对各种token
建立类,在类中自行处理本token
的解析。
数据处理部分
可以看到,主要的求导和化简方法复杂度都不高,可以接受。经过分析,可能是因为层次化设计比较合理,每个类只需要各司其职,即使调用其他对象的方法也可以做到归一化处理,另外笔者进行的化简比较少,所以化简方法要做的事情就不那么复杂。
整个项目涉及到的类的属性,方法以及总体规模统计如下,没有找到合适统计方法,选择手动统计:
类 | 属性数 | 方法数 | 方法规模 | 行数 |
---|---|---|---|---|
Main | 0 | 1 | 良好 | 26 |
Derivable | 0 | 2 | 良好 | 8 |
Expression | 1 | 5 | 良好 | 85 |
Term | 1 | 6 | 良好 | 125 |
Factor | 0 | 2 | 良好 | 10 |
PowerFunc | 1 | 7 | 良好 | 72 |
Constant | 1 | 7 | 良好 | 48 |
Sin | 1 | 7 | 良好 | 72 |
Cos | 1 | 7 | 良好 | 72 |
ExpressionFactor | 2 | 5 | 良好 | 48 |
Parser | 4 | 15 | 多个方法存在面条代码 | 330 |
从这里也可以明显看出来,Parser一枝独秀,如果接下来还要重构的话应该最先被考虑。
分析自己bug的策略
同上。
在这次自测时,对拍机发挥的作用并不理想,暴露出很多需要改进的地方:
- 评测效率不够高,原因可能是这次的表达式比较复杂,个别表达式求导和化简计算量比较大,大概5秒才能测完一组数据,根据讨论区的帖子我发现也可能和解释器解释语言慢有关系。
- 由于输入数据的长度不容易控制(我是通过限制最终生成表达式的项来控制的,但是仍然会有很大波动),所以生成
WF
数据的机会很大。 - 由于我构造
WF
数据的逻辑是在正常表达式的某些地方(经过观察最可能WF的地方)按照一定的概率生成某些可能不应该出现的字符(在这里我只考虑了大家最可能出问题的空白符和符号),显然这样会错过大量情况,所以这个测试应该是相当不完备的。
在本地测试时,coekjan同学提醒了我一个递归下降分析中的bug,即解析完之后要判断是否还有没被解析的token
,如果有则需要抛出错误格式异常。这样的数据有sin(x))
等。我的对拍机中并没有生成这种数据,所以对拍机测试时并不能枚举到这个问题,更要命的是,单元测试的时候虽然我尝试了类似的用例,但是并不是用来测表达式的识别方法,而是用来测三角函数的识别方法是不是对的,所以也没有找到这个bug。另外,出现这个问题也暴露出了我对递归下降分析法理解不够到位,所以才忘记了这个判断。
不过很幸运,在强测和互测中,笔者仍然没有被找到bug,从正确性上圆满完成了第一单元的项目,但也仅此而已。
寻找别人bug的策略
同上。
这次,由于评测机有大量的缺点,以及不能提交WF
数据,笔者虽然找到了同房同学的bug,但不能hack
。该同学的读入解析模块的策略是枚举错误格式,尝试匹配所有的错误格式,如果都匹配失败,就认为格式没问题,然后去除空白符和多余加减号之后处理(应该是延续了上次的做法),可以看出这样是很容易出问题的。但互测要求输入合法,所以直到最后互测中也没人奈何得了他。
不过,别的同学多次找到了同房中某位同学的bug,通过观察hack
记录,发现该同学是没有处理好-1
的省略的问题,所以类似-sin(x)**2
这种数据该程序不能得到正确结果。起初笔者是很惊讶的,随机数据不至于几千组数据都巧妙避开这一点吧?通过分析发现,笔者评测机version 2.0
和version 3.0
使用的类似递归下降的生成策略不能生成该种数据,因为笔者项与项之间连接时直接使用了加号,所以靠评测机就难以找到该同学的bug了,说白了,又是自己写的评测机存在缺陷,小丑竟又是我自己qwq。
心得体会
通过寒假以来这一个多月的训练,我在多个方面都获得了很大的收获:
- 通过pre作业,我对面向对象中封装,继承和多态的理解更加深入了,拾起了有用的正则表达式,还在作业的push下读了一点对学习OO较为有益的书。
- 通过第一次到第二次作业的过渡,我加深了对设计原则的理解(这是我在第一单元作业中最大的收获,有拨云见日之感),使自己的理解不仅仅是停留在书本上的几个例子中,而且还在我自己的代码里得到了体现。
- 通过对项目进行测试,我加深了软件测试技术课程中学到的理论的理解,并使用
JUnit
以及搭建评测机亲手实践了黑白盒测试。通过写测试我也更加确信评测机随机数轰炸不一定是万能的,单元测试想做好也不容易,这俩都是细活儿。 - 训练还帮助我创造机会和新同学交流讨论,帮助我适应复学之后的学习和生活,削弱心理上的割离感。
但是,这一单元又是充满遗憾的。如果完全抱着一种积极主动的心态去完成OO课的任务,能学到的东西就实在太多了。但是在本单元,笔者在有时间的情况下为了保正确性在第三次作业中做了较少的优化,讨论区提到的除优化之外的其他方面的点子很多也没有尝试,放弃了锻炼自己的机会,长期来看,留给自己的只有 “我TM当初没试这个点子” 的遗憾,愤怒和心虚。尝试过,就算做了化简出锅了又能咋样?代价只不过是一个学生在一次作业中拿到一个稍微低点的分数而已(也得亏自己是在学校做事才能代价如此之小),但这样既积累了错误经验又朝更好的方向多做过一点努力,提升的是自己的能力,这才是一个人一辈子的财富,才是一个人升学/就业时心中不虚的理由。
还有三个单元,希望自己接下来能够拿出破釜沉舟的决心去完成接下来的项目,加油吧!