面向对象设计与构造第一单元博客作业

第一次作业总结

类图

总类图

作为第一次作业,总体设计上有不少缺陷,但因为作业复杂度还不算高,因此看着还不是很乱,但其实已经出现接口/类的迷惑定义了,这也部分程度上导致了第二次的重构。

关键部分类图

第一部分是字符串输入后的解析。这部分因为有官方training,所以构造起来还比较简单,架构也基本相仿。

第二部分则是存储字符串的类。这部分我是在training发布前就先自己构建了的,后来也就头铁的这么做了。和官方示例略有不同,并没有把多项式和常数、幂函数当作纯粹的因子,同时也作为了项来处理。这一不同导致了第二周还没开始就准备起了重构。

核心类设计分析

字符串解析类

主要由两个类构成,LexerParse

Lexer:对字符串进行最直接的处理,留有一个方法用于传递当前指向的符号/数字/变量。

Parse:一个工厂,控制Lexer向后解析的同时根据Lexer的返回值组合生成因子、项以及多项式。

优点:考虑到后续可能出现的括号嵌套等问题,所以直接采用正则表达式来解析并非一个很好的选择,因此递归下降法对字符串进行解析更具有普适性。

缺点:本次设计过程中,出于取巧把化简运算直接放在Parse中进行了。虽然结果上不会有区别,但将两种操作(解析并存储,计算)揉在一起不是一个很好的设计思路,毕竟Parse的职责是解析并生成字符串而不是去进行运算化简。

字符串存储类

根据指导书的定义,因子实际分成了三类:表达式、常数、变量x。其中,表达式用多项式来表示,常数和变量x则合并在一起用“单项式”来表示,即a*x**b(b=0时即为常数)。

因子接口Factor:包含了因子通用的方法,如乘法、设置乘方。

项接口Term:此处设计并不好,对于扩展三角项较为困难。第一次这么设计的原因在于开始对多项式与单项式都定义了加法和乘法运算,而加法运算是项所独有的,所以认为这两种因子也同时具有了项的特性。但实际上项应该是独立于这两种因子的,这点在第二次添加三角函数后会体现出来。

多项式Polynomial:内含单项式的列表,可以和其他因子(多项式、单项式)做乘法,和其他项做加法(合并同类项),以及实现自身的乘方运算来去除乘方的括号。

单项式Monomial:包含一个系数coefficient和变量x的幂次index,可以做多项式除了乘方以外的运算。

优点:对两个实际的运算存储单元定义了各自的运算规则,并返回统一的接口类型,比较符合内聚的思想,外界调用的时候不必再考虑实际的运算过程。因子=多项式/单项式的结构也没有问题。

缺点:Term作为接口的设计是违背与实际层次结构的。表达式-项-因子的层次结构比较清晰且符合实际,而把本来是因子的多项式/单项式看作项容易导致结构上的混乱,同时直接把单项式看作一个基元存储在表达式中也不合理,打破了层级之间的递进关系,极大的限制了后续对于项的定义的延申。

基于度量的结构分析

代码规模分析

整体结构比较简洁,没有过于臃肿的类出现

类复杂度分析

没有十分复杂的类出现,各个类之间较为平均。

方法复杂度分析

这里只截取了复杂度从高到低排列后复杂度较高的方法。

lexer.next()因为要解析各种不同的字符所以if判断较多,v(G)复杂度相对较高。

Monomial.toString()方法因为要综合考虑系数和x的指数,判断也较多。

优化分析

所有所做优化如下:

乘法合并:

常数和常数相乘,带x的项相乘为指数相加

加法合并:

同幂次的项可以相加,如2*x+3*x,不同幂次的项合并为一个新的多项式,如2*x+3*x**2

输出优化

系数为0的不进行输出,如0*x返回空字符串,除非最后的输出为0

指数为1的不输出指数项,如x**1输出x

平方输出由x**2输出x*x

系数为1或-1时不输出系数,如1*x输出x,-1*x**2输出-x**2

未做优化

正项提前,如-1+x输出为x-1

bug与测试

测试数据构造

用c++写了一个测试样例生成器,生成逻辑根据指导书规则而定,如下:

根据规则构造了一些常量,形成常量池

最外层为一个多项式,包含了随机个数的项,每个项通过createTerm()生成

每个createTerm()方法可以调用上面的四个方法生成包含的因子,为了避免嵌套设置了一个全局开关保证createSubExp()中调用的createTerm()不会再调用createSubExp()

bug分析

第一次作业较为简单,全屋无人被发现bug。

