2024 北航 OO 第一单元总结

唐凌-22373272 学生 2024-03-21 00:15:41

2024 北航 OO 第一单元总结


一、程序架构分析

需求分析:第一单元的主体结构为一个文法分析器,第一次作业要求实现对一个含有括号、加、减、乘、乘方的式子的展开功能,随后第二、第三次作业中分别要求实现exp指数因子、自定义函数和求导因子的增量开发。

1. 最终程序架构UML图

img

1.1 总体结构分析

我的程序中主要有以下几个部分组成:预处理部分生成文法树部分计算和输出部分

其中,预处理部分包括字符串处理、函数替换、字符串转token流的功能;

生成文法树部分采用递归下降算法,构建expr-term-factor-......的语法树结构,其中Parser为生成器;

计算和输出部分,将文法树的根表达式(root Expression)转化为基本项(Basic term),并实现加、减、乘、乘方、求导运算。

1.2 预处理部分

UML图中的浅蓝色表示部分,首先读取函数部分,实例化Function对象并储存在ArrayList<Function> functions中由Function对象记录函数名、函数表达式、函数形参,由Main中创建的Lexer对象进行字符串处理、函数替换,并将字符串转化为token流。

1.3 生成文法树部分

由两部分组成,第一部分为生成器Parser,由浅绿色表示,第二部分是文法树元素,由深蓝色表示。

Parser对象,可以读取Lexer中的token流,实现了parserExpr()parserTerm()parserFactor()方法,对文法树进行递归生成。

元素部分,由Expr-Term-Factor三层构成。Expr对象中有termsops分别记录每一项和加减号;Term对象中有factors记录每一个因子;在不同因子中,管理的形式不统一,特殊地,ExponentialDerivative类中还管理了一个表达式作为作用域的表达式。因为所有因子元素在下一步都将被转化为基本项,因此将这项功能抽象为toPoly()方法,使用Factor接口,由所有因子元素实现。

1.4 计算和输出部分

从文法树的根节点表达式开始,将表达式转化为基本项(多项式)。规则为:表达式的多项式为每个项的多项式想加减,每个项的多项式为项的每个因子相乘,每个因子的多项式由统一范式给出定义。

多项式由Basicterm类实现,用ArrayList<Mono> monos管理每一个单项,每一个单项——Mono中拥有该项的系数,x的次数,exp的表达式,表达式由Basicterm类对象递归实现,即每一个Mono的统一范式都形如:ax^b * exp(expr)
每一个Basicterm都形如:Mono_1 + Mono_2 + Mono_3 + ...
这些项由因子元素相乘、项元素想加减得到,因此还需要实现基本项的加法、减法、乘法、乘方、求导运算,至于合并同类项的方法,还需要实现判断两个单项x的次数,exp表达式是否相等的方法。

特别地,对于求导因子,由求导法则可以将问题转化为对每一个Mono求导,而此时可以运用一个基本的求导公式,从而实现万物皆可导。而根据这个公式,我在BasictermMono中都实现了求导方法。
dx(ax^b * exp(expr)) = abx^(b-1) * exp(expr) + ax^b exp(expr) * dx(expr)
最终,将所有Mono输出得到答案,输出过程实现了一定的优化方法化简方法

1.5 优缺点分析

首先,整体框架还算清晰,预处理部分、文法树生成部分、计算和输出部分功能都是相对独立,仅从这三个模块的角度而言耦合度是相对较低的,因此在debug的过程中,可以分别就这三个部分进行断点输出快速检索出问题的位置。增量开发的时候,往往只需要针对一个部分进行修改即可,例如第二次作业的exp函数只需要更改Mono结构即可,自定义函数做字符串替换仅需在字符串处理部分更改即可;第三次作业的求导,只需要新增求导计算部分即可。

整个框架经历了一次重构,第一次作业我实现了后缀表达式的方法用于计算,但考虑到后续可能增加的其他运算,我进行了重构,即不在对文法树进行压栈操作之后再计算,而是直接在文法树转化基本项的过程中就进行计算,但得益于几个处理部分的功能相对独立,我进行的重构的工作量也仅限于计算部分的局部重构。

