Summary of OO Unit 2

OO Unit Two总结

Written by: 18373541-xiaohan209

代码概况


第一次作业

  • 设计策略

    ​ 本次作业采用的设计策略较为简单。因为只涉及一部电梯,所以只有两个线程,一个是InputHandler,专门负责往Dispatch中的列表中添加请求。Dispatch类是Elevator类和InputHandler的共享对象,整个程序是一个标准的生产者消费者模型。同时Elevator类也是一个线程,负责从Dispatch中搬运请求至自己的内部的队列中。本次的多线程题目更注重线程安全的考察。对于线程安全的设计如下:

    1. Dispatch中设置有work变量,用来表示是否请求已经全部输入完成。电梯的线程由于是一直循环判断,只要托盘中有请求就需要拿出来处理,托盘没请求就要等待,但如果不通知电梯托盘中已经没有新东西会加入了,那么电梯永远不会停下。work变量就是用来防止电梯永远等下去的变量。

    2. 读写互斥。这种互斥是如何解决的呢?在本次作业当中只会涉及到InputHandler添加进请求,同时电梯在读取Dispatch当中的队列内容。此时只需要对读取和添加进请求均加锁即可。

    3. 写写请求没有涉及到,因为只有一个电梯和一个输入,输入是按序输入不会有冲突。

      整体UML图如下:

Elevator类在此次作业当中的逻辑全部都是用while的大循环来执行的。三次作业均按照首先判断电梯该不该关,在此即为Dispatch类是否为work状态,以及自身status是否等于0,如果status不等于0或者在work那么电梯不关。接着开始寻找目标,即从Dispatch中找到一个请求,把出发楼层作为目标,如果此时已经到达该层,则将到达楼层作为目标。这个请求是从队列中第一个出发楼层为人数最多的楼层的请求。也就是电梯为空的时候将等待的目标作为主请求。然后根据目标和当前楼层来判断电梯处于将要向上还是向下的状态。然后开始分成两个分支:

  1. 在有人要下电梯或者有人要上电梯的时候开始执行开关门分支。首先开门,进行下人。此时要更新主请求,因为主请求可能已经出电梯了,并且还有更新电梯下一步的运行方向。然后根据可捎带原则再上人,关门。然后直接返回到循环开始。
  2. 没有人要上下的话直接由状态决定是up还是down,分别执行两个函数。

正因此电梯类的run方法中调用了多层函数,并且这些函数又有可能调用电梯内部的其他方法,这是本次作业在代码层次上唯一做的不是太好的点。下图展示了对于本代码方法以及类级别的分析。

