面向对象第四单元作业/最终总结

一、本单元两次作业的架构设计  

1.1 第一次作业架构设计

第一次作业的主要任务是基于类图的一些查询指令,大体来看架构的实现有两种方式:

一种是把类图的结构反映到代码的结构中,也就是为类设置相应的数据结构,每个类是一个实体,其他的类图关系可以通过为这个类添加相应的属性实现。

第二种方法是直接面向功能,创建类图类,然后统一在内部设计各UML元素的数据结构。

两种方法的思路不一样,第一种我称之为面向内容,因为程序的数据结构和类图的内容息息相关,第二种我称之为面向功能,因为所有的数据结构全都是为了实现某查询指令而设置的。

我选择的方法是第二种,看起来,第一种思路是一个更自然的方法,因为你的程序的类设计可以直接按照UML来,但是为了方便功能的实现,我才用了第二种方法。

架构设计具体细节如下:

Main:主控类,启动程序。

UmlInteraction:交互类,负责信息收发。

ClassGraph:类图类,实现本次作业的核心要求。

Memo:记忆类,存储之前查询过的指令结果,加速查找。

1.2 第二次作业架构设计

基于第一次作业的架构直接拓展,对每个图,新建一个对应的类,然后分别实现,同时扩大UMLinteraction类。各类的功能如下:

Main:主控类,启动程序。

UmlGeneralInteraction:交互类,负责信息收发。

ClassGraph:类图类,实现上次作业的核心要求。

InteractionGraph:交互图类,实现交互图指令的要求。

StateMachineGraph:状态图类,实现状态图的指令要求。

Memo:记忆类,存储之前查询过的指令结果,加速查找。

Checker:规则检查类,检查各个规则。

这里面又个问题,就是规则的检查其实只是检查了类图,所以更自然地应该把这个检查的方法放在类图类里,但是这样一个类行数太多,虽然没有超过代码风格检查的要求,但是分开放会好一点,所以我分成了两个类,这样Checker为了检查就必须要传入参数,就需要把类图的有关信息传入到Checker类里,这样的传输必须要保证传入的量是不可变的,否则会导致检查完规则之后类图的含义发生变化,导致出错。

虽然面向功能的架构设计未必是最简洁的,也未必是最优雅的,但是这是一种思维的训练,这样的设计证明我对于对象的理解已经超过了实体层次,上升到了逻辑抽象的层次,也就是说我设计对象不再是直接把研究问题内容中的对象直接抽象出来,而是对功能进行抽象。

虽然这一单元作业因为没有处理接口重名不重ID的问题不慎炸了一个点,但是实践基本上证明这样的设计还是没有什么问题的。炸点更多是因为我比较懒惰,没有经常看讨论区,也不太仔细看通知,尤其是很多时候已经写完了又出一些补充说明,这种情况下就心累不想改了。

二、自己在四个单元中架构设计及OO方法理解的演进  

2.1 第一单元

本单元作业一共分为三次,主要任务为多项式求导,第一次仅要求对非复合的幂函数多项式求导,第二次增加了正弦余弦的函数形式依然会出现复合,第三次则允许函数复合。要求程序在任何输入情况下都不会崩溃,且能正确识别出用户输入是否合法,对合法的输入输出尽量短的求导结果,对不合法的输入输出WRONG FORMAT。

      首先整个程序的运行需要主控类,负责实现输入、输出、求导的过程控制。主控类能够把用户输入的字符串转化为内部的存储结构,而这个存储结构是由类实现的。经过对表达式的分析,以及编译技术学到的文法知识,不难发现,表达式由项构成。

