0. 作业简介
对表达式进行化简,展开不必要的括号,并尽可能缩短表达式长度。作业迭代分三步走:
下面对笔者的架构进行分析。
1. 架构设计体验
1.1 第一次作业
注意到输入表达式的定义有三个特点:
- 具有严格的文法定义;
- 具有大量可能出现的空白项;
- 有可选项,如:
Expression := [add/sub] Term | ...
第一个Term含有可选的正负号。
基于第一个特点,以及指导书的提示,我果断采用递归下降法分析文法,这样在后续迭代时只需增加文法模块即可;而若使用正则表达式,相当于自己填堵了对括号嵌套的迭代空间。
基于第二个特点,我借鉴了编译技术中的词法分析,将输入分割为Tokens(词法单元)的列表。由于空白符是可选项,我的分割并不依靠它,而是利用正则表达式([+\\-*x^()=]|[0-9]+)
逐步提取有效的tokens。在迭代时若出现新的文法,只需增加正则表达式的内容即可。
基于第三个特点,不同于大部分同学在预处理时“合并符号”,我采用了回溯式匹配,即先默认没有这个可选的符号,向下匹配,如果下层匹配失败则识别可选项,若仍失败,则向上报告。这样设计能够完全依据文法定义分析,如果有不合法的情况能够立即报错,增加健壮性。并且性能并没有明显影响。(本想使用异常报告失配,后来了解到这样开销很大,因此又修改成return null
报告失配)
public void parseExpression{
Term tmpTerm = parseTerm();
if (tmpTerm == null) {
//mismatching, thus tracing back
//parsing sign
if (tmpTerm == null) {
//still mismatching
return null; //reporting mismatching
}
//detecting following terms
}
}
第一次的架构可以用下图概括:
1.2 第二次作业
在自我思考和讨论中,发现第二次作业有三大焦点:
- 函数的展开:可以增加预处理模块,在字符串的意义上进行替换,类似于C语言的宏;也可以改变文法,增加对函数的分析;
- e指数的存储索引:合并同类项时,由于第一次大多以x的幂作为单项式的索引,因此需要改动。同学们各显神通,有的使用类似于Hash冲突的链表法解决同幂不同exp的情况,还有开发
List<List<...>>
的结构…… - 缩短长度。常见的策略有提取公因子、凑项等。
经研讨交流,本着“绝不重构,少量修改,可以新增”的原则(实际上是“开闭原则”),作出了如下解决方案:
- 采用类似展开“宏”的方式替换自定义函数。这样的好处是:自定义函数对于主体的处理程序是透明的;而如果修改文法,需要支持多变量,导致大量重构;
- 引入新的类MonoTag,作为Monomial的索引(相当于同类项的标识符),重写equals和compareTo方法,然后将之前的
TreeMap<BigInteger, Monomial>
直接替换为TreeMap<MonoTag, Monomial>
即可。这样可以使架构不必调整,减少重构风险。MonoTag内容如下:
1.3 第三次作业
函数嵌套定义很简单:只需要复用展开函数的方法,对每个新函数的定义式都做一次展开即可。
而求导与表达式在解析和求值上都有很大共性:都含有子表达式,在运算时都要先对子表达式求值,唯一的区别就是DiffFactor在子表达式求值后还需求导。因此只需重写getSubPoly函数,在上层调用时利用多态性,自然地完成求导工作。这样不用修改上层逻辑,将上层不需要知道的细节隐藏。决定直接将求导因子继承于表达式因子,复用相关代码:
1.4 最终架构与迭代空间
1.4.1最终架构
架构修改路径由下图总结:
1.4.2 迭代的空间
- 如果需要实现积分,只需要继承ExprFactor即可(与求导类似);
- 可以实现非法表达式的检查。由下文的类图及代码可以发现,我预留了一个异常类IllegalExpressionException,
(最初为了标记失配,后来改成了)用于表示没有匹配的文法。上文提到,由于我是严格按照文法回溯解析的,对符号多余、括号失配、非法指数等异常都能做到检查。由于有异常处理框架,故只需完成对异常的逻辑检查即可。
2. 对最终程序结构的度量分析
2.1 代码量与方法规模统计
2.1.1 代码量
2.1.2 方法规模
类名 | 公有方法数 | 私有方法数 |
---|---|---|
func.function | 4 | 0 |
func.funcManager | 5 | 3 |
grammar.ConstFactor | 2 | 0 |
grammar.DiffFactor | 2 | 0 |
grammar.EExponentFactor | 3 | 0 |
grammar.Expression | 3 | 0 |
grammar.ExprFactor | 4 | 0 |
grammar.Factor | 6 | 0 |
grammar.Monomial | 25 | 5 |
grammar.MonoTag | 3 | 0 |
grammar.Parser | 7 | 11 |
grammar.Term | 5 | 0 |
grammar.VariableFactor | 1 | 0 |
InputHandler | 5 | 1 |
Lexer | 3 | 0 |
Main | 1 | 1 |
部分方法的访问属性还能再优化。
2.2 方法复杂度分析(含分支)与改进方案
主要关注ev指标(基本圈复杂度),它表示真正增加程序路径数目的条件分支。
由于绝大部分方法基本圈复杂度都很低(控制流路径不超过2),故上图只展示ev大于2的。下面对ev最高的parseFactor提出优化方案。原方法的逻辑如下:
private Factor parseFactor() throws IllegalExpressionException {
//...
if (firstToken.equals("x") {
//parsing variable
} else if (firstToken.equals("(")) {
//parsing expression
} else if (firstToken.equals("dx")) {
// parsing derivative
} else if (firstToken.equals("exp")) {
//parsing exp
} else {
//must be a number, or mismatching
BigInteger constValue = parseSignedIntegerAndMove(false);
if (constValue == null) {
return null;
}
//parsing
}
//...
这一段有两个问题:
- 使用大量的串行分支判断,实际上是除了对整数的处理外,在逻辑上可以并行的;
- 违背SOLID中的单一职责原则。完成了判断+处理两项任务。
通过对C语言函数指针的理解,我设想制造一种基于“查找表”的优化。经学习,了解到Java中的函数式接口(只封装一个方法):
private interface FactorFactory {
Factor createFactor(String firstToken) throws IllegalExpressionException;
}
只需要将这个接口作为Map的第二元,将标志字符串作为索引即可实现。然后将解析逻辑封装成适配于上述接口的函数,即可完成“打表”:
Map<String, FactorFactory> factorMap = new HashMap<>();
factorMap.put("x", this::createVariable);
factorMap.put("(", this::createExpr);
factorMap.put("dx", this::createDiff);
factorMap.put("exp", this::createExp);
最终解析业务的代码简化为很简洁的形式,并且基本圈复杂度由9降至1。
String firstToken = tokens.get(currentIndex);
Factor factor;
FactorFactory factory = factorMap.get(firstToken);
if (factory != null) {
factor = factory.createFactor(firstToken);
} else {
factor = createConstFactor(firstToken);
}
2.3 内聚与耦合度量与分析
我们关心如下两个指标:
- CBO:一个类与其他类之间的依赖关系数量,包括其与其他类的关联、聚合、继承等关系;
- LCOM:用于评估类的内聚性的度量指标。它衡量了一个类中的方法之间的关联程度。
Parser的CBO最高,是因为其调用了几乎所有的文法类,用于解析表达式。这导致类较为冗长。
Function的LCOM最高,是因为类内部的方法没有共享数据成员。如图所示,reverseSign,setExponent等各自改变相应的成员变量,而没有形成共性的目标。
2.4 类图与类设计分析
2.4.1 类图
经三次迭代,最终类图如下:
2.4.2 设计理念简析
定义了两个包裹:grammar和func。func内部的类主要负责自定义函数展开的业务,grammar完成文法解析与化简的逻辑。两个包均只有一个类与Main有依赖关系,达到解耦目的。
为降低主类复杂度,输入解析单独封装为InputHandler类;Main负责实现各类的调度流程;为达到复用的效果,Lexer类的业务被封装为一个静态方法,减少调用的复杂度。
public static ArrayList<String> tokenize(String rawStr) {
Lexer lexer = new Lexer(rawStr);
return lexer.toTokens();
}
在func包中,Function类维护函数信息,包括名称、参数表、定义等,为FuncManager所管理,FuncManager完成替换逻辑。
grammar包的设计原因与历史渊源,在“架构设计体验”已经详细分析,不再赘述。
3. bug分析
本单元三次迭代在中测和强测以及互测中均一次性通过,故分析两个在调试中出现的bug。
3.1 共享内存修改导致出错
hw1中,在计算Term时,有以下语句:
public void addToPolynomial(Map<Integer, Monomial> polynomial) {
//...
for (Map.Entry<Integer, Monomial> entry: subMap.entrySet()) {
entry.getValue().multiplyAndUpdate(tmpMonomial);
Monomial.addToMap(polynomial, entry.getValue());
}
}
我在计算单项式相乘时,选用的是更新当前单项式。到第二次作业时,由于需要将幂与exp内部表达式相乘,导致原本两个引用共享的对象被修改,从而出错。这是由于设计复杂,将运算与修改混合到了一起导致bug。因此第二次对Monomial类的运算做出了大量修改。逻辑是:
将Monomial只有在构建时(增添系数、幂指数)允许修改;一旦建成,后续任何方法(Monomial相乘、合并同类项等)都不允许修改原操作数,而是返回新的对象。
许多同学提出“无脑深拷贝”的策略,我之前也写出过这种多余的代码:
res.power = new BigInteger(m1.power.toString());
后来发现,问题的本质是可变对象与不可变对象的区别。大胆将属性声明为final
,即使有共享的内存,也没必要担心它被修改。例如,MonoTag中的属性:
private final BigInteger power;
private final Map<MonoTag, Monomial> expOfE;
这个类就没必要深拷贝。
而只有对于可变对象,才要递归地拷贝,直到遇到不可变对象为止。
3.2 输出逻辑复杂导致出错
hw1的输出是直接使用的大量判断,认知复杂度为9,基本圈复杂度达到3:
public String printWithOnlyNegSign() {
if (coefficient.equals(BigInteger.ZERO)) {
return "";
} else if (power == 0) {
return coefficient.toString();
} else {
String coeStr;
if (coefficient.equals(BigInteger.ONE)) {
coeStr = "";
} else if (coefficient.equals(new BigInteger("-1"))) {
coeStr = "-";
} else {
coeStr = coefficient.toString() + "*";
}
String varStr = power == 1 ? "x" : "x^" + String.valueOf(power);
return coeStr + varStr;
}
}
在hw2中,仍沿用这种逻辑,导致一些没想到的情况出现bug。例如:
coefficient = -2, power = 0, exp=x
,输出成了-2exp(x)
。这是因为幂为0就没加*。
解决方案:拆分逻辑,分为打印系数、幂、e指数三大部分,每个子方法只需要知道还有没有后续内容即可:
public String printWithOnlyNegSign() {
if (coefficient.equals(BigInteger.ZERO)) {
return "";
} else {
return printCoe(!power.equals(BigInteger.ZERO) || !exp.isEmpty()) +
printPowFunc(!exp.isEmpty()) + printExp();
}
}
代码由19行降至了7行,认知复杂度由9降至3,基本圈复杂度由3降至2,可见复杂度和稳定性的关联。而拆分、下发业务,是降低复杂度的途径。
4. 发现他人bug
策略:数据生成器和人工数据结合。人工方面,除了测试理解题意的exp((-x))
和性能的dx(exp(exp(...
,还有针对代码架构的测试:
例如,评测机发经常报解析异常的错误,于是阅读代码,发现这位同学直接在输出表达式的方法中调用println
函数,而非交给主类输出。猜测递归打印exp内部表达式时会多输出换行符,于是制造exp((x))
,输出exp((x\n))\n
,hack成功。
5. 优化
除了基本的合并同类项、简化表达等策略,第三次还加入了合并同类项,使用gcd方法求公因子在放到exp外面即可。
提取公因子时,我再次犯了修改Monomial不可变成员的错误:
for (Monomial mono: exp.values()) {
mono.coefficient = mono.coefficient.divide(gcd);
}
经过分析,不应该修改coefficient和power,能改的只有exp(容器类),改为:
for (Monomial mono: exp.values()) {
Monomial newMono = new Monomial(mono);
newMono.coefficient = mono.coefficient.divide(gcd);
newExp.put(newMono.getTag(), newMono);
}
exp = newExp;
教训:应该严守约定,不要投机取巧。否则破坏代码风格和逻辑结构,导致出错且很难查出。
6. 心得体会
- 迭代时,切忌打补丁,一旦发现逻辑不清晰的地方立即修正,否则只会为将来遗留更大的麻烦!
- 充分利用研讨课和网上资源,了解更清晰、高级的实现,闭门造车不可取。
- 做充分测试,手工边缘条件+数据生成器随机测试,过中测并不是终点。
7. 未来方向
- 建议在总结博客周,对一些公认的架构优美、表达清晰的代码进行分析讲解,方便大家学习。
- 建议适当对数据生成和判定提供一些指导。