框架中的一些细节也存在着一些缺陷,例如:我选择对函数做字符串替换,由Lexer调用Functionsubstitute方法实现。但在从形参到实参的替换的过程中有诸多可能导致bug的细节需要考虑,应对这些细节,我没有实现一个单独的方法处理,而是在替换方法中直接用一些特殊的策略——例如换字符的方式解决问题,这样做可能导致对其他类型数据(比如多元素)的处理拓展性就不是很好。同时处理很多这样的细节也会使得单一职责原则的实现效果变差。

除此以外,在Basicterm中判断表达式相等的方法中,我的实现方法为两个表达式相减,判断是否为“0”,但是减法同样调用了isEqual方法,导致嵌套调用,使得复杂度增大。同时,在求导方法中,对于exp中嵌套exp的表达式,会涉及BasictermMono中求导方法的反复调用,使得类与类之间,方法与方法之间的耦合度增大。

2. 度量分析

本次总结中,我使用的度量分析工具均为IDEA中自带插件,代码规模分析工具为Statistics,复杂度分析工具为Metrics Reloaded,呈现效果如图所示:

2.1 代码规模

img

  • 总代码规模为1024行,源码规模863行,占比84%
  • 代码行数最多的两个类为 Lexer(253行) 和 Basicterm(228行)
    • Lexer中实现了字符串的预处理,其中moveFunc()(52行)方法行数最多,反思发现由于把字符串处理过程的对StringBuider的操作过于细节,没有实现记录实参和字符串处理功能的分离,其他方法,如removeSquare()可能冗余。
    • Basicterm中的方法较多,除了构造方法,共有12个方法,其中,有5个方法为计算相关的方法,功能分离较为细致,涵盖加,减,乘,原子加法,求导方法,3个方法提供优化使用,方法规模控制在合理范围内,优化方法可以进一步封装。
2.2 类复杂度

img

指标解释:

  • OCavg:类中的非抽象方法的平均圈复杂度
  • OCmax:类中的非抽象方法的最大圈复杂度
  • WMC:类中方法的总圈复杂度

可以看到,复杂度较高的情况主要集中在LexerFunctionBasicterm类中。在我个人看来,LexerFunction作为字符串替换层面的操作类,要考虑很多细节,本身就是就较强的面向过程的特点的,因此复杂度会相对较高,而Basicterm的圈复杂度高是因为在isEqual()isSimple()的方法中,多次调用了自己的toString()方法和其他方法,使得复杂度较高,具体情况在后续分析中给出。

2.3 预处理类复杂度

img

不难发现,复杂度最高的是Lexer构造方法自身,反思该部分代码,发现将String转token流的具体过程甚至是字符的细节处理都是在构造方法实现的,这样就使得构造方法本身复杂化了;

另一处moveFunc方法也是类似的问题,我的策略是对于一个函数,找出其所有实参,再做字符串层面的替换,考虑到对括号嵌套层数计数、逗号、实参变量存储等等因素的处理都在一个方法中,使得代码的效果开始趋向于面向过程了。

2.4 项和因子类复杂度

img

项和因子(包括表达式)的复杂度表现较好。这些类中的方法主要为构造方法,语法树的结构也足够清晰和层次分明,易于实现高内聚,低耦合的代码。

2.5 计算类复杂度

img

复杂度问题最大的方法是Basicterm.isSimple()方法,这个方法的作用是判断exp中的表达式是否是是单个因子(即这里定义的最简),从而优化exp中的第二层括号,问题在于:对于判断是否最简的问题中,条件太多,因此分支语句if较多,而每一次都要用到该对象中的coe、exp等元素,甚至还调用了自身的toString()方法,使得复杂度自然上升。

其次是sbnum()方法,这个方法是对形如 a*x^b 进行化简的方法,需要考虑的因素有 a 的取值, x 的指数,具体情况又极其复杂,因此又会导致和之前一样的分支语句if...else较多,且多次调用自身属性的特点。如果要对这样的情况进行优化,可能需要重新找到不同情况的异同点进行合并减少分支语句,并将每一步的化简再分发给子方法,想办法减少对自身属性方法的调用,从而降低复杂度。