可以看出本次相对之前求导的作业来讲,每个方法的行数都变少了,对于方法不会出现idiot方法,也不会出现god方法。其中调用层次是不可避免的,本次作业控制在了合理范围内。另外对于每个类,代码行数也基本上能控制下来,封装新也是比求导作业有了显著提升。当然这也确实是多线程本来就需要分工,并且生产者-消费者模型也需要提前将职责划分好。同时Implementation出现了一些魔数的smell,暂且不谈。那么接下来是对于SOLID原则的分析。

  1. SRP原则:单一职责原则在本次作业中较好的体现了出来,三个类比较独立。但是Dispatch类当中由于是两个线程的共享对象,其中的大部分函数都是由Elevator类来调控,其实更好的办法是将方法当中传进来的参数做到与本类无关。即Elevator可以预处理一下传进来的参数,而后Dispatch不会修改参数,只根据自己类的方法来进行计算,然后返回给返回值,在此只有findTargetchangeTarget方法有些许欠缺。
  2. OCP原则:本次对于InputHandler类此原则实现的很好。不过对于ElevatorDispatch类,此原则并不显著。原因是如果单一电梯升级到多部电梯,那么等待队列可能就得有多个,但是由于一部电梯,所以直接将电梯等待队列直接等于生产者-消费者中间的托盘,并不能很好的体现这点。除去等待队列,对于电梯开关门时间和人数以及楼层间运行时间,本次OCP原则体现的比较显著。只需要额外在类中添加各种属性即可,因为调用这些信息的地方很少,基本上都是由十几行的方法来使用属性,并且与其他方法间相关性不大。
  3. LSP原则:本次并未使用子类和父类的引用,抛开此原则不谈。
  4. ISP原则:本次并没有用到太多的接口,但是出于扩展性考虑如果涉及到不同电梯间的属性不一样可以采用电梯都继承于同一个接口然后自己有自己的内部属性。但是本次所有作业都是电梯间类似的,并不存在这样的区别。当然本人也想过给InpuHandler或者Dispatch类注册一个所有电梯列表,使得其能控制电梯,但是这样不同类之间耦合就比较大,并不能做到很好的SRP。
  5. DIP原则:由于没有接口所以DIP原则也抛开不谈。
  • 自己的BUG:

    本地测试修复:

    ​ 本地测试主要调整的bug就是在请求输入完毕之后怎样让Elevator类获取这个信息,一开始就是根本停不下来,原因是我将信息设置在Dispatch中间并没有设置保护的锁,导致不能及时获取信息。另外就是后面加了保护锁之后有时候电梯会提前停下来,这是因为我在电梯判断终止条件的时候,只用了Dispatch当中还有没有人,以及是否在work,这时候可能电梯内部还有剩余没送到的,电梯就终止了。这个bug改好之后就没问题了。

    互测:

    ​ 这一轮我没有被hack,自己hack到了一个人。本次我想到的数据,基本还是电梯间楼层的随机选择,以及按照每1s一次放入请求。但正是因为这种0.5s的规律性导致我使用了很多很多的数据都没有hack到别人。最终我是35中1。最终我们屋中另外一个数据让我很意外。hack到别人成功的数据是1s的时候放一个请求,然后70s左右再来一个,总共就两个请求,但是相隔很长时间,这种数据是我没想到的。其实没想到的主要原因还是因为不了解多线程,后面深入了解了之后就会发现在时间间隔较特殊或较极端的情况下线程调度就会容易产生问题。


第二次作业

  • 设计策略

第二次作业的设计策略由于加了电梯的数量,但好在输入数量的方法已经在官方输入包中给出,所以无非是要将一个电梯拓展到多部电梯,这就涉及到数据的冒险了。本人想到两个方案,一个是在托盘之上再加一个调度类也就是真正的Dispatch类,同时托盘暂且用Buffer类代替。这样的好处在于负责调度的类能够获取全局的信息,站在更高的角度看问题,能够统筹兼顾全局。并且调度类的好处在于能减少电梯和托盘间的信息交流,并且可以减轻电梯间的冲突,电梯只用管理内部队列即可。但缺点就是一个调度类需要管理所有的电梯,两三个还好说,如果多个电梯的话容易造成硬编码或者判断过多过于复杂。但是如果采用第二个方案可以三个电梯和六个电梯在代码量上来说是一样的,因为每一个电梯都是同一个策略,以电梯为单位进行争抢,而不是凌驾于各类之上的调度类。并且第二个方案将各任务分派给每个电梯,不容易出现GOD类,另外对于时间上的优化包括可捎带的实现,电梯类的硬编码更少,基本都是用各自贪心的算法来达到全局较优的解。当然,这里的想法也为我第三次作业的崩溃打下了伏笔。

确定方案之后就要针对方案的弱点进行改进。其中本次有几个坑点,第一个就是楼层中间1和-1有断层,这个好解决直接在up和down函数中加入特判即可。另外就是有超载情况,此时只要在关门前上人的函数中进行判断数量的保护,以及在判断这层是否需要开关门的时候加上特判即可。在线程安全方面,由于多部电梯,所以存在数据的竞争,数据的竞争发生在将Buffer中的请求挪入到Elevator类的情况,挪入之后电梯间不会访问因此不会有竞争。此时只需要将每次从Buffer中加入请求变成一个原子操作,即发现可以加入并且需要加入则立即添加进一个,中途不能被任何别的进程打断。另外读读和读写的情况与上述第一次作业一样,只不过本次有了写写冲突。就是在将一些请求加入到电梯的等待队列中的时候并不一定保证这些请求能上电梯(可能是不符合捎带原则或者电梯满员了等原因),所以在每次电梯上人完毕后需要清空电梯等待队列。此时就是将请求从Elevator添加到Buffer类。