在第一次作业中,项的组成成分单一,所以表达式和项足以应付所有情况,并且输入输出可以直接在表达式类中一次性完成。

      第二次作业虽然加入了新的函数类,但是考虑到他们对求导运算封闭,所以其实每个项可以写成一个a*x^b*sin(x)^c*cos(x)^d的形式,因此我依然没有创建因子类,我只需要对每个项维护好abcd四个系数即可。

      第三次作业中,嵌套的出现,每个项彻底失去了统一性,所以必须要增加因子类,而因子有很多种,按照其种类,可以分为三个子类:幂函数子类、正弦函数子类和余弦函数子类。随着情况的复杂,对于对象的构造和输入的处理也不能一次完成,所以把对于输出输出的分析分发到各个类的构造函数中,求导过程也要如此。而且为了避免递归下降子程序分析的麻烦,我对字符串进行特殊处理,维持了正则表达式的使用。

综上,我一共有主控类、多项式类、项类、因子类,而因子类作为父类有幂函数子类、正弦函数子类和余弦函数子类。

2.2 第二单元

三次作业的多线程设计出现了巨大的变化。

2.2.1 第一次作业:两线程策略

  由于第一次作业只有一部电梯,而且可以采用先来先服务的傻瓜调度,按照封装的思想,电梯内部执行任务的细节无需关心的话,和生产者消费者问题别无二致。于是采用了Collector线程读取输入,即生产者,而电梯线程执行请求,为消费者。两个线程之间需要互斥访问请求队列,所以另外抽象出Schedule类,把队列及其方法封装起来。这样的设计中,调度器并不是一个线程,电梯和Collector分别调用Scheduler的方法互斥访问请求队列,根据队列是否为空使用wait和notifyAll也可以很容易地避免轮询和死锁。

2.2.2 第二次作业:三线程策略

  第二次作业虽然还是一个电梯,但是增加了调度请求,所以调度器需要发挥作用。这里存在一个对调度器的理解问题。调度器既可以看作是一个协调区,只对各个请求进行排序,让电梯自行对选择的任务进行路径规划(捎带);或者,电梯是一个只能够执行运动装卸和开关门的机器,由调度器对输入的请求(Request)进行翻译转化,转化成简单的指令(Order)。

  由于对未来需求的误判,我把程序向着有助于调度算法扩展的方向进行构造,所以我在第二次作业中加入了Scheduler线程,让它对与传来的请求进行翻译,翻译成一系列上楼和下楼的Order,如果有新来的请求可以捎带,就会在这个Order序列上插入上人和下人的Order,也就是说,电梯所有的动作,都有Schduler安排妥当。

  这样的设计需要维护两个队列,一个队列是Scheduler和Collector互斥访问的Person request队列,一个是Scheduler和电梯互斥访问的Order队列,Scheduler封装所有的调度算法,也集中管理类所有的互斥访问的方法。

  这样的设计在当时的视角下,所具有的优点是能够比较容易的扩展到多电梯的情况,届时只要再增加一个平衡调度的算法决定请求分发给那个电梯就行了。

  缺点在于,为了防止电梯拿到一个过于巨大的请求而丧失了捎带的机会:比如直接拿到从1楼到15楼的请求,结果这个过程中电梯一直在休眠,那么就不能相应中间可以捎带的请求。为此,必须把每上一层楼当作一个Order,避免出现上述情况,但是这样会有很多Corner Case非常恼人,比如在电梯折返楼层因为方向不明确而出现一些不合常理的捎带。

