面向对象设计与构造第二单元作业总结

面向对象设计与构造第二单元作业总结

​ 总体来说,相较于第一单元的作业来说,本次作业有很多独有的特点,如对架构合理性的要求较高,测试难度更大,可复现性较差,优化方向容易特例化等。此外,本单元还第一次涉及多线程的问题。

​ 在本次作业中,有较多的架构类型可以使用,较为主流的方法为集中式的调度器法多电梯竞争法。在第一次实验中我使用了调度器法,而在第二次和第三次作业中则使用了竞争法。但是由于某些细节的实现问题,本单元实验均没有达到很好的效果。对此,我也进行了较多的反思。实现的具体架构,产生问题的原因,以及在复盘中发现较好的方法和学到的东西,我将在下文中详细说明。

1. 程序架构分析

1.1 第一次作业

1.1.1 整体说明

​ 第一次作业涉及的情景较为简单,只有一个电梯与输入的请求的交互,因此只需要较为简单的设计架构。但是我在本次作业的设计过程中犯下了过度设计的问题,提前过多的考虑到了后续实验的架构问题(在第一次作业来看是不必要的,也没有很大程度上增加可拓展性),且完成情况较差,导致程序性能很差。

1.1.2 设计架构

​ 如图1所示,本次实验我使用了调度器的方法。请求通过输入线程类InputThread接受输入信息,并将请求信息添加到WaitQueue等待队列中。随后,调度器线程类Scheduler将等待队列WaitQueue中的任务放入电梯属性类Plan中。最后,电梯线程Elevator通过分析Plan中的各种参数实现电梯的运行。

1.1.3 架构缺点分析

​ 第一次作业中最大的问题是过度设计的同时没有处理好任务的调度问题。在我的设计中,Elevator线程只处理Plan类中的任务。

​ Plan类中的任务我设计为不能同时存在超过6个任务(当然,这里本也可以设计的更多,只要搭载不冲突即可)。在某个时刻添加到Plan类中的6个任务可能是当时最优的6个任务,但是随着后续任务的进入,该6个任务可能逐渐失去最佳任务的地位。我在这里使用的方法是交换器,将Plan中较差的任务与WaitQueue中的较优的任务进行交换。但是,由于最后由于该部分写的条理性较差,担心出现bug,因此在最后一版提交中删除了。

​ 上述问题导致了第一次作业的性能效果较差,仅有少数几个点的性能分较高。

1.1.4 优秀架构浅谈

​ 与别人交流再加上自己的反思,我认为以下几个方法是较优的:

  • 扫描算法。虽然扫描算法是较为经典的算法,但是其效率在测试中有着很不错的表现。同时,扫描算法实现的难度较低,不容易出现问题,且具有较好的可拓展性。在只考虑一个电梯的情况下,使用一个等待队列配合一个搭载了扫描算法的电梯,能够达到比较不错的效果。

  • 局部最优的贪心算法。通过设计一个电梯附带的类,该类可以模拟计算当前任务处理顺序的时间成本,并选择优先执行时间成本较低的任务。这种方法在大多数情况下的表现优于扫描算法(在实际过程汇总少部分情况下性能很差)。但是,该方法还有一个较大的问题,便是CPU计算量过大,虽然在使用时间戳的记录方式(在下文中将会提到)时不会对电梯的运行时间产生影响,但是有CPU运行时间超时的风险。

  • 基于权重的计算方法。该方法会给每一个等待的任务赋予对应的权重,该权重受任务起始楼层,任务前进方向,当前电梯运行情况的影响。该方法相较于上面提到的贪心算法降低了计算量。但是也会带来其他的问题,如可能产生死循环(可以通过调整解决),部分样例表现极差和参数调整消耗大量时间等。

    ​ 还有一些在交流中了解到的其他的方法就不一一列举。

1.1.5 复杂度分析

​ 本次实验虽然设计较为简单,但是没有做到有效且细致的划分类,导致某些类的复杂度较高,其中scheduler虽然删除了复杂度相当高的请求交换函数,但还是有着很高的复杂度。在以后的代码实现过程中应当注意控制各个类的复杂度。

1.2 第二次作业

1.2.1 整体说明

​ 第二次作业相较于第一次作业加入了新的电梯,对调度有较高的要求。我在本次作业中花费了大量的时间进行优化,但是在最后一次的修改结束后忽视了测试,导致产生了bug。虽然最后程序的性能有了非常好的效果,但是有着出现了bug和架构耦合度较高的问题。

1.2.2 设计架构

​ 本次实验我采用了电梯竞争的方式。由于设计问题,只使用了四个类,耦合度过高(详情见1.2.5 复杂度分析)。在本次作业中,没有使用scheduler调度器。类MainClass作为函数入口,InputThread作为输入线程,接收输入的数据并将其存放到WaitQueue类中储存。Elevator类直接向WaitQueue类申请任务,并进行执行。

