OO第一单元总结

一、摘要

​ 在第一单元的三次OO作业当中,我收获了很多。事实上,我对自己的代码重构了三次,也就是说,我的代码可扩展性比较差,我不得不每次都重新写我的代码。一开始的设计,很多是由于输入的限制而采用的比较取巧的思路。前两次作业,甚至没有运用到面向对象的多态、继承。直到最后一次作业,我才比较好地运用面向对象的思维方式,实现了作业的要求。

二、作业实现

第一次作业

UML图

image

程序度量

Dependency metrics Tue 30 Mar 2021 08:01:41 CST
Class Cyclic Dcy Dcy* Dpt Dpt* PDcy PDpt
Main 0 1 2 0 0 1 0
Monomial 0 0 0 1 2 0 1
Polynomial 0 1 1 1 1 1 1
Package Cyclic PDcy PDpt PDpt*
0 0 0 1

架构与实现方法

第一次作业十分简单,我采用的是比较面向过程的思路。在主类中,读入一个字符串。在Poly类中对其切割,提取出每个Monomial(单项式)。对每个单项式,在Monomial类中根据正则匹配提取所有系数相乘的结果,指数相加的结果。Poly中用一个List存储项的指数和系数,遇到相同的指数就进行合并。

你好像很好奇有什么必要建这几个类,然而好像确实没必要。

BUG分析

面向过程十分好写,也十分简单,我仅仅一个小时就搞定了这次作业,而且没bug。在互测环节通过大规模集中测试,成功hack了几个人,但这个过程十分无脑,也没有发现什么集中的问题。

第二次作业

UML图

image

程序度量

Dependency metrics Tue 30 Mar 2021 08:00:36 CST
Class Cyclic Dcy Dcy* Dpt Dpt* PDcy PDpt
Cosine 0 0 0 1 5 0 1
Expression 0 1 2 1 3 1 1
Main 0 2 5 0 0 1 0
Node 0 1 3 2 2 1 1
Parse 0 1 4 1 1 1 1
Trigonometric 0 1 1 1 4 1 1
Package Cyclic PDcy PDpt PDpt*
0 0 0 1
META-INF 0 0 0 n/a

架构与实现方法

由这条流水线一样的UML图不难发现,我这次又双叒叕面向过程了。相比上一次多项式,可以表示为指数和系数的关系,这次加入三角函数的表达式,可以表示为(x的指数,sin(x)的指数,cos(x)的指数)三元组和系数的关系。

表达式树的构建

与上一次不同的是,这一次加入了括号,于是我使用了表达式树做解析,而不是正则匹配。这部分内容主要在Parse中实现。表达式树的思路涉及到核心内容,我认为有必要仔细地说一说。

在去掉空格,合并连续的加减号以后,我们接下来要将这个表达式转变为表达式树。中缀表达式转表达式树,只需要每次提取最后一次运算的符号,并对两边进行递归。然而不难发现,与一般的中缀表达式不同,我们这个表达式存在sin和cos。这个不难处理,我们可以将sin和cos看成一个一目运算符。继续分析,我们发现+和-号,有时表示“加减”,有时表示“正负”,其含义,运算优先级完全不同。我通过判断+/-的前一个字符,来识别这个符号究竟是加减,还是正负,并将其进行转义,方便接下来计算优先级。一般来讲,正余弦,正负这样的一目运算,优先级应该高于耳目运算。然而,我发现了一个问题。在\(-x**2\)中,应该先计算乘方,后计算负号。在\(x**-2\)中我们先计算负号,再计算乘方。符号的顺序影响运算的顺序,那么,正负号和乘方应该是同一优先级的。而一般来讲,同级运算应该从左往右计算,乘方与正负号确实从右往左运算。这让我着实感到神奇。

总结一下构建表达式树的操作。首先去除包含在表达式最外层的括号,使得至少有一个字符不在括号里。然后找到不在括号里,运算级别最低的符号,并对两边分别递归。

求导部分

但在后面的实现中,还是较为面向过程的思路。鉴于前两次作业不太符合面向对象的要求,我就不多叙述其不好的架构了。