2.2.3 第三次作业:两类线程的回归

  事实证明,第三次作业给出的全新需求完全不能和我之前的设计兼容,因为电梯具有个性(不同的容量、速度、停靠楼层等)使得如果把调度问题完全集中在调度器中会导致调度器过于复杂。所以,Scheduler应该仅仅被视为是一个请求的收发装置,协调各个电梯之间的任务分配。另外考虑到换乘,Scheduler也是各电梯进行同步的场所

  根据上面的认识,我又重新回到了第一次的设计,Scheduler不再是一个线程,而是集中了各种共享队列和方法的集合。

  这一次的共享对象有4个,三个电梯各自的字典,这是一个楼层到各层上下人的映射。以及一个等待队列,用于电梯之间的换乘合作。Scheduler中针对这些数据结构进行操作,被电梯和Collector调用。

  Collector先从控制台得到请求,然后调用Scheduler的方法将其分发到电梯的字典中(映射了每个楼层和该楼层上下的乘客),如果需要换乘的话,也需要将其加入到等待队列中。电梯也互斥访问字典取得请求。因为字典的存在,电梯每到一层就检查一下是否有人上下电梯,这样可以避免Corner Case,而且可以实现捎带。同时,为了兼顾效率与性能,我的调度算法具有内生随机性,陷入极差情况的概率极低。

  此外,我还专门设计了Scheme类,用来屏蔽个类电梯的停靠楼层等方面的差异,降低调配和分发的逻辑复杂度。任何请求都会先转化成一个Scheme,里面包含了请求的基本信息和换乘的要求(如前文所述,随机化选择),返回给Scheduler一个保证合法的分配方案,之后Scheduler在根据Scheme的安排拆分成Order(这里的Order和第二次作业含义不同,只是具有换乘信息的PersonRequest而已)加入到电梯中。

2.3 第三单元

  三次作业的整体架构几乎不变。除了第三次增加了两个类,其他的几乎不变。当然增加的两个类主要是服务RailwaySystem,所以耦合度并不高。

      Path类。三次演化中,Path类是几乎不变的,除了第二次意识到第一次每次都查一遍点导致超时所以增加了一个变量记录DistinctNode外,就没有更改过了。

      PathContainer/Graph/RailwaySystem类,这个类实现的方法在不断增加,从PathContainer到Graph,这两次作业之间的改动是很少的,只需要增加一些方法。之前实现的方法没有任何改动。

      但是从Graph到RailwaySystem架构发生了比较大的变化。新增了两个类,其中Pair类比较简单,其实使用javafx.util.Pair即可,但是考虑到jar包运行的问题,我自己实现了一个简单的Pair。另外一个类MsGraph,这是一个图类。这个类的主要作用是实现所有图相关的数据结构和算法,因为第三次作业中有很多不同类型的图。所以,此处的MsGraph的作用是纯粹的图类,所谓纯粹,是因为其中任何的数据结构都不和本次问题发生关系,所有的节点和边都是抽象的。而RailwaySystem类则主要实现用户输入到这张纯粹图的映射,为图类屏蔽问题的差异。所以,其实第三次作业中,MsGraph才是Graph的演化,(BTW,Ms是My super的意思),MsGraph是在第二次实现的图类基础上增加了迪杰斯特拉方法求最短路产生的。而RailwaySystem则主要是建立各种索引让各个图相互配合,完成功能。

2.4 第四单元

见第一章。

2.5 理解的演进

对面向对象的理解不断加深。

第一单元作业中,类的设计依赖于实际的数学公式,加上之前编译技术对与语法的认识所以应该说没有经历很多思考就直接设计了一个大致方向,得到了类的设计。

第二单元作业中,因为设计多线程问题,这方面第一次接触,所以仔细研究了课上测试的代码,学习了课上代码对于多线程的设计理念,照猫画虎是以学习为主,自己的思考并不算太多,但是因为设计过程中出现了一次线程个数的切换,所以对整个多线程架构的设计有了比较深入的理解。

第三单元作业虽然联系的重点是按照规格写代码,但是针对这个问题的架构设计本身还是值得思考。第一次作业已经明确规定了要求实现的接口,所以我就是用简单至上的原则,直接一个接口一个类的实现了,非常简单。第二次作业和第三次作业稍显复杂,但是随着对问题认识的深入,我逐渐抽象出图的概念,并把有关图的基础运算(最短路、搜索)等集中到一起,而且我在构图是尽可能屏蔽了和图本身不相关的信息,整个架构呈现三层,最外层用于表示,中间层用于转化,最里层是纯粹的图结构,和编码方式全部无关。为了屏蔽表示的复杂性引入中间层是计算机领域常见的方法。这样的屏蔽虽然本身造成了一定的复杂性和开销,但是却能够为程序的拓展提供便利,因为可以把所有的改动限制在中间层及以上。我这样的设计也确实为第三次作业的完成提供了巨大的方便。

