BUAA_2020面向对象_第二单元总结

BUAA_2020面向对象_第二单元总结

O、写在前面

第二单元相较于第一单元,相当于长眼睛与眼睛没长全的区别。这个单元崇尚设计优先的原则,在每一次动手写代码前,都将本次作业的结构图画好,将每一部分的实现与交互形成文字。然后开始写代码的时候,按图索骥即可。不得不说,这为我分析代码和修正bug带来了极大的便利。

当然这样干的弊端也是较为明显的,那就是容错率较低。由于重架构的设计,那么一旦架构不适用或者存在问题,那么就需要重构;除此之外,架构的清晰意味着代码更为分散与简洁,从代码层面来讲局部代码的平均复杂度降低,其抽象程度较低,自然会导致代码量上升,这是一个平衡的抉择。

最后,尽管架构优先是我认为的一个能提高效率的方法之一,但由于整个过程由最初的设计主导,因此在完成作业设计前,是否能较好掌握基础知识,是一个关键因素。否则,可能会因为“太嫩”而使架构粗糙,或隐含重大bug。不过想每次设计都完美化是一件非常困难的事情,因此只能先做出差强人意的设计,然后接受debug的艰难过程。

一、作业架构

作业的架构设计都形成了文字,从最初的《第二单元从0到1》到最后的《第三次作业策略设计》,为了让自己脑子足够清醒、不遗忘某些一闪而过的细节,我将自己的脑回路都记录了下来。尽管如此,还是产生不少瑕疵。

第一次作业

第一单元将电梯当做一个傻瓜机器人,而我将每一次的请求封装为一个move对象,通过一个moveMethod共享对象来进行策略类Strategy和Elevator的交互。

 

 

但是这样干我没有意识到一个极其困难的地方,那就是move的更新,电梯的本质是一个状态机,对于每一层的请求更新,他都可能更新运行策略,而不是分析当前的所有请求得出一个move,执行完了再生成下一个move,因此move的更新是个问题。在测试中,还存在move的覆盖问题,即前一个move未执行完成,由于指令集得到了更新,于是move更新,覆盖了未执行完的move。最后是效率问题,一次性更新的move可能没有考虑到执行期间的指令集更新,从而让一些本来可以携带的指令没有携带,会导致超时问题。这些bug都成为第一次作业的致命问题。

第二次作业

第二次作业进行了重构,贯彻“电梯是个状态机”的思想,不再把电梯和策略分离。并且迭代指令分发的结构,将垂直电梯和水平电梯分别按照每个楼座和每个楼层进行管理。

 

 

 

 

这次作业完成的较好,但是策略有一点瑕疵。由于采用的是look策略,但是在边界情况处理的不到位,尤其明显的是电梯到最后将要终止的时候,尽管没有指令,也会运行到头才停止,这大大增加了运行时间,属于赘余的部分,第三次作业修正了这个问题。

除此之外,本次作业最大的bug当属其中的一个方法了,由于电梯和“轨道”(控制器)是通过一个ProcessQueue进行指令的分发,但是由于电梯的look算法要求对已有指令可见,因此电梯获取指令不能仅仅从ProcessQueue中getOneRequest,而需要遍历其中有的指令。因此催生出一个很反人类的方法,即从ProcessQueue中取出指令队列getRequests。在这次作业中,这个方法使用后,在之后进行了迭代,但是我最开始并没有将该代码块加锁,这就导致指令队列在迭代的过程中可能更改,从而抛出异常而出错。尽管修正后没有问题,但是这样的设计确实有伤架构的优雅性,算是得到了教训。

第三次作业

在这里,我结合代码的UML图来阐述三次迭代之后的架构

 

 

 

 

其顺序结构依次为:

InputTread---WaitQueue---Dispatcher---ProcessQueue---__Building---ElevatorRequestQueue---__Elevator---Inserter---WaitQueue

其中WiatQueueProcessQueueElevatorRequestQueue均为共享对象,三者单独作为类是由于其中的属性不尽相同,且需要满足的需求也不同。而控制器LiftBuildingHorizonBuilding负责从Dispathcer得到分发的指令,然后继续分发个下属的多个电梯。

第三次作业是真正意义上的迭代产品,相较于第二次,重构了电梯的运行策略,将运行策略简化为

look();     // to check who can get in and let them in
close(); // close the door
move(); // move to upper neighbor floor
checkout(); // to check who will get out and let the out

并在look中,利用维护一个reverse变量来判断电梯是否需要掉头或暂停,解决了第二次作业电梯冗余行为的问题。

线程协作