二、架构设计体验

1. 架构迭代与成型

1.1 HW1、初识递归下降

第一次作业,要实现:读入一个包含加、减、乘、乘方以及括号(其中括号的深度至多为 1 层)的单变量表达式,输出恒等变形展开所有括号后的表达式。

得益于课程组提前给出的递归下降算法的分享,和课上对BNF范式的了解,很容易建立起整个语法结构,即Expr-Term-Factor的层次结构,且在后处理中尝试完全字符串展开处理失败后,我构建了基本项-单项结构,只是第一次作业中的需求很简单,只有系数、x、指数三项,因此单项可以仅仅是指数和系数的HashMap,但这样的结构在完成任务的基础上已经可以处理多层嵌套括号了。

值得一提的是,第一次作业中,我经历过一次小重构,但主要是对计算形式的方法的重构,对整体的结构影响并不大,开始我采用了后缀表达式的方法实现计算,只需要将每一个单项(这里指 a*x^b)作为一个数,和常数的四则运算一样,将基本项和符号压入栈中进行计算,但很显然,这样的实现对于四则运算之外的计算就显得力不从心了,但考虑到后缀表达式本身就是对树结构的后序遍历的结果,那么,其实可以直接利用树的结构,在每一层对下一层进行直接的计算就能返回该层的答案,例如我们要得到某个的结果,那么只需要将它的下一层——因子相乘即能得到;如果要得到表达式的结果,那么只需要对它的下一层——相加减即能得到。于是,我删除了后缀表达式的运算方法,在原本压栈的过程中即进行计算。

Expr为例,代码中的tokens为最终实现计算的栈,bts记录Basicterm入栈顺序,先将下层的terms压栈,再把符号压栈,下层操作类似,直到非表达式因子层就只把因子压栈:

public void toTokens(ArrayList<Token> tokens, ArrayList<Basicterm> bts) {
        terms.get(0).toTokens(tokens, bts);
        for (int i = 0; i < ops.size(); ++i) {
            terms.get(i + 1).toTokens(tokens, bts);
            tokens.add(ops.get(i));
        }
    }

修改过后的结构则直接在表达式转化为Basicterm的过程中就完成了计算,进而省略了冗余的后缀表达式计算的步骤。

public Basicterm toPoly() {
        Basicterm basicterm = new Basicterm(new BigInteger("0"),
                new BigInteger("0"), new Basicterm());
        basicterm = basicterm.addbt(terms.get(0).toPoly());
        for (int i = 0; i < ops.size(); ++i) {
            Token temp = ops.get(i);
            if (temp.getType() == Token.Type.ADD) {
                basicterm = basicterm.addbt(terms.get(i + 1).toPoly());
            } else if (temp.getType() == Token.Type.SUB) {
                Basicterm subed = terms.get(i + 1).toPoly();
                basicterm = basicterm.subbt(subed);
            }
        }
        return basicterm;
    }
1.2 HW2、新增指数因子和自定义函数因子

新增的指数因子,破坏了原来的指数-系数HashMap结构,因为单项不再像原来那样简单了,此时,需要一个更复杂的结构来表示单项,且为了应对更多情况应当具备一定的拓展性。所以,在这一次作业中,我实现了Mono类,那么Basicterm只需要管理Mono对象的列表即可,那么,我对单项的定义为:ax^b*exp(expr),expr的最终存储形式也是一个Basicterm,因此,Mono类中需要管理的属性为系数、x的指数和 exp 的表达式。

另一个问题是新增的自定义函数因子,一个棘手的问题油然而生:字符串替换 or 函数调用

对于自定义函数的实现,一种想法是在字符串预处理时就将其替换掉,这样就完全不需要考虑文法树的建立和计算时对于自定义函数的考虑,另一种想法则是在文法树建立时如果遇到函数,则找出对应的实参再做数学层面的替换。