第四单元作业则是更加抽象的类设计。首先我明确区分了两个概念,我们研究的内容和我们的设计。因为他们在这一单元作业中是重合的。UML是用来研究架构的,我们的设计架构恰好就是UML的内容。这样的重合性也许是一种干扰。很容易的我们的设计思路就跟着图本身走了。但是,考虑到UML工具StarUML的内部组织,老师上课也强调过,StarUML工具里每个元素各个字段的id是管理工具自己设计的,并不和所画的类图直接一一对应,所以我觉得这种区分是正确的思路,会给我们的设计带来一定的便利。

总体来看,对架构的设计从前两次作业直接对研究的问题内容抽象,逐步演化到一种逻辑抽象,也就是为了方便问题的解决抽象问题的解决过程,然后设计类去实现。

三、自己在四个单元中测试理解与实践的演进

3.1 第一单元测试

  首先是手动测试,手动测试的时候,我加入了死循环,并且使用IDEA中Run with Coverage的模式运行,这个模式的好处是可以在结束运行之后告诉你各段代码时否被覆盖,这种方法简单快速,而且能让你迅速有针对地把所有代码执行一遍,甚至可以起到简化代码的作用。我在第一次作业中使用这种方法,发现有几处代码无论如何也覆盖不上,后来仔细分析了一下,是因为x无论如何不会成为一个求导的结果,所以那里的逻辑组合系数和指数都是1这个分支其实永远不会进入,所以我果断删掉了这个分支。当然这个方法存在致命问题,为了测试我不得不修改已经写好的代码,这样测完了如果没改回来,就可能造成致命风险,比如卡评测。

      之后是自动化测试,对于正确的用例,我写了一个python脚本,可以根据正则表达式,生成目标表达式,在使用python中subprocess指令,调用自己的java程序,识别控制台输出,然后使用python的sympy进行求导,计算。这个方法最大的好处是真正实现了黑盒测试,把需要测试的代码使用子进程的方式启动,利用管道获取控制台输出。但是问题也很明显,就是随机生成的用例往往没有针对性,而且因为正则表达式太复杂,稍微长一点的表达式,生成和计算都需要很长时间,而且还很容易超出计算限制。不过,这个方法至少实现了大量测试,在一定程度上确保程序的正确性。

     对于错误情况分析,则比较难,理论上,只要正则表达式是正确的,看起来对于所有错误都会输出WRONG FORMAT!。所以核心是建立正确的正则表达式分析方法,借助编译技术中学习的语法分析方法,从语法树分析,按照DFA分析,最终得到的正则表达式不会出问题。

3.2 第二单元测试

3.2.1 在设计上分析进行避免

  设计的时候,抽象出Scheduler类,把所有的互斥访问工作全部集中在同一个类里,方便设计和检查,并且容易出问题的地方集中起来。

  此外,对于互斥场景的访问,一定要使用规范的格式书写代码,力求一种对称性和统一性,比如下面是我第二次作业中取出请求的代码,严格按照同步——检查(可选)——操作——唤醒的格式来写。

View Code

  此外对于多个锁的情况,一定要避免嵌套加锁,防止出现死等,为此,我采取了分别加锁处理的情况,但是如果在中间切换,就可能出现潜在的数据一致性问题。所以我对于可能出现这种问题的情况,我采取了检查A——操作B——操作A的方法,避免出现对A直接操作后线程切换,然后出错的情况。

  但是第三次作业中需要同步访问控制变得更加复杂,复杂来源主要是换乘的电梯通信和关机指令的复杂。

  电梯的通信要求对等待队列进行互斥访问,这个的实现和Order队列大同小异。

  关机指令则略显复杂,需要避免死锁和插入异常。为了避免死锁,当Scheduler发出关机信号后需要唤醒所有线程。此外,为了防止出现插入异常(一个电梯完成换乘乘客的第一阶段任务,要从等待队列取出后,但还未插入到下一个电梯的指令队列中前,发生了线程切换,这个电梯发现没有任务插入且等待队列为空,收到关机信号后就可能会关机,导致后续的请求没有被处理),需要先插入到电梯中再将其从等待队列移除。

