面向对象第二单元作业总结

一、线程设计策略

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

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

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

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

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

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

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

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

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

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

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

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

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

  Collector先从控制台得到请求,然后调用Scheduler的方法将其分发到电梯的字典中,如果需要换乘的话,也需要将其加入到等待队列中。电梯也互斥访问字典取得请求。因为字典的存在,电梯每到一层就检查一下是否有人上下电梯,这样可以避免Corner Case,而且可以实现捎带。

  此外,我还专门设计了Scheme类,用来屏蔽个类电梯的停靠楼层等方面的差异,降低调配和分发的逻辑复杂度。任何请求都会先转化成一个Scheme,里面包含了请求的基本信息和换乘的要求(直接随机化选择),返回给Scheduler一个保证合法的分配方案,之后Scheduler在根据Scheme的安排拆分成Order加入到电梯中。

二、基于度量的程序结构分析

  考虑到作业难度依次递增,代码越来越复杂,所以代码度量重点分析最后一次作业。而类图则全部分析侧重对比。

2.1 类的分析

  首先类的部分度量指标如下。

 

  先看每个类独立的指标。可以看到,除了电梯类外,其他类的属性都不超过10个,方法个数控制在15个以内,public方法数略低于总方法数,只有少数几个方法用于内部的功能的辅助。

  再看类间关系指标。可以看到这一次扇入扇出都不高,本次实验的数据抽象特征并不明显,所以其实内聚耦合的问题并不是主要矛盾。 

   从总体规模来看,除了电梯类和Scheduler的规模略高,超过了150行,其他类控制在120行一下。电梯类其实还可以继续化简,之所以过长是因为这个类里有很多方法从第一次一直保留到了最后一次,始终在之前的基础上进行调用,所以有很多不必要的冗余。

2.2 方法分析

  方法的LOC统计如下(分析工具存在bug,方法名和类名一样)

  可以看到所有方法全部控制在50行一下,仅有两个方法超过30行,所以在最大程度上保证了程序的精简。

  从CC数量上看,有四个方法超过了5,不是很好,但是子Scheme中的两个方法CC虽然高,但是并不复杂,因为并没有很复杂的if嵌套结构,这里只是因为不同的电梯的特性不同导致的分支比较多,当然我也可以进一步抽象,只是因为只有三个电梯,所以直接枚举还是可以接受的。而在Scheduler中的高PC值确实是因为这里的逻辑比较复杂,可以考虑进一步化简。

  而在参数的个数方面,控制比较成功,只有一个方法参数超过了5个,这个的化简方式应该是通过继承PersonRequest来化简这个逻辑,因为两者之间有很多信息是重复的。 

2.3 类图分析

  第一次作业的类图

 

  第一次作业使用单例模式,主线程创建电梯线程,Collector线程和Scheduler对象,是一个典型的生产者消费者模型,规范合理,唯一的问题是枚举类其实是多余的,这个主要是基于后期扩展的考虑。

  第二次作业结构十分复杂,因为Scheduler成为了一个线程,把来自Collector的Request翻译成Order序列供电梯取得执行。依然严格按照生产者消费者模型来实现,只不过变成了两层生产者消费者,但是规范性还是得到了保证,依然是单例模式。

  第三次作业不在把Scheduler定义为线程,电梯能够自主在每一层楼取得request,所以第三次的类图本质上是在第一次作业的基础上拓展了,新建了Scheme类,这个类的作用很单一,只是为了屏蔽不同电梯的特性,今儿Scheduler把Scheme翻译成Order供电梯执行,不过这里的Order其实和Request很接近,只是增加了换乘信息。

  可以看到,第一次任务简单所以类图也很简单,第三次作业基于第一次扩展,所以类图虽然复杂但是清晰,只是在第一次的基础上增加两个类。但是第二次的类图复杂度不亚于第三次,尽管第三次任务的复杂度远高于前者。之所以出现这个问题还是因为第二次作业中的设计对于Scheduler提出了很高的要求,而且保证了一定程度的拓展性(虽然后面没有用到),所以很复杂。

2.4 UML协作图分析

  使用IntelJ的plantUML插件进行绘制。

第一次作业

  第一次作业简单规范,因为直接先来先服务,所以不需要增加Order类,直接传递PersonRequest。

第二次作业

 

  第二次作业中,由于需要捎带,所以引入Scheduler,同时把PersonRequest转化为Order,借助OrderStore传递给Elevator。

第三次作业

 

  第三次作业,因为电梯有三个,各有特性,所以在把PersonRequest转化成Order之前,需要先将其转化为Scheme方便Scheduler处理,同时由于取消了Schduler线程,所以去除了用于传递请求和Order的中间类。

2.5 基于SOLID原则的评价

  S.O.L.I.D.是指SRP(单一责任原则)、OCP(开放封闭原则)、LSP(里氏替换原则)、ISP(接口分离原则)和DIP(依赖倒置原则)。

  SRP的核心是一事一地,尽最大限度分离各个类的责任,避免重合或一个类做太多事。这个原则在三次作业中都满足了,第二次作业开始引入Order把人的请求转化为更适合电梯执行的操作。电梯只负责运动,Scheduler负责所有的调度和分发。第三次作业Scheme类专门屏蔽电梯的特性。

  OCP是指所有的扩展应该是基于现有代码的基础上进行增加新的方法,而不是直接修改某个方法内部的逻辑。本单元作业的重构程度依然比较大,能够实现扩展的只有电梯类,电梯类的方法从一开始只有上下楼到后来扩展允许每一层停靠检查,都是在原有基础上增加方法来实现的。

  LSP要求子类应该包括父类的所有属性。本次作业不涉及父子类的问题。

  ISP可以简单认为是接口版本的SRP,核心是一个个接口不应该和太多功能相关,本次作业不涉及接口问题。

  DIP要求模块之间尽可能依赖于抽象,而不是模块之间的依赖,抽象不能依赖于细节。这个原则没有实现,主要集中体现在Order类和Scheduler类的依赖上,Order基本上没有什么功能只是一个数据结构和有关的操作,但是Scheduler高度依赖这个类内部的实现,每当我发现Order需要修改某些属性来适应一开始没有想到的要求时,就需要引发Order类的方法到Scheduler方法的一连串修改。所以应该进行进一步的抽象。

三、程序错误分析

3.1 在设计上分析进行避免

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

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

 1 public PersonRequest handleReq() {
 2     PersonRequest pr;
 3     synchronized (requests) {
 4         while (requests.size() == 0) {
 5             try {
 6                 requests.wait();
 7             } catch (InterruptedException e) {
 8                 e.printStackTrace();
 9             }
10         }
11         pr = requests.remove(requests.size() - 1);
12         requests.notifyAll();
13     }
14     return pr;
15 }
View Code

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

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

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

3.2 大规模测试

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

四、心得体会

  这次作业吸取了上次作业的教训,尝试保留程序的拓展性,减小重构的成本。然而事与愿违,预测错了方向,导致重构力度反而高于预期。所以因个真正好的架构,应该能够保持尽可能多的拓展方向,以应对任何方面可能出现的变化。

猜你喜欢

转载自www.cnblogs.com/sdycodes/p/10740228.html
今日推荐