444
社区成员




由于一单元的作业有3次,且每次作业均是以上一版本的增量扩展,所以在作业开始前设计一个好的架构至关重要。一个好的架构能减少每次作业的工作量,便于扩展,减少重构;也能让代码结构清晰,便于debug。本人3次的作业均没有进行大量的重构,这里在架构设计方面给出我自己的心得。
课程组在第一次作业时就推荐使用正则表达式或者递归下降的方式对输入进行解析,个人经验来看递归下降是一个更优的选择。递归下降的结构层次更清晰,且易于扩展,而如果对正则表达式不熟练,很容易导致匹配出现bug,且后续的扩展会加入一些复杂因子和运算,只用正则表达式处理可能比较困难。(身边有朋友第一次作业用正则表达式匹配做,第二次直接全部重构,改成递归下降了)课程的安排在第2次课才会讲到继承和接口,这里强烈建议在第一次作业时就开始使用这些结构,能使代码更便于维护和扩展。就3次作业下来的体验来看,尽管有多个类有父类子类关系,但它们的解析和化简方式却有很大差异,而且大多数类都有相同的处理步骤(如化简、求导、比较、打印等),这里建议主要使用接口而非继承。
第一次作业的要求相对简单:
读入一个包含加、减、乘、乘方以及括号(其中括号的深度至多为 1 层)的多变量表达式,输出恒等变形展开所有括号后的表达式
先从Lexer
谈起,我从Lexer
便开始对输入表达式进行一定形式的处理,具体的处理如下:
Lexer
读到的下一个字符为空白符,则跳过空白符直至读到下一个有效字符x
、y
、z
变量时将后续的指数也读入(如果有的话),返回字符串x
或者x**n
读到)
时也可以这么处理,因为)
与变量的存在形式相同,即只有)
或者)**n
Parser
中主要有3个方法:parseExpr()
解析表达式、parseTerm()
解析项、parseFactor()
解析因子。parseExpr()
在最顶层,向下调用parseTerm()
,而parseTerm()
向下调用parseFactor()
。在从下向上解析各部分的同时也一并进行化简,这样当上层方法调用下层方法时,得到的是已经化到最简的元素,便于该层次的解析和化简操作。
Operation
类是一个方法类,记录了各种运算的处理方法和化简操作。
我的结构包括项Term
类与因子Factor
,其中Factor作为接口,有表达式Expression
类、幂函数Power
类和常量Number
类实现了Factor接口。Expression
中包含Term的容器terms
、记录指数的exponent
;Power
中包含变量power
(即记录变量是x、y或z)、指数exponent
;Number
中仅包含常量数字。所有的Factor类均含有变量positive
,记录该因子的正负(这里与对符号的处理相关)。Term
中包含Factor的容器factors
、系数coefficient
(类型为BigInteger
)。
这里容器的选择均是HashSet
,因为其遍历和查找速度快于Arraylist
,性能更好。有同学选择HashMap
,以Hashmap<String, BigInteger>
的形式存储项、以Hashmap<String, Integer>
形式存储因子,这在第一次作业中很容易实现查找和比较功能(因为项和因子的结构较为简单,很容易转化为固定规律的字符串),但本人认为这样的方式不易于扩展,后续加入的三角函数因子使项的内容更加复杂,需要另外的容器存储三角函数。使用HashSet
容器,后续无论是什么样的因子,只要实现Factor接口均可加入容器,使用统一的容器更能体现各种不同因子本质上的统一。
下面从下向上描述程序的过程:
parseFactor()
通过调用Lexer
中方法得到因子。本设计中将所有的+ -
视为后续紧邻因子的正负属性,不区分+ -
是加减法还是正负号。
if (lexer.peek().equals("+") || lexer.peek().equals("-")) {//读到+-则递归调用自己,将正负属性赋予得
boolean positive = lexer.peek().equals("+"); //到的因子
lexer.next();
Factor factor = parseFactor();
factor.setPositive(positive);
return factor;
}
parseFactor()
方法遇见(
时会调用最上层的parseExpr()
得到表达式因子,因为是递归调用,此时得到的表达式因子保证是已经化简到最简形式。
parseFactor()
会对因子进行因子层面的化简:如果指数为0,则返回一个值为1的Number
;调用Expression
中的isZero()
方法判断表达式因子是否为0,如果是则返回一个值为0的Number
(如果指数不为0)。
parseTerm()
得到parseFactor()
返回的因子后将其加入Term
的因子容器中,并在这一步中实现项的化简。如果加入因子为Number
,则改变Term
的coefficient
;如果是Power
,先判断项中是否已有该变量,有则只改变其指数,无时才将Power
加入容器terms
;如果是Expression
,无条件加入容器(表达式的展开在parseFactor()
中处理)。一旦项的系数变为0,则后续因子的加入都可无视掉。
parseExpr()
中每加入一个新的项,先将项中的表达式因子全展开得到一个表达式,将该表达式与现有表达式相加并合并同类项。注意,如果项中的表达式的项数过多或者指数过大,在展开表达式括号的过程中得到的中间表达式可能项数过多,有爆栈的风险,建议在展开过程中多使用combineTerms()
(本设计中用来合并同类项,化简表达式的方法)。
最后将化简后的表达式输出,本架构设计了一个方法类Output
。并没有重写各个类的toString
方法来实现输出的原因是:最后结果的输出也有长度上优化的空间,设计一个单独的类来进行这个工作,便于后续增量扩展后对优化的扩展和管理。这里给出第一次作业优化的思路(第一次作业是拿性能分满分最简单的一次,因为优化的空间小,后面作业的优化实现又麻烦,优化空间又大):如果表达式中有系数为正数的项,将它放到第一项(少输出一个负号);将x**2
优化为x*x
。(其他的优化都是很容易想到的正常的优化,这里不多赘述)
第二次作业加入了自定义函数与三角函数因子。
尽管自定义函数的调用被视为变量因子的一种,但其处理方式基本与表达式因子相同,并不需要为其单独再设立一个新的类。下面是对自定义函数的处理方式:
Parser
中新增属性HashMap<String, String> definitions
,将自定义函数名(f、g、h)作为key值,将自定义函数体作为value加入该容器。Parser
中新增方法addDefinition()
获取自定义函数定义。
这里要对自定义函数体进行一定处理,因为本设计对自定义函数的调用是使用字符串替换的方式,具体原因如下:
有自定义函数定义f(x)=x**2
,调用为f(sin(x))
,可将实际调用时参数以字符串形式取出,再将该字符串替换函数定义的函数体中的对应参数,即用sin(x)
替换掉x**2
中的x
,得到sin(x)**2
,再以该字符串建立新的Lexer
,调用parseExpr()
方法得到自定义函数因子(实际跟表达式因子相同)。但此时会出现一个问题,如果函数含有多个变量,如f(x,y)=x+y**2
,调用为f(y,x)
,则先将函数体中x替换为y得到y+y**2
,再替换y为x得到x+x**2
,这显然不是正确的答案。解决方式为,在addDefinition()
中获取自定义函数定义时,将函数体字符串中的参数按在f(…)
中出现的先后顺序,依次替换为$1$,$2$,$3$
(保证替换的内容在表达式中不可能出现即可),这样实现还有一个好处是不用再另外存储函数定义时使用的参数。
在函数调用过程中对参数的提取也容易出现问题,如果是简单地以符号,
划分各个不同的函数参数,当遇见诸如f(g(x,y),z)
等函数参数为函数调用并也出现,
的情况时便会出现问题。有两种解决方式:一是通过括号匹配的方式,一个参数中出现的(
和)
数量肯定相同,借助此读取出各个参数;二是直接在参数字符串开头的位置使用parseExpr()
得到一个表达式因子,因为对表达式的解析过程中不会处理符号,
,所以当读完一个参数遇见符号,
时parseExpr()
会停下解析并返回一个Expression
类,此时用lexer.next()
方法跳过,
再用同样的方式获取下一个参数直至遇见)
停下,最后将得到的表达式因子用toString()
转化为字符串即可得到字符串形式的参数(这里需要重写各个类的toString()
方法)。
if (lexer.peek().equals("f") ||
lexer.peek().equals("g") || lexer.peek().equals("h")) {
String body = definitionHashMap.get(lexer.peek());//该段代码并非完全正确,仅展示操作流程
ArrayList<String> parameters = new ArrayList<>();//因为由Parser中的parseFactor()实现该段代码
parameters.add(parseExpr().toString());//所以将存储自定义函数相关信息的definitionHashMap设为
while (lexer.peek().equals(",")) { //Parser中的属性,便于直接使用
lexer.next();
parameters.add(parseExpr().toString());
}
for (int n = 0; n < parameters.size(); n++) {
body = body.replace("$" + n + "$", parameters.get(n));
}
Lexer lexer1 = new Lexer(body);
Lexer lexer2 = this.lexer;
this.setLexer(lexer1);
Expression expression = this.parseExpr();
this.setLexer(lexer2);
return expression;
}
需要为新的三角函数因子构建新的类Trigonometric
,该类同样实现Factor接口,其属性如下:
private Expression content;//三角函数内容
private boolean positive;//见第一次作业中对+-号处理
private int exponent;//指数
private boolean isSin;//记录其为sin还是cos
因为三角函数的加入,合并同类项时判断两项中各因子是否相等以及将三角函数因子加入项时判断项中是否已有该因子变得更加复杂,因为判断两三角函数是否相等实际上等同于判断两表达式是否相等。此处为Factor
接口设置compare()
方法(层次化实现比较),便于判断各因子是否相等。(有同学是将表达式和项按一定规律转化为字符串,通过比较字符串来判断是否相等,这种方式对于第一次作业中相对简单的因子可行,但加入三角函数后项与因子的复杂度大大增加,建议还是遍历各类中内容来判断相等比较合适)
对于三角函数的优化,本人并没有去卷sin()**2+cos()**2=1
和二倍角公式等,仅仅是将三角函数中表达式的正负识别出来,实现如sin( Expr )+sin( -Expr )=0
、cos( Expr )*cos( -Expr )=cos( Expr )**2
等优化。实现方法为:在遍历俩表达式的各个项判断其是否相等时,设置一个标志位flag
,其默认为0,若表达式中第一个项与另一表达式中一项相同,flag
置1,符号相反则置为-1。在后续遍历过程中,一旦出现flag
变化,则视为两表达式不相同,如果没有变化,则根据flag
确定两表达式是相等还是互为相反数。
第三次作业加入了求导因子并且允许自定义函数调用其他已定义的函数。
自定义函数如果有求导算子,需要先求导,再带入实参,例如函数h(x) = dx(x)
,自定义函数调用h(sin(x)) = 1
而不是h(sin(x))=cos(x)
。所以对自定义函数的处理不再是简单的提取字符串再替换,而是要先对自定义函数先进行解析,去掉可能出现的求导算子。因为解析一个自定义函数时,已定义的自定义函数已经输入并解析,此时处理目前的自定义函数操作等同于处理最终的待化简表达式,所以并不需要特殊处理自定义函数调用其他已定义的函数的情况(直接使用parseExpr()
解析自定义函数,最终输入的表达式与函数体中均可能有其他自定义函数的调用,处理方式一致)。最后只要将解析后的函数体使用toString()
转化为字符串形式,后续处理与第二次作业相同。
由于实际上需要考虑求导的因子只有幂函数Power
和三角函数Trigonometric
,而常量Number
并不需要求导处理,所以本架构中只有表达式与项两个层次有求导方法makeDerivative()
。具体地,在项的makeDerivative()
中遍历各因子并进行求导。本设计中求导操作并不是随着表达式解析的过程一起进行的,而是把待求导的表达式因子解析并化简后,再对其进行求导,这样可以保证需要求导的项中不会出现表达式因子(得到的表达式已经将括号展开),并且只需要增添几个方法,不用对先前作业中的程序进行大量的重构。
第三次作业相较前两次较为简单,也没有新的优化空间,仍使用第二次作业的优化即可(或者也可以再多写一些有关三角函数的优化,这玩意的优化是写不完的)。
本人3次作业均没有进行大量的重构(虽然性能分也不高就是了),下面仅展示第三次作业后最终的程序类图,各个类及其属性和方法已在前文解释。
本人前两次作业均未出现bug,不在此过多赘述。
第三次作业时互测被hack了一刀,原因是因为我对所有的因子均加入了属性positive
,在求导过程中忽略了其的影响,导致最后的结果与正确结果相反。解决方式是在求导过程中将各因子均视为正。
在第三次作业本地调试过程中,出现了有关深浅克隆的bug,建议在Factor
类中重写clone()
方法,以层次化且规范的形式写深克隆操作。bug出现的具体场景如下:项sin(x)*cos(x)
对x求导,具体过程为,先对sin(x)
求导,得到cos(x)
,将其他因子进行克隆与得到的cos(x)
组合成项,得到cos(x)**2
,再对下一个含x的因子进行相同的处理操作得到-sin(x)**2
,最后将所有项相加得到项求导后的表达式cos(x)**2-sin(x)**2
。如果克隆操作时没有进行完全的深克隆,第一步求导时实际上更改了原项中cos(x)
的指数,等到后续对原项遍历到cos(x)
并进行求导时,实际上是对cos(x)**2
求导。
本人在3次作业中一共hack他人6次,分别是对前导0的处理(表达式因子指数含很多前导0)出错、自定义函数调用出错、三角函数优化(二倍角优化)时出错。
hack策略为在测试数据中任何可以的地方加入空白符、前导0和连续正负号(如果采用的是正则表达式匹配的方式解析并且正则表达式不够严格,很容易在此处出现问题);对于特定的三角函数优化进行hack(许多人的bug均是在优化三角函数时出现,如cos(0)=0
、多倍角优化出现问题)。
类图与各个类设计考虑已在我的架构中展示。
使用MetricReloaded分析代码,结果如下:
Method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
Expression.Expression() | 0 | 1 | 1 | 1 |
Expression.addTerm(Term) | 0 | 1 | 1 | 1 |
Expression.clone() | 2 | 1 | 2 | 3 |
Expression.compare(Expression) | 18 | 7 | 3 | 9 |
Expression.getExponent() | 0 | 1 | 1 | 1 |
Expression.getTerms() | 0 | 1 | 1 | 1 |
Expression.getType() | 0 | 1 | 1 | 1 |
Expression.isPositive() | 0 | 1 | 1 | 1 |
Expression.isZero() | 4 | 4 | 2 | 4 |
Expression.makeDerivative(String) | 8 | 2 | 5 | 6 |
Expression.setExponent(int) | 0 | 1 | 1 | 1 |
Expression.setPositive(boolean) | 0 | 1 | 1 | 1 |
Expression.toString() | 7 | 1 | 4 | 4 |
Lexer.Lexer(String) | 0 | 1 | 1 | 1 |
Lexer.getNumber() | 5 | 1 | 6 | 6 |
Lexer.getPower() | 6 | 2 | 5 | 5 |
Lexer.ignoreSpace() | 8 | 3 | 4 | 7 |
Lexer.next() | 22 | 2 | 9 | 17 |
Lexer.peek() | 0 | 1 | 1 | 1 |
MainClass.main(String[]) | 1 | 1 | 2 | 2 |
Number.Number(String) | 0 | 1 | 1 | 1 |
Number.clone() | 1 | 1 | 1 | 2 |
Number.getNum() | 0 | 1 | 1 | 1 |
Number.getType() | 0 | 1 | 1 | 1 |
Number.isPositive() | 0 | 1 | 1 | 1 |
Number.setPositive(boolean) | 0 | 1 | 1 | 1 |
Operation.Operation() | 0 | 1 | 1 | 1 |
Operation.addExponent(Factor, Lexer) | 9 | 3 | 6 | 6 |
Operation.addFactor(Term, Factor) | 48 | 8 | 13 | 15 |
Operation.combineTerms(Expression) | 9 | 1 | 6 | 6 |
Operation.makeExpr(Expression, Term) | 20 | 3 | 9 | 9 |
Operation.makeTerm(Term, Term) | 4 | 1 | 4 | 4 |
Operation.multiExpr(Expression, Expression) | 10 | 1 | 5 | 5 |
Operation.multiTerm(Term, Term) | 1 | 1 | 2 | 2 |
Operation.powerNum(Term, Factor) | 13 | 5 | 7 | 7 |
Output.output(Expression, int) | 19 | 4 | 6 | 9 |
Output.printTerm(Term, int, int, StringBuilder) | 39 | 1 | 18 | 18 |
Output.printTri(Trigonometric, StringBuilder) | 12 | 1 | 7 | 9 |
Parser.Parser() | 0 | 1 | 1 | 1 |
Parser.addDefinition() | 2 | 1 | 3 | 3 |
Parser.parseExpr() | 2 | 1 | 3 | 3 |
Parser.parseFactor() | 21 | 10 | 13 | 16 |
Parser.parseTerm() | 1 | 1 | 2 | 2 |
Parser.setLexer(Lexer) | 0 | 1 | 1 | 1 |
Parser.supFactor() | 0 | 1 | 1 | 1 |
Power.Power(String) | 0 | 1 | 1 | 1 |
Power.clone() | 1 | 1 | 1 | 2 |
Power.equal(Power) | 1 | 1 | 2 | 2 |
Power.getExponent() | 0 | 1 | 1 | 1 |
Power.getPower() | 0 | 1 | 1 | 1 |
Power.getType() | 0 | 1 | 1 | 1 |
Power.isPositive() | 0 | 1 | 1 | 1 |
Power.setExponent(int) | 0 | 1 | 1 | 1 |
Power.setPositive(boolean) | 0 | 1 | 1 | 1 |
Power.toString() | 0 | 1 | 1 | 1 |
Term.Term() | 0 | 1 | 1 | 1 |
Term.addFactor(Factor) | 0 | 1 | 1 | 1 |
Term.clone() | 2 | 1 | 2 | 3 |
Term.compare(Term) | 6 | 3 | 5 | 6 |
Term.equal(Term) | 42 | 10 | 10 | 14 |
Term.getCoefficient() | 0 | 1 | 1 | 1 |
Term.getFactors() | 0 | 1 | 1 | 1 |
Term.makeDerivative(String) | 38 | 2 | 12 | 13 |
Term.setCoefficient(BigInteger) | 0 | 1 | 1 | 1 |
Term.toString() | 6 | 1 | 4 | 4 |
Trigonometric.Trigonometric(boolean) | 0 | 1 | 1 | 1 |
Trigonometric.changeSin(boolean) | 0 | 1 | 1 | 1 |
Trigonometric.clone() | 1 | 1 | 1 | 2 |
Trigonometric.compare(Trigonometric) | 2 | 2 | 2 | 3 |
Trigonometric.getContent() | 0 | 1 | 1 | 1 |
Trigonometric.getExponent() | 0 | 1 | 1 | 1 |
Trigonometric.getType() | 0 | 1 | 1 | 1 |
Trigonometric.isPositive() | 0 | 1 | 1 | 1 |
Trigonometric.isSin() | 0 | 1 | 1 | 1 |
Trigonometric.setContent(Expression) | 0 | 1 | 1 | 1 |
Trigonometric.setExponent(int) | 0 | 1 | 1 | 1 |
Trigonometric.setPositive(boolean) | 0 | 1 | 1 | 1 |
Trigonometric.toString() | 1 | 1 | 1 | 2 |
Class | OCavg | OCmax | WMC |
---|---|---|---|
Expression | 2.46 | 9 | 32 |
Lexer | 4.17 | 11 | 25 |
MainClass | 2 | 2 | 2 |
Number | 1 | 1 | 6 |
Operation | 6 | 14 | 54 |
Output | 10 | 14 | 30 |
Parser | 3.14 | 12 | 22 |
Power | 1 | 1 | 10 |
Term | 4 | 13 | 40 |
Trigonometric | 1.23 | 3 | 16 |
分析指标:
CogC
认知复杂度,出现循环、条件、switch、与或操作、递归与嵌套时复杂度增加,使代码更难以阅读和理解
ev(G)
基本复杂度,衡量程序的非结构化程度,非结构化成分将提高程序的理解难度和维护难度。
iv(G)
模块设计复杂度,衡量模块与其他模块的调用关系,复杂度越高表明模块耦合度越高,使得模块难以隔离、复用与维护。
v(G)
衡量模块判定结构的复杂度,数量上表现为独立路径的条数,即合理的预防错误所需测试的最少路径条数,越高则表示程序越难测试与维护。
Lexer.next()
因为要根据当前的字符进行对各种因子的提取和分析,用到大量switch-case,因此复杂度较高。
Operation
类中一些方法要对表达式进行化简,因为需要处理的因子种类较多,每种不同的因子均需要不同的化简处理,因此这些方法的复杂度较高。
同样的,因为项和表达式种类相对单一,而因子种类较多且处理方式差异大,Parser
类中对因子的解析方法parseFactor()
复杂度远高于对表达式和项的解析方法parseExpr()
与parseTerm()
。
但凡是有关对不同种类因子分别处理的方法,其复杂度均偏高,如Term.makeDerivative()
和Term.equal()
,项需要分别处理不同种因子,而表达式中仅需处理项一种内容,所以表达式的相关对应方法复杂度低于项中的方法。(这里体现出我的代码的一个弊端,因为我认为需要求导的因子仅有两种,所以我将对因子的求导操作全统一写在项中,但这会导致项中方法的复杂度偏高,更正确的方式是为每个层次的结构都加入求导方法,降低复杂度)。
Output
类因为要对各种特殊的情况进行输出的优化,所以代码中含有大量的条件语句,并且要频繁地调用类的方法,这使得耦合度较高。本人将输出的优化统一写在这个类中实际上是一种偷懒的行为,随着作业的迭代该部分逐渐扩写和复杂(完全是屎山),强烈建议用重写各类的toString()
方法的方式进行输出,将优化分散到各个类中,实现层次化的输出与优化,减少代码复杂度。
回顾第一单元的3次作业,无疑第一次作业是最困难的,因为刚从假期的状态转变回来,程序的架构也需要完成从0到1的转变,我在第一周的体验可以说是相当痛苦。尽管上学期有上过oopre课程,但过去一个寒假,相关的语法和面向对象思想基本已经忘完了,不过正是因为困难,当我在作业截止前一天提交并一次性全部通过中测测试点时,心中的成就感也是前所未有的。在后续的作业迭代中,看着自己的代码功能逐渐扩展,这是一件很值得欣慰的事情。经历3次作业,我深刻地认识到一个好的代码架构有多么的重要,它不仅仅是减少了我每次作业的工作量,从另一个角度讲,它还成为了推动我oo学习的动力。写屎山是很痛苦的,不仅是后续的维护和扩展痛苦,当你写这段代码时你心中明白它很糟糕,做这样的工作你的心情也会很难受。如果有一个好的架构,当你敲代码时你明白你在扩展和升级你的代码,让它变得更好更强大,这种有意义的事情会激励着你继续工作下去,当完成它时心中也会有无与伦比的成就感。从某种方面来说,这也是一种让工作与学习变得更加轻松愉快的方式吧。