第一单元的主题是表达式展开,要求依据文法定义对输入的表达式进行解析,进行必要的计算后输出。第一次作业要求对表达式进行括号展开;第二次作业新增指数函数和自定义函数的展开和运算,并允许括号嵌套;第三次作业新增求导因子,并允许“自定义函数的定义式中出现已定义的其他自定义函数”。
程序架构分析
uml类图
三次作业后最终的架构如下。
-
表达式解析类
Parser
类和Lexer
类负责通过递归下降法对输入的表达式进行解析。Lexer
获取输入的字符串,像一个“指针“沿着字符串移动,返回读取的信息;Parser
接受信息,依据文法解析生成不同表达式成分的对象。Expr
,Term
,Basic
,Factor
为表达式中不同层次组成成分的类。除指导书中表述的表达式、项、因子三个层次外,我在项和因子间插入了基本项(Basic
)层次,对应幂次运算符^
,而将因子定义为不含指数;这样就可以根据运算划分表达式层次,每个层次对应一种运算符:表达式(Expr
)可以看作是项(Term
)的集合,项之间通过+-
连接;项看作基本项(Basic
)的集合,基本项之间通过*
连接;基本项的形式是因子^因子
。- 因子(
Factor
)定义为接口,其中定义了计算(toPoly()
)和代入实参(substitute()
)方法。每种因子都实现这个接口,从而实现了对多种因子的统一管理。 FunctionDefine
为静态类,负责解析自定义函数的定义式。Process
负责对表达式字符串进行处理,包括去除空白符,合并连续±号,去除指数前的+号等。
-
表达式计算类
-
Unit
类为计算时的最小单元,形式为:
a ∗ x b ∗ e x p ( P o l y ) a*x^b*exp(Poly) a∗xb∗exp(Poly)- 由于指数函数运算的特殊性,多个
exp()
相乘可以合并成一项,因此可以将Unit
定义成如上简洁且方便运算的形式。
- 由于指数函数运算的特殊性,多个
-
Polyno
类负责多项式运算,通过ArrayList
管理组成它的Unit
,实现了加减、乘法、幂次、求导运算,并在运算过程中完成合并同类项。
-
优缺点分析
- 优点:我的架构基本符合高内聚,低耦合的思想,从而有较好的鲁棒性和可扩展性。
- 解析和计算解耦:
- 在解析和建立表达式树时,并不关心计算的问题,只是忠实地记录读取到的信息。
- 在计算时,所有表达式成分都是
Poly
,依据不同的运算规则完成运算即可。
- 计算的高内聚:
- 我将自定义函数代参和求导也视作一种计算,在顶层只需要调用
toPoly()
方法计算即可,不需要关心都需要进行哪些运算。
- 我将自定义函数代参和求导也视作一种计算,在顶层只需要调用
- 解析和计算解耦:
- 缺点:
- 一些方法的实现比较啰嗦
toString()
方法直接打印x
的字符串,如果需要输出多变量表达式则可扩展性很差- 基本没做优化
度量分析
-
Method Metrics(只展示复杂度高的方法)
Unit
的toString()
方法中要对系数、指数的各种情况进行复杂的特判从而实现输出优化,因而复杂度飙升。Parser
的parseFactor()
方法要对各种不同的因子类型进行判断,通过不同的方法构建各种因子的对象,因而复杂度非常高。- 除此之外,其他方法都较为简洁。
-
代码量
架构设计体验
- 第一次作业
- 初步实现了解析与计算的解耦,并将这一思想沿用至后两次作业;
- 解析表达式时采取递归下降算法,天然支持括号嵌套,并在需要加入对更多类型的因子的解析时有很好的可扩展性;
- 计算表达式时,
Polyno
类中我选用HashMap<BigInteger,BigInteger>
管理运算的对象,以指数作为key
,系数作为value
。这样在合并同类项时非常方便,但无法支持更多种类多项式的运算(如后面加入的指数函数),可扩展性较差。
- 第二次作业
- 由于指数函数的加入,原先
HashMap
的数据结构已经无法完成计算,因此引入了Unit
类,将Polyno
看作Unit
的集合。 - 在实现指数函数的运算时,主要在
Unit
和Polyno
的equals()
方法上遇到了问题:Unit
中指数函数的指数部分存储为一个Polyno
类对象,而Polyno
类又是Unit
类对象的集合;在判断Unit
相等时要判断Polyno
相等,在判断Polyno
相等时又要判断每一个Unit
相等,无穷地递归下去。解决方法是定义Polyno
中存储Unit
的ArrayList
为空时值为0,并将其作为递归的终点。- 解决了无穷递归,在合并同类项时又出现了问题,原因是混淆了
Unit
和Polyno
中equals()
的定义。在我的代码中,Unit.equals()
用于判断两个Unit
是否可以合并,而Polyno.equals()
则是判断两个多项式是否完全相等。如果在Polyno.equals()
中调用每个Unit
的equals()
方法就会出现错误。将判断两个Polyno
相等的方法改为计算二者相减后是否等于0就解决了这个问题。
- 在自定义函数的处理上,我没有采用字符串替换的方式,而是进行对象层面的替换。我的想法是,将“代入实参”视作是自定义函数因子的计算行为,在
toPoly()
方法中实现,这样就达到了行为上的统一。toPoly()
方法流程:- 解析形参表达式
- 调用substitute()方法,传入形参实参映射表作为参数,通过递归层层往下,再层层返回
- 返回一个Polyno类对象
- Expr返回所有Term.substitute(…)相加的结果
- Term返回所有Basic.substitute(…)相乘的结果
- Basic返回Factor.substitue(…)再幂次的结果
- 对于Factor:
- 常数直接返回自己
- 变量返回根据映射表得到的实参的toPoly()得到的结果
- 指数Factor返回它的指数部分上的factor调用substitute()得到的结果
- 由于指数函数的加入,原先
这样写完以后足以通过第二次作业,但其实有一个问题悬而未决:自定义函数因子作为一种表达式成 分,它也应该实现substitute()
方法,但在此时我没有想出合理的做法,只是粗暴地将其和toPoly()
方 法等同。这就为第三次作业支持形参中的自定义函数埋下了地雷。
-
第三次作业
-
求导的实现非常容易。我依旧贯彻“解析与计算解耦”的想法,并没有对每个表达式层次实现求导,而是直接在
Unit
类和Polyno
类中实现求导方法,从而将求导也蕴含在了表达式的toPoly()
过程中。(当然,这得益于本次作业中
Unit
的形式简单。如果像往年出现三角函数的累乘,这种做法就行不通了。) -
对于自定义函数,第三次作业新增自定义函数可以作为形参。由于我之前没有实现自定义函数因子类的
substitute()
方法,无法将形参表达式中出现的自定义函数进一步替换;虽然看上去只是个小问题,但真正着手去实现时却发现困难重重,转眼间距离重构的泥潭仅有一步之遥。为此我苦恼了很久,在参考了一些博客和讨论区的帖子后,最终决定不进行大规模的重构,而是将替换与计算进一步解耦:-
substitute()
不再返回Polyno
,而是返回void
,只替换,不计算。(狠狠夸夸xmgg的讨论区帖子“命令-查询分离原则及其在接口设计中的应用”,醍醐灌顶,把我从前仅停留在感性直觉上的认识剖析得一清二楚)
-
变量因子记录自己是否是形参(
private boolean isFormal
),如果是,则记录下对应的实参表达式(private Expr actualPara
)。actualPara
默认为空;如果不为空,且此时isFormal
为真,则说明需要对actualPara
进行进一步的替换。这就实现了对形参中出现的自定义函数的深层替换。(这里感谢yyb同学的讨论区帖子给我的启发)
-
-
尽管期间付出了大把头发,最终的改动却只有寥寥数行,终于实现了这一要求。
- 新迭代情景——多变量 (只要文法定义不变,解析部分就不需要大改,只需要更改计算部分)
Unit
中需要储存每个变量的指数和系数,equals()
方法也会更为复杂toString()
输出时不能直接输出字符串x
,可能难以避免与变量因子类的耦合- 多变量下的偏导和全微分问题o_O
程序中的bug
三次作业中,仅有第一次作业在强测和互测中被发现了bug,原因是Parser
类中的parseExpr()
方法在解析表达式时没有考虑表达式开头可能出现的符号。由于我对表达式进行了预处理(如果整个表达式以符号开头则在最前面插一个0),就忽视了这方面的处理,而在处理括号中的表达式因子时就会暴雷。
其余两次作业强测和互测都没有发现错误。(虽然性能分被干碎了)
发现别人的bug
由于代价函数的限制,互测中很难构造出压力测试(当然有大佬构造出来了orz),主要通过构造一些嵌套来增加数据的复杂度。第一次作业进了C房菜鸡互啄,在两位同学的代码中发现了输出优化导致的错误,针对这个问题构造了可以发现bug的测试数据。
优化
能力所限,我只进行了一些最基本的优化,包括系数为1或-1时省略,指数为1时省略,为0时省略变量x
等。对于指数函数没有做任何的优化(省略括号,提公因数等),一切以正确性为重。
心得体会
整篇总结遣词造句已尽可能精简,写到现在却不知不觉已经洋洋洒洒近三千字,仅聊以记录一个月来在oo作业上的思考。oo第一周的作业可谓当头一棒,尽管已经经历过oopre的洗礼,却还是被这一棒敲了个晕头转向。期间经历过无数个闭上眼还在脑内断点调试的夜晚——好在最终还是平稳落地了。有些遗憾的是在优化上没有多下功夫。
加深了对递归的理解。本次作业中大量用到了递归调用,使我进一步加深了对递归思想的认识。递归思想就是“相信后人的智慧”。
多多关注讨论区。讨论区很多同学的见解都非常有启发性,让我受益良多。
建议
我认为第一单元的设计已经非常好了,从迭代设计到实验、训练、公众号文章的架构引导都足见课程组投入的心血。尽管hw1一上来,以及hw1到hw2之间的难度曲线较为陡峭,但经过一些努力依旧可以较好完成。