其次就是算法方面,可捎带原则还是本次算法的重点。当电梯中没有请求的时候就需要将Buffer中的某一个请求当做主请求。这个主请求的选定是根据现在有人的楼层当中最远的楼层当做主请求,此时能捎带的比较多。由于会有数据的竞争,导致许多电梯都可能会将这一层当做主请求,所以避免电梯白跑,电梯会在空的时候抢占式的将此请求加入到自身的等待队列中。当走到一层之后就看是否可以捎带,如果可以捎带的话,开关门上下人之后需要切换主请求。并且此时开关门之后需要flush掉等待队列,防止许多不可捎带的请求占用等待空间,将其返还给Buffer可以发挥其他电梯的作用。

综合上述给出本次代码的UML图。

本次还是采用的电梯和输入两种线程,然后中间的共享对象为Buffer类。InputHandlerBuffer的信息交流还是一样的,只不过本次需要在InputHandler当中读入电梯数量,所以在其内部直接创建电梯线程更方便。在第一次作业的基础上主要是增加了Elevator当中的方法和属性。其中增加属性来存储开关门上下楼时间是为了之后可能有的拓展,增加了另外flush函数之前已经提到是用来移除等待队列的。这个flush函数只有在开关门之后才会使用。

由于在Buffer当中会被抢占,以及线程安全角度考虑,所以产生了一个返回请求的findtarget和一个into函数,分别用来在电梯空的时候寻找一个请求加到电梯等待队列,和电梯到某一层看看有没有可捎带的加入等待队列,此时不加入电梯的内部队列中是因为有可能人满,或者可能更改请求导致加入不了。

以下是代码smell分析。

可以看出本次的代码量明显要多一些。但是比较满意的是将方法平均的分开来了,最长的方法也不过30行左右,这方面有所提升。不过在另一份Implementation smell中检测出来有过长的判断,由于太长在此不放出来了,但是确实是我的一大问题。这些判断起初并没有那么的长,只不过是由于逻辑问题或者修复bug而直接一点一点加上去的,所以之后的设计要么就直接设计好判断层次,要么就是在添加完判断以后看看是否能够继续分层或者整合,这样就能解决这一问题。同时findTarget的对别的东西的调用明显要多一些,其实这个函数也可以大致再拆成两个函数,只不过中间的参数传递比较麻烦,并且还需要进行线程安全的保护,还是写成一个函数更有可读性。另外虽然Elevator类编码较多,但是相对其他同学的调度类来讲,还是比较可以的,每个电梯的编码都是一样的明显要舒服一些,可拓展性更高。

SOLID分析如下:

  1. SRP原则:本次SRP原则实现较上次作业更佳。虽然电梯的功能多了,但是对于上次作业的问题进行了整合,于是更好地封装了电梯类,同时对于Buffer类也是设置了不同的函数来返回请求,将电梯和Buffer基本能达到完全脱离,再加任意多的电梯,只需要交给InputHandler处理即可。
  2. OCP原则:上文已经提到对于一些拓展,已经设置了部分属性,可以不用对已有属性进行修改。但是在intofindTarget两个函数中其实这一原则并没有很好体现。这两个函数其实都是将请求添加进电梯的,但是都是用最基本的最简单的函数进行的,其实后面想一想可以将这两个函数做一个统一,都是添加请求的,那只需要知道要拿走哪个即可,只要判断拿哪个最优则可以交给其他的函数。这样的话即便换一个算法,也是新增方法,并在统一的函数当中调用即可,就像第三次作业有了换乘的情况,那么寻找请求的方法肯定有变化,这时候要修改就需要整个修改方法,但是如果做了统一之后,之前的方法保留都可以,只要不调用即可。
  3. LSP原则:本次并未使用子类和父类的引用,抛开此原则不谈。
  4. ISP原则:本次并没有用到接口,但是OCP原则中谈到的其实可以应用于电梯类和托盘类。如果想要不通电梯运用不同调度策略那就可以进行接口的使用,只需要将调度的方法进行抽象,之后每个类运用不同的即可,此时就需要将接口建立在所有电梯的层次之上。
  5. DIP原则:由于没有接口所以DIP原则也抛开不谈。
  • 自己的BUG:

本地测试修复:

一开始的bug就是线程安全方面的,刚输入一个请求就会数组越界。这是因为所有电梯都看到了一个请求,当判断的时候都发现请求在Buffer的队列中,但是当需要取走的时候便只能取走一个,于是就会出现取到不存在的请求的情况。这种bug的出现是因为read-write并没有进行原语的操作,所以在findtarget的时候只要改成判断有没有和返回给Elevator的请求绑定在一个函数当中即可。

另外的bug就是在加入到Buffer当中的请求有输入的线程产生的也有电梯的线程产生的,一开始加入的环节我并没有保护好,使得有的时候flush的内容,所以直接给加入的函数上上锁就能保证安全,但其实效率会稍微降低。

互测数据:

本次互测我没有被hack,但是也没有hack到别人。本次还是想第一次一样,只不过对于时间序列可能会手动调整一番,使得出现一些极端数据,比如在最后的时候再来一个请求等,但是很可惜并没有hack到别人。再看别人的数据,其实基本上也跟我的差不多,但是可能会在特定的时间点出现奇怪的bug。个人认为多线程除了在时间上可以出极端数据,在执行过程中的预判不太好实现,以致于并不能想出比较有针对性的数据。


第三次作业

本次作业可是我心头之痛,因为这次的中测和弱测都有的点没有过,到了强测为0分,后来导致我直接重构代码,一次修复了强测的所有bug,但是总的来说修复之前其实是更贴合生活实际的,只不过由于请求的灵活度太大导致无法靠几个普通的函数限制住。这就像我不小心制造了一个熊孩子,在电梯当中本来要去6楼,电梯快到6楼,又突然想去3楼,快到3楼发现还是5楼好。这样就导致电梯紊乱,甚至于有的时候电梯都该停止了,但是熊孩子突然想乘坐停止的电梯。

  • 设计策略

首先修改之前和修改之后的代码的整体框架类似,都是沿用了第六次作业的代码结构,进行了一定的拓展。总体思路是将InputHandlerElevator类当做线程。这两个类中间有一个Buffer类充当共享对象,也就是继续沿用的消费者-生产者模式,其中Buffer是托盘。电梯抢夺资源,循环中先判断是否需要休息,然后寻找目标,并将目标加进自己的waiting队列,然后更新电梯状态(电梯决定往哪里走)。然后判断是否有要上的人和下的人,有就需要开门关门,开门下人,关门上人,都在电梯开关门边缘进出。然后如果这一层开关门直接进入下一次循环,如果没有的话决定向上还是向下挪动一层。而InputHandler负责识别不同的请求,并将请求源源不断加入Buffer当中。PartRequest是对PersonRequest的继承,增加了一些属性,和一些方法。

