OO-UNIT1课程总结

概述

        面向对象设计与构造课程第一单元以含单变元的表达式计算化简为要求,在作业中逐步迭代增加新的功能,使得我们较为完整地实现一个简单的符号计算程序。在实现过程中,不仅让我们掌握了递归下降处理复杂表达式的方法,还在编程迭代过程中不断领悟面向对象思想在编程中的应用。其中,hw1要求实现含单变元x单层括号的整式的加、减、乘、乘方四种运算;hw2新增指数因子,并允许自定义函数以及多层括号嵌套;hw3中新增运算求导,并允许了自定义函数调用已定义的自定义函数。

I 程序结构分析

        1.1 项目类图

预处理阶段

由于表达式中存在自定义函数,需要在最开始将函数替换为对应的表达式。程序最初创建Funparse类,并将自定义函数替换规则解析录入Funparse类,之后调用预处理函数根据Funparse记录的规则来替换自定义函数,并清除所有空白符。

解析构建

由Main入口将字符串传递给Polynomial类,它会调用并构造Monomial类以此来构造自身。Monomial类会进一步在FactorConstruct的统一管理下,调用构造因子,由此来构建自身。

(多项式由单项式组成,单项式由多项式组成)

注意,特殊因子,指数因子会调用FactorConstruct来构造自身(指数内部是因子);表达式因子和求导因子会调用Polynomial来构造自身(其内部是表达式)。

上图为计算表达式的值时的类间关系

计算化简输出

由最上层的Main调用getValue方法后,递归下降式的求值。(表达式,单项式,因子均实现各自的getValue方法)。过程中涉及的所有运算方法,均在Digit类中实现。因为多项式并非单纯的整式运算,定义新的数据类型Coefficient,并在该类内部实现自身运算方法。并由一个CoeffUnit来统一管理系数的运算。

       1.2  类的规模度量

属性个数方法个数总规模
Coefficient420266
CoeffUnit217191
ComFactor3341
DerivationFactor2314
Digit07143
ExpFactor3329
FactorConstruct5678
Funparse36112
Main5119
Monomial3451
PolyFactor3322
Polynomial48127

在类方法的数量和类的代码行数上,计算类Digit以及伴随的定义数据类型Coefficient和CoeffUnit。表达式的计算是本次作业的一大核心,项目中所有的加,减,乘,乘方以及求导均在这些计算类中实现。而构建表达式的过程调用的类由于采用递归下降,是对同一方法的递归调用,这些类中的方法较少,但对方法的复用度极高。

在方法中控制分支标红(较多),圈复杂度较大的方法集中在解析因子以及输出表达式的方法上。由于我们需要增加许多特判来判断当前应该如何解析,或者当前以何种形式输出,这种情况的多样性使得一个方法中的分支结构过多。为了保证程序的正确性,增加了许多特判也即if-else结构,这样也导致了程序变得“屎山”。

        1.3 类的依赖内聚     

图上为IDEA生成的关于本项目的CK指标汇总

        CBO指标代表者类与类之间的耦合度,绝大多数类都和至少4个及以上的类存在依赖关系。这是由于,几乎每个类都要调用计算类Digit中的方法,而且所有的表达式的数值都是以CoeffiUnit的hashMap进行存储,都会依赖这个类存在。另外,每个类都会被上层类创建调用,并调用下层类,产生较高的CBO,意味着各个类之间的依赖性较强。

        LCOM指标代表类中缺乏内聚性的方法数量。与上文的每个类中的方法数相比,缺乏内聚性的方法较少。本人项目实现中,几乎所有类向外部提供的公共方法大多只是一个入口,它会调度类内部的一些其他私有化方法协作来实现外部的要求,而这些方法毫无疑问共享数据。