每个node包含一个运算符(为叶子节点时为常数或变量),子树的表达式,子树表达式的导函数(自然也是表达式)。显然,每个节点子树的表达式和导函数,都可以通过其儿子的表达式和导函数得到。那么只需要一次后序遍历,就可以得到整棵树的表达式和导函数。

在具体实现上, 我采用了一个大switch,外面的函数里实现不同运算的求导。这很不面向对象,但写起来很舒服。

前文已经叙述过,我的表达式是三元组到系数的映射。在具体写的过程中,大概是三级,分别是x的指数到Trigonometry的映射,Trigonometry是sin(x)的指数到Cosine的映射,Cosine是cos(x)的指数到系数的映射。维护表达式的加减乘等计算即可,最后逐级输出。

BUG分析

十分悲剧的是,这一次作业,我有一个巨大的bug,掉进了B屋(亦或是C屋?)。前文中谈到,除正负号和乘方以外,其余运算应当从做往右运算,而事实上,我刚好把这个写反了。之前我的写法是先遍历一遍找到不在括号里,且运算级别最低的符号。再从左往右遍历第一个不在括号里,且运算级别等于它的。事实上,正确做法是从右往左遍历。由于这个bug过于离谱,我在互测环节光荣成为了提款机,强测中也只拿到了70分。

这个bug十分愚蠢,本来应该是可以很容易就测出来的。事实上,最后我只是改了个循环顺序就完成了全部的bug修复。这并不涉及到复杂的耦合等问题,我坚信它在代码行和圈复杂度上无任何差异(明示点题!!!!)。

由于这一次喜提B屋,和我同组的人bug都比较多。印象最深的,是一个类似(((((((((sin(x))))))))))的测试(事实上括号层数应该比这个还多),卡T了三个人,还有一个人被我用x+x+x+x+x+...+x卡T了。大概是在表达式的处理中,其复杂度随项/括号的增加,指数型上升了。

第三次作业

UML图

image

代码度量

Dependency metrics Tue 30 Mar 2021 07:55:57 CST
Class Cyclic Dcy Dcy* Dpt Dpt* PDcy PDpt
Constant 0 1 1 3 7 1 1
Cosine 3 2 6 2 6 1 1
Expression 3 3 6 5 6 1 1
Factor 0 0 0 7 9 0 1
FormatChecker 0 0 0 1 1 0 1
Main 0 2 10 0 0 1 0
Node 0 7 7 1 2 1 1
Parse 0 2 8 1 1 1 1
Sine 3 2 6 2 6 1 1
Term 3 6 6 2 6 1 1
Variable 0 1 1 2 7 1 1
Package Cyclic PDcy PDpt PDpt*
0 0 0 1

架构与实现方法

第三次作业终于面向对象了。首先,我来描述一下每个类中主要的方法。

FormatChecker类

采用递归下降的方式,进行输入字符串的合法性检测。这个类对外只有一个静态方法,输入一个字符串,输出boolean类型的结果表示这个字符串是否是合法表达式。递归下降的思路已经有很多人介绍过了,就不多做阐述了。

Parse类

将合法的表达式转变为表达式树的形式,方法同第二次作业。

Node类

与第二次作业一样,每个node包含一个运算符(为叶子节点时为常数或变量),子树的表达式,子树表达式的导函数(自然也是表达式)。显然,每个节点子树的表达式和导函数,都可以通过其儿子的表达式和导函数得到。那么只需要一次后序遍历,就可以得到整棵树的表达式和导函数。

在具体实现上, 我采用了一个大switch,这很不面向对象。这主要由于我第二次作业是这么写的,而我懒得改了。事实上,可以根据节点中运算符的类型,对每种运算建一个类,继承node类,并实现其中的生成原函数方法,生成导函数方法。

Factor类

这是一个抽象类,有Variable, Const, Sine, Cosine四个子类。Factor类包含一个大整数表示指数,一个判断相等的方法。判断相等不需比较指数,因为这个方法主要用于一个项内部相同因子的合并。

Const和Variable表示常数因子和变量因子,没什么好说的。值得一提的是,Sine类和Cosine类。它们包含一个表达式表示其内部的东西。虽然根据定义,sin和cos里的应该是因子,但显然我们可以把他们统统看成表达式,而且这样是能帮助化简的。在Sine和Cosine类因子判断相等时,需要看里面的表达式是否相等。这需要调用Expression类的相等方法。