修改之前的不同在于,InputHandler部分完全朴素地加入请求,即,封装为PartRequest然后直接加入到Buffer中,请求在被加入到电梯的等待队列之后,开始自由变更stepFloor这个属性,表示中间可能因换乘而提前出去的楼层。这样的设置,我自认为更符合常识,电梯中的人不断调整自己的请求,寻找合适的楼层换乘(主要是取决于电梯的运行方向,以及总的经过层数),但这就是罪恶之源。第一:由于使用过度的贪心算法,没有设置好约束,在电梯每次更新乘客需要的stepFloor中会出现由于搭载主请求改变了电梯的层数,导致乘客并不能在预想的地点下电梯,或者乘客由于自主选择目的楼层而与电梯运行方向不符合,反而更长时间的问题。第二:这也是这个结构的硬性bug,就是在请求到达stepFloor的时候需要重新往Buffer中写入新的需求,即完成部分请求再切割,这样导致需要花费过多的成本和变量来控制到底还有没有新的请求,否则会造成还没有加入完请求,有的电梯就已经停了的困境。本程序电梯之间相互独立,在InputHandler中建立线程之后就直接自主运行,并且是Elevator调用BufferBuffer无法对每个Elevator直接调用。那么这样的问题我尝试了设置working变量解决。在Buffer中添加一个整体的类似"全局"变量的一个变量。也就是对于所有电梯来说都知道的变量,这个变量记录着里面需要切割新请求的数量。但是由于请求的可改变性,有时候请求会变换成原来的不切割的样子,这个变量并不好掌控,源头就在于过度的让请求自由决定。第三:最优的定义。最优我的方法是利用一个预先设置好的map,保存能通过一部电梯到达目标楼层的所有楼层,这样在这些楼层中挑选一个,使得总经历层数最小。但是这就有bug,因为电梯上下行会改变,所以电梯由于会有一步是寻找目标,并且这里要做出对电梯走向的预判,有的时候预判之后加进等待队列或者进入到乘坐队列中目的地又修改了,这就产生了bug,甚至导致有的出现死循环,就是电梯一直在某两层中间徘徊,但是乘客不进入电梯。后来简化了算法虽然不死循环了,但是还是会WA。

那么修改之后成为了什么样呢?首先是代码逻辑真的清晰了不少,省去了很多方法。这也导致性能的下降,因为不能灵活调整请求的变更楼层了,以及电梯的人员和上下行信息不能灵活的为切割请求提供方便,所以浪费了很多时间。具体是什么思路呢?其实主要是针对之前的bug请求的自行切割问题。这个问题说大,确实比较大,以至于我的代码直接重构了一番,说小也小,无非是改变了其中某一步的操作顺序,以及挪动了一部分属性。解决的办法就是将PartRequest的切割直接在InputHandler中解决。并且把之前提到的map(在每个电梯和buffer当中都需要用到的)直接放到Cutter类中,这个类专门切割请求,计算最优路线。于是其中每次找最佳楼层的步骤全部省略,因为换乘步骤已经由Cutter类完全确定了,只不过PartRequest改完之后运用priority进行数据冒险的控制,即必须满足换乘之后的内容在换乘的第一步进行完之后再加入电梯。并且此时电梯判断到底改不改停止线程也变得简单。直接寻找是否有满足的出发和“到达”(这里到达有可能是到达换成楼层)的请求,如果没有并且输入完毕,则退出线程。这样的判断其实是回归成了第六次作业的判断循环结束的方法。需要注意的是,修改之后的方法每次某一楼层到某一楼层所切割出来的请求都是固定的,这就可能导致某些电梯过于拥挤,但是这不妨是一种可行的方法,比修改之前的更加好约束每一个请求。

所以修改之后的UML图如下。

可以看出Cutter类存放了电梯的数据,以及每一层能直接到达的楼层,采用冗余数据的方法来达到对电梯层的map的构建。对于ElevatorBuffer来讲只不过增加了一些方法来实现,如果有被切割的请求,那么一部分执行完之后要让另一部分执行就需要搜索ID,找到一致的就把还在Buffer队列中的每一个请求的优先级提高。说是优先级,其实就是如果优先级不为0则不执行,只有优先级为0的才可以执行。优先级以及是不是最后一个执行的请求本次设计了一个继承于PersonRequest的子类PartRequest,添加了优先级和part属性,方便请求的管理。