作业的线程协作主要体现在分发请求的线程之间的协作,包含输入线程、dispathcer类分发线程、Building类分发线程,指令通过这些线程流水到最终的电梯类中。除此之外还有两个关键线程,一个是电梯线程,负责处理到来的请求,以及运行和输出;一个是插入线程,其与后文的指令分解策略息息相关,负责将某条指令的nextRequest重新插入到总的waitQueue中。

本单元的主题是多线程,最需要关注的问题是读写冲突、原子操作等常见bug,尽管所言甚是,但是在第三次作业还是踩坑,所以说实践出真知,不实践的都是假知......这部分留给讲第三部分作业的bug的时候详述。

二、关键策略

1、分发策略

由于第三次作业涉及一层或一个楼座有多个电梯,因此我在每一层和每一座都设置了一个控制器,称之为“Building”。待dispatcher将waitQueue中的指令,按照其出发地和目的地,分发到各building后,由building按照一定的策略分发给其中的电梯。

对于分发策略,这里遵循两个原则:

  1. 可携带原则

    如果某个电梯恰好可以携带,自然优先分给这个电梯。

  2. 复杂性低原则

    当没有电梯可以携带的时候,我们选择电梯人数最少的电梯。当然这里其实也算一种简化,还有其他可以考虑的因素来衡量“方便接送乘客“,只不过担心会增加代码的冗杂程度,以及实现了也不一定具有普适性,因此仅用电梯人数这个直观的变量作为复杂性衡量的凭借。

2、电梯调度——look策略

首先对于前两次作业,其架构不能说复杂,因此最为关键的策略当属调度策略了。第一次作业的look作业不愿多说,属于追求一个有问题的架构而把策略的效率给拉低了。第二次作业和第三次作业均采用了look策略,这里只对第三次作业修复的look策略进行简述。

在前面所述我的调度算法的结构中,关键方法在于look(),即找到能进电梯的人。在该方法中大致分成如下三步:

  1. 判断电梯是否完成了所有人的请求,并且暂时没有请求到来:电梯的direction置0,电梯进入等待状态。

  2. 获取请求队列进行迭代:只要现在方向上有需要接送的乘客,reverse变量置false;若有可携带的乘客,则携带该乘客,更新out列表。

  3. 检查reverse变量,如果为真,则电梯切换运行方向。

整体思想不复杂,look策略甚至可以用一句话来概括:如果该方向上还有请求,则能携带就携带;反之,则掉头。但实际是现实,还是需要认真对待边界条件的处理。

3、指令分解——动态分解策略

第三次作业最难的地方,就在于如何将一座电梯完成不了的指令分解给多个电梯来运行。在这里我选择的做法是,动态分解。所谓动态分解不过是多个静态分解的叠加,即每次都从整条指令分解出一个原子指令。所谓原子指令,即能通过一个电梯就能解决的指令。

举个例子来阐述该策略:

mainRequest: 1-FROM-A-2-TO-B-2
Step1: 1-FROM-A-2-TO-A-1, 1-FROM-A-1-TO-B-2
mainRequest: 1-FROM-A-1-TO-B-2
Step2: 1-FROM-A-1-TO-B-1, 1-FROM-B-1-TO-B-2
mainRequest: 1-FROM-B-1-TO-B-2
Step3: 1-FROM-B-1-TO-B-2, null

所谓分解问题,其实就是找到一条折线路径,能满足曼哈顿距离就是最优的结果。首先需要注意的点是顺序问题,即分解出时间较晚的指令不能先执行。

通过上面的例子容易看出,我才用的是每次都分解成两个指令,其中,分解出的第一条指令为原子指令,第二条是下一步待分解的指令,如此迭代,最终将整条指令分解成诸多原子指令的集合,然后一步步执行即可。但事实上,一次性分解完,是不太明智的选择,一方面可能使大量指令堵在这个地方而增加时间开销;另一方面,分解出的大量原子指令如何组织是一个大问题。

因此我的方法是,每次仅仅分解成上面的两条指令,然后将这两条指令一同流水,直至传入电梯,待电梯执行完第一条原子指令后,再将剩下的指令重新插入总waitQueue中。

对于如何分解指令,我在dispatcher中设置了一个getSwitchTo方法,该方法的方针是:到达目的地楼座。在这个方针下,该方法分为三种情况:

  1. 如果该层有满足条件的横向电梯,则返回目的楼座即可

  2. 检索沿该指令垂直方向上的各层,如果某层有满足条件的横向电梯,则返回该楼层。

  3. 检索与该指令垂直方向相反的各层,如果某曾又满足条件的横向电梯,则返回该楼层。

dispatcher只需要根据返回值的范围:<10>='A'来重新组装requestnextrequest即可。

