前言
由于去年的种种失误,我今年不得不再次接受本课程的洗礼。相对于第一次学习此课程,第二次学习的压力要下降许多,遇到的困难也少了很多。今年的四个单元主题与前一年相比没有太多变化,这对于我来说是十分友好的。
本单元的学习主要包括StartUML工具的使用以及对UML文件的解析,主要目的都是让我们更深入地了解各种元素的结构和组织方式,以及检验模型有效性的原则。StartUML是一款轻量级的图形化UML建模工具,入门简单、使用方便,相比于idea自带的UML图导出工具,StartUML更加强大。StartUML支持类图、顺序图、状态图的绘制,且自带一定的规则检查功能,对于初学UML的人来说十分友好,并且有利于加深对模型及元模型的理解。本单元的三次作业从StartUML生成的mdj文件出发,要求编写程序解析文件,并执行相应的操作。其中,通过mdj文件导出解析数据的功能在官方包的接口中已经实现,我们需要完成的是对各种元模型生成相应的架构。
作业架构设计
本次作业最终需要实现一个UML类图分析器,可以通过输入各种指令来进行类图、顺序图、状态图有关信息的查询,并检查模型的有效性。从大体上分析来看,三种UML图的主体元素(类图中的类、接口,顺序图中的生命线,状态图中的状态)可以抽象为有向图中的点,而其他元素则可以抽象为有向图的边(例如继承、实现、消息、状态转移)以及点的参数(属性、操作)。
在类图中,各个UML元素呈现树形结构,但是官方接口中给出的元素模型并没有体现元素之间的关系。对于实现树形结构,我想到两个思路:一是构建自己的UML元素类,继承官方接口,并添加属性来记录元素之间的关系;二是仍然利用官方的接口类,不建立子类,而是手动记录各个元素之间的索引结构,利用Map来存储。结合之前学过的数据库的知识,在经过思考之后,我决定采用第二种思路,理由如下:
- 不构建子类,为实现比较以及拷贝操作带来极大的方便
- 大多数操作仅仅需要获取相关元素的数量,而不需要知道具体信息,采用id作为索引结构对象更方便。
这种建模方式在查询时主要用到的方法是Map
的键值访问,整体方法调用减少,降低耦合性(个人理解)。这种方法的缺点就是会使得三个Interaction
拥有非常多参数,自身变得冗长。本单元作业的元素结构相对来说并不复杂,如果树形结构元素多、关系复杂(例如大型数据库的设计),这种建模方式显然不合适。因此,采用这种结构仅仅是为了作业实现上的方便,它实际上并不是很符合面向对象设计的整体思想。
使用这种建模方式,在通过输入进行建模时也较为方便(不需要实例化新的对象,只需要在各个Map
中记录)需要注意的是,由于输入数据不保证顺序,因此需要三次读入,否则会出现NullPointerException
异常。
本单元作业最难的地方在于对各种元素结构的理解,以及模型的建立。简单来说,本单元从无到有的过程可能是最困难的。由于我去年已经学习过相关方法并且经历过有关的惨痛教训,因此在这方面花的时间显著减少。对于具体方法的实现,我使用了和去年基本一致的思路,具体可参考这篇博客。
对于作业中类的架构,三次作业基本一致,和去年相比增加了分包等操作,第三次作业的类图如下:
架构设计及面向对象方法的演进
对于本学期的作业,整体来说,前两个单元我自信满满,bug很少,强测从未失误,重振面向对象荣光,我辈义不容辞;后两个单元我基本上能进互测、作业有效就算成功。虽然课程前期和后期的落差很大,但不得不承认的是每个单元都让我学到了很多。当然,其中一些知识是去年就应该收获的。
第一单元
本单元作业的主题为设计函数求导工具,目的是熟悉面向对象编程的设计过程,完成从面向过程到面向对象的转变。由于有了前一年的教训,并且相关知识也掌握得更多,这一单元得作业我使用了和前一年完全不同的架构。
第一次作业为简单多项式的求导,仅有加减乘运算。该作业本身实现较为简单。当时课程组建议我们使用正则表达式,但由于去年的经验,我知道后面一定会实现嵌套表达式功能,因此我几乎完全放弃了正则表达式,从一开始就采用了递归下降分析的架构,仅仅在部分终结符的分析中采用了正则表达式方便判断。事实证明,这种设计极大地方便了之后作业的实现。
第二次作业增加了嵌套表达式以及三角函数项的求导。和去年相比,今年的第一、二次作业难度可以说是质的飞跃,但由于我从一开始就采用了良好的递归下降架构,我在本次作业的工作仅仅是修改一些递归下降子程序,总体用时不到2h。遗憾的是,由于当时我看错了题目,导致一个细节的地方出现bug,在互测中被hack。
第三次作业增加了复合函数的求导需求,并且增加了错误格式判断。和前面相同,我的工作仍然是修改递归下降子程序。并且我预留了错误处理接口,错误格式判断也很方便。
总的来说,在经历过前一年的面向对象课程以及编译技术课程的学习之后,本单元作业对我来说并没有什么难度。
第二单元
第二单元作业的主题是设计多线程可捎带电梯。单元学习的核心内容是理解线程交互机制与锁机制,并利用同步方法保护线程安全,在这样的基础下设计最合理的电梯调度算法。和第一单元一样,我重新设计了架构。
第一次作业是单部可捎带电梯的设计。我在参考对比了多种建议的架构后,采用了将交互对象分为输入器、控制器、电梯三个线程的设计架构,这样的设计符合经典的生产者——消费者——托盘模型,同时由于本次作业线程停止的条件较为简单,因此线程安全的问题较容易解决。但是由于我一开始没有发现新设计架构的问题,导致中测提交次数过多,被扣除了一定的过程分。
第二次作业是多部有限容量可捎带电梯的设计,电梯数量固定。本次作业在算法上和上次并无太大区别,我的调度算法采用了LOOK算法。和去年不同的是,我没有采用随机数调度策略,而是根据当前电梯剩余容量的比例,动态判断乘客电梯的分配,最终性能较好。
第三次作业在上一次作业的基础上,增加了电梯到达楼层的限制以及电梯的动态加入,动态加入使得线程有可能从中途创建。另外,各部电梯之间存在交互,所有电梯线程必须同时停止,因此需引入监听者模式,每部电梯将自身状态的变化(工作或闲置)报告给输入器,若输入器停止且每部电梯空闲,则电梯线程可以安全停止,并且能够保证将所有乘客运送到目的地。由于当时的个人原因,我没有实现换乘功能,但我在保证乘客能够到达目的地的情况下尽可能优化了调度算法,使得最后没有换乘的情况下性能分依然较高。
第三单元
第三单元作业的主题是模拟社交网络,和去年想比,本单元的内容将“社交网络”模拟得更加真实,部分方法的违和感大大降低。从第三单元开始,作业的整体架构不再需要自己设计,重点关注的是方法的理解及实现。
第一次作业需要实现人以及网络的模型,没有特别困难的方法,重点关注的是容器的选择。由于每个人的id唯一,因此在社交网络中可以同时使用HashMap和ArrayList访问每个人,查找用HashMap,遍历用ArrayList(发挥各自的优点)。尤其要注意JML语言描述的细节,每种情况对应落入的分支,一个小小的失误就有可能使整个架构运行结果错误。
第二次作业加入了群组以及消息的模型,重点在于群组内部的方法时间复杂度的实现,强行遍历会使得运行超时,因此要注意缓存的使用。另外,使用缓存时尤其要注意每个方法对于模型元素是否有影响,并及时更新。当时我套用了去年作业的模板,忽视了一个属性的更新,使得强测直接爆零,算是惨痛的教训。
第三次作业增加了一些有难度的算法(如最短路径)。事实证明,最短路径必须尽可能优化时间复杂度,对此我学习了堆优化的Dijskra算法以及PriorityQueue
的使用。本次作业的一大难点是方法的理解,由于JML语言采用形式化的描述,在直观性上存在一定缺陷,因此需要掌握一定的图论知识,帮助理解。同时,本次作业引入了多种Message
的子类,一定程度上帮我们复习了多态的知识。
第四单元
第四单元的内容前面已经分析过,在此不再赘述。
测试的理解及演进
在有了去年的经验以及其他相关知识的基础(例如Python语言、操作系统、命令行),我在第一单元成功地编写了自己的评测机。若评测机发现结果不对则从程序头部开始逐方法调试,并演算出期望结果,若某方法实际执行结果与期望结果不同则问题出现再单步进入,找到bug。这种分析方法能够精准地定位bug,同时互测也能发现其他人的bug。不过,直接编写评测机的一个缺点是思路会被自身的思维所限制,例如第二次作业由于我本身使用的正则表达式有问题,因此没能发现程序中的bug,导致互测被hack。当然,总体来说评测机使用起来还是非常方便的。
第二单元由于是多线程运行,调试相当困难,因此自动评测机的使用十分重要。可以说,本单元离开了评测机寸步难行。由于当时时间的限制,我本地只能实现带时间戳运行程序,没有实现自动生成数据以及评测。好在我运行的结果都及时通过了中测,并没有出现逻辑上的重大错误。另外,本单元测试需要注意的一点就是CPU运行的时间,通过JProfiler工具可以知道CPU运行时间的分布,如果发现CPU运行时间明显偏高的部分可以在需要测试的方法前后输出调试信息,以检测是否存在暴力轮询的行为。当然,如果觉得Jpro麻烦也可以在每个加锁的前后暴力使用print调试(对每个位置添加标志,判断输出位置),这样虽然不能知道程序CPU运行的具体情况。但是仍然能够发现大部分轮询的问题。
在去年的课程中,无论是从环境配置还是使用方法来说,OpenJML以及JMLunitNG使用体验极差,今年我没有再次使用它们。不过,对于Junit测试单元我使用得更加成熟。通过编写测试逻辑,可以在不运行整个程序的情况下发现异常,然后再进入相应方法寻找bug。由于有JML规格描述,因此根据描述得出结果比较简单。由于我本学期的另一门屑课让我不得不使用该工具,因此我并没有花多少时间就掌握了测试技巧,并学会生成覆盖率报告。
对于第四单元测试,我回归了手动的方法。由于测试数据是从命令行导出,因此还需要使用StartUML绘制相应的模型,并使用官方包导出,每次测试必须要使用完整的数据,此时Junit的优势并没有得到太多体现。事实证明,大部分bug来自于建模的过程中而不是指令的执行,因此在发现此类bug时,需要手动调试程序,并找到输入过程的bug。
课程收获
重修OO课程已经结束,虽然因为个人测试过程的漏洞,后两个单元强测成绩不佳,最后可能也得不到理想的分数,但总体而言收获丰富,重修还是值得的。
重修课程最主要的收获还是课程内容理解的加深。去年线上上课,因为我消息闭塞,和同学们少有交流,每次作业及实验都是完全按照我自己一个人的思路在实现,很多时候我的思路本身就存在问题或者是缺陷。今年在与同学们交流之后,我见识到很多未曾见过的思路,同时也纠正了我对于一些内容理解上的偏差。
除此之外,实验课的收获也大大提升。与去年相比,今年的实验课有充足的时间让我们掌握理论知识(也可能与我已经学过一遍有关),实验内容的设计也更加合理,删除了一些像是强行加入的内容,并且实验紧扣作业主题,可以看出课程组是非常用心的。本学期的每次实验体验都比较好,从中学习到的知识也更加丰富。
课程改进建议
1.强烈希望修改某些checkstyle规则,或者对于不同的作业应用不同的checkstyle规则。尤其是最后一单元作业,我花费了大量时间在调整checkstyle上,特别是文件限制在500行以内,该规则的合理性有待商榷。本身接口文件就有接近200行,加之今年加入了很多实现起来并不容易的方法,导致无论如何我都没法在500行之内实现所有方法,最后不得不创建一个新的类进行继承,但对于作业本身而言,这种继承结构毫无意义,反而破坏了原本良好的架构,增加产生bug的可能。我认为至少部分作业的checkstyle规则值得修改。
2.部分助教回答问题时的态度值得商榷。举例来说,当时我第二单元第一次作业提交次数超过10次,按规则会被扣分,但并没有给出具体的规则。于是我询问了至少3个助教关于扣分的规则,但居然没有一个助教给出能够解决问题的回答。同时,讨论区内很多有意义的问题会被莫名其妙地标上“已解决”标签,但实际上并没有解决。在课下,部分助教对于课程内容的描述语气有时也会扰乱同学们的心态(最常出现的例如“如果你······,就有可能体验补给站一月游/重修一年游哦”),这种情况可能会影响到同学们对于课程的整体评价,希望课程组能够注意到这种情况。
3.完善bug修复机制。今年课程组对于恶意hack同质bug的行为增加惩罚非常值得点赞,但目前的机制仍然有让人恶意利用,达到内卷目的的可能。举例来说,如果一份代码出现有5处bug,在互测阶段被hack了84次(如果所在互测屋有8人,他最多可能会被hack 7×12=84次,超过这个数字会导致作业被记0分),且这84个数据都命中了全部5个bug,那么他在修复bug时,如果一次性修复所有bug将因违反合并修复只能修复1个bug规则而导致审核不通过,但如果只修复一处bug则不能多通过任何一个测试点,导致无法提交,因此最终他只能选择非合并修复,最终被扣分84分,虽然屋内其他同学都会因hack同质bug次数过多而互测得0分,但该同学将会得到极低的分数,相比较而言该同学的损失更严重,这显然是不合理的竞争。希望制定bug修复规则时能够考虑到包括但不仅限于此的特殊情况。