考虑到函数调用的方法需要对计算方法进行修改,且需要对函数本身进行表达式的转化处理,加上函数中最多有三个形参,可能会出现混乱,因此我最终选择了字符串替换。

具体做法为,根据括号计数和逗号的出现,找出函数的实参,并建立形参-实参映射,最终全部将形参替换为实参。这样做咋一看好像很简单,但是实际运行时出现了各种各样的问题,常见的有:

  • 替换x时,将exp中的x也替换掉了,解决方案:事先将exp替换为e
  • 如果先换y/z,再换x,可能导致换过之后的实参中的x被当成形参替换掉了,解决方案:先换y,z,再换x

这些细节的问题较为繁琐,是导致bug高发和代码复杂度增加的重灾区,但如果处理得好,就能为后续的建立语法树和计算步骤带来极大的便利。

1.3 HW3、新增求导因子

到了第三次作业,大家心心念念的求导它终于来了,但是基本的架构实际上在前两次作业已经基本成型,求导的实际复杂度也并不大,当然,这可能和善良的课程组没有选择三角函数有着极大的关系,对含有exp的单项求导,本身就是一件简单的事情,而对多项式求导,无非是对每一项求导再相加。因此,求导的功能只需要在原来的基础上,对BasictermMono进行求导计算增量开发即可。

本次作业,值得注意的一点是:函数调用可以嵌套之前出现过的函数,那么我们在进行函数替换的时候,可以进行一个小小的微调,按照函数出现顺序反向进行函数替换,很自然且舒服地解决这个问题。

2. 架构可拓展性

整体而言,经过三次作业的历练,我们经历了对数据的拓展和对功能的拓展两方面,每次都是在前一次的基础上的增量开发,在一次次重构风险的洗礼下,我们的代码其实也在一点点的实现SOLID原则,尤其是单一职责原则思想的应用和开闭原则的应用,使得增量开发可以只增加代码而不更改或只改少量的旧代码。

例如:如果要实现新的属性如三角函数因子等,那么,原有的框架不用变,原有的计算方法不用变,只需要增加三角函数的转化基本项的方法、Mono中的三角函数属性以及三角函数的求导方法。这就得益于我们在基本项中管理的是每一个Mono而不是具体的单项内容。

例如:如果要实现新的功能,那么我们可以将其视为一种新的计算方法,在Basicterm中为其增加方法本身和求导方法即可。

三、遇到的bug和解决方法

在第二次作业强测中,我遇到的bug是这样的:

img

或者是这样的:

img

上面的bug为压力测试的结果,在当天极限将exp由Integer型改成BigInteger型数据后,没想到出现了TLE的结果,后来经过分析代码的运行过程发现,从计算中间的某个乘法步骤开始,时间突然就开始呈指数级增加,那么这是怎么回事呢?当我进入调试之后,发现了一个巨大的问题:一个乘数项的monos中,有一大半的系数为0,而计算的项数竟然高达两千多项,实际参与运算的可能至多也就几十项,这样一来,无效的运算大大浪费了资源,于是我在计算过程中就删除了系数的为0的冗余项。同时还有一个问题,那就是我之前的操作一直是把乘方展开为乘法,但在极限的数据(例如本数据)下,最里面的括号尚且已经展开了八个x相乘,外面一层层拓展,之后的计算量同样是惊人的,甚至可能在拓展乘方的时候就已经花费了大量时间,于是,我又更改了乘方的处理方法,不展开,直接在因子中将乘方转换成连乘。最终,成功解决TLE的问题。

下面的Bug则完全属于指导书没看清(),对于格式要求的理解错误,当然也从侧面反映出一味地追求长度性能可能会因小失大,这一点的确需要在今后的任务中多加注意。

四、测试策略

君子善假于物也,对于其他大佬们做出来的评测机,我一直抱着学习的心态使用。当然,由于评测机的样例总是会超cost代价,因此总是不得不从出问题的样例中找到出错的地方,进行“针对性”改造,实现定点爆破。我的构造样例的测试策略大概如下:

1. 简化问题