第二次作业

 类图

总类图

解析部分类图

解析部分的架构没有发生改变,仅在内容上添加了对于新增的三角函数、求和函数、自定义函数的解析处理方法。

存储运算部分类图

此部分改动较大,不仅根据新增要求添加了许多类,还进行了部分重构。主要重构了Term,严格遵循 表达式-项-因子 的层次结构进行构建,也因此对原来类的耦合关系进行了调整。

核心类设计分析

字符串解析

对于新增的自定义函数和求和函数,沿用了因子表示法而不是字符串替换法。由于函数声明与表达式处理时的解析区别,分成了两个类。

FuncParser:用于解析函数声明,存储函数名、形参列表、函数表达式的对应关系。

ExprParser:用于解析待处理表达式,解析函数时会根据FuncParser的存储结果生成形参与实参、表达式相对应的函数。

存储计算

根据重构后的层次结构,形成了如下的类。

Polynomial:只存储包含的项Term,可以做的运算只有两个项之间的合并(merge()方法)以及自身的乘方运算(pow()方法)。

Term:包含两个互斥的部分。项在化简完成前,factors存储在解析时的所有因子。项内化简完成后,用monomialtriangles存储一个项的全部特征:系数、(x的)指数、所有三角函数。只可以进行项和因子/项的乘法运算。

Factor:在解析时的最底层形式。化简时,函数因子中的Variable会被替换为多项式因子,然后将函数因子以多项式的形式返回进行运算。最后存在的因子只会有MonomialTriangle

Optimizer:在所有基础化简(加法、乘法的合并同类项)完成后进行三角优化。

基于度量的结构分析

代码规模分析

 

可以看到比较长的代码主要出现在TermPolynomial这两个类上。这是因为主要的运算(乘方运算、乘法合并、加法合并)都集中在了这两个类上。

类复杂度分析

Optimizer只包含了两个方法,简化三角函数与简单的sin^2(x)+cos^2(x)=1的化简。复杂度较高一个是因为需要比较繁杂的判断,如三角的类型、内部因子的符号、两个项的相等判断等,另一个原因是三角的平方和需要把每两个项都进行一次比较,因此至少需要两个显式的for循环。

Term的复杂度源于乘法运算,每次都需要对被乘数factor进行判断,根据类型进行不同的运算方式。

ExprParser的复杂度源于因子数量的变多,

方法复杂度分析

Optimizer的两个方法已经在上文叙述过复杂的原因,此处不再赘述。

解析用的parseFactor()方法上文也已提到过。

mult()replaceAll()方法都有一个共性,即需要根据存储因子的不同类型采取不同的化简/替换策略,或许将这些方法都重写为一个调度用的方法,再为每种化简的实现都新写一个方法并调用可以稍微降低复杂度。

Triangle.toString()方法在输出内部因子时,因为Monomial存在x**2优化为x*x的优化,而这在三角里是非法格式,因此需要额外再将x*x变回x**2,增加了复杂度。

优化分析

在保证继续实现上次作业的优化的基础上,新增了以下优化。

三角优化

sin(0)化为0cos(0)化为1sin(-1)化为-sin(1)cos(-1)化为cos(1)

sin(x)**2+cos(x)**2形式的化简为1(仅限三角项只有1个且该项为平方项)

优缺点分析

优点

整体架构比较符合层次化设计思路。用因子替换而非字符串预处理替换更贴合指导书中对自定义函数、求和函数是因子的定义。未来可扩展性较强。

缺点

部分类的复杂度较高,部分方法略显冗长。

Optimizer类并非必须,本身实现一些的功能可以在三角类里提前判断好并实现,运算逻辑有些混乱。

bug与测试

测试

沿用了上次的样例生成器,增加了三角函数的生成逻辑。

测试中成功发现了自己三角化简中的一些bug,比如把sin(-1)**2化简成了-sin(1)**2

自身bug分析

这次在强测中被测出了sin(0)**0的问题,原因在于化简sin(0)0时忘记判断次方,这与不合适的运算逻辑导致Optimizer类中出现的冗长代码也有关系。互测中也仅被查出该处错误。

互测与他人bug

