【2022春-面向对象】第一单元总结
【2022春-面向对象】第一单元总结
一.架构生成思路
前言
第一单元围绕着表达式解析这一个大主题。在讨论具体如何实现表达式解析之前,我们首先要明确我们的任务是什么。
这个任务已经在课程组给出的三次作业的介绍的第一部分“训练目标”中体现:
第一次作业:通过对表达式结构进行建模,完成单变量多项式的括号展开,初步体会层次化设计的思想。
第二次作业:通过对表达式结构进行建模,完成多项式的括号展开与函数调用、化简,进一步体会层次化设计的思想。
第三次作业:通过对表达式结构进行建模,完成多层嵌套表达式和函数调用的括号展开与化简,进一步体会层次化设计的思想。
任务显而易见,概括起来即为“化简”二字。而每次作业的迭代都可以视为将“化简”这个工作的复杂程度增加。
初次见到此任务的时候,或许会感觉无从下手,这时候我们则需要从总任务出发,去拆成一个个更小的任务,再将小任务转化成具体的对象与方法,形成完整的代码。
这其实就是“分而治之”的思想,或者说是“大事化小,小事化了”的处世哲学。或者借用荣老师的说法,我们解决这种问题的方法就是“雇程序员”。这个大任务交给你,你第一眼不知道怎么做很正常,但好在你可以“雇程序员”,把大任务分解成多个小任务,每个小任务交给一个特定的“程序员”来完成,然后将这些程序员组织起来。至于程序员怎么干活,你在处理这个大任务的时候可以暂时不用管,你知道的只是“这个程序员能够帮我完成某件事情”。只不过你之后要成为你雇的程序员,把分割后形成的小任务解决掉(当然具体怎么解决,你可以递归地雇另外一批程序员来完成)。
下文将结合此次的具体任务阐释怎么分解任务。
注1:以下分解过程中的每个子任务不必全部都设置成独立的对象,仍然要具体问题具体分析。此外在分解任务的过程中会自然地引入必要的对象来明确我们的分解过程。
注2:在分解任务时不要管具体的小任务怎么做,也就是不要过度拘泥于细节。要做的只是下发任务。当然大概地思考一下每个小任务是否可行也是可以的。
第一层分解:任务 = 输入 + 处理 + 输出
我们再次用一句话概括任务:“输入表达式字符串,处理表达式,去除括号并尽可能化简,输出化简后的表达式字符串。”
任务本身就含有第一层分解:输入,处理,输出三段式。在OO中,通常是设计三个对象的方式进行。不过此次作业的输入对象直接使用课程组的ExprInput
,输出只需要设计"表达式"对象Expr
的toString
方法,这里便不为输出设计额外的对象,当输出较为复杂时再考虑将其封装成一个对象。处理部分也是一个对象,起名叫Handler
,这个对象可以设置其一个方法,负责输入字符串,输出表达式对象。
注意到在分解的构成中我们自然地引出了诸多对象:ExprInput Expr Handler
。在分解过程中,我们会对这些对象的认识逐渐加深,加深的具体体现则是属性以及方法数的增多(所以当对象首次引出的时候可能不足以给出其所有的属性和方法)。
本次分解中新增的对象与方法:
在下一次分解中,将会给出表达式对象的具体构成。(之所以不在第一次分解就给出,是因为这不符合笔者个人思考的流程。只有当任务清晰到一定程度时,才足以对表达式对象的组织有一定认识)
第二层分解:处理 = 中缀转后缀 + 后缀转表达式
然后是第二层分解:显然是针对最核心的处理部分。
此次分解实质上是针对Handler
类的work
方法进行分解。
为了完成化简这一任务,笔者本次采用的是中缀表达式转后缀表达式的算法,然后将得到的后缀序列转化成“表达式”这一对象。
也就是说我们将处理部分分为了两者:“中缀序列转后缀序列,后缀序列转表达式”。这样的好处是不用拘泥于原本复杂的表达式形式(因为其本质上是一个中缀表达式),而我们只关心最终得到的表达式对象是什么样的形式。
所以在表达式对象的设计中,不需要有表达式因子,函数因子等等,因为这些不属于最终形式。到了这一步,我们知道了表达式的构成应该是什么样子:只关心最终形式。而再回到题目描述看一看,我们知道最终形式则是表达式-项-因子的三层结构。这一部分也是需要设计的,根据题目要求即可自然地引出如本节末尾所示UML图结构。
为了进行下一层分解,我们需要细化一下每个小任务的构成。任务1:接受中缀形式的字符串,返回由操作数和运算符组成的后缀序列。任务2:接受后缀表达式序列,返回运算过后的结果(以Expr
对象的形式)
这里定义“序列”的具体形式是ArrayList<Element>
,Element
接口由操作数Expr
与运算符Operator
实现(序列中每个表达式即为一个操作数),而Operator
包括Add
Sub
power
sin
function
等等运算。我们为了分解任务又引出了上述许多对象与接口。值得一提的是,我们将sum
函数以及自定义函数等等,甚至左括号右括号逗号都当作一个Operator
对象,这是为了处理过程的统一性。
本次分解中新增的对象与方法:
第三层分解(1):中缀转后缀 = 解析字符串 + 栈操作
此次分解实质上是针对Handler
类的getSequence
方法进行分解。
在这层任务中我们接到的是中缀字符串,我们要转化成后缀表达式序列。为了从字符串形式过渡到具体的Element
对象,我们自然地引入新的对象Parser
,它负责从前往后遍历字符串,每次都可以调用其getNext()
方法给出中缀表达式的下一个Element
对象。
对于解析到的每一个元素,按照中缀转后缀的算法,按照运算符优先级以及左括号右括号的特殊处理进行栈操作,遍历上述元素的过程中,维护一个运算符组成的栈。得到后缀表达式序列。我们发现"运算符优先级"属于运算符的一个属性,遂将其加入到运算符成员变量中。
下面以例子的形式阐明思路:
例如表达式x*(x-233)
第一步解析字符串:Parser对象会依次返回:x,*,(,x,-,233,)
元素的类型分别为:Expr,Mul,LeftBrace,Expr,Sub,Expr ,RightBrace(变量和数字都视为一种Expr)
第二步栈操作:维持栈中元素优先级从栈底到栈顶递增(不必严格递增),具体过程不再赘述
最终得到的后缀表达式应为:x x 233 - *, 元素类型为Expr,Expr,Expr,Sub,Mul
那么问题又来了:Parser如何完成解析?如何维护运算符组成的栈?当然这些问题都需要我们逐个解决,这可能需要第四次分解,第五次分解......但是我们还是要保持上文一直在强调的分解原则,不去关心小任务的具体细节。因此再深一层的分解层次便不再赘述。
本次分解中新增的对象与方法:
第三层分解(2):后缀转表达式 = \(\sum\)各运算符的运算
此次分解实质上是针对Handler
类的getResult
方法进行分解。
在这层任务中我们接收到的是后缀序列,我们要返回最终形式的表达式对象。我们同样需要维护一个装有表达式的栈。遍历后缀序列,当遇到Expr则入栈,当遇到运算符,则对于栈顶的k个元素执行该运算。看,又一次自然引入了运算符的属性:操作数个数k,以及方法Operate
:执行运算。
注:operate设计成了一个抽象方法,每一个具体的运算符都要实现此方法。
例如后缀序列x x 233 - *
首先x入栈,栈中元素:x
又一个x入栈,栈中元素:x x
233入栈,栈中元素:x x 233
-是运算符Sub,取栈顶2个元素,Sub.operate(x,233)返回的表达式入栈。栈中元素:x x-233
*是运算符Mul,取栈顶2个元素,Mul.operate(x,x-233)返回的表达式入栈。栈中元素:x**2-233*x
栈中剩下的就是最终的表达式。
什么?具体的运算符如何运算?Operate方法怎么实现?交给第四次分解去做就好了。
本次分解中新增的对象与方法:
至此,尽管没有将分解过程描述到底,但大体框架已然呈现。在之后单元的设计中,这种分解任务式的设计思路有助于我们巧妙地化解任务的难度。
于是乎我们整体的UML图也形成了:
二.度量及架构优缺点分析
类复杂度与方法复杂度
这里只截取了前面数据有标红的类与方法。
不难看出主要的问题在于Parser类和Hander类。直观地来看,这两个类也是逻辑较为复杂的类,代码行数较多的类。
Handler
Hander类是处理部分的核心,getSequence
方法需要将字符串转成后缀序列,其中需要Parser对象不停调用getNext
方法来不停获取下一个元素,然后进行栈操作。
下面给出核心部分代码:部分内容已略去
while (parser.hasNext())
{
Object obj = parser.getNext();
if (obj instanceof BigInteger)
{
Expr expr = new Expr((BigInteger) obj);
elementsSequence.add(expr);
}
else if (obj instanceof VarFactor)
{
Expr expr = new Expr((VarFactor) obj);
elementsSequence.add(expr);
}
else if (obj instanceof RightBrace)
{
//1
}
else if (obj instanceof Pos || obj instanceof Neg || obj instanceof Comma)
{
//2
}
else if (obj instanceof Operation)
{
//3
}
}
五层if-else结构的设置在现在看来确实是有些丑了,之所以如此设置的原因是栈操作本身就需要判断各种特殊符号,这也是这个方法本身的局限性。当然没有一个方法是“最好的”,或者是“最完美的”,如果不停否定原有的方法去另起炉灶,很有可能最后迷失了方向。但其实我们完全可以想办法去把这样的结构拆开,没错仍然是“分而治之”的思想。
其实栈操作无非分为两种,一种是针对操作数,一种是针对运算符。上面的代码中的五种情况,前两种都属于“操作数”,后三种都属于“运算符”。
那么这里完全可以将这两个部分分解开来,各自考虑,把这两个部分各丢给另外一个类或者另外一个方法去做。比如说设置一个StackManager
类,让他负责运算符的栈操作,Handler
就不用管栈操作的具体实现了,只需要把Paser
读出的运算符丢给StackManager
就好了。你问Handler
栈操作怎么做,他不管,他只知道StackManager
可以替他完成任务。
如果这样拆开来看,就只有两个分支了,看着也美观一些。不过上面的方法在笔者写代码时并没有考虑,究其原因是仍然遗留着之前编程时面向过程的习惯,一main到底的习惯,没有将分而治之贯彻到底。
Parser
Parser类负责将读入的字符串解析成一个个Element
,核心就是getNext()
方法。
而getNext
方法则需要遍历字符串,依据读到的字符解析成各种Element
。是的,这些Element
包含了Operation
的所有子类,可想而知代码会是什么样子。
如果说上面的五层ifelse尚可接受,那么下面的代码则更为“恐怖”:同样省去了部分语句块
public Object getNext()
{
if (Character.isDigit(str.charAt(pos))) //数字
{
return new BigInteger(str.substring(startPos, pos));
}
else if (Character.isLetter(str.charAt(pos)))//变量or函数
{
String name = str.substring(startPos, pos);
if (name.equals("sin")) // 函数
else if (name.equals("cos")) //...
else if (name.equals("sum")) //...
else if (definitions.containsKey(name)) //...
else
}
else //符号
{
if (str.charAt(pos) == '+')//...
else if (str.charAt(pos) == '-')//...
else if (str.charAt(pos) == '*')//...
else if (str.charAt(pos) == '(')//...
else if (str.charAt(pos) == ')')//...
else if (str.charAt(pos) == ',')//...
else//...
}
}
源代码的这一个方法有120多行,笔者的代码风格分数少了20分就源自于这里。
之所以如此多的if else就是因为要return的数据类型太多了,每一个if else代码块都对应返回一个元素。虽然诸多运算符类的出现是为了分而治之,是为了后续计算的方便,但是生成运算符的过程却没有履行分而治之的思想。把所有情况都在一个函数考虑了。可见“分而治之”包括了两个层面:将不同类型数据进行解耦,以及将复杂的数据处理过程进行解耦。
优缺点分析
看类图不难看出,Element接口下的类很多,有20种之多。但除此之外,负责数据处理的Handler与Parser似乎显得格外孤独,也格外笨重。因此可以将本次架构的优缺点总结为:“数据层面的架构设计较好,但数据处理层面的架构设计较为单一。“
三.bug分析
浅克隆与深克隆问题
第二次作业新增了自定义函数。为了从变量代换层面进行函数的处理,我的思路是设计一个Expr
类里的substitute()
方法:
public Expr substitute(VarFactor vf, Expr expr)//把expr代入进变量vf
即先把函数表达式设计为含有多个变量的表达式,然后对函数表达式调用其substitute方法,将变量x,y,z依次替换成实际的表达式参数。但是致命的地方在于,这个方法将变量替换为了共享同一个内存空间的Expr引用。
例如如下样例:
1
f(y)=-y+sin(y)
f(x)
先把y换成x,变成-x+sin(x)
,但执行运算时就会出错,因为本次代换过后,实际上的后缀序列是这样的:
首先执行负号Neg
的Operate
方法,将x这个表达式变为-x。但实际上,此次变换连带着将整个后缀序列的x都变为了-x。这显然不是我们想要的:
这本质上就是浅克隆和深克隆的问题。你改变了一个对象的属性,连带着其他所有引用也全部被改变了。第一种解决方法就是在变量代换上实现深克隆,在代换过程中复制一份一模一样的expr即可解决问题。
解决这样的问题还有一个方法:尝试将Expr设置为不可变对象。也就是不设置Expr
的set add
等等方法,在创建对象之后属性则不可更改,执行Neg运算时是产生新的Expr而不是改变旧的。
容器访问越界问题
这其实是老生常谈的话题,初学C语言时几乎所有人都干过数组越界的事情,笔者自己也不例外。而且到了Java,容器变成了ArrayList等等对象,同样要考虑这个问题。
出错的样例:
0
sum(i,0,0,x)
很简单的样例。原因在于当sum函数参数为0时就会出现这种问题。
让我们看看原来sum的operate方法:
@Override
public Expr operate(ArrayList<Expr> parameters)
{
VarFactor varForIteration = (VarFactor) parameters.get(0).getFactor();
BigInteger startNum = parameters.get(1).getTerms().get(0).getCoeff();
BigInteger endNum = parameters.get(2).getTerms().get(0).getCoeff();
Expr oldExpr = parameters.get(3);
//...
}
这一段代码正在获取sum函数的4个参数。由于将所有数字所有变量都看作了Expr,需要不停调用内层容器的get方法来获取数字信息。但是这一块并没有做越界检查,即如果容器为空,再调用get(0)则会喜提Runtime Error。恰好当那个整数为0的时候,Term容器为空。解决方法也很简单:特判即可。
这个bug的教训是:时刻小心各种情境下的容器访问越界问题,包括get(0)这种边界。测试的时候也要针对这种边界做足测试。
四.心得体会
作为面向对象的第一单元,最大的困难我认为在于编程习惯与编程思想的转变。从面向过程到面向对象这样的思想上的变化只能依靠一次次的试错来实现转变。面对一个问题,到底该如何构建合适的对象,没有标准答案,但每个人的心中都有一杆秤。在不断迭代尝试的过程中,心中的那杆秤则会逐渐明朗起来。