面向对象设计与构造 第二单元 总结
一、程序结构分析
1. 第五次作业:单部ALS电梯调度
(1)类图与时序图
(2)设计策略
初次接触Java多线程,在编程的过程中,不仅需要考虑功能正确性,还需要额外考虑线程安全性,两者需要兼顾才可以保证程序能够运行得到预期结果。
① 重要类介绍
Input
:处理输入并存放进请求队列,读取到null
结束。RequestQueue
:继承自阻塞队列LinkedBlockingQueue
,暂时存放从标准输入获取的请求,等待后续线程取走请求。Controller
:调度器,负责从RequestQueue
中取走请求,并将请求委派给电梯的进入队列。EnterQueue
:电梯的进入队列,按照楼层存放未进入电梯的乘客。Elevator
:电梯,响应进入队列与离开队列的请求。ExitQueue
:电梯的离开队列,按照电梯内乘客的目标楼层存放乘客。
② 线程设计
从类图与时序图可以看出,笔者采用的是生产者与消费者的设计模式,本质更像是流水线,MainClass
线程负责启动其他线程,Input
线程负责获取输入,每获取到请求后,通知Controller
线程取走请求,然后Controller
线程再将请求安排到Elevator
线程的进入队列EnterQueue
中。
形象化表述便是:Input -> RequestQueue <- Controller -> EnterQueue <- Elevator
,RequestQueue
和EnterQueue
为共享对象,其内部方法需要上锁。
③ 电梯运行算法
由于第五次作业只有一部电梯,笔者经过网上调研,了解了一些算法,最后采用改良版look算法实现电梯。
- 电梯闲置:
EnterQueue
非空,电梯响应同方向最近的乘客请求,如同方向没有请求,则反向。EnterQueue
为空,电梯静止,等待唤醒。
- 电梯处于运行状态:
- 同方向存在楼层的
ExitQueue
非空或EnterQueue
(包含当前楼层同向请求)非空,电梯继续向同方向运行。 - 同方向所有楼层的
ExitQueue
为空且EnterQueue
(包含当前楼层同向请求)为空,电梯调转方向。 - 电梯到达某一层:
- 该层的
ExitQueue
非空,电梯开门。 - 该层的
ExitQueue
为空,但是EnterQueue
存在发出同方向请求的乘客,电梯开门。 - 该层的
EXitQueue
为空,且EnterQueue
不存在发出同方向请求的乘客,电梯不停靠,继续移动。
- 该层的
- 同方向存在楼层的
- 输入结束:
Input
线程通知Controller
结束,Controller
线程通知Elevator
结束,流水传递结束信号。 - 简而言之,电梯在一次开门中就处理完毕当前楼层所有事务,不管当前楼层要上电梯的乘客移动方向是否与电梯方向一致,发现同方向没有进入和离开的请求时,电梯再调头。
(3)量化分析
本次架构设计的复杂度还是非常理想的,只有电梯类判断调转方向的look()
方法复杂度过高,因为其中包含过多条件逻辑。
method | ev(G) | iv(G) | v(G) |
---|---|---|---|
Elevator.look() | 8 | 9 | 12 |
Total | 66 | 87 | 98 |
Average | 1.57 | 2.07 | 2.33 |
class | OCavg | WMC |
---|---|---|
Controller | 2.0 | 4.0 |
Elevator | 2.25 | 27.0 |
ElevatorQueue | 1.25 | 10.0 |
EnterQueue | 1.86 | 13.0 |
ExitQueue | 1.67 | 10.0 |
Input | 2.0 | 4.0 |
MainClass | 1.0 | 1.0 |
RequestQueue | 1.75 | 7.0 |
Total | 76.0 | |
Average | 1.80 | 9.5 |
2. 第六次作业:多部智能电梯调度
(1)类图与时序图
(2)设计策略
本次作业在上一次的基础上,增加了多部电梯与载客量,这也意味着需要考虑新增的请求分配给哪一部电梯的问题。由于上一次架构的可扩展性很强,因此完成本次作业的大部分时间都用在了对于性能优化的思考上。
①新增类介绍
ElevatorSimulator
:电梯模拟器,用于模拟某一部电梯完成所有请求所需要的时间,需要对电梯当前的状态进行克隆,因此实现了电梯队列的克隆方法。
②线程设计
这一次的线程设计与上一次完全一样,但是发现了上一次设计中的一个线程安全Bug,将在程序Bug分析部分说明。
③电梯运行算法
- 2-5部电梯:沿用了上一次的改进look算法,由于电梯满载时,同方向的
EnterQueue
请求即使到达相应楼层也不能进行响应,因此满载后增加了方向的判断。- 电梯满载,且同方向存在某楼层
ExitQueue
非空,不改变方向。 - 电梯满载,且同方向所有楼层
ExitQueue
为空,此时再向同方向运行已没有意义,调转方向。
- 电梯满载,且同方向存在某楼层
- 1部电梯:由于加入载客量的限制,当一部电梯请求很多时,开门响应反方向乘客请求显然会浪费电梯内部空间,因此使用纯look算法来运行,每次开门只接待同方向乘客。
④请求分配算法
增加电梯数目,意味着新增的请求可以有多种选择,笔者思考了一段时间,最终选择贪心模拟方法来实现优化,一旦乘客被分配出去,便不会再改变要乘坐的电梯。
贪心模拟的流程:
- 对每个电梯当前的状态进行克隆,生成电梯模拟器
ElevatorSimulator
。模拟器继承自Elevator
,取消了电梯运行睡眠的时间。 - 将新增请求添加到模拟器的
EnterQueue
中,开始模拟电梯运行。 - 返回该部电梯运行所需时间。
- 选择时间最短的电梯,安排该请求到其
EnterQueue
。
该算法较为复杂, 涉及到对线程类的操作,且贪心类算法很容易忽略掉最优解,克隆的电梯状态可能与请求到来时的状态存在误差,影响分配结果。
(3)量化分析
由于增加了更多的条件判断逻辑,复杂度有了明显升高,体现在Elevator
类的look()
、see()
等电梯方向判断的方法上,但也在可以接受的范围内。
method | ev(G) | iv(G) | v(G) |
---|---|---|---|
EnterQueue.enterElevator(int,int) | 6 | 5 | 6 |
Elevator.see() | 8 | 7 | 11 |
Elevator.look() | 9 | 11 | 14 |
Total | 110 | 145 | 168 |
Average | 1.72 | 2.27 | 2.62 |
class | OCavg | WMC |
---|---|---|
Controller | 3.5 | 7.0 |
Elevator | 2.43 | 51.0 |
ElevatorQueue | 1.4 | 14.0 |
ElevatorSimulator | 1.6 | 8.0 |
EnterQueue | 2.6 | 26.0 |
ExitQueue | 1.875 | 15.0 |
Input | 1.67 | 5.0 |
MainClass | 4.0 | 4.0 |
RequestQueue | 1.75 | 7.0 |
Total | 137.0 | |
Average | 2.14 | 15.22 |
3. 第七次作业:多类动态智能电梯调度
(1)类图与时序图
(2)设计策略
在上次作业的基础上,对电梯分类,不同电梯可以停靠的楼层不同,需要考虑换乘策略;支持动态增加电梯,笔者直接在Controller
线程中创建新的电梯线程。
本次作业的设计历经坎坷,在设计时内心一度很崩溃。因为调度的思维被第六次作业完全禁锢住,导致实现过程非常复杂且繁琐,甚至误判轮询问题为算法时间复杂度过高,屡次为降低CPU时间而更改算法,连续奋战多天才完成。
这里也不得不承认,笔者写程序的能力仍然比较差,即便设计架构后进行着笔也经常在非常多细小简单的地方碰壁,遇到设计时没有考虑到的问题。面对越发复杂的程序架构,全局性的把控做得非常不好(头脑一片混乱不知所措),仍然需要付出更多时间练习。
①新增类介绍
Person
:乘客,由于这一次存在换乘,不能直接使用PersonRequest
来表示出发地和目的地,因此单独新增了一个类,这个改变也使得笔者几乎重构了代码,将所有方法的参数从PersonRequest
改为Person
。ChangeQueue
:换乘队列,存放待换乘的乘客,当乘客离开第一部电梯时,将其激活到EnterQueue
中。该类方法均上锁。Output
:由于指导书中写明,输出存在线程安全问题,使用该类将其上锁包装。
②电梯运行算法
这一次使用的是纯look算法,因为初始三部电梯可达楼层交集较少,与第六次作业的单部电梯较为类似。
关于换乘:
- 若电梯
EnterQueue
和ExitQueue
非空,则忽视换乘请求,优先处理电梯当前任务,且每到达一个楼层激活当前楼层已经能够换乘的乘客。 - 若电梯
EnterQueue
和ExitQueue
为空,则电梯运行至待换乘的楼层等待。(这个想法是导致笔者程序出现了轮询情况的元凶,后面会谈)
③请求分配算法 I
延续第六次作业,笔者认为第七次作业也可以采用模拟的方法,由于存在换乘情况,一部电梯无法真实模拟出情况,笔者准备采用电梯群贪心模拟的方法,具体流程如下:
- 克隆每个电梯状态。
- 将新增请求分配给所有可行的电梯,分别进行性能(运行时间 + 乘客总等待时间)的模拟,起始时间从0开始计算。
- 对于需要换乘的请求,每次模拟需要拆分分配给两部电梯,第二部电梯的模拟需要依赖于第一部电梯乘客离开电梯的时间。
该方案实现到最后时发现了两点问题:
EnterQueue
和ChangeQueue
无法共享“克隆人”。- 算法时间复杂度疑似过高。
④请求分配算法 II
既然不能对群体进行模拟,笔者决定只对需要使用的电梯进行贪心模拟,具体实现与第六次作业基本相同,模拟过程忽视了换乘队列的所有请求,因此该方案贪心得到的甚至连当前时间下的最优解都不是,导致性能大打折扣。
关于换乘楼层:笔者决定固定1,5,15三个换乘点,静态换乘。
⑤请求分配算法 III
由于上面的方法通过中测时,仍然存在CPU时间较高的测试点,笔者又改出了对电梯状态进行加权来分配请求的想法,但是加权参数调节十分困难,效果甚至不如算法 II,因此最后放弃了该算法。
(3)量化分析
由于这次作业差点无效,因此最终完成得非常仓促,舍弃了架构,复杂度直线上升(换乘楼层判断方法最高,因为if和else太多)。
method | ev(G) | iv(G) | v(G) |
---|---|---|---|
RequestQueue.respondRequest() | 5 | 6 | 7 |
Controller.addPerson(PersonRequest) | 1 | 6 | 8 |
EnterQueue.waiting(ExitQueue,ChangeQueue,int) | 3 | 10 | 11 |
Elevator.run() | 3 | 11 | 12 |
Controller.Controller(RequestQueue,ArrayList) | 1 | 8 | 13 |
Elevator.predictLook() | 9 | 10 | 13 |
Elevator.look() | 10 | 12 | 16 |
ElevatorSimulator.simulate(Person,long,boolean) | 1 | 15 | 19 |
Controller.judgeChangeFloor(int,int) | 12 | 1 | 33 |
Total | 165 | 229 | 295 |
Average | 1.72 | 2.38 | 3.07 |
class | OCavg | WMC |
---|---|---|
ChangeQueue | 2.0 | 20.0 |
Controller | 5.71 | 40.0 |
Elevator | 2.5 | 60.0 |
ElevatorQueue | 2.17 | 26.0 |
ElevatorSimulator | 3.67 | 11.0 |
EnterQueue | 2.09 | 23.0 |
ExitQueue | 1.78 | 16.0 |
Input | 2.0 | 4.0 |
MainClass | 1.0 | 1.0 |
Output | 1.0 | 1.0 |
Person | 1.11 | 10.0 |
RequestQueue | 2.67 | 8.0 |
Triple | 1.0 | 4.0 |
Total | 224.0 | |
Average | 2.33 | 17.23 |
4. SOLID原则分析
(1)单一职责原则
一个类应该只有一个发生变化的原因。
这个原则实现得比较好,如果电梯类能够减小负载,会更佳。在第七次作业中,调度器没有拆分,职责过于复杂,有违该原则。
(2)开闭原则
一个软件实体,如类、模块和函数应该对扩展开放,对修改关闭。
笔者在迭代开发的过程中经常忽视该原则,对已有的类方法进行修改,甚至对成员数据结构进行修改,终究是方法普适性不够,还需要在开发中增加对需求的预测。
(3)里氏替换原则
所有引用基类的地方必须能透明地使用其子类的对象。
笔者这次迭代出现继承关系的只有电梯队列ElevatorQueue
,但是并没有直接实例化基类的对象。这里其实可以将基类的方法使用protected
保护起来。
(4)接口隔离原则
1. 客户端不应该依赖它不需要的接口。
2. 类间的依赖关系应该建立在最小的接口上。
本单元作业没有单独设计接口,故略去分析。
(5)依赖倒置原则
1. 上层模块不应该依赖底层模块,它们都应该依赖于抽象。
2. 抽象不应该依赖于细节,细节应该依赖于抽象。
本单元没有明显的抽象关系,故略去分析。
(6)总结
没有程序能够完全实现SOLID原则,甚至有些设计模式偏离了SOLID原则。具体原则的遵循需要根据需求灵活改变、优劣权衡后得出。
二、程序Bug分析
1. 第五次作业
- 自测:只发现了算法上的Bug,某些情况下电梯会两层楼间死循环。
- 中测:第一次提交就出现了
RTLE
,经过检查依然是电梯掉头判断的Bug。幸运的是,没有出现线程安全Bug,时间也都十分合理。 - 强测和互测:没有出现Bug,但由于强测时评测机的负载很高,导致出现很大时间差,两个点性能不幸爆零,在本地复现后(Bug修复环节也交了,但是听说会更改一部分测试点,笔者没有进行更细致的关注,就不作横向对比了)发现运行时间有加快数秒的也有减慢数秒的,甚是玄学。
2. 第六次作业
- 自测:发现了第五次线程安全的Bug,在null与最后的请求相隔一段时间到来时,电梯无法被唤醒,运气的是评测机无法测试出该Bug,但这个Bug确实给笔者足够的警示,来不断检查自己的线程安全问题。
- 中测:一次通过,算是比较顺利。
- 强测和互测:没有出现Bug,且强测性能得分很理想。
3. 第七次作业
第七次作业几乎全是Bug,经历了好一番修改才修好Bug,优化算法也没有再考虑其他的方向。
- 使用第一版调度算法时,由于无法共享“克隆人”的问题,贪心模拟根本无法结束。
- 使用第二版调度算法时,中测CPU出现了3秒的情况,笔者误以为是算法复杂度过高,又改了算法。
- 使用第三版调度算法时,中测CPU出现了7秒的情况,笔者才意识到一定是出现了轮询问题,经过仔细地检查仍然没有发现轮询所在地。后经过dalao推荐,使用VisualVM对jar包进行监视,总算是找到了线程安全Bug所在。当调度器通知各个电梯结束时,由于电梯的
wait
依赖于结束信号的无效,因此结束信号到来,还有换乘任务的电梯开始了暴力轮询。 - 强测和互测:没有出现Bug,而且性能分居然很高,笔者认为自己的架构设计属实配不上分数。
三、互测发现其他人Bug策略
由于定时投放以及检查时间、输出正确性都很困难,笔者开发了评测系统,使用的都是比较基础的Java和Python知识,没有做过多的功能性与数据可视化完善,评测系统博客链接:2020面向对象设计与构造 第二单元 评测系统开发。
1. 第五次互测
互测期间,笔者专注于评测系统的维护,一直在测试评测系统正确性,只是随便构造了一些数据,发现了房间内成员的线程安全Bug。
同房内还存在如下Bug:
- 两个程序存在CPU轮询情况。
- 一个程序响应第一个请求时瞬移了电梯,即优化掉了0.4秒的时间,只要第一条请求在0.4秒之前到来,就必然出现电梯移动过快的Bug。
2. 第六次互测
评测机负责随机数据测试,笔者则观察了同房间成员的架构,没有发现CPU轮询的行为。
经过评测机的对比检验,笔者程序的性能比较经得起考验。
房间内只有一个程序被发现Bug:
- 某种情况下,会抛弃掉一名可怜的乘客,不拉TA到目的地,即算法问题。
3. 第七次互测
房间内出现了非常面向对象的程序,与笔者自己的架构形成了鲜明对比,笔者花了很久时间研读该程序。
同样经过评测机的对比检验,笔者程序的性能并不理想,常在房间内垫底,不过也在意料之中。
利用评测机发现了两个Bug:
- 一个程序存在
A
类和B
类电梯载客量错误的Bug,是研读指导书不够细致,忘记修改所有电梯的载客量所致。 - 一个程序存在超时问题,且这个Bug在随机数据的轰炸下仅仅出现了两次,笔者没有查明具体原因。
四、总结
经过又一单元的磨练,笔者有了很多收获。
- 多线程程序可以极大地提高运行效率,但是线程安全问题的捕获依然是现今计算机领域的难题。
- 生产者消费者模式,包括后来提到的观察者模式等,与计组的流水线有异曲同工之妙。
- 开发评测机也顺便学会了很多Python相关的知识,了解了新的工具VisualVM,收获了不少乐趣。
但是笔者仍旧要反思自己。
- 思考的角度太单一,受到禁锢之后便无法跳脱出来,希望以后可以拓宽思维,不要局限在一个地方,愈陷愈深。
- 随便舍弃架构的行为属实不可取,在编程前的架构设计中,既要关注细节,更要把控全局。
- 我们是来学习的,而不是来赚取分数的。
对OO这门课体验很好,希望自己再接再厉。