前言
在 Unit1 中,最终要求实现功能:读入一系列自定义函数的定义以及一个包含幂函数、指数函数、自定义函数调用、求导算子的表达式,输出恒等变形展开所有括号后的表达式。
度量分析
初次使用经典的 OO 度量,利用插件工具获取了如下指标:
OCavg
:Outgoing Coupling Average,度量类对外暴露的接口的平均耦合度。将类的对外方法的调用者数量除以类的对外方法数量。低耦合度意味着类之间相互依赖程度低,类更容易独立修改和重用。OCmax
:Outgoing Coupling Maximum,度量类对外暴露的接口的最大耦合度。找出类的所有对外方法,并计算每个方法的调用者数量,取最大值。高 OCmax 值意味着类与某个其他类存在强依赖关系,修改其中一个类可能会影响另一个类。WMC
:Weighted Methods per Class,度量类的方法的复杂度。将类中所有方法的 cyclomatic complexity 值相加。方法的复杂度指的是方法包含的逻辑路径的数量。CogC
:Cognitive Complexity,度量方法的认知复杂度,即理解和维护方法所需的认知努力。将方法的 cyclomatic complexity 值与方法的语句数量相乘。ev(G)
:Essential Cyclomatic Complexity,度量方法的本质圈复杂度,即方法中独立路径的最小数量。使用深度优先搜索算法遍历方法的控制流图,并计算图中所有环的独立路径数量,表示控制流结构的复杂程度 。iv(G)
:Irreducible Cyclomatic Complexity,度量方法的不可约圈复杂度,即方法中不能通过消除条件语句而减少的圈复杂度。将方法的控制流图转换为不可约形式,并计算图中环的独立路径数量。值越高,控制流结构越难以通过重构来简化。v(G)
:Cyclomatic Complexity,度量方法的圈复杂度,即方法中独立路径的数量。使用深度优先搜索算法遍历方法的控制流图,并计算图中所有环的独立路径数量。表征控制流结构的复杂性。
UML 类图与设计考虑
-
MainClass
:程序执行入口,调用 tool 处理输入tool package
Lexer
:词法分析,分解字符串为 token 流Parser
:递归下降解析表达式Processor
:字符串处理,去括号合并连续正负号FuncMap
:存储函数形参及表达式,调用时解析表达式并替换实参Token
:文法基本单元Type
:Token 类型
expression package
Factor
:表达式构成项的通用接口,可转为 Formula 类型Argument
:变量因子,含幂次FuncFactor
:函数因子,调用 tool.FuncMap 带入实参Num
:数字因子ExpFactor
:指数函数因子,含表达式及幂次Term
:项,由因子相乘而成Expr
:表达式/表达式因子,由项相加而成,含幂次DerFactor
:求导因子,对因子求导而成Formula
:item 集合容器,支持加、乘、幂、求导运算与同类项合并Item
:最终表达式的基本组成部分,含系数、变量幂次、指数函数Type
:Item、Formula 类型
总体来看,工具类和数据类划分明显,各个类之间层次清晰,呈明显的递归解析架构,功能明确,但由于 Factor 下最终都要转化为Formula
类来参与运算化简,或许不需要另设置这些类存储数据而是在解析时就统一解析为Formula
类处理好
代码规模
共 16 个类、74 个方法、831 行源代码。平均每个类有 5 个方法、2 个属性、52 行源码。对于初期的课程作业而言应该算是适中的代码量。
内聚与耦合
类与其他类的耦合度越高,方法越复杂,类越难以理解和维护。下面将分析指标表现不佳的类和方法。
如图所示。Formula
类的平均耦合度较高,是因为解析过程中的各个层次的类均调用了其构造以及运算方法,其和Item
类的方法复杂度较高主要是因为合并同类相过程中递归判断的相等方法以及获取转化为字符串时的递归获取类型的方法。此外,Lexer
类用较多语句集中判断和存储了各个语法成分的类型增加了代码认知复杂度和控制流的分支数量;Parser
作为递归下降解析的主要类继承了众多数据类,解析过程中未把正负括号的处理抽象出来也增加了逻辑分支和流程复杂度;它们和Processor
这些工具类在不同的场景下反复调用也提高了和其他类的耦合度。
架构设计体验
共 3 次作业,一开始就在课题组的正确指导下采用了递归下降的解析结构,在后面的作业中经历了 2 次迭代,没有进行重构。
架构迭代
-
第一次作业
读入一个包含加、减、乘、乘方以及括号(其中括号的深度至多为 1 层)的单变量表达式,输出恒等变形展开所有括号后的表达式。
根据文法,表达式由“+/-”和“*”划分为
表达式->项->因子(含表达式)
层次清晰的结构,且最后的多项式由统一形式的单项 a ∗ x b a*x^b a∗xb相加组成(减法变为负项统一),故采用层次化递归下降的架构解析带括号的表达式,并统一转化并存储为基本项及其组成的多项式进行运算展开括号与化简//Parser.java //parseExpr() ... while (type == Token.Type.SUB || type == Token.Type.ADD) { lexer.next(); terms.add(parseTerm(...)); } //parseTerm() ... while (type == Token.Type.MUL) { lexer.next(); factors.add(parseFactor()); } ... //parseFactor() ... if (type == Token.Type.ARG) { factor = parseArg(); } else if {...} ...
-
第二次作业
新增需求:支持嵌套多层括号;新增指数函数因子,指数函数括号内部包含任意因子;新增自定义函数因子,但自定义函数的函数表达式中不会调用其他自定义函数。
上述递归下降的处理方式可以很好的处理多层括号的嵌套结构,迭代集中在自定义函数和指数函数上。自定义函数采用先存储函数形参及表达式,然后解析到函数因子时再解析内部实参列表,接着带入表达式返回带入后的字符串,最后调用
Lexer
和Parser
解析该表达式返回;指数函数方面更改基本项为 a ∗ x b ∗ e x p ( c ) a*x^b*exp(c) a∗xb∗exp(c)的形式存储,相应的更改多项式的运算逻辑,递归改写equals()
方法和相应的hashCode()
实现同类项的合并。//FuncFactor.java ... this.funcStr = FuncMap.callFunc(funcName, actParas); Lexer lexer = new Lexer(funcStr); Parser parser = new Parser(lexer); this.funcExpr = parser.parseExpr(); ...
-
第三次作业
新增需求:支持求导操作,新增求导算子;允许嵌套定义自定义函数
由于在第二次作业中采用递归解析而非字符串正则替换的方式处理自定义函数,已经可以实现嵌套定义的递归结构,无需迭代,架构的可扩展性可见一斑。求导操作方面,将其视为一种施加在基本项上的运算即可。
//Item.java public Formula derItem() { Formula formula = new Formula(); ... tmpFormulaLeft.add(tmpFormulaRight); formula.mul(tmpFormulaLeft); return formula; }
新迭代情景的可扩展性
假设新场景:增加三角函数因子sin()
和cos()
。
表达式的层次并不受影响,仍可采用已有的递归下降解析架构,只需要更改基本项,加入三角函数的幂次及内部参数,并改写多项式的加乘求导运算逻辑,改写同类相合并逻辑,处理方式参考指数函数。
分析自己程序的 bug
bug ,问题原因与解决方法
-
分支覆盖
在第二次作业中出现了 bug,当指数函数内部因子为负数时(例如,exp((-x))
)会直接忽略整个指数因子。原因时在优化性能考虑将公因数提取到指数函数外时,判断了只有是正数才可提取到指数却忽略了负数的情况,遗漏了逻辑分支而缺失了相应的功能代码。先考虑分支种类再具体编写逻辑功能。 -
拼写错误
在敲代码过程中有时心力疲惫,不知所云,在一次次
Tap
键的脆响中胡乱编排着属性与变量,如坠云雾之中。注意命名的区分度与可辨识度,所见即所得。 -
深浅拷贝
本次作业过程中还遇到深浅拷贝这一棘手的 bug。例如在两个
Formula
对象在参与运算时没有对其管理的基本项Item
对象进行深拷贝就会造成多个Formula
对象管理同一个Item
对象的行为,当其中一个Formula
对象更改时另一个Foemula
对象也随之更改。用深拷贝递归创造新的对象分配新的内存进而解耦。
设计与复杂度相关
bug 集中在复杂度较大的Formula
类和Item
类,当复杂度较大时,难以把握整体的程序逻辑控制流,易造成 bug,设计时应该降低类和方法的复杂度,可以通过内聚降低耦合,抽象功能等方式。
分析自己发现别人程序 bug 所采用的策略
自动化测试
利用 python 的sympy
模块进行表达式的等价判断,进而编写自动化测试脚本对别人的程序进行黑盒测试发现 bug。
bug 定位
发现 bug 后调出特定测试用例,逐步删除简化测试用例的结构,直至定位到bug的数据结构,结合调试功能发现源码的逻辑疏漏之处,精准挑出 bug 并设计测试用例 hack。
分析自己进行的优化
在保证正确性的前提下,尽可能缩短表达式长度。
优化方案
对于 a ∗ x b ∗ e x p ( c ) a*x^b*exp(c) a∗xb∗exp(c)中的 b ∗ e x p ( c ) b*exp(c) b∗exp(c)相同的基本项合并系数 a a a,对于 e x p ( c ) exp(c) exp(c)项,若 c c c为 a ∗ x b a*x^b a∗xb或 a ∗ e x p ( c ) a*exp(c) a∗exp(c)的形式且 a a a为非 1 1 1的正系数,则提取出来减少一层括号。
Trade off
还可以进一步通过提取 e x p ( ) exp() exp()内系数公因式的方法进一步化简表达式,但因为之前的优化实现过于臃肿,功能逻辑结构有些混乱,暂时搁置了。为提高代码简洁性,可以将优化功能提取到工具类而非在功能类中实现,一个方法干一件事。还可以实现一个线性的贪心优化,至于其他启发式的搜索优化方法仍在研究中。
Get it right!
在优化过程中出现了bug,最重要的是保证代码的正确性,优化前想好更改的代码逻辑会不会对原有架构的正确性造成影响,优化后做好充分测试检验。
心得体会
通过第一单元的学习,实际感受到了层次化设计以及面向对象编程抽象业务逻辑的操作方法,学习了如何处理解析问题的复杂性。期间因为想到哪写到哪的不规范行为整的十分狼狈,也因此感受到迭代开发与测试的科学之处。
路漫漫其修远兮,吾将上下而求索。
未来方向
第一单元通过表达式解析的业务场景训练了学生层次化建模抽象的能力,未来或许可以增加关于更多的关于自动测评机等知识,或许可以增加诸如设计文档,代码注释,变量命名等工程化规范。