BUAA-2024-OO-第一单元

杨昊天-21373026 2024-03-20 16:46:13

第一次作业

第一单元任务要求与迭代

第一单元的核心任务是完成对表达式的解析,最基础要求是多项式的展开,譬如:

input:(2*x^3+x)^2+2

output:2*x^3*2*x^3+2*x^3*x+x*2*x^3+x*x+2

以上的方式实现了简单的拆分,但是性能衡量会着重考察输出的长度,因此一个理想的输出是:

output:4*x^6+4*x^4+x^2+2

在第二次作业中,新增的内容是:支持嵌套括号、支持指数函数因子、支持自定义函数,譬如:

input:2

f(x)=x^2

g(y)=2+y

f(x)-g((x^2-3))

output:1

在第三次作业中,新增的内容是:支持求导运算dx,譬如:

input:1

f(x)=x^2-x+2

dx(f(x))

output:2*x-1

通用架构分析

我们从第一单元的题目要求中细致的文法约束便可理解,作业希望我们通过文法解析的架构完成对输入的处理,最后得到一个形如语法树的结构,我们以hw1的文法约束为例:

  • 表达式 →→ 空白项 [加减 空白项] 项 空白项 | 表达式 加减 空白项 项 空白项
  • 项 →→ [加减 空白项] 因子 | 项 空白项 '*' 空白项 因子
  • 因子 →→ 变量因子 | 常数因子 | 表达式因子
  • 变量因子 →→ 幂函数
  • 常数因子 →→ 带符号的整数
  • 表达式因子 →→ '(' 表达式 ')' [空白项 指数]
  • 幂函数 →→ 'x' [空白项 指数]
  • 指数 →→ '^' 空白项 ['+'] 允许前导零的整数 (注:指数一定不是负数)
  • 带符号的整数 →→ [加减] 允许前导零的整数
  • 允许前导零的整数 →→ ('0'|'1'|'2'|…|'9'){'0'|'1'|'2'|…|'9'}
  • 空白项 →→ {空白字符}
  • 空白字符 →→ (空格) | \t
  • 加减 →→ '+' | '-'

我们会考虑建立以下类:表达式类、项类、因子类。因子类包含子类:常数因子、表达式因子、幂函数因子:

 也就是说,我们需要通过文法解析最终得到一个表达式类。我们将表达式类、项类、因子类、幂函数因子类、常数因子类以及后续的指数函数类分别命名为ExprPower Term Factor SimplePower Num。

解析架构

对于输入的解析,我们在实现的过程中采用经典的Lexer、Parser架构,具体细节不再展示,其中Parser类的方法包含:

public ExprPower parse(Lexer lexer);
private ExprPower parseExprPower();
private Term parseTerm();
private Factor parseFactor();

此处的递归下降结构在hw1就实现了对多重嵌套括号的支持。

计算架构

表达式ExprPower是我们在文法解析得到的最终结果,由Parser、Lexer、ExprPower、Term、Factor及其子类构成我们的解析架构。但是作业要求我们拆开括号乃至进一步合并同类项,因此我们至少需要对ExprPower进行进一步处理。

一种可能的想法是,将表达式理解为项的相加,将项理解为因子的乘积,特殊处理、展开表达式因子,实现因子之间的相乘、项之间的合并同类项。但是这样的架构有一个重要的隐患在于,我们对于解析架构里的类的组成是基于文法约束的,譬如我们的表达式由一个前导符号以及包含若干Term的List组成,是因为文法:

表达式 →→ 空白项 [加减 空白项] 项 空白项 | 表达式 加减 空白项 项 空白项

但是如果我们希望某类表达单项式的和,我们应该让"由一个前导符号以及包含若干Term的List组成"的表达式来执行这个职能吗?

因而我们单独提出一个类表达单项式Element,我们重新考虑一个单项式应该有的组成部分,应该包括一个系数cof、一个幂函数SimplePower(以及后续作业加入的指数函数Exp),而单项式组成的类Elements,即多项式,应该被理解为若干个单项式组成的HashSet。

而我们从解析架构的ExprPower表达式、Term项、Factor因子转化为计算架构的Element、Elements的过程可以理解为一种化简,回看这个过程,我认为实际上采用Visitor设计架构完成这个化简更为妥当,但是彼时没有多想,便在这三个类里面内置simplifty函数了:

private Elements simplify();

好在化简这一过程涉及的类不多,因而没有造成什么麻烦。

那么我们主要的化简过程,对于表达式而言就是逐个调用项的simplify(),并将得到的Elements进行合并,此处就有了我们Elements需要实现的第一个函数,与另一个Elements进行合并同类项:

public Elements mergeElements(Elements elements);

