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语言中),难的是你能想到,而且每次都能想到需要保护它。

2019年北航OO第2单元(电梯模拟)总结的更多相关文章

  1. 2019年北航OO第1单元(表达式求导)总结

    2019年北航OO第1单元(表达式求导)总结 1 基于度量的程序结构分析 量化指标及分析 以下是三次作业的量化指标统计: 关于图中指标在这里简要介绍一下: ev(G):基本复杂度,用来衡量程序非结构化 ...

  2. 2019年北航OO第四单元(UML任务)及学期总结

    第四单元两次作业总结 第十三次作业 需求分析 本次作业需要完成一个UML类图解析器,所需要解析的只有符合UML标准和能够在Java 8中复现的UML类图.查询指令存在两种:仅与所查对象有关的指令,以及 ...

  3. 2019年北航OO第4单元(UML)总结

    1 架构设计 经过了接近一学期的程序设计训练,在这一单元的第一次作业中我就非常注重架构的设计,竭力避免像之前一样陷入"第一次作业凑合,第二次作业重构"的不健康的迭代模式.整体上来说 ...

  4. 2019年北航OO第三单元(JML规格任务)总结

    一.JML简介 1.1 JML与契约式设计 说起JML,就不得不提到契约式设计(Design by Contract).这种设计模式的始祖是1986年的Eiffel语言.它是一种限定了软件中每个元素所 ...

  5. 2019年北航OO第3单元(JML)总结

    1 JML语言的理论基础及应用工具链 1.1 JML语言 Java建模语言(JML)是一种行为接口规范语言,可用于指定Java模块的行为.它结合了Eiffel的"契约设计(design by ...

  6. 2020北航OO第四单元总结

    2020北航OO第四单元总结 一.本单元架构设计 本单元作业是实现一个UML图解析器,其中实现接口及主要框架课程组已经提供,只需要我们完成特定功能. 在第一次作业时,感到十分迷茫,不知道如何下手,最后 ...

  7. 2020北航OO第三单元总结

    2020北航OO第三单元总结 本单元要求是根据JML规格完善代码,初看是一个简单的代码照搬实现的东西,但最后才发现由于CPU时间的限制,还考察了大量优化策略及数据结构中关于图的知识,是一次非常注重细节 ...

  8. 北航OO第四单元——UML图解析

    北航OO第四单元--UML图解析 作业要求简析 刚接触本次作业可能需要花上一会才能搞清楚到底是要我们写个啥,在这里简单说一下: UML图的保存格式.mdj文件是以json文件的形式存储的,将每一个Um ...

  9. 2019年北航OO第二单元(多线程电梯任务)总结

    一.三次作业总结 1. 说在前面 对于这次的这三次电梯作业,我采用了和几乎所有人都不同的架构:将每个人当作一个线程.这样做有一定的好处:它使得整个问题的建模更加自然,并且在后期人员调度变得复杂时,可以 ...

随机推荐

  1. 实验:JS判断浏览器中英文版本

    <script type="text/javascript"> var lang = (navigator.systemLanguage?navigator.syste ...

  2. 活字格Web应用平台学习笔记3-显示数据列表

    活字格第二课的目标,用活字格创建一个简单的在线数据管理系统. 看下设计界面.刚开始跟着点,有点懵圈,到做完,回忆了一下,其实就是先建一张表,然后,把表和页面联系起来,即在页面中划出一些区域,和表的字段 ...

  3. [转]SQL SERVER 2008 登陆失败(SQL和windows都没有对应的权限)

    转自:http://www.cnblogs.com/zerocc/p/3425431.html 昨天在测试一些权限今天早上来就发现SQL SERVER 登陆不上去,报错为: 用户登陆失败:消息 184 ...

  4. 学习MVC之租房网站(十二)-缓存和静态页面

    在上一篇<学习MVC之租房网站(十一)-定时任务和云存储>学习了Quartz的使用.发邮件,并将通过UEditor上传的图片保存到云存储.在项目的最后,再学习优化网站性能的一些技术:缓存和 ...

  5. Python+Selenium笔记(六):元素定位

      (一)  前言 Web应用以及包含超文本标记语言(HTML).层叠样式表(CSS).JS脚本的WEB页面,基于用户的操作(例如点击提交按钮),浏览器向WEB服务器发送请求,WEB服务器响应请求,返 ...

  6. 6个讨喜的 ES6 小技巧

    [编者按]本文作者为 Axel Rauschmayer,主要介绍6个 ES6 小技巧.文章系国内 ITOM 管理平台 OneAPM 编译呈现. 在本文中,笔者将介绍6个由 ES6 新功能带来的小技巧. ...

  7. Android应用耗电量统计,无需USB连接

    Android应用耗电量统计一直是一个很头疼的问题,手工统计耗时太长,自动化统计又不是非常精准(执行自动化代码需要通过USB连接,而USB又会充电,这就造成统计数据不准).后来从前辈那里得知可以通过a ...

  8. 单元测试,模拟用户Get登陆,并携带登录后的token访问接口

    HttpClient _httpClient; HttpClient _businessHttpClient; private async Task<string> GetAccessTo ...

  9. 使用MyEclipse建立working set

    1.用eclipse或者MyEclipse开发久了后,会有很多的项目,就算关闭了还会有很多,这是需要建立一个working set,相当在工作区中建立项目文件夹分类放自己做过的一些项目. 如下图:   ...

  10. Cisco DHCP 配置方法

    首先假设两台思科路由器,R1(服务端)连接R2(客户端),组成一个简单的链式局域网,下面就来实现DHCP,配置的命令及其解释如下: 1.R1 dhcp服务的配置 dhcp#configure term ...