前言
在学习过JML规格描述语言之后,本单元进行了UML(Unified Modeling Language)的学习。和JML单纯用语言描述的形式不同,UML通过可视化的图形形式,对一系列有关类的元素进行抽象化建模,帮助开发者更高效地理解大规模、复杂系统的模型,这对于理清对象之间的关系、设计对象的架构具有重要的意义。
在本单元的学习中,主要包括StartUML工具的使用以及对UML文件的解析,主要目的都是让我们更深入地了解各种元素的结构和组织方式,以及检验模型有效性的原则。StartUML是一款轻量级的图形化UML建模工具,入门简单、使用方便,相比于idea自带的UML图导出工具,StartUML更加强大。StartUML支持类图、顺序图、状态图的绘制,且自带一定的规则检查功能,对于初学UML的人来说十分友好,并且有利于加深对模型及元模型的理解。
本单元的三次作业从StartUML生成的mdj文件出发,要求编写程序解析文件,并执行相应的操作。其中,通过mdj文件导出解析数据的功能在官方包的接口中已经实现,我们需要完成的是对各种元模型生成相应的架构。
作业架构设计
第一次作业
架构分析:
本次作业最终需要实现一个UML类图分析器,可以通过输入各种指令来进行类图有关信息的查询。类图主要元素主要包括类、接口、操作(方法)、属性、参数以及类与接口之间的各种关系(继承、实现、关联等)。从大体上分析来看,类与接口形成的关系可以抽象成一个有向图,并且包含参数、属性等。
对于一个UmlClass元素来说,与其关联的属性有关联的类、实现的接口、包含的属性、操作等,而操作又包含参数,在官方的接口中这些并没有体现。一种可行的建模方法是建立UmlClass类的子类,使其包含这些元素,但这样需要增加很多类以及构造方法,类之间的关系也会变得复杂,不符合低耦合、高内聚的设计原则。为了避免出现以上问题,我的设计思路是不建立子类,而是通过HashMap对于各种关系进行建模。举例来说,建立类实现接口的关系可以使用类id→实现接口id的集合这种HashMap结构,其余关系同理。使用这种建模方式可以减少方法的调用,降低耦合性(个人理解),当然也有缺点,就是会使得UmlInteraction类拥有非常多参数,自身变得冗长。需要注意的是,由于输入数据不保证顺序,因此需要三次读入,否则会出现NullPointerException异常。
第一次作业最难的地方在于对各种元素结构的理解,以及模型的建立。由于在实现第一次作业时我对于UML各种元素的用法并不熟悉,导致花了很长时间读懂官方代码,期间对于架构的实现基本毫无进展,最后也导致检查时间紧张,有不少bug没有检测出。相对而言,方法的实现过程比较简单,大部分操作直接查询类中的元素即可,稍有难度的是查询实现接口的列表,以及类中操作的个数。由于实现接口的列表需要考虑继承的接口和类,因此可以采用BFS遍历每个类实现的接口(不考虑父类),然后使用Set将类及其父类实现的接口合并。统计类中操作的个数的关键难点在于限制条件(例如有参、无返回值)。我采用的做法是先统计出所有的方法,然后根据每个限制条件访问方法,并逐一去掉不满足条件的方法。
由于本次作业我仅仅使用了一个类,因此类图略过。
bug分析:
本次作业的bug主要出在BFS时没有考虑每个节点是否访问过,没有标记访问位,在出现循环继承的情况时会导致无限循环。(当然,这种情况违反了UML类图的原则,在第三次作业时会考虑,但此次作业的测试数据并不保证不出现)。此外,由于对于部分HashMap键值没有初始化,导致有些时候会出现NullPointerException(例如当一个类中没有方法时,查询方法就会抛出异常)。本次作业的强测得分也较低。
第二次作业
架构分析:
本次作业的目标是扩展类图解析器,使得可以支持对UML状态图和顺序图的分析,可以通过输入相应的指令来进行相关查询。此次作业我完全仿照上次的架构进行设计,因此在构思架构上花费的时间较少。主要难点还是在于对于扩展元素的理解及使用。由于上次作业的基础及讨论区的帮助,我较快地读完了官方接口。本次作业的一个关键步骤在于将三种UML图统一到一个类中,对此我采取了分别设计三个MyUmlClassModelInteraction、MyUmlStateChartInteraction、MyUmlCollaborationInteraction,然后在目的类中创建这三个类的实例,分别调用其中的方法。
作业的类图如下:
bug分析:
本次作业由于方法实现起来较为容易,因此较不容易出现bug,只错了一个点,唯一的坑点在于部分UmlAttribute的parent不是类图中的元素,此时应当忽略,但设计时没有考虑这一点,导致出现NullPointerException。
第三次作业
架构分析:
本次作业的目标为在上次作业基础上扩展解析器,对模型进行有效性检查。 UML语言对于模型进行了严格的限制,存在这样一些模型,他们要么违反了java语言的语法规则(如类不能循环继承),要么违反了UML的设计原则(如接口的所有属性均应定义为public),但他们可以生成相应的模型,设计者有时会忽视这些规则,导致设计出无效的模型。StartUML可以检测一部分问题,但不能涵盖全部,针对这一点本次作业要求对模型的有效性进行检查。
本次作业并没有增加新的模型,理解起来很容易,但方法实现有一定的难度,用到了一部分图论的知识,关键难点在于检测循环继承、重复继承、重复实现。算法并不太难,对于循环继承采用逐点DFS的方法,标记访问原点以及当前访问的点,若相等则存在循环继承。对于重复继承,可以采用标记访问位,从原点出发进行BFS,若遍历到某个已经访问过的点则BFS源点存在重复继承(重复实现同理)。另外,对于算法的优化可以分析以下情况:若某点继承(实现)某个违规的点(指存在重复继承或实现),则该点一定违规,利用该规律建立违规点的集合,可以减少遍历的次数。
作业类图设计如下:
bug分析:
本次作业的bug主要在于没有考虑到类和接口继承自身的情况(java语言在检测到该情况时会报错,但UML不会报错),导致出现该情况时程序死循环。因此一定要注意java语言和UML原则上的区别,防止惯性思维导致的bug。
课程内容回顾
经过一学期精彩而又艰难的OO学习,我终于完成了最后一次作业,课程的压力也告一段落。这门课从一开始就注定不是一帆风顺的,我几乎每一次作业都经历了bug寻找及修复的过程。从面向过程到面向对象,整个过程需要很多的训练。课程分为四个单元,从认识面向对象编程思想,学习面向对象过程的常见操作(多线程)到学习描述性语言,层层递进,我从中学到了很多。
第一单元:
本单元作业的主题为设计函数求导工具,目的是熟悉面向对象编程的设计过程,完成从面向过程到面向对象的转变。本单元的作业是从头到尾完全由自己设计架构并实现,在给予自由发挥空间的同时,也对初步接触面向对象的我不小的考验。本单元作业实现的核心是正则表达式,通过该单元的学习,以及出现bug的分析,我对正则表达式这一常见工具有了深入的理解。
第一次作业为简单多项式的求导,本身要求实现目标是较为简单的。我建立了一个关键的类——项(Term)。在本次作业中该类即可满足基本需求,剩下的就是表达式的解析以及结果的输出。在与dalao们交流之后,我把解析表达式过程分为提取项、提取参数两部分,层次较为清晰,实现起来也较为方便,最终没有出现严重bug(除了因失误互测时在系数为-1时会输出错误符号),算是一个良好的开端。
第二次作业增加了乘积项以及三角函数项的求导,并增加了对于格式正误的判断。过程比前一次作业复杂了不少,但是好在每一项仍然可以用通项表达,因此对于储存结构的设计并没有很困难。此次作业解析字符串的方法与上一次类似,多了一个从乘积项提取因子的步骤,但由于增加了判断格式,我上一次作业并未考虑,因此难以扩展,解析字符串部分只能重构。同时解析过程中由于设计的缺陷,强测过程中出现了一些bug。
第三次作业增加了复合函数的求导需求,并且增加了错误格式判断的复杂性。对于当时知识匮乏、基础薄弱的我来说,这次作业简直是一次噩梦,这应该也是所有作业中体验最差的一次。由于函数解析式不能用统一的正则表达式提取,本次作业架构我全部重构,构造了大量的表达式元素类(多项式、正弦函数、余弦函数)。关键步骤在于首先使用队列算法提取出括号中的内容以及括号外的内容,然后递归下降,直至最内层是原子表达式。由于复合函数求导法则的存在,只要构造好了表达结构,函数求导并不困难。在这次作业中,我采用了经典的工厂模式设计,大大减少了分支的判断。造成体验差的原因是本身方法内部细节过多,以至于出了bug时很难定位。不过好在最终我修复了所有的bug,最后强测也只错了一个结果为0的点(无输出),算是增强了一些自信心。
第二单元:
第二单元作业的主题是设计多线程可捎带电梯。多线程在面向对象设计过程中是一种重要的设计思想,可以说现在实用的大多数程序都需要用到多线程(例如很多软件支持后台播放音乐,就用到了多线程)。单元学习的核心内容是理解线程交互机制与锁机制,并利用同步方法保护线程安全,在这样的基础下设计最合理的电梯调度算法。
第一次作业是单部可捎带电梯的设计。我在参考对比了多种建议的架构后,采用了将交互对象分为输入器、控制器、电梯三个线程的设计架构,这样的设计符合经典的生产者——消费者——托盘模型,同时由于本次作业线程停止的条件较为简单,因此线程安全的问题较容易解决。第一次作业我存在的主要问题是调度算法太差,性能分过低,后面我改进了算法。除此之外,由于多线程程序调试困难,且本地测试时难以做到定时输入,因此我在本地调试的过程中体验很差,后来借助评测机以及JProfiler工具改善了这一问题。
第二次作业是多部有限容量可捎带电梯的设计,电梯数量固定。本次作业在算法上和上次并无太大区别,我的调度算法采用了LOOK算法,需要注意的是本次作业每种电梯的载客量不同,对此我采用了随机数调度策略,以某类电梯载客量×该类电梯的数目为权重产生随机数,根据产生的随机数分配对应的电梯。这种策略的优点是一开始乘坐的电梯就固定,在捎带时不会对其他电梯线程产生干扰,减少电梯线程之间的交互,保证线程安全,但牺牲了一定的效率。
第三次作业在上一次作业的基础上,增加了电梯到达楼层的限制以及电梯的动态加入,动态加入使得线程有可能从中途创建,而换乘则涉及到最短换乘路线的问题。我一开始采用的是随机数楼层换乘法,但这样做效率太低,后来优化了算法:从当前楼层同时上下逐一遍历,若存在可以换乘的路线则确定该楼层为换乘楼层。换乘策略的制定采用工厂模式,从需求产生开始就固定,也保证了安全性。另外,由于换乘的问题,各部电梯之间存在交互,所有电梯线程必须同时停止,因此需引入监听者模式,每部电梯将自身状态的变化(工作或闲置)报告给输入器,若输入器停止且每部电梯空闲,则电梯线程可以安全停止,并且能够保证将所有乘客运送到目的地。
第三单元:
第三单元作业的主题是模拟社交网络。该单元学习了JML规格描述语言,对方法的功能、影响进行形式化描述,完成的目标是实现JML语言描述的功能。从第三单元开始,作业的整体架构不再需要自己设计,重点关注的是方法的理解及实现。
第一次作业需要实现人以及网络的模型,没有特别困难的方法,重点关注的是容器的选择。由于每个人的id唯一,因此在社交网络中可以同时使用HashMap和ArrayList访问每个人,查找用HashMap,遍历用ArrayList(发挥各自的优点)。尤其要注意JML语言描述的细节,每种情况对应落入的分支,一个小小的失误就有可能使整个架构运行结果错误。
第二次作业加入了群组模型,重点在于群组内部的方法时间复杂度的实现,强行遍历会使得运行超时,因此要注意缓存的使用。另外,使用缓存时尤其要注意每个方法对于模型元素是否有影响,并及时更新。例如,addtoGroup方法会影响组内几乎所有的属性。在本单元的作业中,Junit测试单元是十分有效的工具,能够对每个方法进行检查,检测出很多未注意的bug。
第三次作业增加了一些有难度的算法,包括最短路径、双连通分量、连通块的个数等。最短路径一定要注意时间复杂度,堆优化的Dijskra算法是不错的满足条件的选择。对于连通块的个数,可以采用缓存机制,在添加点时连通块个数+1,在添加边时判断两点是否存在路径,若存在则连通块个数-1。对于双连通分量,可以采用Tarjan算法,当然这种算法细节较多,容易出现bug,最终我的Tarjan算法也是存在bug且没能发现,因此我改用删点DFS的方法,最终也能满足时间限制。本次作业的一大难点是方法的理解,由于JML语言采用形式化的描述,在直观性上存在一定缺陷,因此需要掌握一定的图论知识,帮助理解。
第四单元
第四单元的内容前面已经分析过,在此不再赘述。
测试方法的理解及演进
第一单元的程序和以前接触到的编程较为类似,整个架构完全由自己设计。加之当时我掌握的工具甚少,因此第一单元我基本采用最传统的方法测试:编写测试数据→输入→检查结果,若发现结果不对则从程序头部开始逐方法调试,并演算出期望结果,若某方法实际执行结果与期望结果不同则问题出现再单步进入,找到bug。这种分析方法能够精准地定位bug,但是测试过程相当繁琐,如果程序较长测试一次要很长时间。同时,由于某些情况下手动得出期望结果存在困难(例如:循环10000次),因此这种方法存在一定的局限性。不过我的架构并没有出现这种情况,因此通过这种方法我能够发现几乎所有bug,并且idea自身的调试功能使用体验较好。
第二单元由于是多线程运行,调试相当困难,因此自动评测机的使用十分重要。(当然,由于我本身技术水平很菜,评测机的大部分逻辑是白嫖的。)可以说,本单元离开了评测机寸步难行。另外,本单元测试需要注意的一点就是CPU运行的时间,这就需要借助JProfiler工具,可以通过该工具知道CPU运行时间的分布,如果发现CPU运行时间明显偏高的部分可以在需要测试的方法前后输出调试信息,以检测是否存在暴力轮询的行为(这种行为有时自己并未意识到)。
第三单元测试的核心就是Junit测试单元。通过编写测试逻辑,可以在不运行整个程序的情况下发现异常,然后再进入相应方法寻找bug。由于有JML规格描述,因此根据描述得出结果比较简单。本单元还学习了一些关于JML的工具(例如OpenJML语法检查工具以及JMLunitNG测试数据生成工具)。但由于这些工具很长时间未维护更新,加之JML小众,因此这些工具的使用体验并不好。
第四单元测试的核心仍然是Junit,和第三单元相比,第四单元官方包的内部逻辑极其复杂(很多内容设计到我的知识盲区),因此很难用传统的单步调试方法测试。当然,由于测试数据是从命令行导出,因此还需要使用StartUML绘制相应的模型,并使用官方包导出。
课程收获
本学期的OO课程已经基本结束,经过四个单元、12次代码作业、4个月的训练,我的面向对象编程水平在很多方面有了极大的提升。
首先是一系列工具的使用。最基本的就是IDEA开发环境。我之前使用的java IDE是eclipse,相比于IDEA,eclipse的智能程度要低很多,支持的拓展工具也少,同时调试的体验也不如IDEA。IDEA能够自动检测语法中的错误,甚至能够发现很多自身难以发现的不合理代码(例如如果你写了if (A) x = true,IDEA会建议修改成x = (A))。当然,这与IDEA本身收费有关系,这就要感谢课程组给予我们免费使用IDEA的途径。同时,课程组提供的代码风格检查工具也十分好用,纠正了我前一年形成的差劲的代码风格,现在我在写代码时会下意识地保留空格和空行。
课程最主要的收获当然还是面向对象知识水平以及编程能力的提升。每次代码作业与课程内容、实验内容都有很大关系,这督促了我们及时地学习(当然,中间有两次实验因为状态不好翻车)。在接触一种全新的编程技能时,往往会遇到许多困惑与bug,而课程网站提供的讨论区涵盖了我们大部分的疑惑,也有很多热心的同学提供常见bug的分享以及思路的设计,这大大减少了我走的弯路。我在中间几次作业中,经常运用浏览讨论区→发现新知识→到CSDN上深入学习新知识的学习模式,让我掌握了很多没有了解过的算法和设计思路。作业互测模式以及奖惩模式的设定,也增加了我构建评测机、发现程序bug的能力。
除此之外,便是编程思路的转变。以前接触的编程都是面向过程的编程。但学习了面向对象编程的知识后,我深刻地体会到了面向对象编程的优势。对对象进行方法和操作,大大增强了程序的内聚性,使得程序易于维护,并且不容易意外修改程序数据。举例来说,对于C语言中的strcmp函数,可以用数组名(恒定指针)作为参数,也可以用一般的字符指针,甚至二维、多维指针作为参数,中间对于内存的修改是不可预料的,需要程序员自行维护,但java的面向对象的方法以及JVM机制就很好地避免了这些问题。同时,类、接口的继承、实现模式增强了程序的可拓展性,掌握这些知识对于以后编写逻辑更加复杂的程序是十分必要的。
课程改进建议
1.目前课程上课展示的代码较为抽象,没有具体在IDE上操作,这样有时候代码难以理解,如果结合代码运行演示可能会使得课程内容更容易理解。
2.给予作业更多提示,特别是每单元的第一次作业,由于整体逻辑需要重新构思,而学习的内容又是新的知识,因此很多时候容易走弯路,甚至完全南辕北辙,如果增加提示会减少这种情况。
3.增加特殊情况下的bug修复机制。举例来说,如果一份代码出现有5处bug,在互测阶段被hack了100次,且这100个数据都命中了全部5个bug,那么他在修复bug时,如果一次性修复所有bug将因违反合并修复规则而审核不通过,但如果只修复一处bug则不能多通过任何一个测试点,导致无法提交,希望设定bug修复的同时,也能考虑到包括但不仅限于此的特殊情况。
线上学习体会
本学习OO课程与往常最大的不同就是全程采用了线上学习的方式。因为新冠肺炎疫情的原因不能返校学习,但在线上学习的体验还是比较好的。首先,课程按照既定的时间线进行,研讨课也照常进行。在研讨课上,由于不露面的原因,更多的同学敢于提出有价值的问题,这也间接促进了交流。同时,由于课程采用录播课堂的方式,对于一次没有理解的内容可以通过反复多次观看加深理解,因此对于部分内容的理解线上课程起到了显著的积极作用,这不失为线上学习的一种特别收获。