2022-OO-Unit1
2022-OO-Unit1
mashiroly
1. 引子
在供参考的博客要求中,可以体会到课程组呼吁同学们“将关注点置于已完成的代码”,分析其结构与问题。本文当然不会缺失这一部分,但本文更愿意将重点放在“设计”,缕清这一个月来认识“面向对象”的思路变化。
2. 设计
先简述迭代过程。
hw1:在题目要求上增加可处理多层括号;
hw2:在题目要求上增加可处理三角函数嵌套因子;
hw3:更新自定义函数嵌套。
2.1 递归下降与统一表示
递进式的三次作业中,架构的整体思路是一致的,即归纳为“递归下降”与“统一表示”。hw1已经确定了思路,也因此没有经历大规模重构。因此,本小节不强调三次作业题目的区别,大部分直接对hw3的题目表述进行分析。
对个人而言,这也产生了新的问题,眼高手低意味着第一周工作量极大。
2.1.1 递归下降:从形式化表述到结构化数据
上图为根据形式化表述简化的表达式结构。根据形式化表述,Expr、Term与Factor天然存在层次关系与递归定义。我们根据上图的方式建立句法分析类Lexer、解析类Parser,将表达式的解析划分为三层,按字读取、正则表达式辅助、顺序地逐层解析、返回下一层的结果。
在Factor一层,我们遇到了ExprFactor、Tri和SelfFunc三种涉及递归的Factor,具体实现用一句话概括,就是“左括号递归,右括号返回”。实现ExprFactor到Expr的递归,我们就可以处理多层括号嵌套。实现Tri和SelfFunc到Factor的递归(下一步往往是再次把解析任务交给ExprFactor),我们就可以处理这两种函数的嵌套。
这时,我们会遇到一个问题:现在我们的确能将表达式解析成树状结构了,但我们最终目的是对表达式展开化简——所谓“返回下一层的结果”,返回的是什么呢?为了构建起树状结构,为了父节点能够”看见“子节点,这个”结果“理应是对象本身,并把子节点对象加入父节点对象的属性。但是,子节点对象所表示的数据以何种形式交给它的父节点对象呢?另一种表述是,最底层的子节点对象(叶结点)究竟以什么形式存储数据呢?
这个问题直接催生了“统一表示”的思路,并使得设计和实现紧密关联。
2.1.2 统一表示:化程序员所见为程序之所见
对于程序员而言,不论是4
,还是(x+1)
,还是(sin((+-x**2-1)**3))**4-+-3*cos(0)
,甚至是不合法的++--+123
,都是人脑可理解、可计算的“算式”,这是因为人脑完全理解“算式”的计算顺序、计算规则,并能够完全看见“算式”的全部信息并同时利用。
但对程序而言,句法分析类Lexer只能看见当前解析到的位置,对象只能看见对象本身,可以访问子节点对象(因为已经包含在属性中)。如果不能做好数据的管理,那么所谓“对象协同”仅止于构建一棵树。
站在程序员的角度,既然不论何种“算式”都共用同一套计算规则,我们就应该建立一套完备的统一表示,让各级对象的数据可以交互。统一表示成什么因人而异,重要的是有其思想。在我的设计中,建立Basic类:
public class Basic {
private BigInteger coef = BigInteger.ONE;
private BigInteger pow = BigInteger.ZERO;
private HashMap<Factor, BigInteger> sin = new HashMap<>();
private HashMap<Factor, BigInteger> cos = new HashMap<>();
}
完备地将树中处于叶结点Factor的数据表示为$$coefx**pow\Pi(\sin(Factor)a*cos(Factor)b)$$,例如:1
,x**2
,sin(x)**4
.如需拓展其他函数,只需修改Basic类即可。
Basic类不是叶结点Factor。(1+x)
显然需要两个Basic对象才能表示,因此需要容器basics管理Basic对象,并将容器作为表示该数据的对象的属性。现在,我们可以将2.1.1节图中最下一排(从Num到Tri)全部建类,唯一的属性是basics。结合抽象层次关系,我们让这些类统一实现Factor接口,运用归一化的思想,建立归一化访问,无差别引用下层数据。对于SelfFunc和Sum,实践中感觉使用Factor接口比较困难,因此按照2.3.4节的方法当作Expr处理。
在我的设计中,basics是HashMap<Basic, BigInteger>,其中BigInteger是Basic作为一个因子整体的幂次。如(2*x)**2 -> <{2,1,…},2>
我们终于解决了叶结点Factor如何存储数据的问题,从最底层向上返回的对象终于可以将数据交给上层参与计算了。但统一表示还能更新一步:不论是Term还是Expr也能用basics的形式表示,因此将Expr和Term也实现Factor接口。显然Expr和ExprFactor类是一致的,我们统一为Expr类。自此,任何一级合法表达式都能用basics表示,只需在类内填补构建、计算、优化的方法了。
2.2 搭建架构
整体思路已经在2.1两小节清晰表述。Parser类和Lexer类协作,提取当前字符(串)的数据,并将数据分配给对应的对象;所有实现Factor接口的类都具有属性Basic,只有读到叶结点时,才会调用Basic中的方法存储数据并返回。Term的方法实现了Factor之间的乘法与幂次,合并同类项,保证交给Expr的Basic幂次为1 且已合并同类项(出于HashMap容器的要求)。Expr的方法实现了Term之间的加法及合并同类项。最后通过toString直接将顶层Expr的basics属性输出。
2.3 难点问题
2.3.1 连续符号
连续符号对我而言是hw1的一道坎。最初采用符号计数器,但由于正则表达式的设计,遇到形如+-(...)
无法处理。换用思路:遇到负号则递归,返回时取相反。实现过程中发现很难确定什么时候返回,bug频出。最终采用了直接改写原串为+1*-1*(...)
的形式,并控制pos回调,顺利解决。
2.3.2 幂次
对幂次的处理可能是最纠结的一步:到底给Term处理还是给Expr处理?最终,由于幂次与乘法的相似,将其交给Term,保证返回给Expr时整体的exp一定是1,更体现对象的内聚,也算多了一种debug的方案。
2.3.3 解析自定义函数与求和函数
由于感到困难(偷懒),并没有对自定义函数与求和函数重新建树处理,而是利用对括号建栈解析、字符串替换得到新的Expr,重新建Parser、传入函数表解析,这样返回的仍是Expr,作为Factor,交给上级处理。
3. 度量分析
3.1 类的内聚与耦合
OCavg:平均操作复杂度。
OMax:最大操作复杂度。
WMC:加权操作复杂度。
class | OCavg | Ocmax | WMC |
---|---|---|---|
expression.Basic | 2.533333333333333 | 10.0 | 38.0 |
expression.Cos | 1.3333333333333333 | 3.0 | 8.0 |
expression.Expr | 1.5 | 4.0 | 15.0 |
expression.Num | 1.3333333333333333 | 3.0 | 8.0 |
expression.Pow | 1.3333333333333333 | 3.0 | 8.0 |
expression.SelfFunc | 2.142857142857143 | 6.0 | 15.0 |
expression.SelfFuncList | 1.0 | 1.0 | 3.0 |
expression.Sin | 1.3333333333333333 | 3.0 | 8.0 |
expression.Sum | 1.6666666666666667 | 3.0 | 5.0 |
expression.Term | 2.4444444444444446 | 9.0 | 22.0 |
MainClass | 1.5 | 2.0 | 3.0 |
parser.Lexer | 2.8 | 11.0 | 28.0 |
parser.Parser | 3.6 | 11.0 | 18.0 |
Total | 184.0 | ||
Average | 1.978494623655914 | 5.0 | 13.142857142857142 |
两个解析类的复杂度较高,这是递归下降本身的因素。而数据类中Term复杂度极高,归因为乘法和幂次依赖多次遍历,未能找到更好的方案。
3.2 方法的复杂度
CogC:认知复杂度。
ev(G):基本圈复杂度。
iv(G):设计复杂度。
v(G):圈复杂度。
选择复杂度最高的方法列出。
method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
expression.Basic.addCos(Factor, BigInteger) | 9.0 | 4.0 | 5.0 | 5.0 |
expression.Basic.addSin(Factor, BigInteger) | 9.0 | 4.0 | 5.0 | 5.0 |
expression.Basic.equals(Object) | 4.0 | 3.0 | 5.0 | 7.0 |
expression.Term.Merge(HashMap, HashMap) | 8.0 | 4.0 | 5.0 | 5.0 |
expression.SelfFunc.reference(String) | 8.0 | 1.0 | 7.0 | 7.0 |
expression.Term.multBasics(Factor) | 24.0 | 1.0 | 9.0 | 9.0 |
expression.Basic.toString() | 18.0 | 2.0 | 10.0 | 10.0 |
parser.Lexer.next() | 13.0 | 2.0 | 10.0 | 11.0 |
Total | 146.0 | 117.0 | 172.0 | 191.0 |
Average | 1.5698924731182795 | 1.2580645161290323 | 1.8494623655913978 | 2.053763440860215 |
就平均复杂度而言,各方法还算比较简单。复杂度最高的不出意料仍是multBasics。此外,由于没用单独的优化类,优化完全通过toString内部的分支实现,分支复杂。
4. 测试与bug分析
测试主要采用自动评测与手动构造边界数据结合。由于没用自己动手实现评测机,这里描述构造和互测出的bug。
- 连续符号,如2.3.1所述;
- 自定义函数和求和函数”字符串替换实现,遗漏了需要加括号的情况。
总之,bug均出自较“面向过程”的步骤,所在模块复杂度均较高,企图采用trick避免对结构的设计。在之后的作业中应直面问题,训练的目的在“学会“而非”答题“。