BUAA OO2023第一单元总结
文章目录
问题描述
输入表达式,输出表达式的恒等变换的结果。要求去除表达式中不必要的的括号
在第一次作业中要求单层括号的展开、因子包含常数因子、幂函数、表达式因子
在第二次作业中加入的支持多层括号展开,因子新增自定义函数因子、三角函数因子
在第三次作业中加入求偏导因子
度量分析
1. 复杂度分析
类复杂度如下:
选取三个较大的类进行方法复杂度分析:
BasicOperation
进行一些基础的操作:
Paser
进行递归下降的分析主体:
Derivative
进行求导表达式替换:
由上述复杂度分析结果可以看出:
-
与
OrdinaryTerm
类相关的方法复杂度较高,原因如下:- 在类中相似的定义了
variblaX
、variableY
、variableZ
分别进行x y z
的存储,在后续的项相乘和判断中极大地增加了分支量 - 在类中重复的定义了
variblaX
、variableY
、variableZ
分别进行x y z
的存储,事实上判断是否存在x y z
可以用指数记录
- 在类中相似的定义了
-
paserVar
的方法复杂度较高,原因如下:设计者没有认识到
lexer
的作用,其实是将lexer
的作用融合到了paserVar
方法中
2. 行数统计
3. 类图
笔者的架构较为简单,但是没有很好的体现层次化设计,即没有设置接口和多态,类的设置较为单一,且自定义函数类和求导类都与字符串替换,为单独的存在,主体类只有:Term
三次迭代架构简介
hw1
1. 表达式预处理
在第一次作业中,从繁杂的信息中提取出关键,做好架构的整体设计是对之后的迭代具有巨大意义的。
首先是符号的处理,在基本概念中定义:因子具有符号、项具有符号、常数因子可以为负、正号可以省略。符号处理需要统一化,在我的架构中,先消去连续的正负号,并认为项与项之间仅为相加的关系,将剩余的正负号全部并入因子的符号,因子与项的符号可见层次化模型。
注意到,在形式化表述中,“空白项” 可以为空,而空格和 tab 的表述均在空白项的意义之下,故先对表达式进行空白字符的统一删去
2. 层次化模型
由于本次的作业中关于表达式、项、因子的定义存在递归关系,即表达式是项的加减运算,项是因子的相乘,因子中也可以含有表达式因子,而事实上,表达式即为一个 String 类字符串,所以采用了归一化定义方案:
- 去除表达式的概念(不设置表达式类),对表达式的分析即为对字符串的分析
- 统一项与因子的概念,建立项类(Term类),因子用 Term 类进行包含表述,若因子为表达式因子,即用 Term 列表进行表述
对于 Term 的构造即需要考虑到考虑到项的特征,也需要考虑到因子的特征。因子的累乘构成项,考虑到项中含有可以直接相乘的元素:如系数,符号,指数。构造项的结构如下:
public class Term {
private boolean positive; // the notation of factor
private BigInteger coefficient;
private boolean variableX; // is variable x exist ?
private boolean variableY;
private boolean variableZ;
private int powerX;
private int powerY;
private int powerZ;
}
3. 解释器的构造
首先,需要确定对 expression 的分析我们希望的得到的是什么:我们希望通过对表达式的分析去除括号,获得等价的、只含有必要括号的表达式,也就是字符串,其中,我们还需要对表达式的项进行合并同类项的操作来减少表达式的长度,使其更加的简洁美观~~(性能分++)~~,也就是说:我们需要通过解释器获得表达式的分析值——项的列表,然后进行合并同类项操作,再打印表达式。
至此,我们将 expression、term、factor 三种类型统统可以由 Term 类来表述:
e
x
p
r
e
s
s
i
o
n
=
A
r
r
a
y
L
i
s
t
<
T
e
r
m
>
t
e
r
m
=
T
e
r
m
f
a
c
t
o
r
∈
T
e
r
m
expression = ArrayList<Term> \\ term = Term \\ factor \in Term
expression=ArrayList<Term>term=Termfactor∈Term
注意到:因子在含有表达式因子的时候也需要用项的列表来表示,所以可以再次对概念进行统一:
e
x
p
r
e
s
s
i
o
n
∣
t
e
r
m
∣
f
a
c
t
o
r
=
A
r
r
a
y
L
i
s
t
<
T
e
r
m
>
expression \space | \space term \space | \space factor = ArrayList<Term>
expression ∣ term ∣ factor=ArrayList<Term>
至此,定义解释器 Paser 的层次化结构:
- paserExpression 对表达式进行分析,返回表达式的分析值—— Term 列表
- paserTerm 对项进行分析,返回项的分析值—— Term 列表
- paserVar 对因子进行分析,返回因子的分析值—— Term 列表
这种抽象方法笔者认为统一了返回值,更有利于后续分析的清晰度
4. 解释器的实现
在总体上我们调用 paserExpression
进行对整个的字符串进行分析,自然而然的想法是以 expression.length()
作为分析的结尾,paserExpression
调用 paserTerm
,paserTerm
调用 paserVar
进行下降解析,当 paserVar
遇到左括号时:由于第一次作业中只含有一层括号的限制,我选择直接向后找到右括号,并进行字符串的截取,递归再次调用 paserExpression
。
由于在做第一次作业时比较慌乱,对于之后作业会出现的多层括号并没有去过多的考虑,虽然上过先导课但是还是面对庞大的系统难以下手,虽然在多层括号时仍然可以使用字符串截取的策略,但总的来说:笔者认为字符串截取是简单但危险的,递归调用是复杂但优雅的,字符串截取的危险之处详见 bug 分析部分,在第二次作业中调整
paserExpression
结尾条件属实花费了不少时间。
Paser 类构造如下
public class Parser {
private Lexer lexer; // 仅用于读 * ,实际上没什么用,可以删去
private int position;
private final String expression;
}
在第一次作业的 Paser 中:
-
lexer
只负责读取*
来判断项是否到达结尾。(笔者对 lexer 的构造及其简陋以至于可以删掉) -
position
是指向expression
的指针 -
expression
是输入的字符串
对于下降分析的处理,将表达式看成是项的相加,不需要在表达式中记录是加上该项还是减去该项,全部看成相加,将加减运算符归入因子的符号中
hw2
1. 自定义函数的储存
构造自定义函数类如下:
public class CustomFun {
private String functionName;
private ArrayList<String> variableList = new ArrayList<>();
private String functionExpression;
}
其中:varialbeList
按照自定义函数的参数循序存储参数,防止在参数替换时出现紊乱
小小的坑点:
在自定义函数的读取过程中,应该先用
=
分割自定义函数,将表达式分离,再用( ) ,
分割左边的参数取出形参,因为在第三次迭代中可能会出现f(x,y) = g(x,y)
的形式。并且,在因子分析的过程中仅仅可以用
=
分割,不能用( ) ,
分割,因为函数传参中可以为表达式因子和自定义函数
2. 自定义函数的读取
在 paserVar 中,如遇到 f g h f \space g \space h f g h 则进入自定义函数分析方法,截取自定义函数的全部部分,传入的是一个字符串
先将自定义函数中的形参替换成实参,在替换时需要注意:
错误示例:
采用字符串替换的方法。根据函数形参与实参的对应关系,直接使用
replaceAll
进行替换,并重新解析表达式例如:
函数定义:
f(x,y)=sin(x)+cos(y)
,并且假定等号后面的字符串表达为:String model = "sin(x)+cos(y)";
而函数调用:
f(x**2, z*3)
, 那么直接用代码表示的话就是model = model.replaceAll("x", "(" + "x**2" + ")") .replaceAll("y", "(" + "z*3" + ")" );
但事实上:这种替换本质上是有先后顺序的,倘若第一次的
replaceAll
存在实参和形参的冲突,则第二次的replaceAll
会出现问题
例如:函数调用:f(y, 0)
,的时候,正确的结果应该为f(y, 0) = sin(y) + 1
,而上述代码输出为f(y, 0) = 1
正确做法:
记该函数的定义式为 expr1
,定义一个字符串 expr2
作为函数替换之后的字符串,遍历 expr1
,若遇到 x、y、z
,则进行实参替换,其他则依次加入 expr2
中
在自定义函数完成实参替换之后,将得到的字符串调用 paserExpression
即可返回自定义函数的分析值
3. 三角函数的实现
构造三角函数类如下:
public class TrigonometricFun {
private boolean isSin;
private ArrayList<Term> trigonometricTerms = new ArrayList<>();
}
由于正第一次作业下的 Term
是可以直接进行相乘的,而三角函数不可以直接相乘,于是在 Term
中将相乘的三角函数用列表存储,并把原来的 Term
类命名为 OrdinaryTerm
,重新定义 Term
如下:
public class Term {
private OrdinaryTerm ordinaryTerm;
private ArrayList<TrigonometricFun> trigonometricFun = new ArrayList<>(); // restore multiplied trigonometricFun
}
4. 三角函数内表达式的读取
在总体上我们调用 paserExpression
进行对整个的字符串进行分析,自然而然的想法是以 expression.length()
作为分析的结尾,在第一次作业中笔者也是这么设计的
但事实上,Paser 是一个递归下降的解释器,在分析表达式因子的时候仍需用到 paserExpression
,若以 expression.length()
作为分析的结尾,就不得不对表达式因子进行切割,重新构造一个 Paser
类进行分析,分析为项的列表再返回,频繁地字符串切割笔者认为有失程序的“优雅性”,笔者采用的是以 )
作为 paserExpression
的退出条件,这种想法是基于多层括号和三角函数内的表达式的考量
定义:paserExpression 方法的含义为 position 所指向的 ‘(’ (保证一定是左括号)所匹配的 ‘)’ 之间的表达式的分析值
在输入的表达式两端加上括号之后再进行 paser
分析就可以返回整个表达式的分析值,注意:这种设计下源表达式两端也需要加上括号
这就使得:position
为全局的表达式指针,在遇到多层括号和三角函数的时候不需要构建新的 Paser 对象,也不需要进行字符串的切割,直接调用 paserExpression
递归分析即可
hw3
1. 求导策略
在第三次作业中加入了对表达式求偏导的要求如:dx(expression)
,由于作业中说明了求导因子只会出现一次且不会嵌套,故可以在进行 Paser
分析前先对含有求导因子的部分给替换,转换成第二次作业中不含有求导因子的式子,再调用 paserExpression
即可 偷懒
先扫描字符串,寻找字符串是否含有 d
,即求导因子,并将该求带因子块截取出来,进行分析,由于在需要求导的表达式内不会含有求导因子,所以先对该表达式进行分析,返回项的列表,将对表达式(字符串)求导转换为对项的列表求导
对项的列表求导策略如下图所示:
bug 分析
1. 字符串截取
前面提到:字符串截取是简单但危险的,递归调用是复杂但优雅的,我的三次作业的 bug 都集中在字符串替换的括号问题上:
-
在对源字符串的预处理中,需要另外对源字符串两端加上括号,这是源于我对
paserExpression
的定义,即在调用paserExpression
的时候需要保证此时position
指针指向的位置一定为(
-
由于我在自定义函数处理和求导策略中都使用了字符串替换的方法,符号问题如下:
考虑一段简单的求导因子:
-dx(-x-x^2)
我在读到
d
之后进入求导因子分析函数,返回一个字符串替换掉原来的求导因子,分析得到的字符串为-1-2*x
倘若直接替换,则为:--1-2*x
此时会有两个错误:
- 出现了连续的负号,之后的程序应该是建立在 “没有连续的正负号之上的” ,这样会报错
-2*x
项符号错误
所以应该在返回时加上括号:
-(-1-2*x)
整理之后再调用
paserExpression
分析表达式返回项的列表
2. 关于深拷贝
当类中含有列表 ArrayList<class1>
的时候,在类的构建中:
new ArrayList<>
只分配了列表的内存,并未分配列表内元素的内存,而 ArrayList.add()
方法本身就是浅拷贝方法
在进行多次 add 操作后可能会导致不同指针指向同一块内存,一处改变处处改变
// 浅拷贝
arrayList.add(class1);
// 深拷贝
arrayList.add(new class1);
hack 策略
笔者在三次作业中 hack
次数和经验较少,没有结合被测程序来设计测试样例,在 hack
策略方面理论性地总结以下几点:
- 构造小巧的数据,一次测试只针对一个点
- 构造临界数据,如
Integer
数据边界,sin(0)
、cos(0)
、(0)**0
等 - 寻找被测代码的关键点:如预处理方法等,针对性地构造数据
心得体会
在这三次作业中笔者最大的痛点就是完全没有采用层次化的结构,始终在用 Term 去包含 Factor 进行统一,以至于最后甚至放弃的 Expression 类,全靠一个 Term 类苦苦支撑着全部的任务,这种架构源自于第一次的作业,届时笔者对于递归下降的认识过于浅薄,在OOpre最后一次作业偷懒没用递归下降,并苦于表达式、项、因子的递归定义难以入手,虽然上过先导课但还是缺乏一些面向对象的思想,在经过一晚和一上午的思考之后才勉强拿出一个当时认为是完美且统一的架构,现在看来是多么的愚蠢和扁平,在 Factor 的规模若是进一步增大的话,再用 Term 去包含 Factor 则会使其显得臃肿杂乱,甚至第二次作业中险些重构,后来发现修修补补还能用。此外,笔者在第二次作业和第三次作业中没有进行任何的优化,一方面是因为过多的 ArrayList 的引入使比较变得困难,一方面是因为 debug 已经接近 ddl ,深拷贝的错误实在是难找。
虽然但是笔者的这个架构还是有一定的优势的,仅对于完成作业来说,简单的统一,简单的嵌套更适合我这种小白,求导策略中暴力的递归和不考虑递归深度的复杂度使得我在第三次作业的调试中一命通关,舍弃优化虽然限制了分数的上限,但是避免了许多优化产生的 bug 极大地提高了我的下限,仅限于完成作业来说或许行得通,但是对于训练来说也给我敲响了警钟。
或许不久的将来,也或许是本学期oo结束之时,我再回头看这些程序,看到的更多的不是愚蠢而是稚嫩,追风赶月莫停留,平荒尽处是春山,希望在今后的三个单元中,更多的是面向能力的学习,而不是面向作业的学习。