II 架构设计体验

        2.1 迭代过程

         2.1.1 hw1

        第一次的作业中,只要求实现含x的多项式计算。这时只存在三种因子:表达式,常数,变量。这时由于数据的简单,我并没有将这三个分为三个类,而是放入一个类中,用一个统一的字符串来储存它们。从整体架构上来看,我仅仅含有五个类:Main,Digit,Polynomial,Monomial,Factor。这从外部来看十分简陋,而且缺乏统一的管理,我项目这时还带有部分的面向过程的影子。在这个时候,我存储各个项,表达式的值采用的是hashMap,'x'的次幂作为索引,'x'的系数作为value。这样对化简计算带来了便利,十分容易判断是否可合并缩短。

        2.1.2 hw2

        第二次作业堪称最难的一次,新加入的指数因子不仅需要我们新加入一种因子类型。还需要重写众多计算方法。在这次迭代上,我改掉了以前简陋的因子管理。通过FactorConstruct类来统一生成不同类型的因子返回给Monomial类。原来的Factor类被我改造成了一个接口,众多因子的具有相同的方法:getValue()和setExp()方法。因子被我分成三类:代表常数和变量因子的ComFactor(求值简单);代表表达式因子的PolyFactor(内部实际存储的是一个表达式);代表指数因子的ExpFactor(内部实际存储一个因子)。

public interface Factor {
    HashMap<BigInteger,CoeffUnit> getValue();

    void setExp(BigInteger exp);
}

        第二处相较于第一次变化大的增加了自定义函数。这里存在两种方案:预处理和后处理。这两者各有优点,预处理简单且对先前代码的复用性高;后处理应对风险以及可扩展能力高。由于我不想太多的增加代码,还想重复应用之前写好的,便直接采用了字符串替换的预处理。这里的替换逻辑如代码所示:

private String replace(String prePoly) {
        String string = prePoly;
        ...
        for (i = 2;i < len;i++) {
                ...
             if (functions.containsKey(string.charAt(i))) {//code(1)
                string = string.substring(0,i) +
                        replace(string.substring(i));//优先替换内层,递归式处理
                do something;
                ...
            }
            if (digitNum == 0) {
                if (!String.valueOf(stringBuilder).isEmpty()) {
                    do something;
                }
                break;//循环终止条件,括号栈清空
            }
            if (string.charAt(i) == ',') {
                do something;
            }
            stringBuilder.append(string.charAt(i));//code(2)不是特殊字符不进行替换
        }
        return strReplace() + string.substring(i + 1);//返回字符串为函数替换部分+未被替换部分
    }

        方法首先搜索当前要替换函数的参数,根据逗号作为分隔符。当在参数内遇到函数标识符f,g,h时此时再次进入替换程序,并将string更新为被替换一次的字符串,再从当前位置继续之前操作,直到匹配到所有参数,然后将带入实参替换后的字符串返回。这样递归式的替换代码行数较少,用不超过两个方法就可以解决。

        第三处大改是对计算方法的大改。我们第二次作业的基元改变:

                                        a*x^b\rightarrow a*x^b*exp(poly)

        这使得数据存储和计算需要彻底改变。为了减少架构的大幅改动,本人保留了原有的计算框架,原有的系数是BigInteger a,现在改为特殊系数Coefficient   a*exp(poly)。对这个‘系数’我实现了计算方法。但由于两个同次幂变量前的‘系数’可能不可加带来的问题,加入CoeffUnit类来进行对这些不可加的'系数'的管理。简单来说,这个类实际就是Coefficient的一个列表,维护内部元素,调度内部的系数进行运算。

        2.1.3 hw3

        第三次的迭代可以说是最简单的一次。新增的自定义函数可以调用其他自定义函数对于字符串替换几乎没有任何需要改动的地方。唯一需要实现的就是求导运算。课程组是希望对每一个因子实现求导方法,将上层的求导操作递归的向下层传递。这样需要对每个因子进行方法的实现,个人认为是一种不明智的方法。而且这种方法是一种先求导后化简的方法,十分浪费性能。就举个不恰当的例子:

                ​​​​​​​        ​​​​​​​        ​​​​​​​        x*\frac{1}{x}*x*\frac{1}{x}*x*\frac{1}{x}*x*\frac{1}{x}

对这个式子进行求导,需要求导8次,然后再进行56次乘法和8次加法;化简后求导则需要进行7次乘法和1次求导,性能优劣可以直接看出。

        除了性能上的劣势,还有复杂度的劣势。由于求导因子的内部千变万化,直接对原始表达式求导将会触发很多情况,求导难度较大。如果我们将表达式化简为基元的和形式,那么求导过程将是固定过程,考虑到是最后一次迭代,对于特定形式求导构造一种简单的方法来解决,既节省代码量,性能值也非常高。

        2.2 假设迭代

        我假设未来第四次迭代出现多变元,以及增加隐函数形式。

        ​​​​​​​        ​​​​​​​        ​​​​​​​        x^2+y^2-f(x,y)=4