3.2.2 大规模测试

  在设计上尽可能避免了死锁和数据竞争情况的出现以后,开展大规模随机测试,随机生成请求输入后,检查输出的操作是否合法,经过了上百次测试后,可以基本保证程序的正确性。

3.3 第三单元测试

3.3.1 使用Junit进行单元测试

      不同的方法测试难度并不一样,容易测试的方法一般有两个特征,一是返回值可能的数量少比如布尔类型的方法,一种是逻辑比较简单没有算法比如增删路径。这些比较容易测试的方法主要是isConnected, CONTAINS_*以及addPath之类,可以用Junit进行单元测试,构造几个用例就基本有信心保证正确性。以下是第二次作业图类测试部分Junit测试代码。

View Code

      测试结果如下:

 

3.3.2 Corner Case测试

      对于路径中有诸多平行边,起点和终点一致的查询等等corner case进行测试。比如:

PATH_ADD 1 2 2

CONTAINS_EDGE 2 2

PATH_ADD 1 2 2 2 3

PATH_REMOVE 1 2 2

CONTAINS_EDGE 2 2

      诸如此类的corner case还有很多,经过测试后都没有问题。但是这样的测试极为有限,而且并不能保证正确性。

3.3.3 搭建对拍框架进行多人对拍

      还有一些方法并不容易测试,比如最短距离等等,即使是写一个对拍程序,对拍程序本身的正确性也不易保证。此外各个方法之间的综合作用是否会出问题也不容易使用Junit测试。所以必须进行整合测试。但是这次作业不像电梯,电梯可以有一个另外的逻辑推断结果的合理性,但是这次测试并没有。可是我们又没有标程,为此只能通过群体智慧进行测试。随机生成测试用例后,运行若干同学的jar包,然后把结果进行比对。如果大家输出的结果都一样,那么就有比较大的把握认为程序是正确的,如果有一个人和其他人都不一样,那大概率是这个人错了。

      我搭建了一个多人对拍框架,它具有以下几个特征:

      1 并发测试:同时运行多组java进程进行测试提高测试效率。采用python线程池,每个python线程开启一个新的Java进程。

View Code

      2 计时服务:提供时间统计服务,作为算法执行效率的参考。

      3 邮件通知:运行大规模测试很耗时,所以我是在树莓派上跑的,隔一段时间check一下树莓派很麻烦,所以我设置邮件通知方法,运行完以后向我发送邮件。

      当没有发现不同时,部分结果如下:

 

      具体技术细节见开源代码库:https://github.com/sdycodes/JavaDestroyCorner.git  (开源已经过jar包拥有者同意)

3.4 第四单元测试

3.4.1 面向查询指令构造Corner Case

最欧一个单元的测试中,才用了先构造测试用例后写代码的方法。现根据需要完成的指令,考虑一些比较特殊的情况,以及一般的情况,构造测试用例。然后再进行代码实现。下图是几个测试的例子。

3.4.2 大规模随机测试

这一单元作业开展大规模测试是很困难的,主要是因为UML图的绘制不能随机生成,所以其实随机生成的只能是一些指令,如果类图本身不够复杂,其实再多的指令也并没有太大意义,这反过来又进一步说明了手动构造测试数据的重要性。

3.5 测试的理解与演进

我从一开始就重视测试的要求,从一第一单元作业开始,我就是用了大规模测试的方法来尽可能避免程序错误,但是因为一思维惰性对于cornercase不愿意去想,总觉得大规模暴力测试应该可以实现绝大多数情况的覆盖。这样虽然我测试了大量的样例,但是其实测试是比较低效的,不过采用树莓派24h不间断运行倒也没有太大问题。

第二单元作业的测试有难度,主要是因为多线程,然后模拟真实电梯的运行,速度很慢,但是检查正确性的逻辑并不复杂,可以按照每个人的轨迹去检查。