三、BUG分析

三次作业的bug不多,但个个致命。除了前面所说,第一次作业的架构问题以及move更新和覆盖问题。

第二次作业有一,就是在look中迭代电梯指令队列的时候没有加锁,导致对指令队列的增减操作不能同步,因此会抛出异常,或产生错误结果。

第三次作业由于需要同时维护两个队列:requestsnextRequests,因此在访问取出的时候,很可能不同步。举例来说,若二者更新不同步,则可能在获取一个request的同时获取其nextRequest的时候,其nextRequest还未更新,导致错误。换句话说,二者的更新,是一个原子操作,因此解决办法是,将更新requests队列和nextRequests队列的语句加锁;或者并入一个加锁的方法中,同时对requests队列和nextRequests队列进行更新。

除此之外,在de第三次作业的这个bug的同时,还发现HashMap不是一个线程安全的容器,ArrayList同样,因此可以更换为Hashtable与synchronizeList来保证其线程安全,或者自己重新定义一个容器,将每个方法加锁即可。

四、复杂度分析

1、方法复杂度分析

Dispatcher.getSwitch(int, char, int, char)93.016.09.029.0
Dispatcher.run() 34.0 4.0 13.0 14.0
LiftElevator.look() 28.0 4.0 13.0 18.0
HorizontalElevator.look() 27.0 4.0 11.0 15.0
HorizontalElevator.initialDirection() 15.0 2.0 5.0 7.0
LiftElevator.initialDirection() 15.0 2.0 5.0 7.0
HorizontalElevator.run() 14.0 1.0 9.0 9.0
LiftElevator.run() 14.0 1.0 9.0 9.0
HorizonBuilding.dispatch(PersonRequest) 12.0 3.0 6.0 6.0
HorizonBuilding.run() 11.0 4.0 6.0 7.0
InputThread.run() 11.0 3.0 6.0 6.0
Inserter.run() 11.0 4.0 5.0 6.0
LiftBuilding.run() 11.0 4.0 6.0 7.0
WaitQueue.getOneRequest() 11.0 5.0 8.0 9.0
LiftBuilding.dispatch(PersonRequest) 7.0 3.0 4.0 4.0
Dispatcher.Dispatcher(WaitQueue) 4.0 1.0 5.0 5.0
Dispatcher.updateCanGet(int, int) 3.0 1.0 3.0 3.0
HorizontalElevator.checkout() 3.0 1.0 3.0 3.0

复杂度最高的是分解指令的方法getSwitch,由于需要检索多次,循环嵌套多,所以复杂度高也是显然的。第二名的方法仍然属于dispatcher,这是由于run方法中有多部判断,且需要分别处理ElevatorRequestPersonRequest两类指令,复杂度高也情有可原。在这部分其实可以将不同的处理分支各封装为函数。

接下来就是两个look函数了,这部分是稍带策略的实现,也是因为需要遍历请求队列,从而致使复杂度增加,对其中细节加以封装也许能降低其复杂度。

2、类复杂度分析

Dispatcher11.7526.047.0
LiftElevator 3.3076923076923075 13.0 43.0
HorizonBuilding 3.25 5.0 13.0
HorizontalElevator 3.125 12.0 50.0
LiftBuilding 2.75 5.0 11.0
InputThread 2.5 4.0 5.0
Inserter 1.7777777777777777 4.0 16.0
WaitQueue 1.6666666666666667 6.0 15.0
ProcessQueue 1.5714285714285714 3.0 11.0
ElevatorRequestQueue 1.375 2.0 11.0
Main 1.0 1.0 1.0
Print 1.0 1.0 1.0
Elevator     0.0
Total     224.0
Average 2.871794871794872 6.833333333333333 17.23076923076923

容易看出,类复杂度与方法复杂度在某种意义上是对应的,方法复杂度靠前的几个方法所在类,其类复杂度依旧靠前。

五、心得体会

多线程的debug过程是极其痛苦的,由于结果不能复现,本地测试多组也不能找到bug所在。每次以为自己发现解决bug的方法,兴高采烈的提交,却仍旧是同样的错误。

经历了一个月多线程的磨砺,最重要的体会是,重视知识的理解。synchronize、lock、线程安全容器等诸多线程的专有名词,不求甚解的结果就是被bug海淹死。为了不要以身试险,请学好基础知识,多看相关的博客。

 
posted @   tsyhahaha  阅读(126)  评论(2编辑  收藏  举报
相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 2025年我用 Compose 写了一个 Todo App
· 张高兴的大模型开发实战:(一)使用 Selenium 进行网页爬虫
点击右上角即可分享
微信分享提示