444
社区成员
经过上学期cpp课的学习,我对面向对象的编程思想有了初步的了解,熟悉了编写多个类文件,共同构成一个项目的操作。而这次OO第一单元的表达式展开与计算的任务仍然对我极具挑战。
经过迭代后的Hw3最终设计,由于本单元没有经历重构,以下各次作业中的结构均可在此UML图中找到痕迹,因此我将它置于文章第一部分,便于查阅。
本次作业借用了训练中给出的架构,采用递归下降法自顶向下解析表达式,提取出层次结构后再自底向上返回后缀表达式的做法。
根据文法,最上层的结构为表达式,它由多个项组成,项之间以+
和-
划分,如:-1 + x ** 233 - z ** 06 +y
。
项则由多个因子组成,因子之间以*
划分,如:+ x * 02, - +3 * x
。
因子分为三种:幂函数因子,常数因子与表达式因子,其中表达式因子是用一对小括号包裹起来的表达式,可以带指数(非负)。如:x ** +2, y ** 02, z ** 2, +02
。
得到后缀表达式后,运用数据结构的知识,建立操作数栈,遍历后缀表达式时出入操作数栈,计算即可得到表达式展开的结果。
本次作业共新增10个类与1个接口。
程序建立了每个层次结构对应的类,如:Expr, Term, Pow, Var, Number等,各个类的属性为下一级别结构的对象。所有类统一实现Factor接口,这是因为项本身不具有含义,可以重新分类为此前的各个类,也就是说各个层次在被括号包裹时均被视为因子,需要自顶向下重新解析,这样做利用了接口的性质,对表达式和变量进行归一化处理,程序结构清晰。
用Lexer和Parser两个类可以实现递归下降,Lexer意为词法解析器,Parser意为语法分析器,利用Lexer解析出的规则符号与待解析内容,自顶向下分门别类地使用方法,如parseExpr
->parseTerm
->parseFactor
,递归处理输入的表达式,将结构层次存入各层次对应的类中。
后缀表达式的生成在通过递归下降后十分简单,重写各层次结构的toString
方法使之输出分别对应的后缀表达式,由于递归的性质,使其自底向上自然返回即是我们所需的后缀表达式。
在对后缀表达式进行运算时,只有形式化定义了基本项才能进行合并同类项的处理,在此次作业中,基本项的形式为:$kx^ay^bz^c$。因此我建立了Mono类存储基本项的各参数:k、a、b、c,它们是合并同类项的指标。其中有基本运算方法mulMono
对可以合并的同类项进行操作。
再建立一Poly类进行合并同类项,首先判断基本项是否相同以合并,若不能,则通过addPoly
和subPoly
两方法添加项与符号(此处可以合并为统一方法!),用ArrayList<Mono>
存储合并后的多项式,多项式即为基本项的累加。
本次作业的强测和互测均出现问题。bug是我使用replaceAll()
方法试图在输出时忽略系数1,即去掉1*
,但没有想到系数可能有多位的情况,如将11*x
错误优化为1x
,自然大量数据点出现wrong answer。
这个错误告诉我处理表达式时通常不能使用简单的字符串替换方法,而是在对象层面进行处理,判断Mono类对象num属性的值是否等于1,就可以修复该bug。
性能方面主要欠缺在对表达式首项正负号的优化,以及将x**2
缩短为x*x
的优化上,减去性能分后强测成绩为76.0303。
在测试上,可使用大数进行边界测试,在适当的位置,如项与表达式前添加空白符和正负号来检验程序的稳定性,设置含正号和前导0的指数,若没有恰当处理,可能报错。可能的样例有:- +2222222122222 * y + - 2 * x * (+ z ** +00233213231832)
。
本次作业主要的新增内容是三角函数和自定义函数两种全新的因子,为了把它们加入原有的表达式结构,我对它们分别进行了不同的操作。
对于三角函数因子,我同样进行递归下降分析生成后缀表达式的方法,注意合并同类项时对三角函数中内容判断相等的方法即可。
对于自定义函数,注意到自定义函数先输入,因此可以先分析它的参数构成,在预处理中采用单纯的字符串替换方法,读取输入的表达式中的实参,进行替换后使之不含任何自定义函数。
本次作业共新增2个类。
为了预处理时替换自定义函数,我新增了Func类,其拥有的rules属性中包含了替换的规则,用HashMap<String, String>
存储,便于匹配。两个方法replaceFunc
和replaceString
分别可以读取表达式中的自定义函数和实现根据规则的字符串替换。为了处理自定义函数的参数嵌套,在读取实参时,若其中含有函数符号[fgh]
,递归调用replaceFunc
方法。
对于三角函数因子,我新增了Tri类使之同样拥有Factor接口,在层次上平行于Var类,所以在Parser类的parseFactor
方法中新增读入三角函数符号sin、cos的分支即可。由于三角函数符号sin和cos可被看作单目运算符,因此对操作数栈出入规则进行稍微改造,在处理三角函数时只出栈一个操作数,使之适应现有的后缀表达式。
注意到加入三角函数后,运算的基本项变为:$kx^ay^bz^c\prod sin(factor)\prod cos(factor)$ 。
注意到sin和cos包裹的因子不再变化,合并同类项时因子经常有所差异,要在基本项中乘上新增的三角函数,为此我在Mono类中新增mulTri
方法,实现基本项中sin和cos的连乘。最后只需在Poly类的toString
重写方法中加入三角函数的输出规则即可。
本次作业的强测与互测均没有出现错误,性能方面主要欠缺在三角函数的优化上,缺少对于诱导公式、平方和甚至倍角公式的优化,减去性能分后强测成绩为90.9154。
在测试上,这次作业主要的难点有三角函数的嵌套以及实参含自定义函数的情况。自定义函数定义时的变量乱序同样值得考虑。可能的样例有:
2
f(z) = z ** +02
h(z,x) = sin(3 * x ** +2 - - cos(- + z + 1))
h(f(y), 21111122222) ** +0
本次作业的一大难点是求导,在思考求导的基本法则后,我发现求导的过程同样可以划分出层次结构,表达式、项、因子等概念同样适用。因此我巧妙地将其与递归下降的神器——语法分析器Parser结合,分析出结构后分别求导,模仿toString
重写方法,在Factor接口中实现derive
方法,各个类自底向上的返回求导的结果。这也让我联想到一个问题,toString
的重写是否可以直接返回运算的结果而非后缀表达式?不由十分惭愧,一开始的架构就不是最优解。
本次作业还新增了自定义函数的定义嵌套,只要掌握了递归思想,相信不是难事。在读取自定义函数规则时同样调用replaceFunc
方法,将定义中的自定义函数替换掉即可,十分自然。
本次作业中没有新建求导类,而是把求导作为接口实现的方法进行处理。
注意到表达式中不只含求导运算,我先将求导运算作为因子处理,Parser类的parseFactor
增加求导因子分支,读取到时递归调用主函数,对其单独解析,进行一个完整的递归下降分析,返回类型为表达式因子,结合进一开始的流程。
由于此前接口的处理已经实现了表达式和项的归一化,因此只需在Factor接口中实现derive
方法,这也意味着各个类均要有各自的derive
方法,分别对应三角函数求导法则,幂函数求导法则,乘法求导法则,已然完成大多数的处理。为了合并的简洁,我建立了mergeExpr
和mergeTerm
方法,最后返回即为求导结果,十分强大。
本次作业的强测和互测均出现较大问题。错误是在求导时,表达式层面各个项的符号连接,减号后的项包含的符号均应变号,类似于减法去括号法则的缺失。
性能方面仍然延续Hw2的缺陷,但我认为剩余可选的优化难度过高,优先保证程序的正确性更为重要,以免出现负优化。减去性能分后强测成绩为60.3462。
在测试上,可进行求导与自定义函数的嵌套,以及自定义函数在定义时的嵌套(只能含有之前已输入的自定义函数)。可能的样例有:
2
f(z) = z ** +02
g(x,y) = y + 2 * (f(- x*2))
- +2 - -dx(+ (-2 * sin((g(y,x)))) ** 2) ** 1
简单统计各个类的总行数、核心代码行数以及空白率:
可见,MainClass、Func、Poly类的行数较多,空白率也偏低。其中Poly类含核心代码300行,方法众多且规模不小,俨然有成为巨型类的趋势,若继续迭代,应当对其进行优化,以符合高内聚低耦合的面向对象思想。
由于项目总方法较多,以下只展示各类的复杂度。根据类的平均与极限方法复杂度等统计数据,可以简单的检索出特定方法,留待优化,指标如下:
指标 | 全写 | 含义 |
---|---|---|
OCavg | Average Operation Complexity | 平均方法复杂度 |
OCmax | Maximum Operation Complexity | 最大方法复杂度 |
WMC | Weighted Method Complexity | 加权方法复杂度 |
具体分析结果如下:
有趣的是,复杂度较高的类依然是我们提到过的行数巨头们:MainClass、Func、Poly。这三类的共性是均对类管理的容器(ArrayList、LinkedList、HashMap)进行过forEach
遍历,而非简单的利用容器已实现的get
方法。
第一单元的学习内容主要涉及用类来管理对象,了解对象与类的设计必要性,以及抽象层次结构,涉及了类的继承、接口以及多态等知识。在具体代码的实现上,我反复遵循递归的思想:解析表达式,替换自定义函数,求导的链式法则都有它的身影。在图论中我曾接触过这一利器,再次实践,我有了更深的理解。
随着代码的迭代以及同学的分享,我意识到了在OO课程中,好的架构远比一些语法层面的小技巧要重要,它能不费力气的进行增量开发,角度通常十分自然。因此,构思架构和准确开发在这门课中同样重要。
在测试层面,根据题目定义的文法进行覆盖测试是减少bug的必要条件,尽量对每一种可能的结构进行测试,如符号的正负、变量的种类、因子的种类、三角函数与自定义函数的嵌套、求导的种类,将它们进行组合,生成多样的数据点,能及时发现程序中的很多漏洞。我们可以发现,上一次作业的高质量测试点在这一次作业可以沿用,随手记录很重要。
数据超过了int范围则是提醒我进行边界测试的重要性,常与数据复杂度有关,能够解决tle等问题。在函数中间加入输出中间变量的语句也是测试的一个好方法,能帮助我迅速定位出错的位置。
总而言之,实践出真知,正是经过近千行代码的编写,我对面向对象的编程思想有了更深的感悟,收获匪浅。
本文章撰写时使用的辅助工具(按出现顺序排列):