互测中主要通过生成器构造的数据对其他人的输出进行检验,发现了和自己出过的同样的bug(sin(-1)**2

组内其他被发现的bug还包括对大数的解析运算,三角乘法化简。大多是和新因子的组合运算,都不是很偏的测试样例(第三次的互测借助这一特点发现了其他人不少bug)。

第三次作业

类图

本次作业需要新增的内容不多,基本是对原来方法的扩展。此外,为了能继续实现原本的优化,对部分因子的运算进行了修改。

核心类设计分析

Factor接口

所有因子有化简自己的共性,因此应该在因子接口添加这一方法,在初次访问某因子时先让其自身完成化简,实现化简的内聚,减少如上次作业的Optimizer化简三角函数时的高耦合。

三角/自定义/求和函数类

因为新增了函数嵌套的可能,所以在替换时应该遵照基本的 项-因子-内层表达式的思路去递归替换。

 三角/单项式/多项式类

为满足三角内部是多项式时不同三角项的合并,对这三个类定义了哈希值运算来定义多项式里最小的项,通过保证该项恒正来方便多项式判等,而不会出现如sin((x-x*x))sin((x*x-x))不能判等的情况。

基于度量的结构分析

代码规模分析

相比于第二次作业,本次代码主要增加在了Triangle类上,TermPolynomial类也有所增加。其中,TermPolynomial的增加主要是为了含多项式的三角函数判等而新生成的哈希值计算等方法,增加量正常。Triangle类的增加主要在于内部因子的复杂性增加,因子可以是常数、幂函数、三角函数、多项式的任意一种。由于多项式的最终形式可能可以变成其他三种,因此对于多种可能的判断造成了略显冗长的代码。

类复杂度分析

通过把三角化简并入到Triangle类内进行,Optimizer类的复杂度有所下降,但因为扩展了对sin(x)**2+cos(x)**2这种化简得处理范围,所以总复杂度反而有些提升,详细可在方法复杂度中看出。

为了加强合并的力度,可以对合并后的新项立即尝试合并,因此改变了TermPolynomial的合并逻辑,因此导致复杂度更高。

方法复杂度分析

 复杂度最高的是三角函数的简化以及项的乘法运算。乘法运算的原因和第二次作业相同,三角的化简方法simple()复杂度高的原因主要出现在由多项式化为三角函数/单项式,或是由三角函数化为单项式的判断上。例如,sin((x*x))应该化简成sin(x**2)sin((1*sin(x)))化简成sin(sin(x))sin(cos(0))化简成sin(1)等等,这些化简都需要至少同时需要三个类相关联,且情况众多。因此造成了三角化简的耦合度与复杂度较高。。

三角恒等变换在上次作业的基础上做了一些改进,只要项里含有平方项就会尝试进行平方和的运算,同时也扩展了1-cos(x)**2类型的运算。因此复杂度依然较高。
因为三角恒等变换的算法不够好,对于cos(x)**2-1这种并不能进行化简,因此在Polynomial.simple()方法中会将化简完的项从后往前再遍历一次,正反交替直至项数不再改变,导致该方法复杂度上升。通过新建一个方法实现正反化简可以一定程度降低复杂度

其余复杂度较高的方法在第二次作业中均有叙述,此次作业的迭代并未有过多修改。

优化分析

合并同类项

最大程度实现因子与因子,项与项的合并,如:

sin((x-x**2))*sin((x**2-x))合并为-sin((x-x**2))**2

sin(sin((-x)))*sin(sin(x))合并为-sin(sin(x))**2

sin((x-x**2))-sin((x**2-x))合并为2*sin((x-x**2))

sin(sin((-x)))-sin(sin(x))合并为-2*sin(sin(x))

三角优化

当项里有一个三角项的指数为2时,会根据是否存在与其对应的项进行平方和合并,如:

x*sin(x)*sin(x**2)**2+x*sin(x)*cos(x**2)**2合并为x*sin(x)

x*sin(x)-x*sin(x)*cos(x**2)**2合并为x*sin(x)*sin(x**2)**2

优缺点分析

优点

本次迭代完全沿用了第二次作业的架构,在实现基本功能(即满足课设要求的输入输出)时所做改动很少。依然具有良好的可扩展性。

缺点

优化使得类的耦合程度大幅提高,部分方法因此变得冗长,且时间复杂度较高(因数据长度限制,实际无影响),如果需要做新的优化,或是需求发生改变,用于优化的代码可能需要大幅改动。

bug与测试

课下测试

沿用前两次使用的生成器,结合sympy可进行除自定义函数、求和函数外的测试,最终在此部分未发现有bug。

自身bug分析

强测在三角函数嵌套自定义/求和函数处出现问题,互测也同样被发现。此bug出现原因是三角内因子可以嵌套函数,而函数的simple()方法仅仅只实现了实参的替换,并未对替换后的多项式进行化简。此问题在课下自测作业二的时候曾发现过这个问题,其出现位置在项与函数因子相乘的地方。当时在乘法运算处进行了修改,对返回的多项式再次调用simple()方法化简后再进行运算。而在第三次作业中忽略了三角函数中的可能。课下测试中因为只能手写样例测试求和函数,也忽略了这一点。如今看来应该是函数的simple()方法功能定义不妥,应该和其他因子一样化为最简形式,而非返回实参替换后的结果,让调用者再去自行化简。

互测与他人bug

前两次容易出现的bug本次测试中依然有人出现,如sin((-x))**2化简成-sin(x)**2,这可能是由于对新增需求而重写的新优化不完全而导致的。另外还有sin(-x)这种格式错误,以及公测中不曾出现的求和函数中的大数问题。

第一单元总结

 架构设计体验

本单元作业中,在第一次到第二次作业的过程中进行了部分重构,而第二次到第三次则没有重构,直接进行了迭代开发。能明显感觉到,架构是随着需求,从简到繁的。

在设计之初,应该尽量保证架构具有足够高的扩展性,支持在尽可能多的方向上进行扩展。同时,也可以简单预测一下可能的扩展方向,以做好准备。如本单元的字符串解析部分,考虑到可能出现的括号嵌套需求而采用了递归下降法而不是用正则表达式去解析。

往届的作业具有一定的参考价值,可以借鉴提前思考可能出现的需求。如果能提早看看往届的命题,在设计之初考虑三角因子的影响,重构也许就不会发生了。而第二次作业时提前看了眼去年的题目,预感到了第三次的无限制嵌套问题,所以提前在函数和三角部分做好了准备,改动起来就比较容易。

心得体会

优化适度而为

优化是个无底洞。第一次作业还较简单,能做的优化也不多,当时甚至还遗憾忘记了做正项提前的优化。但从第二次作业开始,优化难度就大幅增加了。第二次作业能做的主要就是三角项的正负号、内因子为0时的处理,以及平方和为1的各种变形。这时候策略的不同就已经显现出来了,有的大佬能处理各种情况下的恒等变换,本蒟蒻却只能做简单形式的平方和为1,还在内因子为0的优化上出了bug。第三次作业大部分时间都在补充第二次的优化,结果没想到强测几乎只能合并同类项,优化完全没用上。可以说是完全输了。

全面测试很重要

因为拿c++写了个表达式生成器和sympy对拍,本单元作业大部分都是用生成的数据测的。虽然有常量池来约束随机性,但还是有些缺陷。比如单独的sin(0)**0这种就很难出现。生成器适合对大而长的数据进行测试,比如各种嵌套、复杂乘方运算,但小而精巧的数据不易生成,比如0(0)**0等。这些应该是再手动测一测的。

自动化测不了的一定要手动测全。比如求和函数sum,要考虑全所有情况,sum内的各种可能的表达式,sum作为因子嵌在其他因子内等。不要相信自己的迭代没有问题!想着理论上没有任何问题,但不知道哪块就漏了一步,甚至笔误。(曾经被idea的联想补全把term敲成了this,d了一小时的bug)

架构设计是基础

虽然此次的重构只是部分重构,基本仅涉及了三个类,但已经非常令人痛苦了,从开始重构到消除bug直接就是一下午的时间。因此在抽象设计的过程中,不要只面向现在,再多想想以后怎么办。

理想很美好,现实很残酷

承接上一条,初步设计好的架构看起来总是很美好的,但随着一步步的实现,就会发现前面的设计中总会漏掉一些奇奇怪怪的可能情况。这时,要马上去修改还是先放下不管是一件很令人纠结的事。马上翻回去改,等改完了回来继续码代码的时候就要面临人生的终极三问。我是谁?我在哪?我要干什么?不改的话,debug的时候就有的受了。目前我采取的策略是,在前面有问题的地方先补个TODO,简单说一下此处的问题和需要补充修改的点,然后继续完成当前的任务,等到写完这一部分再到前面去修改。idea把TODO高亮这件事还是比较人性化的。

一些其他补充

强测的强度果然还是不够强。第三次作业居然没有对求和函数爆int的检测。然而却恰好测中了我的bug,霉比竟是我自己。建议强测的样例也能在前一次的样例上迭代开发,能覆盖测试先前的需求。毕竟新需求和老需求的兼容性也应该是要考虑的。不会真有人不用上次作业的强测来检验吧

posted @ 2022-03-25 09:32  alonelysnake  阅读(40)  评论(1编辑  收藏  举报