前两次作业自动化测试都是比较简单的,因为检查正确性有另外的方法,比如表达式求导的正确性,只要带入数值检查,电梯的正确性,只要检查每个人的乘电梯的轨迹是否合理即可。但是第三次第四次作业,检查起来就有难度了,因为没有另外的逻辑,检查需要的逻辑和求解问题一样,比如求解最短路是用来Dijstra算法,那么验证的时候还是需要算一个Dijstra,这样如果两个dij出自一人之手,其实检查并无意义。所以,我搭建了多人对拍框架,类似对答案的方式,同学之间相互认证,出现不一样大家一起探讨,保证程序正确性。

前面三次作业中,我高度依赖自动化测试,原因很简单,测试用例的构造可以全部随机生成,所以与其处心积虑构造测试用例,不如直接自动生成测试来的简单稳妥。每次测试都部署在树莓派上7*24h不间断运行,所以也不太需要担心有什么特殊的测试点没有测到一类的问题。但是第四次作业却是遇到了自动化测试的困境,因为UML图实在不能随机生成,所以这又逼迫我重新回归了手动设计。

总体来看,前三个单元的作业让我实现自动化测试的水平不断提高,而第四单元的作业有让我重新回归对问题本身的思考。此外,测试和实现的顺序也在第四次作业发生转换。

这个过程还伴随着其他工具的使用,比如JUnit,JProfiler以及IDEA自带的插件等等,这些工具的使用也都能够方便我去测试,发现bug,特别是Coverage的评估能够指导我构造出高效的测试代码。

四、课程收获

4.1 工具链的使用

这门课接触了很多工具。

IDE:IntelJ IDEA

单元测试工具:JUnit

线程工具:JProfiler

UML工具:StarUML、z3

了解了很多语言和表示方法:

Java语言、JML语言、UML图的规范

4.2 工程代码能力的提升

因为代码风格的检查,指引我形成良好的命名、缩进、加括号的习惯。这样写出的代码才有可能成为有质量的代码。

结合IDEA的自动补全、代码建议、快捷键的使用,写代码的速度和效率极大的提升,灵活使用条件断点、变量监视等方法极大地加快了debug速度,认识到一个高效的生产力工具是何等重要。

这也是第一次进行系统性的Java代码书写,Java是比较普遍使用的语言,掌握之很有必要。不过Java里面的还有很多复杂的语法包括面向对象的特性我还没有用到,日后还要继续学习。

多线程能力的训练,第一次实战多线程,在OS课上学过管程,当时就发现Java实现并发控制本质上就是管程,所以还算比较快的认识到这一点。多线程程序是比较有意思的,不过出了错误不能复现确实比较有挑战。

4.3 面向对象思维的训练

这是这门课一个很重要的学习目标,也是我收获最大的一部分。

从第一单元开始,这门课就不断强调关于面向对象的设计理念,除了掌握了一些基本的说法和面向对象的概念以外,在这四个单元的作业中反复强化的架构设计才是对面向对象理念进行学习的最好方法。而这种潜移默化的能力训练很重要,但是却有不易表达,但是从几次作业的架构设计演进中可以略知一二。

五、立足于自己的体会给课程提三个具体改进建议

5.1 关于性能分的意见。

比谁短、比谁快我觉得不好,助教也已经说过这个东西主要是给学有余力的同学做,那么问题就来了,首先,学有余力的同学是否需要这点分数的激励,其次,学有余力的同学想不想做这个事,我觉得这些都应该思考。有想积极探索的同学,这个应该鼓励,但是是不是可以在其他方面给予奖励。

5.2 分数计算方法

据说是按照排位给分,这个很残忍,我知道你们会说竞争很重要,社会很残酷,我也不反对,但在公开场合我想走人道主义路线。

5.3 关于课上实验

高工每次实验的时候都已经对所学知识很熟练了,没有起到趁热打铁的作用。

猜你喜欢

转载自www.cnblogs.com/sdycodes/p/11045502.html