1 三次作业的设计策略
经过了上一单元的训练,我也积累了一些设计策略上的经验。在这一单元的一开始,我便尽可能地把问题中的各个功能实体区分开来,分别封装成类,以便于随后作业中新需求的加入。与此同时,我也在有意地控制住方法的规模,依照程序逻辑层次化地设计方法,使得每个方法都不至于过分臃肿,从而增加代码的可重复利用性,减轻编程负担。
接下来,具体介绍每次作业的设计策略及其演进。
1.1 第1次作业
第一次作业的需求较为简单,只需实现单电梯先来先服务算法的调度模拟即可。为了尽可能模拟出电梯运行的真实行为,我采用了双线程设计,电梯和输入分别为两个独立运行的线程。其中,输入线程为main线程,最先开始最后结束。在当时这样做的目的是为接下来可能的多电梯系统作准备。
类的设计需要依照问题中的实体。本次作业中的实体包括电梯、调度器和乘客,由于乘客的全部信息可以由课程组提供的PersonRequest类完全表示,因此只需封装前两个类即可,同时也可以保障程序的可扩展性以及模块化设计。
调度器类(Controller)是一个单例的类(single-instance class),即这个类在程序中只允许被实例化一次。本次作业里Controller的内容很少,成员变量只有一个队列,方法也只有“出队”、“入队”和“判断队是否为空”3个。就这一次作业来说,这个类更像是为了储存和维护乘客队列而封装的,并没有涉及到“调度”的操作,至于FCFS算法的具体实现我放在了电梯类里面。
这里需要特别指出的是,另一种可能的设计方式是调度相关的代码完全放在Controller里面,即一切有关“调度”的内容全部在调度器里,电梯只需“指哪去哪”,每一个动作都由调度器来命令。这样做似乎是实现了功能的分离和耦合度的降低,但是在我看来,这种设计的代价是调度器里的调度方法会极为复杂,因为它需要将乘客的请求按照具体算法进行顺序重排,还要进一步拆解为电梯的执行动作。这就好像有两个聪明的工人,明明可以让他们各自分担一部分“思考”的任务,通过交流共同完成一个复杂的任务,却偏偏只让其中一个人承担全部的“思考”任务,把另一个人当成傻瓜并一步一步教他需要怎么干。
按照上述的设计思想,电梯类(Elevator) 会略微复杂一些。成员变量包括电梯的当前状态,另外还需要一个变量储存调度器类的一个实例(事实上调度器也只有一个实例)。方法包括基本动作(如开门、进1个乘客等),以及由这些基本动作组成的FCFS算法实现。另外一个值得单独说明的方法是停止方法end()。这个方法虽然在Elevator类里,但是并不在电梯线程被调用,而是在main线程(即输入线程)里通过Elevator.end()的方式被调用。该方法首先判断当前程序状态是否满足电梯进程结束条件,若不满足则进入阻塞状态,直到条件满足,结束电梯进程。
程序的整体运行流程:main线程首先实例化相关的类,开启电梯线程;随后开始扫描输入,逐个加入队列;当输入扫描完毕后开始尝试停止电梯线程,成功停止掉之后结束整个程序。
1.2 第2次作业
第2次作业相比第一次作业的不同之处在于调度算法从先来先服务(FCFS)改成了可捎带调度(ALS)。因此程序的大部分内容都不需要改动,只需重写电梯类里调度方法,以及在调度器类里面增加相应的方法即可。具体地,电梯类需要维护一个乘客集合,储存当前在电梯里的乘客。第1次作业中同一时刻电梯里至多只有一个人,所以这样的集合完全没有必要。调度器类新增的方法是“查询是否有可捎带的乘客”,输入参数包括当前运行方向和当前层。相应地,需要增加新的枚举类Direction,内含三种方向:UP, DOWN, STOP。
这里就可以初步看出功能平摊带来的好处。调度器类提供一个查询捎带的接口,而电梯类只需把查询操作集成到调度里即可。如此,将一件较为复杂的工作拆分成两个部分,由两个类分别完成,在保持低耦合度的同时降低了每部分工作的逻辑复杂度。
1.3 第3次作业
第3次作业相比于上一次作业最核心的变化在于电梯数量增加到了3部,而且每部电梯并不是等价的,拥有不同的运行速度、不同的轿箱容量、不同的停靠楼层。由此带来一系列新的问题需要重新思考和设计,包括乘客换乘和多电梯调度等等。总的原则是:能用一部电梯完成的任务绝不用多部电梯完成。下面我来分别就各个方面进行介绍。
首先,需要新增一个Person类,储存原先PersonRequest类里的所有信息,外加当前目的层nextDest以及电梯选择choices。nextDest的加入是因为换乘问题,用来储存“该乘客在当前这部电梯里应该在哪一层下电梯”,若nextDest和toFloor相等则说明该乘客从今往后不用再进行换乘了。choices的加入则是因为电梯停靠层各不相同,用来储存该乘客当前可以选择的电梯。这两个变量会在新乘客加入到乘客队列前由Controller类初始化。
此外,Controller类采用了订阅模式,每个电梯线程在开始时都会向Controller注册。新提供的调度相关的查询接口为“为乘客分配换乘层”(换乘层集合根据所有注册电梯的停靠楼层动态更新)。
Elevator类的改动虽然较多,但大都属于适配工作。值得一提的是换乘问题的处理:电梯每次只关心乘客的当前目的层,而只有在乘客下电梯时才检查当前目的层和最终目的层是否相等,若不等则改变出发层fromFloor并将该乘客重新加入到等待队列中。这样做相当于把乘客的旅程划分为一段段更短的、可由一部电梯完成的旅程。每个电梯的调度算法仍以捎带算法为基础。
2 基于度量的程序结构分析
2.1 量化指标及分析
以下是三次作业的量化指标统计:
关于图中指标在上一篇博文中提到过,不过我觉得有必要再在这里回顾一下:
- ev(G):基本复杂度,用来衡量程序非结构化程度。基本复杂度高意味着非结构化程度高,难以模块化和维护。
- Iv(G):模块设计复杂度,用来衡量模块判定结构,即模块和其他模块的调用关系。模块设计复杂度高意味模块耦合度高,这将导致模块难于隔离、维护和复用。
- v(G):模块判定结构复杂度,数量上表现为独立路径的条数。
从上面三张图可以看出,整体上3个复杂度指标在这一单元的三次作业中都维持在一个较低的水平,仅有个别的复杂方法的某些指标超出了正常值。事实上,和我上一单元写的程序相比,我认为能看出非常明显的进步。另外,异常值超出正常范围的程度也比上一单元好了很多,上一单元有些方法的iv(G)和v(G)超过了15甚至20。具体来说,问题大致集中在第3次作业的Elevator.operate()方法上,而这个方法正是我实现调度算法的地方。虽然我已经有意识地将基本操作独立封装,尽可能降低逻辑结构的复杂度,但是还是未能将其限制在正常的范围内。我想原因是多方面的,可能是算法本身的复杂性导致的,也可能是我的设计存在失误,这一部分也是我在下一单元需要着重解决的问题。不过总体上,我认为这一单元我的程序设计架构在控制耦合度、非结构化程度等方面还是有了很大改善。
2.2 类图及分析
三次作业的类图如下:
从类图的变化可以明显看出,随着作业进度的推进,类的内容越来越丰富,类的结构越来越复杂。这样的变化也是和日益增加的需求相一致的。不过我认为我在这一单元做的还不错的地方在于,从整体的角度上来看,随着作业进度的推进类之间的联系并没有增加多少,这说明这个程序的功能虽然增加了,但是耦合度并没有显著上升。
2.3 线程图及分析
第1、2次作业的线程图完全一致,如下面左图所示,第3次作业的线程图如下面右图所示:
从图上可以看出这一单元的作业虽然是多线程程序训练,但其实线程结构还是极其简单的。三次作业都是从主线程出发,根据电梯数量的不同经由Elevator类开启多个线程,最后线程回到主线程结束整个程序的运行。
2.4 按SOLID设计原则重新审视
先介绍一下什么是SOLID设计原则:
- Single Responsibility Principle,每个类或方法都只有一个明确的指责
- Open Close Principle:无需修改已有实现,而是通过新增加代码来扩展新的功能
- Liskov Substitution Principle:任何父类出现的地方都可以使用子类来代替,并不会导致使用相应类的程序出现错误
- Interface Segregation Principle:用接口类来规范程序的行为,划分程序的功能
- Dependency Inversion Principle:反转传统的依赖关系,高层次的模块不依赖于低层次的模块的实现细节
对于我自己的程序,L和I原则并未涉及,S原则O原则我认为我比较好地遵守了,而D原则并不是完全符合。思考其原因,我认为首先当前的程序还没有复杂到有这样做的必要性,当然不是说D原则并不重要,只是其重要性还没有被很充分地体现出来。另外一个原因是这一单元需求的演进大多属于增补型的变化而不是修改型的变化,也使得我某种程度上忽视了“寻求降低对低层次模块实现细节的依赖”。这一点是我需要在今后的练习中改进的。
3 程序bug分析
需要提前指出的一点是,由于高工的OO课程并没有互测环节,因此以下的分析都是基于我自己在调试过程中找到的bug以及未通过公测用例的情况。此外,这3次作业的所有强测测试点均为AC,所以也不在这里讨论了。
3.1 bug特征与位置
经过了上一单元的训练,本单元的程序几乎没有Java语法以及对象相关的错误出现。大多数bug属于程序逻辑错误或是程序运行到了未知状态,归根究底设计之初并没有考虑所有可能的情况,这样的错误相较于语法错误和对象使用错误更难被发现。另外在多线程程序中调试工作远比单线程程序复杂和繁琐,导致定位bug也变成了一件有些挑战的事情。
至于具体的bug特征,我想以上文提到的Elevator类里的end()方法为例,该方法判断当前程序状态是否满足电梯进程结束条件并结束进程。代码如下:
void end() {
// stop the elevator only if:
// 1. there are no passengers in this elevator
// 2. there are no requests
synchronized (passengers) {
while (!passengers.isEmpty()) {
try {
passengers.wait();
} catch (InterruptedException e) {}
}
}
synchronized (controller) {
while (!controller.isRequestsEmpty()) {
try {
controller.wait();
} catch (InterruptedException e) {}
}
// woken up
thread = null;
controller.notifyAll();
}
}
从代码里可以看出,结束线程的条件有两个:电梯里没人,且电梯外没人等电梯。这个方法里面第1个易出bug的地方在于如何同时锁住两个共享对象。如果用两层synchronized嵌套的话极容易出现死锁。假设先锁住passenger(电梯里面的乘客集合),等待passenger为空(即电梯里的人都下电梯),然后锁住controller等待controller的乘客队列requests为空进而结束线程,但是乘客离开等候队列的唯一方式就是上电梯(即进入passenger队列),但是此时passenger对象已经被锁住无法访问,于是thread变量永远不会等于null,线程永远不会停止。因此我改成了上面代码里的这种顺序锁的形式。当然你也许会问,最后一个乘客上电梯后thread = null;
这条语句不就被立刻执行了吗?是的,但是thread变量为空并不代表线程立刻结束,而是表示现在的电梯正在运送最后一个乘客。结合下面run()方法的大体框架就会一目了然:
public void run() {
while (thread == Thread.currentThread()) {
...
...
operate(pass);
}
...
// here is the true end of this thread
}
我写这个方法时遇到的第2个bug相对简单,是忘记为passenger.wait()匹配相对应的passenger.notifyAll(),导致一旦该线程从这里进入阻塞状态就再也不会被唤醒。
从上面这个例子可以一窥多线程编程中bug的复杂性,一处错误可能波及到多个方法甚至多个类,这使得debug的难度陡然上升。
3.2 bug与设计结构之间的相关性
从以上对于bug位置和特征的总结来看,本单元出现的bug和设计结构之间的关联更加紧密。
静态地看,大多数情况下bug产生在逻辑复杂或是代码量大的类和方法中。动态地看,从线程的角度,bug大多产生在线程“交汇”的地方,如开始、结束条件判断和访问共享对象时。如上一小节提到的,有时线程会早于或晚于我们所预期的时刻结束。所以为了在今后避免类似的bug出现,我们不能只在脑海中模拟程序运行的工作流(因为这样做不稳妥,而且在线程更多的程序中根本不现实),而是要用各种输出和log信息显式地展示出各线程的运行情况,以保证设计逻辑的正确。
4 心得体会
经过这一单元的3次训练,我自己认为已经对多线程编程有了初步的了解。具体来说,能够准确识别出共享对象,并且能够给共享对象的“敏感”操作加上合理粒度的锁。
其实保障线程安全的难点在于架构设计而不是具体的代码实现。基于一个好的的程序架构很容易就能定位到共享对象,识别出原子操作的范围,进而对它们进行保护。那么想得到好的程序架构就要在设计的时候遵循一定的原则,如上文提到的SOLID原则等。一方面,公认的一些设计原则有助于我们设计处更规整、更符合实际、耦合度更低的系统;另一方面,如果我们死板地恪守原则其实也不合理,每个程序都有不同的需求,针对具体问题也要有所变通。
我认为,对于线程安全这个问题,需求的分析和顶层的设计策略固然关键,然而更重要的是时刻紧绷一根弦,时刻拥有一种保护线程安全性的思想,(即老师课上所提到的,防御性编程)。保护线程的安全是相对容易的(至少在Java语言中),难的是你能想到,而且每次都能想到需要保护它。