目录
写在前面
- 本次作业共包含三次迭代:
- 第一次实现了包含加、减、乘、乘方、单层括号的表达式处理
- 第二次实现了嵌套括号处理、自定义函数处理、指数函数处理
- 第三次实现了表达式求导与自定义函数嵌套
- 作业过程中的主体思路为递归下降算法,还包含有诸如字符串替换、SOLID法则的一些小细节,具体处理过程如下:
- 预处理:对表达式字符串进行预处理。
- 构造语法树:采用递归下降算法。
- 第一层递归:分析表达式中的项(以+、-为分隔)。
- 第二层递归:分析项中的因子(以*、^为分隔)。
- 第三层递归:分析因子:
- 表达式因子:若为带括号的表达式或指数函数,回到第一层。
- 自定义函数因子:应用字符串替换后,回到第一层。
- 求导因子:调用求导方法后,回到第一层。
- 幂函数/常数因子:返回相应多项式给第二层。
- 性能优化:进行相关的化简处理。
一、三次具体的作业
- Unit1的主题为表达式化简,需要处理含有加、减、乘、乘方、括号、指数函数、自定义函数、求导等功能的表达式,并进行展开和计算。
1.1 第一次作业
UML类图
类功能介绍
- Main类是程序的入口,负责初始化Preprocessor和Parser对象,并调用它们的方法。
- Preprocessor类负责预处理输入的字符串,包括删除空格,处理加减符号等。
- Parser类负责用递归下降算法解析预处理后的字符串,将其转换为Poly对象。
- Unit类表示单项式,在第一次作业中的形式为 a x b ax^b axb。类内包含了单项的各类操作,如运算操作、重载的构造方法、toString的重写等。
- Poly类表示多项式,在第一次作业中的形式为 ∑ ( a x b ) \sum(ax^b) ∑(axb),数据结构为HashMap<Integer, Unit>。类内包含了多项式的各类操作。
一些细节
- 不同于大部分同学类的结构,我的此次设计中只有5个类,这是因为我并没有设计继承自Factor的各种因子,而是将所有因子用单项式 a x b ax^b axb实现。实现过程中用到了重载的构造方法。
- 同时,我也没有设计Expr、Term类,而是全部以Poly类实现相应内容。这样设计的优点是易扩展(尤其是在第三次作业),缺点是思路比较面向过程。
- 此外,我并没有创建Lexer类,而是将其功能直接在Parser类中通过下面所示的方法实现。我认为这样相较于Lexer.next()会更加好理解,也能体现词法分析本身便是语法分析的一部分。
//以+、-为分割来切割表达式中的项
//跳过嵌套的括号
public List<String> splitExpression1(String expression) {
List<String> tokens = new ArrayList<>();
StringBuilder currentToken = new StringBuilder();
int parenthesesCount = 0;
for (char c : expression.toCharArray()) {
if (c == '(') {
parenthesesCount++;
} else if (c == ')') {
parenthesesCount--;
}
if (parenthesesCount == 0 && (c == '+' || c == '-')) {
tokens.add(currentToken.toString().trim());
currentToken = new StringBuilder();
tokens.add(String.valueOf(c));
} else {
currentToken.append(c);
}
}
tokens.add(currentToken.toString().trim());
return tokens;
}
1.2 第二次作业
UML类图
类设计考虑
- Main类与Parser类中新增了负责处理自定义函数相关的字符串替换方法。
- Unit类的基本表示形式变为 a x b ∗ e x p ( P o l y ) ax^b*exp(Poly) axb∗exp(Poly)。类内重写了equals()和hashcode();与此同时Poly类的数据结构变为HashMap<BigInteger, HashMap<Poly, Unit>>,类内也重写了equals()和hashcode(),并且与Unit类中的同名方法存在递归调用的关系。
一些细节
- 在第二次作业中,使用两层HashMap来实现多项式有助于通过
hashcode()
和equals()
方法快速判断并合并同类项。然而,这种方法本质上是利用容器的数据结构层次化来替代了抽象结构的层次化,且只适用于当前的情景。如果多项式的需求发生变化,例如增加三角函数因子或对数函数因子,那么整个项目可能需要进行大规模的重构。 - 这表明,这次作业的完成更多地面向过程而非面向对象。在这个阶段,笔者选择了一种比较省时省力的方式来完成这次迭代,但这其实不是一个好的选择。因为在软件开发中,我们应该尽可能地面向对象,以提高代码的可读性和可维护性,同时也能更好地适应需求的变化。
1.3 第三次作业
UML类图
类功能介绍
- 在Poly类和Unit类中新增了求导的实现(即getDerivation()方法)。实现过程中存在递归调用。
一些细节
- 由于之前的架构设计得较为清晰,所以本次作业我只花费了20分钟便完成。
二、度量分析
基于DesigniteJava,对经历三次迭代后作业的分析如下:
2.1 Design Code Smells
Type Name | Code Smell |
---|---|
Main | Unutilized Abstraction |
Poly | Cyclic-Dependent Modularization |
Poly | Insufficient Modularization |
Unit | Cyclic-Dependent Modularization |
表格列出了类中的代码异味:
- Main类中发现了’Unutilized Abstraction’,即"未使用的抽象",表示有未使用的接口或抽象类。这是因为在重构过程中,我将Lexer类移除,其功能由Parser类接管,但相关注释未被删除。
- Poly和Unit类中发现了’Cyclic-Dependent Modularization’,即"循环依赖模块化"。这是因为在使用递归下降算法时,我没有定义多种因子及其继承、接口关系,而是用多项式和单项式处理,导致模块间紧密耦合和循环依赖。
- Poly类中发现了’Insufficient Modularization’,即"模块化不足"。这是因为在设计和实现代码时,我没有充分考虑到模块化,而是一味根据新需求堆加新方法,导致功能和逻辑没有被合理划分和封装到独立模块中。
- 通过查阅资料我发现,'Unutilized Abstraction’会导致代码冗余和混乱,增加理解和维护成本;'Cyclic-Dependent Modularization’会增加代码复杂性,降低可读性和可维护性,增加修改和测试难度;'Insufficient Modularization’会导致代码结构和逻辑混乱,降低可读性和可维护性,影响代码的复用性和扩展性。虽然在三次迭代中,这些异味可能影响不大,并且异味出现的主要原因是我没有花费时间维护类的模块化,但在实际迭代开发中,这些异味可能引发问题,值得警惕。同样的道理在下一个异味分析中适用。
2.2 Implementation Code Smells
Type Name | Method Name | Code Smell |
---|---|---|
Parser | splitExpression1 | Complex Conditional |
Parser | splitExpression2 | Complex Conditional |
Parser | parserTerm | Magic Number |
Parser | parserTerm | Magic Number |
Parser | parserFactor | Complex Conditional |
Parser | parserFactor | Magic Number |
Parser | parserFactor | Magic Number |
Poly | oneBracket | Complex Method |
Poly | toString | Complex Conditional |
Poly | toString | Complex Method |
Poly | toString | Magic Number |
Poly | toString | Magic Number |
Unit | equals | Long Statement |
Unit | toString | Complex Method |
表格展示了类的方法中发现的实现代码异味:
- Complex Complex Conditional:这种代码异味在Parser类的splitExpression1、splitExpression2和parserFactor方法,以及Poly类的toString方法中被发现。它表示这些方法中存在复杂的条件语句,包含多个条件判断。这会导致代码的阅读和理解变得困难,同时也增加了测试的复杂性。
- Magic Number:这种代码异味在Parser类的parserTerm和parserFactor方法,以及Poly类的toString方法中被发现。它表示这些方法中存在魔法数字,即直接使用的数字常量,而没有给它们一个有意义的名字。这会导致代码的可读性和可维护性降低,因为其他人可能不清楚这些数字的含义和用途。
- Complex Method:这种代码异味在Poly类的oneBracket和toString方法,以及Unit类的toString方法中被发现。它表示这些方法的复杂性较高,包含多个逻辑分支和循环结构。这会导致代码的阅读和理解变得困难,同时也增加了测试的复杂性。
- Long Statement:这种代码异味在Unit类的equals方法中被发现。它表示这个方法中存在长语句,一行代码的长度超过了推荐的最大长度。这会导致代码的阅读和理解变得困难。
2.3 Type Metrics
Type Name | NOF | NOPF | NOM | NOPM | LOC | WMC | NC | DIT | LCOM | FANIN | FANOUT |
---|---|---|---|---|---|---|---|---|---|---|---|
Main | 0 | 0 | 1 | 1 | 20 | 2 | 0 | 0 | -1.0 | 0 | 2 |
Parser | 1 | 0 | 8 | 8 | 150 | 32 | 0 | 0 | 0.0 | 1 | 3 |
Poly | 1 | 0 | 20 | 20 | 264 | 72 | 0 | 0 | 0.1 | 3 | 2 |
Preprocessor | 1 | 0 | 6 | 6 | 44 | 9 | 0 | 0 | 0.0 | 2 | 0 |
Unit | 4 | 0 | 19 | 19 | 166 | 38 | 0 | 0 | 0.0 | 3 | 2 |
表格展示了不同类的度量指标,其中不太好的指标主要有:
- 加权方法数量(WMC):Poly类型的WMC值最大,为72,这意味着Poly类型的复杂性较高,需要更多的精力去理解和维护。
- 代码行数(LOC):Poly类型的LOC值最大,为264,这表示Poly类型的代码长度较长,需要更多的时间来理解和维护。
- 类内方法相似度(LCOM):Poly类型的LCOM值最大,为0.1,这可能意味着Poly类型的方法之间的相似度较高,存在一些重复的代码。
- 输入耦合(FANIN):Poly和Unit类型的FANIN值最大,为3,这表示这两个类型被其他类型引用的次数较多。
2.4 Method Metrics
Type Name | Method Name | LOC | CC | PC |
---|---|---|---|---|
Main | main | 18 | 2 | 1 |
Parser | Parser | 3 | 1 | 1 |
Parser | splitExpression1 | 23 | 5 | 1 |
Parser | splitExpression2 | 23 | 5 | 1 |
Parser | parserExpr | 17 | 4 | 1 |
Parser | parserTerm | 20 | 4 | 1 |
Parser | parserFactor | 25 | 6 | 1 |
Parser | splitArgs | 20 | 5 | 1 |
Parser | CustomFunc | 16 | 2 | 1 |
Poly | equals | 13 | 4 | 1 |
Poly | hashCode | 3 | 1 | 0 |
Poly | Poly | 2 | 1 | 0 |
Poly | Poly | 6 | 1 | 1 |
Poly | Poly | 5 | 1 | 1 |
Poly | Poly | 3 | 1 | 1 |
Poly | getDerivation | 14 | 4 | 0 |
Poly | oneBracket | 23 | 8 | 0 |
Poly | getHashMap | 7 | 2 | 0 |
Poly | addUnit | 19 | 3 | 1 |
Poly | addPoly | 14 | 5 | 1 |
Poly | subUnit | 21 | 3 | 1 |
Poly | subPoly | 14 | 5 | 1 |
Poly | mutPoly | 18 | 6 | 1 |
Poly | expPoly | 17 | 3 | 1 |
Poly | getGcd | 20 | 6 | 0 |
Poly | setGcd | 9 | 3 | 1 |
Poly | isEmpty | 7 | 2 | 0 |
Poly | delNull | 6 | 2 | 0 |
Poly | toString | 40 | 11 | 0 |
Preprocessor | Preprocessor | 3 | 1 | 1 |
Preprocessor | delBlank | 3 | 1 | 0 |
Preprocessor | handleAddSub | 11 | 1 | 0 |
Preprocessor | handletheFirst | 14 | 4 | 0 |
Preprocessor | handleMinus | 3 | 1 | 0 |
Preprocessor | getResult | 7 | 1 | 0 |
Unit | equals | 10 | 3 | 1 |
Unit | hashCode | 3 | 1 | 0 |
Unit | Unit | 4 | 1 | 2 |
Unit | Unit | 6 | 1 | 4 |
Unit | Unit | 3 | 1 | 1 |
Unit | Unit | 3 | 1 | 1 |
Unit | Unit | 4 | 1 | 0 |
Unit | Unit | 5 | 1 | 3 |
Unit | getDerivation | 25 | 4 | 0 |
Unit | setFuncexp | 4 | 1 | 1 |
Unit | add | 4 | 1 | 1 |
Unit | sub | 4 | 1 | 1 |
Unit | mult | 16 | 4 | 1 |
Unit | getCoe | 3 | 1 | 0 |
Unit | getExp | 3 | 1 | 0 |
Unit | getFuncPoly | 3 | 1 | 0 |
Unit | BaseKey | 3 | 1 | 0 |
Unit | toString | 54 | 12 | 0 |
Unit | getFuncExo | 3 | 1 | 0 |
- 表格展示了不同类的方法的度量指标,从整体上看,这些方法的规模较小,最多不超过54行(Unit类型的toString方法),显示这些方法的结构良好,易于理解和维护。同时,方法的圈复杂度大部分在6以下,这进一步说明了这些方法的逻辑较为简单。
- 然而,有几个方法的圈复杂度较高,例如Poly类型的oneBracket方法和toString方法,以及Unit类型的toString方法,它们的圈复杂度都超过了10。这可能是由于这些方法中存在一些复杂的条件判断和循环结构,使得这些方法的逻辑变得较为复杂。
- 此外,Parser类型的splitExpression1、splitExpression2、parserExpr、parserTerm和parserFactor方法的圈复杂度也较高,这主要是由于这些方法中使用了递归下降的实现方式,在过程中需要进行类型判断,从而增加了方法的复杂度。
三、设计体验
- 在三次迭代中,我的架构没有经过太大的变化,大部分的架构在第一次的作业中就已经成型。这在我三次大差不差的UML类图中就可以看出来。
- 唯一产生较大改变的是Poly类中在第二次作业从单层的HashMap存储Unit变为双层HashMap嵌套的结构。而在第二次作业的分析中我也提到,这种方法本质上是利用容器的数据结构层次化来替代了抽象结构的层次化,且只适用于当前的情景。如果多项式的需求发生变化,例如增加三角函数因子或对数函数因子,那么整个项目可能需要进行大规模的重构,比如说把HashMap的嵌套层次加深,亦或者直接采取ArrayList的容器来存储并新写一个合并同类项的方法。
四、Bug分析
- 比较悲惨的是,我的程序在第二次作业和第三次作业中都出现了Bug,且Bug都是format error,即为了得到更多性能分去掉了不应该去的括号,第二次作业中是出现了exp(-x)的错误,第三次作业中重写了去括号判断的方法,结果写得不严谨,又出现了exp(x*exp())的错误。
- Bug出现的原因还是没有仔细阅读指导书中的对表达式的形式化定义以及写程序时草率的心态。因为如此粗心而丢掉了不该丢的分数,我也感到追悔莫及。我应该端正自己对待作业的心态。
五、互测策略
- 我的测试策略是将评测机与人工简化数据相结合。这种方法效率较高,尽管有时会发现一些同学的程序由于Cost限制而无法进行内存超限(MLE)和时间超限(TLE)的攻击,但这种方法发现的Bug仍然具有较高的有效性。
六、优化分析
- 在第二、三次作业中,我实现了以下优化:
- 正项提前
- exp 内表达式是否可以去括号
- exp 内表达式是否可以提取公因数到指数函数的指数部分
- 现在看来,因为去括号导致出现了Bug,还真是有些得不偿失。
七、心得体会与未来方向
- 我在第一单元碰到的最大困难还是在对于递归下降算法的理解上。这导致我在第一次作业上花费的时间比第二、三次作业加起来还久,也是在周三的上机后才逐渐认识到这一算法的思想。课程组可以考虑在寒假给同学们布置对该算法的预习以及相关的训练任务,不要让同学们因为对算法不理解而不是对面向对象思维不理解而感到沮丧。