Term类

Term类包含一个大整数表示因子,一个int表示变量的系数,两个map<Expression, Integer>,存储正余弦因子及其指数。每次往一个项中加入一个因子,都要先判是否有相等的因子。

Term类除了加入因子等方法以外,还包含判断两个项是否可合并的方法和判断两个项相等的方法。两个项可合并,当且仅当A的每个因子都能在B中找到且指数相等,B的每个因子都能在A中找到且指数相等。两个项相等,是在两个项可合并的基础上,要求两个项系数相等。

Expression类

Expression类包含一个set存储因子。每次向表达式中加入一个Term,需要判断这个Term是否与原本的Expression中的某个Term可合并。

Expression类需要实现判断两个Expression是否相等的方法,这需要调用Term的相等。而在判断Term相等时,其中的正弦余弦因子又要反过来调用Expression的相等,实现了这样的一个递归过程。事实上,这个过程有点类似于比较两课树是否相等。这个过程虽然复杂度十分不优,但鉴于数据范围极小,在计算过程中的表现还是可以的。

在输出一个表达式的过程中,为了让长度尽可能短,我采用了提取最优公因式法。我的表达式在存储阶段,是不存在括号的(或者说不存在表达式因子)。在输出过程中,我查找所有因子,找到其中\(因子长度\times (因子出现次数 - 1)\)最大的一个,将其提取出来,并把包含这个因子和不包含这个因子的分成两类,分别输出即可。

Bug分析

我自己的bug主要在FormatChecker当中。因为其他部分大体框架和第二次作业差别不大。其中最主要的bug出现在指数的绝对值不大于50中。由于我找下一个因子的方法需要返回末尾地址,我不得不将指数的结果放进了FormatChecker的全局变量。一开始,我是在每次找变量因子前将index置为0,在找常数时(不论是不是在找指数)更新index,如果找到了变量因子,就判断全局的index,认为这就是的变量因子的指数。然而在变量因子是三角函数因子时,在判三角函数里面的表达式时,很可能更新里这个index,导致全局的index发生错乱。

这个bug的改法很多,描述起来又有点复杂,我就不花篇幅在这里废话了。产生这个bug,正是印证了全局变量的使用的危险,体现了封装的重要性。

互测过程,发现很多人判WF判的有问题,奈何不让hack。最后发现有人在输出时格式不太对,成功hack了两发。

三、hack策略

一开始,我是试图通过读代码来hack别人的,直到我看到某人写的大正则,于是直接放弃了。不得不采用自动化测试的方式。

我测试的策略基本就是构造能想到的,比较偏门的数据。我还借鉴了另外几位大佬的数据,对我自己的数据进行加强。

四、重构经历

我的重构经历比较多,主要因为我每一次的化简策略都是牢牢针对表达式的性质。

有些人觉得重构是痛苦的,我却丝毫不这样觉得。重构痛苦,是建立在原有代码有利用价值,却又食之无味,弃之可惜。当我明确坚信自己的前一份代码没有用处时,重构有一种放下包袱,轻装前进的快乐。下一次作业和上一次总是长得完全不同,具体图/数据上面已经给出了(再次明示点题!!!!!)。

玩笑归玩笑,我其实每次都是非常明确自己的代码哪里有利用价值,哪里没有。这并不是我代码设计的问题,而是我每次都能利用好表达式的性质。这些性质却可能在下一次作业中不适用了。即便是我对我最后一次的结果已经感到满意,如果表达式求导还有作业4,支持实现表达式因子乘方的操作,我可能又要有一波大的重构。再次重申,我并不觉得这是我设计的问题,觉得我只是把题目的性质,发挥到极致而已。

五、心得体会

第三次作业时有一个晚上熬到了四点,当然主要是周日一整天托福课,完全没有写OO的时间。总的来说还是比较轻松愉快的,收获也还是比较大的。我目前用的最多的还是面向对象的封装性,其继承和多态虽然有所涉及,但还是没有充分发挥出来。期待后面内容带给我更大的挑战。

posted @ 2021-03-30 11:50  郭衍培  阅读(106)  评论(0编辑  收藏  举报