当然对于以隐函数形式出现的函数,我们保证隐函数方程为关于函数f的线性方程,且函数有唯一形式的解析解,且为整式形式。这处改动就要求我们实现一个关于多项式的除法算法,这个步骤并不是很难。

        真正困难的是将多变元储存,这势必会构造新的数据类型来存储。

基元形式再次改变为:a*x^b*y^c*z^d*...*exp(poly)

这次的索引将会异常困难,如果在有限(4个以内)我的解决思路是构造多重hash表来存储。当然求和过程也会更加复杂。

III 程序bug

        在架构设计中我遇到了三次bug。在第hw2中,我因为自定义新的数据类型涉及加减法的方法返回结果不是深拷贝,导致众多不同对象实际使用的是一个对象。如6-3+6;当第一个6变为3时,后一个6也会变为3导致运算直接出错。这个bug让我体会到为什么BigInteger采用新返回一个新的值而不是直接在原有的数值上增加。面对数字这类需要频繁运算的可变对象时,我们需要采用深拷贝。而如果是字符串这类不可变对象则采用浅拷贝即可。

        第二个bug来源于没有认真阅读指导书,错误的把‘-x’当作一个因子,由此导致输出时本来需要增加括号地地方失去了括号。这导致强测和互测直接失分。这种错误是可以很容易避免的,只要认真阅读指导书。所以,一定要认真阅读指导书

        第三个bug是对运算中产生0项的不及时清除。本来这种问题不会暴露出来,因为‘0’不会输出,但是在第三次我优化性能时提取公因子时内部出现了系数为0的项,导致了除法异常。当时在苦苦分析第三次增加的地方哪里出错结果是之前写的有问题,真的把我气笑了。这种在原来架构下不会引发问题的小漏洞可能会在你新加入某个功能后狠狠地背刺你一刀。

IV hack心得

        I.采用数据生成器狂轰滥炸。

        II.查看代码精准打击

        这两种方法都各有优劣,数据生成器依靠大量随机指定数据攻击,这是广度上的一种碰运气。当然如果别人写bug问题太过低级,这是很有效的方法。但是,大部分数据生成器生成的数据都太过随机,在A房中的测出bug概率可谓是相当的低,而且对bug的复现这是一个很困难的事情。这种你需要不停的运行评测程序半天而且不一定能找到bug,实际上也是一个非常浪费时间的行为。

当然你可以挂个服务器,奖励其他人一人一万次。我在第一次的hack中就是通过这种方法炸出来一个人的数字运算有问题。不过数据生成器生成的数据大多不符合代价要求,还需要我们再次缩小数据,找到最小可以引发这个bug的数据才能攻击。

        虽然查看别人代码非常耗费时间,但是可以精准定位。这里的并不是一句一句的看,而是看一些重要部分的实现逻辑。如hw3中某位同学求导采用课程组的方法,这种递归式的求导方式无疑在时间和空间上都十分浪费,在知道这种求导算法性能十分低下时,可以使用多层求导测试诱发性能bug。你可以针对性观看你认为的容易引发bug部分的代码,再针对性给出几个数据点,这种量身定制的bug一般是攻击别人的架构问题,错误的一般不止几行,可以说狠狠地恶心人(不建议这么做)。

dx(exp(exp(exp(exp(exp(exp(exp(exp(exp(x))))))))))

这是一个100%可以攻击采用课程组递归求导的性能hack样例

V 优化策略

        作业性能分的唯一标准是输出字符串的长度,即在保证正确性的前提下越短越好。在第一次作业中最终形成的表达式为\sum{a*x^b},这种情况下只需要注意系数和次幂为1时可以省略,可以简单,然后再将正项提前,这样可以保证表达式做到最短,这个过程是在输出过程中增加一堆特判完成,可以说是有漏洞打补丁,虽然丑但很对。

        第二次作业中出现了exp因子。这里举个例子来说明优化的难度:

exp((12*x+4*x^2))         式①

exp((4*x+6))          式②

exp((1+10000+10000*x+10000*x^2+10000*x^3))         式③

        我们知道,将各个项提取公因子后可以使某些情况下的表达式变短,所以设计了对每个项求最大公因子后提取,这样尽可能可以使表达式变短。在式①中提取公因子’4‘,即可轻松变短1;但是对于式②则恰恰相反,表达式长度反而增长了2。在这里为了防止公因子提取后可能会导致性能下降,实现提取公因子的操作后,随机从众多项中挑选一个观察其变短长度(实际只用看常数是否变短),然后作为平均变短长度乘以项数则是最终变短长度。然后与’^a‘带来的长度增加比对,最后决定是否提取公因子。这样的操作主打一个随缘,但十分有效,在强测中拿到了99分,性能分几乎拿满。

        对于我的这种随缘优化主打一种对已有代码的最小影响,提取公因式是在主体计算结束后进行。这样不会导致计算过程中的值不对,方法做到十分独立,在前面经过测试的情况下只需最后这一步提取公因子确保是恒等变换。实在无法通过测试,也可以把这一步删去,完全不影响项目其他方法。

        但对于式③,我实在无法做到可以分离一项后提取公因子,由于数据存储方式即使分离也会立即乘入合并,在底层架构上无法做到。当然我也没有想到有什么好方法可以快速检测这种情况达到分离,实在是非常想知道100分的’优化仙人‘的算法。

这里附上计算变短长度的方法实现

private int costDigit(ArrayList<BigInteger> numbers, BigInteger commonFactor) {
        int len = numbers.size();
        Random random = new Random();
        int select = random.nextInt(len);
        BigInteger number = numbers.get(select);
        String now = number.divide(commonFactor).toString();
        String last = number.toString();
        if (now.equals("1")) {
            if (len == 1) {
                return last.length() + 2;
            }
            return last.length() * len;
        }
        return (last.length() - now.length()) * len;
    }

VI 心得体会

        我们这一届几乎都是选过oopre课程,在这种心态下最开始第一周具有一定面向对象的基础上编写程序则会感到比较简单。并且由于这一部分的授课内容和oopre秋季内容实际并无太大差别,很容易产生一种轻视随便写写(我本人第一周是随便写的)。但在第二周给了我当头一棒,必须完全舍弃之前的编程影子,重视作业的架构,采用面向对象的思想进行编程,否则感觉无法进行下一次迭代。

        老师的授课内容还是十分重要的。第一,讲述了什么时候该使用继承,什么时候该使用接口。应用到这次作业中就是因子,不同因子属性不同,方法类似,采用接口。第二,要注意所有类都是Object类的子类,这说明每个类都有一个equals和clone方法,我们要明确哪些情况下要重写这两个方法(可变对象和不可变对象),这个就让我知道了深浅拷贝的区别和重要性,并成功改出上文提到的一个bug。

        学生之间的研讨也需要上心。一个单元会有两次研讨,第一次的研讨个人认为是讨论关于如何迭代架构之后的作业,第二次则是在完成一个单元后的总结归纳。一个是大家想办法写作业,一个是写完作业总结经验。一般上,同学之间思想的碰撞可以使我们得出自己的架构有那些需要改进的地方,那些我可以借鉴别人解决问题的思路,以及对于可能遇到bug的解决方法和如何避免,这些都可以在研讨课上获得。

VII 未来方向

        OO第一单元一般都是针对表达式的计算,这一部分主要是考察对象的管理和递归算法的应用。我们在拥有oopre的知识情况下,这一部分其实对我们跨越度其实并不是很大。但我希望课程的难度曲线可以是递进式的,个人认为本单元第一次作业难度适中,但第二次难度剧增,而第三次却异常简单,狠狠地在第二次作业碰鼻子。作业难度设计我认为,难度可以高,但希望平滑的上升,而不是把所有难度倾注在某一次作业中。平滑递进式地难度设计还是非常重要的,虽然说需要助教和这样不会使因为某一次跨越度太大是我们打退堂鼓。这也和OO昆仑课程的特征相匹配。

        

  • 38
    点赞
  • 41
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值