BUAA OO 第一单元总结
一、第一次作业
1.1 任务需求
第一次作业的任务是对一个表达式结构进行建模,完成多变量多项式的括号展开。表达式是一个由x
,y
,z
三种变量,通过加、减、乘、幂次、还有括号构成的字符串。对这个字符串解析,展开其中所有括号就是第一次作业的任务需求。
1.2 实现架构
虽然说第一次作业的要求比较简单,相比之后两次复杂度较小,但是我花费时间的和精力却是最多的。一方面要熟悉 Java 基础知识,学习递归下降法,掌握解析表达式的通用方法,另一方面要思考采用什么样的架构,用什么数据结构存储变量,以及尽可能增加代码的可扩展性。
如下是我第一次作业的整体框架,其中只保留关键的部分内容。

可以看到我第一次作业的架构完全是依照第一次训练递归下降法的代码实现的。之后两次作业也是在这个架构上做局部重构和迭代开发。
1.3 设计思路
这一部分我将着重针对我设计过程遇中到的一些问题,介绍每个类设计考虑。
1.3.1 结点设计:
第一次作业,大多数设计的方案都是按照文法中给出的因子,项,表达式依次建类管理,而操作符的运算则设计成对应类的方法。这种设计比较直观,也符合我们解题的思路(当然还有其他的设计,比如像第一次实验中按照运算符建类)。
下面介绍我类图中结点类的设计考虑:
-
Factor: 接口,用于统一化管理所有因子,包括表达式因子,变量因子,常数因子。该接口定义有一个
getMap
方法,返回HashMap<String, BigInteger>
,这个方法的作用在Term
的设计中介绍。 -
Digit: 常数类,实现了
Factor
接口,封装一个Biginteger
的属性保存表达式出现的整数。实现了getMap
方法。 -
powerFunc: 幂函数类,实现了
Factor
接口,用Str
属性存储变量类型x
,y
,z
, 属性coe
存储对应的幂次。实现了getMap
方法。 -
Term: 项类,在文法中的含义是一连串因子的乘积,其设计是我个人认为在 HW1 中最重要也最难的。有两点考虑,第一,因为
Term
是一些列因子的连乘,所以有化简的需求。第二,表达式因子与其他因子相乘后得到的项其实是一个Expr
,这样会导致Expr
和Term
关系非常紧密,界限很模糊。如果Expr
设计成Term
的集合,会导致合并同类项比较困难。所以我的设计分两步解决这些问题。- 首先用什么数据结构管理
Term
中的因子。根据文法,项是因子乘积,但由于表达式因子的存在,同时我们还有化简的需求,把其看出简单的因子的集合显然不大合理。因此把这个问题转化成项由什么构成。不难发现,第一次作业表达式的最终结果其实是一些列单项式的加与减,单项式的结构则是 a ∗ x b ∗ y c ∗ z d a*x^b*y^c*z^d a∗xb∗yc∗zd,项又何尝不是呢。所以我设计Term
时,加入HashMap<String, BigInteger>
属性,每一个键值对表示一个单项式,键String
代表 x b ∗ y c ∗ z d x^b*y^c*z^d xb∗yc∗zd, 值BigInteger
代表 a a a。这也是为什么我在Factor
接口中定义了getMap
的方法,旨在因子返回一个单项式的集合。比如说Digit
的getMap
可以这样写:
之后只需要在public HashMap<String, BigInteger> getMap() { HashMap<String, BigInteger> digitNode = new HashMap<>(); digitNode.put("", this.num); return digitNode; }
addFactor
方法中实现单项式集合的合并即可,关于乘法,可以抽象出一个方法实现。 - 其次,如何处理
Expr
和Term
的关系,其实只要把Expr
也看成单项式的集合就好了。但是在Expr
中我并不是用HashMap
存储单项式的,我引进了一个基本类Basic
。
- 首先用什么数据结构管理
-
Basic: 基本类,本质上就是单项式,观察我的类图应该能猜出来。那我为什么要对此一举再造一个类表示单项式呢?其实原因就在这个类里的两个方法中。
Basic
实现了Comparable<T>
接口,重写compareTo
方法,用于排序。排序的目的是为了可以把-1+x
简化成x-1
。另一个是toString
方法,输出表达式时依次调用这个方法,可以根据各种情况来输出单项式。 -
Expr: 表达式类,实现
Factor
接口,有一个basics
的属性表示表达式的所有单项式。还有一个指数的属性,表示表达式因子的幂次,对应有一个power
方法,把带有指数的表达式因子展开为指数为 1 的新表达式,也可以在这里面简化指数为 0 的情况。toString
方法用于输出表达式,其实就是依次调用Basic
的toString
方法。getMap
方法,把basics
转化成HashMap
即可。
1.3.2 递归下降法:
递归下降法,是一种按照特定的文法结构解析字符串的有效方法。在第一单元表达式作业中,都可以采用这种办法解析字符串。在我的设计里,把表达式中的各个结点依次建类,按照关键字符为分隔进行解析(也有其他的办法,比如按照运算符建类,就像第一次实验的思路一样)。主要涉及的类有 Parser
(语法分析器)和 Lexer
(词法分析器)。
- Lexer: 词法分析器,就是把表达式拆成一个个子串提供给
Parser
就可以了。 - Parser: 文法分析器,是递归下降法的核心部分,其中有一个
Lexer
属性,根据Lexer
解析的子串来调用对应的Parse
方法。只要理解了递归下降法的思想就不难写了。但是在此次作业中有一个棘手的问题——加减运算的处理。举例说明:
对于这样的表达式,就会遇到多个加减号连着出现的情况。有些做法是对加减号做预处理,合并成一个,但是加减号有很多情况,导致在这方面出现了许多bug。我的做法是在+-(--+1-+x*-5)
Term
中引入了一个属性sign
,在parseTerm
方法中把所有符号一起处理,置为项的sign
属性,这个属性在Term
第一次加入因子时会被处理掉,最后变成1
表示正号。public Expr parseExpr() { Expr expr = new Expr(); expr.addTerm(parseTerm()); // 注意此处的条件,是判断子串的第一位。这是因为Lexer可以解析有符号整数,x-1 会被解析成 x 和 -1 while (this.lexer.peek().charAt(0) == '+' || this.lexer.peek().charAt(0) == '-') { // 注意这里没有调用 next 方法,可以思考一下这样的意义是什么 expr.addTerm(parseTerm()); } return expr; }
这么设计有一个好处就是所有的单项式我们都可以看做用private Term parseTerm() { int sign = 1; while ((this.lexer.peek().equals("-")) || this.lexer.peek().equals("+")) { sign *= (this.lexer.peek().equals("-")) ? -1 : 1; this.lexer.next(); } // 处理所有连着的加减号,转化成 Term 的 sign 属性 Term term = new Term(sign); // ... }
+
连接,管理单项式集合,以及输出表达式有巨大便利性。
1.4 分析评估
1.4.1 度量分析:
以下是我第一次作业的复杂分析数据:
可以看到,复杂度主要集中在 Expr
、 Term
、Parser
三个类中,前两者主要是因为要实现表达式化简,多项式乘法等等,所以复杂度较高,后者作为递归下降法的核心部分,复杂程度高。
代码规模见下图:
第一次作业由于要求不复杂,代码总规模不大。所以每个类的属性以及方法个数都不多,多则7, 8个,少则1,2个。除了多项式的乘法,加法,以及表达式输出涉及到的 toString
方法以外,大多数方法的复杂度都不高。
1.4.2 架构评估:
由于第一次作业,在架构设计上有些不足。比如 Term
Expr
中都设计到多项式的运算,这些方法是可复用的,抽象成一个工具类更好。另外,我的 Basic
单项式类只用于 Expr
中,而在 Term
里却要 HashMap
管理单项式,使得代码看起来缺少统一性,这也为我第二次作业重构埋下祸根。
从整体上来说,此架构还是具有比较良好的扩展性,第一单元的练习我也是按照这个思路,一步步完善来完成的。
1.4.3 BUG 分析:
此次作业中,我虽然中测强测都通过了,但是免不了互测被刀,结果发现自己的错误非常愚蠢。在于优化 x**2
成 x*x
时无脑使用 repalceAll
,导致 x**24
变成了 x*x4
。看别人代码发现这一现象还挺多
总结第一次比较常见的几类 bug:表达式预处理,输出表达式的优化。
表达式预处理常见错误在符号上,如果稍有不慎就会遗漏,而且预处理以后,如果 Parser
没有考虑清楚一样容易出错,比如考虑下面这个例子,符号处理完以后 x
前面还会带符号,如果 parse
方法没有处理符号就会出错。
(-+x**+1)**+00000
其他常见预处理的 bug 还有前导零处理,表达式因子指数展开等等。
输出表达式优化的 bug 也很常见,比如把 1*
直接替换成空串,x**1
直接替换成 x
等等。
总而言之,一切直接在字符串层面的替换操作都有巨大危险,值得警惕。
二、第二次作业
2.1 任务需求
第二次作业在第一次的基础上引入了自定义函数的调用,多层括号嵌套,三角函数,复杂度有所提升。其中,由于我采用的是递归下降法,多括号嵌套在第一次作业中已经实现了,所以第二次作业的任务实际上是完成自定义函数和三角函数。
2.2 实现框架
由于第一次作业的设计中有些不足或可以改进的地方,我第二次作业在其基础上做了局部重构和优化,加入了自定义函数类和三角函数类,同时将多项式,单项式的计算方法重新整理成一个工具类。整体框架如下:

此架构的核心依旧是递归下降法,但是一些地方做了些许调整。
2.3 设计思路
第二次作业中,我把单项式的类也用于构成 Term
主要是因为第二次作业的要求导致单项式的形式变得比较复杂:
a
∗
x
b
∗
y
c
∗
z
d
∗
∑
i
=
1
n
1
s
i
n
k
i
(
F
a
c
t
o
r
)
∗
∑
j
=
1
n
2
c
o
s
k
j
(
F
a
c
t
o
r
)
a*x^b*y^c*z^d*\sum_{i=1}^{n1}sin^{ki}(Factor)*\sum_{j=1}^{n2}cos^{kj}(Factor)
a∗xb∗yc∗zd∗∑i=1n1sinki(Factor)∗∑j=1n2coskj(Factor) ,如果依照第一次作业的思路,用键值对保存单项式,就会比较复杂(因为要重写键的 equals
和 hashCode
方法,我尝试写了一版,但是失败了)。
这一小节我将介绍新增类和改动类的设计考虑:
2.3.1 结点设计:
- Trig: 三角函数接口,继承了
Factor
接口,用于统一管理正弦、余弦函数类。 - Sin: 正弦函数类,实现
Trig
接口,实现了toMonomials
方法。由exponent
属性表示指数,content
属性存数内部因子的字符串形式。实际上是吧三角函数内部因子全部看出表达式因子,调用表达式的toString
方法得到该字符串。 - Cos: 余弦函数类,实现
Trig
接口。设计上基本和sin
一致。 - CustomFunc: 自定义函数类,但是没有实现
Factor
接口! 依照文法,通常来说应该把自定义函数设计成因子的,但是我的设计中并没有这样做,原因有两点:- 第二次作业要求最多可以定义三种函数,我们需要对其保存,需要一个类管理。如果再设计一个自定义函数因子类,那么和上述类的耦合度过高。如果把其并入上述类的设计里,又会混淆自定义函数和因子的概念,类的设计不够纯粹。
- 从另一种角度上理解,其实自定义函数并不是因子,将参数代入得到的新表达式才是的因子。所以设计一个
invoke
函数调用方法,通过实参替换然后解析,返回一个新的表达式。
- Monomial: 单项式类,其实就是第一次作业的
Basic
。属性coe
表示系数,powers
表示三种变量因子及其指数,sins
表示正弦函数及其指数,coses
表示余弦函数及其指数。
方法与第一次作业的Basic
变动不大,加入了isSameKind
方法用于判断两个单项式是否是同类项。同时,因为单项式的构成比较复杂,所以有很多set
、get
、addSomthing
的琐碎方法。 - Term: 项类。和第一次作业相比,改用
Monomials
属性管理单项式集合,其他的设计都类似。但是不再在该类中写合并同类项和多项式乘法的方法了,这一点和Expr
的设计一致。
2.3.2 工具类设计:
为了增加一些方法的复用,我将一些通用的方法写成静态方法构成 CalTool
工具类。比如多项式相加,多项式相乘,还有单项式的深克隆方法等等。这样做的好处很明显,一方面避免重复写相同的方法,代码复用提高,另一方面降低了类设计的复杂性,使类更专注于管理自己的属性,而对类之间的操作抽象成工具,设计更加优雅。
2.3.3 解析方法:
本次作业中,递归下降法需要增加对三角函数因子和自定义函数因子的解析。
- 解析三角函数因子: 在
Parser
中增加对三角函数的解析方法,与解析表达式因子非常相似,依葫芦画瓢即可。 - 解析自定义函数因子: 我设计的思路是将读入的自定义函数先进行解析,存储在
CustomFunc
里,并用一个ArrayList
管理,将其添加为Parser
的静态属性,在解析自定义函数因子时从中找到匹配的自定义函数,调用其invoke
方法。需要说明的是,invoke
方法其实是将实参代入后(调用因子的toString
方法,在字符串层面做的替换),得到一个字符串,实例化新的Parser
来解析这个表达式并返回。这也是把functions
设计成静态属性的原因,由此实现了自定义函数调用时可以重复嵌套的情况。
2.4 分析评估
2.4.1 度量分析:
如下是我第二次作业的复杂度分析数据:



代码总规模:
其中工具类的复杂度偏高,因为它是实现多项式运算和化简的核心部分。单项式类由于写很多繁琐的方法以应对各种场景,方法数偏多。两者的耦合度会高一些,因为工具类完全是依照单项式的属性特点写的。
此外,对于比较复杂,代码量多的方法,我都会把其拆成几个功能相对独立的方法,所以有些方法控制的分支比较多,但复杂度比较平均。
2.4.2 架构评估:
该架构把每个类按功能和含义划分清楚,不存在类和类之间的高度耦合,整体上有比较好的拓展性,为第三次作业提供了良好的基础。
但是,我的架构有个很大的缺陷在于难以进行三角函数的化简。可以看到,我在存储三角函数内部因子时用的是 String
, 意味着如果要优化,要么在字符串层面做操作,难度大且易出错,要么对字符串再解析,这样时间成本太高了,担心 TLE。我尝试过用 Expr
存三角函数的内部因子,未果而终,终究还是自己对 Java 的知识储备太少了。
2.4.3 BUG 分析:
第二次作业中测强测乃至互测都逃过一劫,结果发现有 bug 留到第三次作业互测被找出来了。原因是我在函数调用时用到因子的 toString
方法,而三角函数在写这个方法时忽略了指数为零的情况,稍作修改就过了。代码复杂度和行数都没什么区别,主要是细心的问题。
三、第三次作业
3.1 任务需求
第三次作业增加了求导的需求,函数定义时的调用关系。后者只需要在解析自定义函数时将已解析好的自定义函数加入 Parser
的 functions
属性即可。
3.2 实现框架
有了第二次作业的架构设计,第三次作业显得比较简单了。求导事实上只需要对每个单项式进行即可,单项式直接以 +
连接。求导后的单项式变成一个多项式,此时只要调用我们已经写好的多项式相加的函数即可了。
从下图可见,第三次作业基本只是增加了 DeriveTool
这个求导工具类。

3.3 设计思路
为了实现求导,在 Parser
中增加 parseDerivation
,对解析出来的表达式因子调用求导方法即可。整体架构上几乎不做改变,只需要正确完成每个单项式的求导。
和第二次作业一样,我把求导方法都整合成一个工具类,把方法写成静态的,这样只需要在新类中做增量开发,原先的类几乎不需要改动。
3.3 分析评估
3.3.1 度量分析:
如下是我第三次作业复杂度的数据分析:
可以看到增加的求导工具类并不复杂,因为只需要专注于求导这件事即可。代码整体规模也只在第二次作业基础上增加了一点。
3.3.2 BUG 分析:
第三次的互测出了我第二次作业的 bug… 看来就算强测互测过后,也不代表程序没错,还是要多做测试。反思第三次作业,就是因为写得太快,导致懒得充分测试
四、互测策略
三次作业的互测中,我基本采用同样的策略方法。首先在自己写代码时,记住遇到的每个易错点,设计测试数据并保存。在互测开始时做黑箱测试。其次设计几个特殊样例看别人的程序是否有化简,做了哪些化简。比如第二次作业中,如果实现了诱导公式,就可以设计这方面的数据疯狂测试,因为化简是出 bug 最大的来源。最后,随机阅览别人的代码(但是并没有通篇阅读,因为太费时间了),根据其代码的特点针对性测试。比如看到 replaceAll
就可以往这方面造数据。
结果是三次互测下来一共找了四五处 bug。为了避免 hack 同质 bug,每个测试程序我都最多 hack 一次,其实可以多hack几次的,万一有多个 bug 呢。
五、感想体会
其实在学期开始前我就对 OO 抱有极大的恐惧,但是这第一单元作业完成下来,我发现这门课程似乎也不是那么不友好,自己写代码能力有很大提高。一方面我更加注意代码风格,追求更加优雅的代码结构;另一方面本单元作业很好地训练了我对大规模项目的设计和构思的能力,帮助我深入理解面向对象的设计思想,包括继承和多态,让我对编写结构良好,可拓展的,有鲁棒性的程序更有经验。
从第一次作业的痛苦构思,第二次作业的重构,到第三次作业比较轻松地完成任务,我已经尝到了良好的结构和层次设计的甜头,深刻体会其对大规模项目有多么重要。希望自己在这一方面可以越做越好,也希望之后的作业可以顺利完成。
最后谈谈一些遗憾。本单元帮助我回顾了许多 Java 知识,但是在完成任务过程中也发现自己还是有许多欠缺的地方。比如第二次作业的 HashMap
,因为自己知识储备不足, 只好妥协转向另一种方案,以至于没法实现三角函数的优化。果然,只有扎实的基础知识才能写出更好的项目。对此,我也将在今后学习中不断弥补,夯实基础。