那么实际上这个地方非常值得注意的是这个方法是否会改变调用者本身、又是否会改变传入的参数?如果有必要的话,我们是否需要进行一次深拷贝?相对来说,如果我们不确定某一个方法是否会改变传入的参数,我们选择传入其深拷贝也行是最为保险的做法,但是如果我们一开始就明确一个函数的效果,也许这些就能得到避免,譬如我用函数名前的_强调这个函数不会对调用者进行修改,而函数后的_强调这个函数不会对传入参数进行修改:

public Elements _mergeElements_(Elements elements);

以上便是一个不会对调用者以及传入参数进行修改的方法,返回调用者与传入参数合并得到的Elements。

在OO研讨课上,有个同学表达了一个观点,函数的意义应该区分修改和查询,如果一个函数有返回值,其就不应该修改参数与自身,反之亦然。我认为这样的思路是可能的,但是这毫无疑问会导致我们为同一个功能写出多个函数,因而我采用下划线的方式进行区分。

回到化简的过程,项的化简就是逐个调用因子的simplify(),并将得到的Elements进行相乘(实际上Factor因子是抽象类,而具体来说,比如常数因子Num,其simplify()会得到一个Elements,其中的List<Element>只有一个单项式,这个单项式也只包含系数)。于是我们需要实现有关于Elements的另一个方法,与另一个Elements的相乘,这里就会实现乘法分配律,双重遍历二者的Element,逐项相乘,在相乘的过程中合并:

public Elements _mult_(Elements elements);

提到合并同类项就不得不提到同类项之间的判等,我们重写Element的equals()方法的时候纳入了系数,也就是2*x与3*x两个Element并不能用equals()方法判等,因此我们写了Element类的新的函数:

public boolean canMerge(Element element);

类图

bug分析

第一次作业无bug,比较容易产生bug的地方包括:深浅拷贝、多项式相乘乘完再合并导致TLE...

 

第二次作业

题目分析

我们已经提到,hw2题目加入了指数函数与自定义函数,在此我们详细考察如何在我们的通用架构中适应这种变化。我们将指数函数类命名为Exp,将自定义函数类命名为Func。我们认为指数函数与简单幂函数同质,因而我们扩大了我们的Element,现在Element由系数、简单幂函数与指数函数组成(例如-2*x^3*exp((x-2)))。对于自定义函数Func我们也认为是一种因子,我们在读入函数的时候存储形参与等式右边的式子,在parse时,遇到函数的时候我们调用自定义函数的方法,来将实参替换掉形参作为一个表达式因子来parse。

迭代方案

对于指数函数的指数(譬如exp(x)^2),我们直接将其指数放入内部(转化为exp((2*x)))。  

对于指数函数的输出,如果其中是表达式项我们需要双层括号,譬如exp(2*x)是不合法的,我们可以通过Parser检验其内部的字符串是否满足非表达式项的输出,譬如'x''x^2''exp(x)^2'等,就不需要额外增加一个括号,直接输出'exp(x)''exp(x^2)''exp(exp(x^2))',反之则加上额外的括号,譬如内部为'2*x',需输出'exp((2*x))',这样仅仅在一些我们确定是内部是非表达式项的情况下才省略一层多余的括号,可以避免因为漏加括号产生bug。    

为了支持函数内部嵌套函数,我们将函数字符串替换之后,实际上会生成一个子Lexer和子Parser,对替换完变量之后的函数进行解析,最后得到我们统一的Elements结构。如果函数内部嵌套函数,譬如f(x)=x,调用f(g(x)),那么子Lexer和子Parser就会进一步解析替换掉函数。

对既有架构的挑战

在架构上可能导致重构的点在于一个判等的过程中可能出现的循环,我们提到单项式Element由系数cof、幂函数SimplePower和指数函数Exp构成,那么Element的判等函数:

public boolean canMerge(Element element);

需要考察调用者与参数的幂函数是否相等、指数函数是否相等。

而指数函数的判等需要进一步判断指数函数内部的多项式Elements是否相等,进而又回到对Element的判等。

一个比较简洁的解决方法是,将单项式Element中的指数函数exp初始化为null,在实际存在指数函数时再填写,而不是初始化为exp(0)。

另一个需要关注的地方在于对函数的字符串替换可能导出许多bug,包括:

input:1

f(x)=exp(x)

f(y)

output:eyp(y)

也就是没有注意到'exp'关键词当中含有可能被替换掉的x。

还可能的bug是:

input:1

f(y,x)=x+y

f(x,x^2)

output:2*x^2

这是由于,一开始将'x+y'的'x'进行替换,得到'y+y',然后将'y'进行替换,得到'x^2+x^2'。

类图

 

bug分析

存在一个简单的bug,指数用int保存,最后在一个数据点中超出了范围。一些比较容易出现的bug包括:对单项式的判等陷入无限循环TLE,函数粗暴的字符串替换替换掉'exp'关键词。

 

第三次作业

题目分析