1.2.3 架构缺点分析

​ 首先,本次作业的架构耦合度过高,直接将scheduler类需要完成的任务耦合到了WaitQueue和Elevator中,导致在后续作业的提取过程中花费了较多的时间。并且Elevator类应当分为属性类和执行类,减少单个类中的属性和方法的复杂度并提高可拓展性。

​ 再者,本次作业出现了bug,详情见后续bug分析内容。

​ 最后,竞争的架构仍然存在着部分专门针对样例表现较差的问题。

1.2.4 架构优点分析

​ 本次实验由于采用了竞争的写法并且优化,调整了申请任务的函数实现,在性能上有着很好的效果,对于输入数据较为密集的点表现尤为突出。部分中测测试点速度甚至高于满分所需素的。

​ 在实现的架构,电梯通过竞争获得请求的搭载权,该请求不可同时请求超过3层的任务,并且在运行的过程中不断的请求捎带任务。这种方法使电梯总是在解决自己最方便解决的问题,且基本没有电梯等待现象。

​ 此外,还使用了模拟计算电梯内人数的方式,保证最大程度的载客量。

1.2.5 优秀架构浅谈

​ 下面我仍将列出除了我自己使用的方法以外,我认为表现较好的方法:

  • 带权重的调度器调度算法。该算法与操作系统中的CPU分配策略有一定的相通之处。通过给任务和电梯综合分配权重。根据电梯当前的楼层,电梯内的人数,运行方向和任务的起始楼层和终点楼层中,按照一定的参数进行拟合,得到优先级的权重,电梯根据优先级的权重进行运行。
  • 调度器的暂时空置任务方法。该方法与上述方法并不冲突,只是一个处理任务的方法。具体内容为当调度器得到了一个不适合所有电梯运行的任务且电梯没有在等待的,这个时候,调度器选择暂时不分配该任务,自己保留,等待一段时间后重新检查该任务是否适合分配。有了这个机制,调度器则不急于将大量的任务部署到电梯端,而是只将较为合适的任务分配出去。
  • 任务交换策略,在这里不详细展开。

1.2.6 复杂度分析

​ 由于使用的类过少(没有深入的分析类的各种属性)导致WaitQueue类和Elevator类的内容过多,尤其是Elevator类,复杂度过高。

1.3 第三次作业

1.3.1 整体说明

​ 第三次实验加入了不同电梯类型的限制,调度方面的压力进一步提高。我个人认为在这次作业中,使用集中式的调度器分配的方式略优于竞争的方式。但是由于前面第二次作业已经实现的架构的限制,我最后还是选择了竞争的方式来实现,但是我个人没有写出性能特别高的方法。

1.3.2 设计架构

​ 本次的整体架构与上次作业基本保持了一致。上次作业的耦合度特别高,我花了一部分时间,将电梯请求任务的方法整合到了类Scheduer中,Elevaotr类虽然本身的复杂程度也很高,但是由于需要修改的地方不多,且拆分难度较大,所以保留了下来,没有选择大面积重构。

​ 与第二次作业类似,MainClass作为程序入口,InputThread类接收任务请求并将请求转移到WaitQueue中,各个电梯类通过Scheduler中的方法访问WaitQueue来竞争得到任务。最后各个电梯独立执行任务,如果电梯接到了换乘 的任务,则在任务结束之后,将新的request重新装填到WaitQueue中。

1.3.3 架构缺点分析

​ 本次作业使用竞争的方式的缺点非常明显。

​ 首先,电梯之间只会进行竞争,而往往不会顾及其他电梯当前的状态,电梯之间的协调配合较差。有时也会在不需要换乘的情况下无脑换乘。

​ 此外,竞争的方法也带来了样例不稳定性极高的特点。

​ 最后,单个乘客的等待时间难以控制,容易产生某些乘客等待时间过长的问题。例如在某个强测点中,虽然我的整体运行时间比某同学的运行时间少10s,但是最后的性能分仍然低于他。

1.3.4 架构优点分析

​ 几乎没有重构。。。

1.3.5 优秀架构浅谈

​ 下面是我认为表现较好的方法:

  • 带权重的调度器调度算法。与1.2.5中提到的类似,但是这里将单个乘客的等待时间算入了权重,如果某个乘客等待的时间过长,则其权重会大幅度增加
  • 调度器的暂时空置任务方法,与1.2.5中提到的相同。
  • 贪心模拟算法,在这里不详细展开。

1.3.6 复杂度分析

​ 由于历史遗留问题,各个类的复杂度仍然较高。其中Scheduler的复杂度尤其高。因为每个类型就要重写一个请求优先算法,所以复杂度较高,在后续的作业中,将注意对类似的方法进行抽象,降低复杂度。

​ 不过MainClass就循环初始化几个类就复杂度这么高是我没想到的。。。

2. 程序bug分析

2.1 死锁问题

