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
其中WiatQueue
、ProcessQueue
和ElevatorRequestQueue
均为共享对象,三者单独作为类是由于其中的属性不尽相同,且需要满足的需求也不同。而控制器LiftBuilding
和HorizonBuilding
负责从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按照一定的策略分发给其中的电梯。
对于分发策略,这里遵循两个原则:
-
可携带原则
如果某个电梯恰好可以携带,自然优先分给这个电梯。
-
复杂性低原则
当没有电梯可以携带的时候,我们选择电梯人数最少的电梯。当然这里其实也算一种简化,还有其他可以考虑的因素来衡量“方便接送乘客“,只不过担心会增加代码的冗杂程度,以及实现了也不一定具有普适性,因此仅用电梯人数这个直观的变量作为复杂性衡量的凭借。
2、电梯调度——look策略
首先对于前两次作业,其架构不能说复杂,因此最为关键的策略当属调度策略了。第一次作业的look作业不愿多说,属于追求一个有问题的架构而把策略的效率给拉低了。第二次作业和第三次作业均采用了look策略,这里只对第三次作业修复的look策略进行简述。
在前面所述我的调度算法的结构中,关键方法在于look()
,即找到能进电梯的人。在该方法中大致分成如下三步:
-
判断电梯是否完成了所有人的请求,并且暂时没有请求到来:电梯的direction置0,电梯进入等待状态。
-
获取请求队列进行迭代:只要现在方向上有需要接送的乘客,reverse变量置false;若有可携带的乘客,则携带该乘客,更新out列表。
-
检查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方法,该方法的方针是:到达目的地楼座。在这个方针下,该方法分为三种情况:
-
如果该层有满足条件的横向电梯,则返回目的楼座即可
-
检索沿该指令垂直方向上的各层,如果某层有满足条件的横向电梯,则返回该楼层。
-
检索与该指令垂直方向相反的各层,如果某曾又满足条件的横向电梯,则返回该楼层。
dispatcher只需要根据返回值的范围:<10
或>='A'
来重新组装request
和nextrequest
即可。
三、BUG分析
三次作业的bug不多,但个个致命。除了前面所说,第一次作业的架构问题以及move更新和覆盖问题。
第二次作业有一,就是在look中迭代电梯指令队列的时候没有加锁,导致对指令队列的增减操作不能同步,因此会抛出异常,或产生错误结果。
第三次作业由于需要同时维护两个队列:requests
和nextRequests
,因此在访问取出的时候,很可能不同步。举例来说,若二者更新不同步,则可能在获取一个request
的同时获取其nextRequest
的时候,其nextRequest
还未更新,导致错误。换句话说,二者的更新,是一个原子操作,因此解决办法是,将更新requests
队列和nextRequests
队列的语句加锁;或者并入一个加锁的方法中,同时对requests
队列和nextRequests
队列进行更新。
除此之外,在de第三次作业的这个bug的同时,还发现HashMap不是一个线程安全的容器,ArrayList同样,因此可以更换为Hashtable与synchronizeList来保证其线程安全,或者自己重新定义一个容器,将每个方法加锁即可。
四、复杂度分析
1、方法复杂度分析
Dispatcher.getSwitch(int, char, int, char) | 93.0 | 16.0 | 9.0 | 29.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方法中有多部判断,且需要分别处理ElevatorRequest
和PersonRequest
两类指令,复杂度高也情有可原。在这部分其实可以将不同的处理分支各封装为函数。
接下来就是两个look函数了,这部分是稍带策略的实现,也是因为需要遍历请求队列,从而致使复杂度增加,对其中细节加以封装也许能降低其复杂度。
2、类复杂度分析
Dispatcher | 11.75 | 26.0 | 47.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 |
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海淹死。为了不要以身试险,请学好基础知识,多看相关的博客。