本次作业要求支持求导,我们在parse时直接将求导因子及其内部表达式存储下来,在simplification的时候再对求导因子内部因子进行求导并返回Elements。

也就是将求导过程放到计算架构中。

实现方案的细化

我们将求导因子命名为Dif,包含属性ExprPower exprPower,细化的核心是如何对求导因子内部的表达式因子进行求导。首先考虑对ExprPower的求导,只需要对表达式内部每一个项求导然后将求导得到的Elements 使用方法mergeElements起来即可,同时还需要考虑表达式因子的指数;然后考虑对项的求导,主要是考虑乘法法则,(f(x)*g(x))’=f(x)’g(x)+f(x)g(x)’,我们只需要提取出项的第一个因子作为f(x),剩下的因子作为g(x)然后递归即可;然后考虑非表达式因子的求导,对于幂函数和数字的求导毋须多言,对于指数函数的求导需要用到链式法则,f(g(x))’=f’(g(x))g(x)’,例如exp(f(x))’=exp(f(x)f(x)’。

类图

对既有架构的挑战

计算架构与解析架构并存的方式在前两次作业还好,但是在后续引入求导因子之后,计算架构和解析架构就模糊不清了,例如由于需要对求导因子先化简再求导,而化简的结果是Elements,因而不得不加上对Elements的求导函数,同时求导过程中最终会递归到对因子的求导,所以计算架构也有求导函数,这就使得求导过程时而转向Elements计算架构,时而转向因子的求导与项的求导(解析架构),比较混乱。    

实际上更合理的思路可能是将求导过程完全限制在计算架构中,实现对Elements的求导和Element的求导,这一方面实在是欠妥了。我们以Dif类的方法simplify()举例:

public Elements simplify()
{
    return self.exprPower.simplify().dif();
}

self.exprPower.simplify()返回内部表达式化简得到的多项式Elements,进而对多项式Elements进行求导,将整个求导操作限制在计算架构。

总的来说,解析架构与计算结构分离的设计,其合理性在于,对更多样的表达更友好,譬如加入三角函数或者log函数,在词法语法分析之后,只需要在Element里加入对应的元素,并支持对三角函数或log函数的乘积、判等和合并即可,思路会比较清晰。

bug分析

hw3无bug。比较容易出现bug的地方:初始架构没有明确函数是否会改变调用者以及传入参数,导致在求导过程中出现应该深拷贝的地方被浅拷贝的问题。

 

优化分析

最简单的优化就是合并同类项,我们提到,多项式的合并、乘积都是在计算架构Elements-Element完成的,为了支持合并同类项,我们有方法Elements::canMerge(Elements)判断两个多项式是否可合并,这其中会涉及幂函数、指数函数的equals()方法。  

另一个优化在于,将无意义的项舍弃,譬如将"1+0"简化为"1",将"2*x^0"简化为"2",因而我们在Elements加入了trim()方法,舍弃系数为1的Element,在Element中的trim()方法则是舍弃指数为0的幂函数和指数函数。

最后一个优化在于,譬如,将exp((x))去掉一层括号变为exp(x),但是这一步是很容易出错的,我们用了一个保险的做法,即,以字符串的形式看待exp括号内部的式子,用Pattern看看其是否可以去掉括号,譬如x,x^k,exp(...)^k,数字n,这样的式子毫无疑问只需要单层括号,其余的情况下我们都加上双层的括号以满足对表达式因子的文法。    


 

度量分析

 

从度量的结果来看,Parser的方法平均复杂度最高,因为这其中解决了将token转化为语法树的问题,方法较少(parseExpr parseTerm parseFactor)但是解决的问题复杂,而且每一次作业迭代都会增加这一部分的负担。    

Element类和Elements的WMC最高,主要是这其中解决了多项式的合并、多项式的乘法的计算问题,这其中乘法分配律以及多项式判等会有较高的复杂度。

不难看出,主要的代码集中在Element和Elements类中。这实际上是我们所期望的结构,计算的过程与文法分析的过程分离。

 

如何测出别人的bug

啊啊啊三次作业进的房都是0 bug房实在是巧妇难为无米之炊啊!

 

总结与体会

我是重修,这一届尊嘟好卷555

深浅克隆真的很重要,尤其对容器类加入元素的时候一定要想想直接加入当前这个对象可以吗。

toString()当中应当是非破坏性读取,否则可能出现调试的代码运行与直接运行不相符。

比往年简单了好多,非常感谢,希望继续保持(

 

未来方向建议

没有出三角函数是正确的,一针见血的,客观的,理性的,这种东西可以卷和角公式以及背离了面向对象设计的初衷了,但是可以加回多变量。

...全文
77 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

301

社区成员

发帖
与我相关
我的任务
社区描述
2023年北航面向对象设计与构造
学习 高校
社区管理员
  • YannaZhang
  • CajZella
  • C_ecelia
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