本次代码的方法依旧表现不错,很少有大的长的方法,也很少有无用的方法。并且类的封装也较为到位,因为基本沿用的第六次作业的结构。不过还是老问题,判断的布尔表达式的长度有非常长的地方,可以再整合和分层。下面是SOLID分析。

  1. SRP原则:本次SRP原则实现与上次作业基本持平。在ElevatorBuffer类中无非是增加了判断是否为切割请求以及如果是切割请求之后的一些列操作,这些并不会将ElevatorBuffer的职责打乱,Buffer依旧担任共享对象的角色,变量和方法也可以跟Elevator分离。但是本次在InputHandler类当中,利用Cutter类来进行了切割的操作,个人认为其基本实现了SRP原则,InputHandler类中只有一条语句引用了Cutter类。
  2. OCP原则:本次OCP原则是这几个原则中做的不是太好的,主要是对于可达楼层的变化可能会引起一些函数的修改,OCP原则做的不太好的地方在于Cutter类。这个类做的是切割请求,这次作业中楼层到楼层间如果要切割,那么切割结果一定是固定的,因为我设置的最多只换乘一次,所以直接搜索判断即可,但如果要更深的搜索,甚至每次模拟之后比较结果,那就需要整个大改函数。
  3. LSP原则:本次虽然用到了子类,但是所有地方都用到的是PartRequest类,因为在InputHandler中一开始就把PersonRequest转换成了PartRequest,所以后面可以放心使用,这一原则得到体现。
  4. ISP原则:本次没有用到接口,暂且不谈。
  5. DIP原则:本次的依赖只有request的父子类有依赖,本次没有依赖底层,都是根据上层的官方包中来构建的。
  • 可拓展性

这一部分单独拿出来分析一下,对于第三次作业的可拓展性,本次设计的并不好。主要原因是在Cutter类中切割请求较为死板,如果有几十种电梯,每一个电梯都是从某一层到某一层,那么切割起来并不一定能保证完整,本次由于所有电梯都有几个共同的楼层,所以一次换乘必定能到达,但是如果后面要实现必须通过10次换乘的,那么这个深搜其实并不能保证可以满足。不过也是对一部分拓展有优势的,比如对于更多的电梯,以及电梯的临时调整乘载人数,以及电梯临时要修整等等,这些扩展都可以满足OCP原则,直接加入新的属性或者方法即可。

  • Bug测试

本地测试修复:

修复了停止不了的循环问题,主要是因为请求过于灵活自由,后面减少了请求可以更新的频率,以及减少了请求判断的判断最优解的过程,只是直接比较了楼层间的距离,从而直接判断是否中间换乘下人。但是最终最严重的bug,比如电梯已经关了,但是还有换乘未结束的部分;还有可能某个请求会在电梯当中来回变动,导致死循环或者过长时间的RTLE。

互测数据:

本次没有进入互测屋。


反思心得与体会

对于第五六次作业个人觉得做的很好,基本上都是保留了一开始的架构,在上面添加东西,逐层开发。尤其是第六次作业性能上还是有不错的得分的,但是正是前面做的过于好了,导致后面太相信自己,认为多线程的竞争模式更优。

本次的心得体会主要在第七次作业上。确实是第一次弱测都有错误的点,导致产生了这种无效作业。主要是这次偷鸡不成蚀把米,错误地认为贪心算法一定可以大大提升效率,但是在较难规定最优,甚至最优解反复切换的时候,贪心明显不适合了。并且本来我的电梯的交流信息较为少,第六次作业就是完全抢占竞争式的电梯,然后我并没有增加更多的共享对象,或者设计在各线程之上的调度线程,那么互相间协调的能力就不够强,但又要请求绝对自由化,这显然是不匹配的。

所以在之后的多线程当中,第一点就是判断终止条件,千万不能提前终止,要兼顾全局的线程。另外就是最好可以在所有线程之上制造一个监视器,或者调度器,可以帮助我们管理,主要是debug可能也会有帮助。另外就是线程间不能竞争太猛烈,不能自由度过高,自主调整就意味着更多的代码去获取调整的信息从而管理对象。设计架构一定要从实际角度出发,不能让性能损失了可读性和逻辑。一次的优秀并不一定就是最好的,应该能保证次次调整需求之后,架构都可以兼容,并且都有不错的性能,这样的软件是经得起打磨的。

最终我感觉多线程虽然玄学,但是是更贴近我们生活的应用的,主要在于我们可以不用自己决定顺序,只需要决定什么时候需要等待,什么时候需要工作即可,这能为我们的生活提供便捷。

原文地址:https://www.cnblogs.com/xiaohan209/p/12726525.html