2019年北航OO第四单元(UML任务)及学期总结
第四单元两次作业总结
第十三次作业
需求分析
本次作业需要完成一个UML类图解析器,所需要解析的只有符合UML标准和能够在Java 8中复现的UML类图。查询指令存在两种:仅与所查对象有关的指令,以及需要回溯至顶级父类逐层查询的指令。
实现方案
本次作业需要我们对类图中的组成元素进行重新建模,建模时需要考虑类中的属性、类的继承关系、接口的继承关系以及类/接口之间的关联。为了达成以上目标,我选择将类和接口分别重新抽象为ClassInfo
和InterfaceInfo
类以记录其属性;对于类和接口之间存在的继承关系,则让ClassInfo
和InterfaceInfo
类共同实现一个自己定义的接口Associable
。将接口单独设置为一个类可以减少需要记录的信息数量,降低内存开销。此外,考虑到类图中的操作UMLOperation中同样存在若干需要记录的属性,同样可以重新将其抽象为一个新类OperationInfo
,在对性能几乎没有影响的基础上增加直观性。
对于两种不同指令,其中仅与所查对象有关的指令直接查询即可,而需要回溯的查询指令则直接利用DFS向上查找即可。我在ClassInfo
和InterfaceInfo
针对类的单继承和接口的多继承特性分别实现了其所需的图算法。
在本次作业中,提升性能的关键在于进行缓存。可以进行缓存的情景除了将刚完成的查询结果进行保存以外,在进行DFS回溯的过程中实际上同时获得了整条继承链上的所有类/接口的同种信息,这些信息同样可以进行缓存。
本次作业的UML类图如下:
第十四次作业
需求分析
本次作业需要完成一个UML类图、顺序图和状态图的解析器,除了需要进行合法且Java可复现的图的指令解析外,还需要对不符合有效性检查的图在刚完成创建时报错。
实现方案
正如上一次作业将不同元素进行建模的思路一样,本次增加的建模元素为UML顺序图和UML状态图,将其分别重新抽象为InteractionInfo
和StateMachineInfo
两个类。由于对这二者的查询较为简单,所以不需要对这两种图中的组成元素再进一步建模,只需要将其在对应类中进行保存即可。
在本次作业中同样可以进行缓存,除了上次作业中的查询指令后缓存、DFS回溯途中缓存外,此次作业中由于新加入了模型有效性检查,所以可以在检查有效性时将涉及到的实现接口列表进行缓存。
本次作业中针对有效性检查的算法包括:
- 针对循环继承:采用DFS,并维护从源点到当前遍历节点的路径,当该路径首尾重合时将整条路径上的所有节点都判为存在循环继承。
- 针对重复实现:在先前类继承接口查询返回接口Set的基础上,与先前记录所有实现接口的Set同步维护一个记录所有实现接口的ArrayList。最终通过判别Set和ArrayList的大小是否一致得出是否存在重复继承。
上述的两种算法最大化地重用了先前的代码,实现了最小的修改,并且能很好地利用父类/父接口的缓存数据,在图合法的情况下还能利用判断重复继承过程中的实现接口缓存进行相关指令查询。不过,重复继承的实现并没有很好地遵循开闭原则。其实一个实现了开闭原则的比较好的方式是单独对类和接口的继承保存成邻接表,在其中利用图算法进行分析。
本次作业中的UML类图如下:
四个单元中架构设计及OO方法理解的改进
第一单元:表达式求导
因为先前有过在编译技术课设中对同为面向对象语言的C++的类之间交互的初探,所以在刚刚开始接触Java时可以较快地上手。在第一单元中,我已经开始尽可能地将需求中的特殊名词抽象为类,开始尝试继承和多态带来的开发便利,开始为后续作业可能添加的需求留出空间,并歪打正着地使用了工厂模式进行设计。在最后一次作业中,我将5种不同的因子分别建模成了抽象类Factor
的子类,重载其求导和输出函数,结构清晰。
本次作业中的不足如下:
- 对Java语言特性不够了解:虽然有了构建类的初步尝试,但依旧不会使用
equals()
、toString()
等的重载。对Java自带的集合类型也不够了解。 - 对抽象层次存在疑惑:对于需求中的部分特殊名词,并不能十分明确应该抽象为类还是抽象为属性,导致在第一次作业中许多本应作为类存在的对象被属性替代。这或许是面向过程转向面向对象的最重要的思路转变之一。
- 不满足单一功能原则:关键方法很多都身兼多职,导致了极高的复杂度和令人费解的逻辑。
- 建模上不够优化:第一次作业中系数和指数的简单对应关系造成了后续的定势思维,所以后续在数据结构的选取上极大地增加了优化上的困难。在需求变得复杂之后,应该果断地对先前不适用的建模方式进行摒弃。
- 对空间复杂度的关注不够:由于第一单元中还尚不清楚JVM的特性,以及对程序的压力测试不足,导致了对于
clone()
方法的滥用。
第二单元:多线程电梯
第二单元中我吸取了先前对于建模优化问题的经验教训,从第二单元第一次作业开始就认真思考起了怎样的建模方式才能更好地应对后续的需求增加。所以,从第一次作业开始,我就将多部电梯中需要存在的调度器等类加入了设计中,并选择将每个人作为一个单独的线程加入线程池进行处理,这种提前采坑的策略和自然的建模方式使得后续的实现变得十分清晰,同时也做到了三次作业中极高的代码复用率。对于问题中生产者-消费者模型的发现也使得建模中关键数据结构和算法的选择变得稳妥。
对于多线程的处理上,由于在第二单元开始前我学习了《Java并发编程实战》一书的大半,所以在实现起来少踩了很多坑。尤其是对于不变式的保护,对volatile
和final
修饰符的运用以及对concurrency包中同步容器的运用让这三次作业减少了很多问题。
在本单元中,我对于SOLID设计原则有了很多体会,尤其是另外几个原则与开闭原则之间的关系:LSP、ISP和DIP是帮助实现开闭原则的辅助模式,而DIP则让我对数据抽象的方法有了全新的认识,这使得后续作业中接口和抽象类的使用变得更多了,而代码中的具体实现也更倾向于使用接口而非实体类。
本次作业中的不足如下:
- 对于多线程的背后运行机制尚不明晰:将人作为单独线程会带来极大的线程切换开销,而在初步进行设计时我并没有过多地考虑这一点。
- 没有很好地符合开闭原则:曾经我天真地以为开闭原则只是代码复用,然而实际的开闭原则应该是在不修改原有代码的基础上重新构造,这需要在设计中引入更高的抽象层次。
- 属性与局部变量之间的平衡:回看代码时发现有许多属性在设计时应该被定为局部变量。这样做不仅能提高空间使用效率,更能避免潜在的并发错误。
run()
方法规模过大:不在run()
或call()
方法中添加复杂逻辑。- 对于其他种类锁的了解不够:除了
synchronized
块,Lock
作为一种更可控的锁在某些情况下可能更加有效。这里作为后续的学习部分。
第三单元:JML规格
第三单元的重点除了规格的阅读与撰写,以及对于规格的匹配以外,最让人印象深刻的就是算法和数据结构的选取。为了能够满足时间复杂度要求,必须分析指令的增删改查类型,为对应指令选择最低复杂度的数据结构和算法。
本单元内的作业迭代思路很清晰,每次都可以完整重用上次的代码,然而由于我对继承背后的原理尚不熟悉,所以没有使用继承,这是本单元最大的失误。对于其余的实现,最重要的是图结构的分离,第10次作业中需要引入图计算类将最短路径分析分离,而第11次作业中4种不同的最短开销可以继承共用先前的图计算类而只需重写其中的权值方法。
JML规格实现的权责分离对团队开发十分重要,而规格的引入让我对“抽象”这个词有了更深的认识。它和依赖反转类似,而调用方法的实质是使用其规格定义的功能,而不关心方法的实现。同时,规格也为设计单元测试提供了全套保证。
本次作业中的不足如下:
- 没有对明显具有继承关系的类使用继承:本次作业之前我对属性在继承中如何被传给子类并不明晰,而通过学习,我对于继承关系有了更深的认识。
- 对线程池的使用不够熟悉:本希望多线程并行计算SSSP,奈何对线程池的把握依然不足,这也是之后要学习的一点。
第四单元:UML图
第四单元的架构设计同样十分清晰:由统一的查询类进行查询,背后调用三种不同的UML图类进行分别的计算,而每一种UML图类又关联着其所需单独建立类的的UML对象类。在经过三个单元的练习后,对于类的辨别、数据的抽象方式和架构的设计已经有了成型的认识。
第四单元实际上是通过对UML图的建模与分析对面向对象语言的整体结构、状态变化和时序关系有一个更高层次的认识,从而对程序中的静态关联和动态转移有理论上的把握。UML实际上是设计步骤中需要使用的工具,是很高层的抽象模型,而UML设计的原则也间接地告诉我们在进行OO程序设计时应该遵循哪些原则,考虑哪些方面。
四个单元中测试理解与实践的改进
第一单元:表达式求导
表达式求导任务的测试相对简单,因为输出的结果可以通过自动化的方式判断是否正确。第一单元中我利用Python编写了自动测试脚本,通过Xegar自动生成表达式,并利用Mathematica或Sympy验证结果。这是一种类似于fuzzing的测试方法,通过大量测试概率性地发现自己程序中的潜在漏洞。这种测试在正确性方面比较有效,但是我在构造测试样例时没有构造压力测试和边界测试,导致最终因为爆栈被炸点。实际上,测试不应该只是这种单纯的一招鲜测试思路,而是需要加入人为偏置,对于特殊情况需要进行特判。这让我在之后的程序中更加关注了算法复杂度,减少了不必要的内存占用,并着重进行了压力测试和边界测试。
第二单元:多线程电梯
多线程电梯同样可以通过自动化的方式判断是否正确。不过,由于本单元是多线程程序,故对于同样的测试样例并不保证每次输出结果一致,所以通过大量样例fuzzing测试挑出错误的可能性非常小,即使挑出错误也多半是调度错误而非多线程错误。所以,我在设计时和检查时分别将程序中所有的多线程交互处进行场景分析,尽可能罗列程序运行到某一位置时所有的可能状态及对应的并发情况分析,从源头上杜绝并发问题的出现。在测试中,使用自动测试脚本生成测试,并在本地对输出结果进行判断是否正确,同时设计压力测试和边界测试进行着重检验。在我的实现中,由于每个人是单独的线程,故在JProfiler中可以很明显地看出各实例的运行情况,为查找错误提供了诸多方便。
第三单元:JML规格
有了规格之后,构造单元测试就变成了一件可行的事,因为在规格中已经指明了方法的所有执行路径,因此可以针对每一种可能的执行路径使用测试驱动编程(TTD)的开发模式。因此在这三次作业中我均使用了JUnit进行了对规格(而非代码)覆盖度100%的单元测试,实际效果非常好。设计单元测试时,每条路径的验证应该被设计为单独的函数,这样能够在一次检查中查出尽可能多的错误。
针对JML规格,还有其他的测试方式,例如SMT Solver或者JMLUnitNG,前者在使用中体验尚可,且一旦验证正确必然满足规格要求,是力度最强的测试;而后者自动生成单元测试的覆盖度有限且体验较差。
本次作业中除了单元测试以外的的测试都很难在本地完成,一是因为评测机的接口限制,二是因为验证程序几乎需要把源程序功能全部实现,难以保证其正确性。因此,我们构造了多人的对拍器,每个人提交自己的jar包和测试数据进行对拍(相当于提前做了一波互测),集群智群力解决问题。
第四单元:UML图
第四单元不仅由于相同的原因难以进行本地验证,更不可能自动生成测试样例,这使得测试进度变得缓慢。最初采用的方式是手动构建若干包含自己能考虑到的边界条件的测试UML图,dump为可读格式并手动输入观察结果和图的吻合度,但这样的测试往往会陷入自己用已经考虑到的样例检查自己的怪圈中。最终,我们依然沿用了上次的多人对拍器,每个人分别提交自己构造的若干mdj文件作为公共测试样例,对拍进行结果验证,这样能一定程度上弥补自己思考的漏洞。(在这里感谢大白同学搭建的对拍器~)
总结自己的课程收获
在上这门课之前,我自学了几天Java,看到JVM的一些很奇怪的处理(比如泛型)时,还说着“怎么会有人喜欢用Java这种奇怪的语言”,结果没过多久就真香了。Java并不仅仅是一门语言这么简单,它更代表着一种思维。面向对象思想将问题中的每个关键描述都进行了尽可能高的数据抽象,并通过多种不同的模型进行刻画,将问题进行解耦。相比于面向过程语言将问题杂糅在一起解决,面向对象更加贴合人的思维。或许这也就是为何老师说如果用面向过程语言是很难开发大型项目的原因。
从这门课上来说,这些层级式的具有挑战性的项目着实成为了我每周的快乐之一,大到构思出了一种精妙而简单的整体架构,小到思考出了一个简化实现的小trick,都让人兴奋不已;而最终实现了开始看似不可能的项目之后的感觉更是成就感爆棚,相比之下先前构思的痛苦都可以忽略不计了。说是高强度开发,实际上在构思好结构之后一写起来就会忘记时间。
这门课对我们来说的确是一场不小的历练,其中涉及的许多算法在过去最多只是写过伪代码的程度,而多种数据结构的使用也比其他课程来的更加多样。对于代码风格的检查也是这门课独有的,在过去我一直奇怪为什么没有一门课强调代码风格,毕竟难看的代码不仅会给其他开发者带来极差的第一印象,也会在未来走入社会时给自己的学校抹黑,而这门课作为计算机系的必修课之一能够纠正代码风格实在难得。
不得不说,今年的OO课程相比于去年有了很大的改进,那时的许多槽点在今年已经不复存在,不得不让人佩服本届助教团大刀阔斧改革的决心和敢想敢做的魄力,而这应该也算是今年的OO课程给我带来的感想和改变之一吧。总的来说,OO课是我大学以来上过的体验最好的课程之一:有现代化的IDE,有规则明确且少有变动的指导书,有能引发广泛讨论的论坛,有不着重讲语法细节而是注重更高层次设计理念的理论课,有课程组提供的功能完善的官方接口和可供学习的标程,还有一群共同学习互帮互助的伙伴,这一学期下来的确对许多人都心怀感激。这里尤其要感谢不辞辛苦写指导书和官方接口的老师和助教们,这些内容的工作量应该远大于学生的代码量;以及无私奉献了自己测试的各位伙伴们,让我学到了很多。
立足于自己的体会给课程提三个具体改进建议
- 在每一次作业,尤其是最开始的几次中,可以提供后续作业中可能出现的扩展需求,可以比实际的扩展需求更多或更少,让学生对代码复用和迭代开发的实现有更好的认识。
- 对于SOLID准则、设计模式等的考察应该迭代完成,每一次作业完成后都应该进行相应分析,且准则的讲授应该更早进行。
- 课上作业可以设计为工作量更大,但更易于检查的形式,例如选择+填空+简答的考卷形式,不仅能在保留现有考核程度的基础上更有针对性地检测,同时也能减轻检查的负担。
- (大三高工的同学也想参与研讨课啊QAQ)