​ 在第一次作业中,由于使用了两个共享类,我们在这里不妨称之为类A和类B。同时存在两个线程,线程M和线程N。在线程M中,某一个代码块会先申请类A的访问权,再申请类B的访问权,当两个访问权都拿到并完成操作之后才会释放他们。线程N则相反,就这样产生了死锁问题(后来才知道概念和产生原因)。面对这一个时而出现,时而不出现的问题,我找了很久的bug,最后才从逻辑上发现问题。

线程M:
synchronized(M) {
	synchronized(N) {
		mDoSomething()
	}
}

线程N:
synchronized(N) {
	synchronized(M) {
		nDoSomething()
	}
}

2.2 第二次作业在强测中出现的问题

​ 该问题出现是因为在Night的特殊模式优化中少写了一个=号。在Random模式中进行了较多的测试,对于Morning和Night则仅修改了极少部分内容,由于侥幸心理,只手搓了几个点跑了一下,然后出了问题(所有电梯都满员,且电梯刚好到达2楼时,有一个从2楼到1楼的请求,会超载)。

​ 不能有侥幸心理啊。。。

3. bug自测/互测方式

3.1 黑箱测试

​ 本单元仍然选择使用黑箱测试。随机生成数据,实时输入数据运行jar包。最后使用程序进行检测,检测的程序使用的方法是错误检测,即只会判断是不是某种错误,而不是检测正确性,因此可能有遗漏。

​ 虽然生成的测试点的针对性较差,但是能够检测出一些低级问题。

3.2 手动构造样例

​ 本次实验我也手动构造了一些具有代表性的样例,如在第三次作业中的从4楼到17楼的特例等。这些样例只被我用来进行正确性的检验,但是在性能的调整过程中,对这些样例一般不做考虑(因为可能会影响正常样例的速度,得不偿失)。

3.3 互测hack

​ 在Hack其他人的时候,我选择了多种方式相结合的思路,包括在本地使用黑箱测试进行长时间的扫描,手动构造可能产生错误的样例和尝试构造超时样例。

​ 第二次作业由于时间问题没有进行hack,第一次和第三次很可惜的在本地没有成功hack到人,所以也没有提交样例(应该交几个试试的)。

4. 重构经历总结

4.1 第二次作业的重构

​ 第一次作业我使用了调度器的方法。但是考虑到性能和实现难度的问题,我第二次作业选择了竞争的方式,在保留了前一次作业电梯内扫描算法和输入类的基础上,删除了Scheduler类,同时在WaitQueue中写了申请方法,并将部分竞争方法整合到了Elevator类中。

5.一个提升性能的小技巧

​ 本次作业需要在关门和楼层改变的时候进行足量的等待。这也就是本技巧出现的背景。对于楼层改变,只需要距离上一次楼层改变和关门的时间都满足长度要求即可。对于关门,只需要距离开门的时间满足长度要求即可。

​ 因此,关门,楼层改变和开门的时候,我们记录进行该输出时刻的时间戳,在楼层改变和关门的时候只需要根据该时间戳留出足够的时间即可。

​ 好处:

  • 当电梯等待时间超过单楼层运行时间时,一个请求突然到来,则电梯可瞬移一层。
  • 我们将电梯的总等待时间比喻为睡眠,则电梯可在假装睡眠的时候偷偷进行运算操作。

6. 关于锁的使用

​ 在第一次作业中,我使用了两个共享对象WaitQueue和Plan,其中,WaitQueue仅会被InputThread和Scheduler访问,Plan仅会被Scheduler和Elevator访问,并不会产生死锁问题(修改后,修改前见2.1)。因此我直接用了synchronized进行控制,在尽可能小的单元块上对WaitQueue和Plan的实例进行了控制。

​ 因为我在后两次设计中使用的架构都相对简单,只有一个共享类WaitQueue,且该类只有一个实例waitQueue,因此仅仅在调用与waitQueue相关的方法的时候使用了synchronized进行控制,同时保证代码块尽可能的小。

7. 心得体会

​ 相较于第一单元的作业,本单元作业的架构对于程序的正确性和性能的影响变大了许多,设计出一个好的架构和数据流动通路往往能够事半功倍。并且本次作业对于可拓展性的要求我个人感觉也是比第一次作业更加重要。

​ 本单元虽然自己最后实现的三次代码都有不同程度,不同种类的瑕疵,但是在反思自己的代码和与别人交流的过程中学到了很多。多与别人交流,学习优秀代码的长处,规避别人发现的坑,能够使自己的代码更完善,更具水平。

​ 此外,本单元的学习我还有一些欠缺,如对于多线程测试和调试的方法只了解一小部分。以及对锁的相关知识还没有做到完全掌握等。

​ 最后我可能会在后续的实验中更加注重设计和验证这两部分的内容,这两部分内容往往能给程序质量带来巨大的提升。

posted @ 2021-04-24 16:10  RE_REG  阅读(61)  评论(0)    收藏  举报