我们可以先构造一些远超过Int类型的数据和最终指数较大(就如我在第二次强测中遇到的第一个bug那样)的数据,如果在这样的数据下程序不出问题的话,那么大概率是不会在数据范围方面出现问题的。

因此后续构造数据的时候,就不用选取大数,而可以选一些小的常数和指数;同时对于一些点,不用重复测试,例如连续的正负号,函数嵌套,不同形参顺序等,一组样例对于一个点至多只需出现两次就足够了。这样做的好处在于:一方面,控制样例复杂度,代价函数不容易超出界限,另一方面也方面我们根据易错点手动构造数据和理解。

2. 有的放矢

从互测规则角度而言,在同一个房间中的同学,代码的健壮性基本差不多,你能想到的点别人也基本能想到,因此如果想通过大量测试找出bug时间代价可能会比较高。我们可以选择容易出错的一些点构造数据,做到有针对性地构造数据,例如,在优化输出那一块,有些同学就可能会为了性能而导致了部分情况的格式错误;对于进行函数字符串替换的代码,很有可能对特定的形参顺序出错,这时可以设置两三组不同形参顺序的函数进行嵌套调用;对于计算过程没有删除冗余项(0项)的代码,就极可能对多层的嵌套+指数的形式TLE,可以针对这些易错点去看其他代码,寻找漏洞。

五、优化策略

  • 因为之前的代码架构已经实现了同类单项的合并。对于整体的优化,是尽可能将正项放到第一项,这样可以省略第一个“+”号。

  • 除此之外,优化只能是针对每一个单项的:

    • 系数为正负1,若只有系数非0,则输出系数,否则只保留正负号。
    • x的指数为0,视为1,指数为1,可忽略指数。
    • exp的指数为0,视为1。
  • 对于exp因子,里面的表达式如果仅仅有一个因子,那么可以省略一对括号,反之则不能省略(这一点容易忽略形式上的要求,对形如exp(-x)的例子来说仍需化为exp((-x)) )

ps:我在优化这一块的代码架构中,对于很多情况的判断都运用了if...else的形式,导致最终成品极为面向过程,正确性可以由优化策略的全覆盖测试验证,但是简洁性和复杂度就显得不那么优美,要解决这个问题,或许可以将优化细节进一步封装,每一步优化由单独方法实现,而不是放在优化输出的大方法中。

六、心得体会

这是本学期OO课程的第一个单元,但是一上来的强度感觉并不低,尤其是一开始我对递归下降的理解不深刻,也很难将文法分析这个看上去很面向过程的功能和面向对象结合起来,导致一开始在构思架构的时候遇到诸多不顺。

但正所谓万事开头难,当我实际上手练习和尝试写代码的时候,才发现只有在实践中才能渐入佳境,对于很多实际的问题,可能就是很细节的,比如连续正负号的处理,嵌套括号的处理,乘方的处理,有了想法却不动手实现,就容易越想越糊涂,寸步难行;另一方面,我对于这样一个看上去复杂度极其“高”的工作,最终将其拆解为预处理、构建语法树、计算输出部分,在每一个部分中,设计不同层次的类和方法,在因子层次还设计了接口类,在这一过程中我也更加深入地理解了面向对象的思想和SOLID原则。

经过第一单元三次代码开发的训练,我对于规范性可拓展性的重要性的认识更为深入,好的代码就是一件艺术品,它的每一行、每一个符号都是必要且优美的,如果我们能用统一的规范去完成架构的设计、代码的具体实现,那么不仅代码的正确性、可靠性会有极大保证,即是出现bug,也能很快定位出来;对于工程中的代码,我们往往会经历迭代开发的过程,那么我们或许可以做到眼光放长远一点,实现功能的时候预想未来新增的功能,为未来的增量留有余地。

七、未来方向

我认为可以在第一次作业之前,提供更多的帮助和提示,例如将往届优秀代码的核心部分转化为伪代码供大家参考学习,进而让同学们在参考思路的基础上实现一个新的功能。第一周课上可以多介绍一点有关于递归下降和BNF范式的知识,使同学们对第一单元所用到的基础知识上手更快。

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

301

社区成员

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

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