Object-Oriented Programming Summary Ⅱ
电梯作业总结博客
17373492
电梯,多线程学习中的 "HelloWorld",早在大一就有所耳闻,以至于在坐电梯的时候就思考过:电梯需要怎么写呢?
0. 前言:
偶然的机会,在中关村电子科技大厦中体验了一次 “目的选择电梯” ,第一次接触的时候,看到一个门要关上我就赶紧进去,结果进去之后发现根本没有楼层的按钮,很是纳闷,被人告知这是一种特殊款式的电梯 —— 目的选择电梯, 需要在外面选好楼层,再根据指示进到他给你分配的电梯中去,没想到在作业中就真的采用了这种目的选择电梯。有了直观的接触之后,感觉自己对这次作业的理解应该要更加深刻一些吧,动手也没有那么困难。
吸取了求导三次作业所经历的各种 艰难困苦 之后,对如何 应付 写好作业有了一些个人的经验,所以在电梯单元的作业中,我采取了一些细微的改进,主要有以下几个方面:
- 在第一单元的作业中,由于对Java语言的不熟悉,多半采用的是 先面对过程,再面对对象,甚至缺失了面对对象的环节 ,但是经过观摩优秀的代码和老师课上对抽象剥离的思想, 面对对象的封装、继承、多态三大特性 ,以及 几种设计模式的介绍 ,我对面对对象有了初步的认识,至少不再像是C语言一样, 一条龙的思想 ,而是开始思考如何才能 分封 不同的模块和功能, 各司其职。
- 充分的思考 之后再动笔。在第一单元的作业中,我几乎都是拿到需求文档就开始编写代码,在一边码字一边思考,直接导致的就是回过头来发现很多地方设计的比较 死板,没有大局的观念。比如在判断
WF
的时候,我就是单纯的需求中说要判断错误,我就在主函数中加上动辄200行的特殊错误判断,对每一种错误判断一次并System.out(1)
。这样子做虽然不是说不行,但是对于WF
的判断,完全可以在提取表达式的时候,如果遇到某个表达式的某项无法匹配,就可以说明是格式输入有误了,现在回头看原来的代码中的特判感觉非常丑而且很难读。 所以第二单元的作业中,我每次都是 周五 拿到需求文档,先通读一遍有个大概的印象 周六周日的时候充分思考,比如说记下可能实现的方式和可能需要注意的问题,需要设计几个部分等等, 然后周一才开始动笔写。虽然说第二单元的第一次作业时,周一动笔我慌的不行,甚至怀疑是否能完成作业,因为完全不会使用多线程,但是当自己的代码一次就通过之后发现 思考后动笔 ,就是 磨刀不误砍柴工 的最好例子。三次作业我都是周一动笔,甚至周一下午才开始动笔,但完全不影响周二早上就能交上通过的版本,剩下大半天的时间充分测试并尝试优化。 - 采用先构造一定的样例来测试自己的思路 。这个是从研讨课中学到的方法,通过动笔前先构造样例可以使自己的思考更加充分。虽然第一次是傻瓜调度,但是第二次的捎带,我在动笔前就充分思考什么情况下应该捎带的情况,如何设置主请求,如何保存主请求
虽然指导书上都是中文但是我开始的时候就是看不懂在说什么,所以只能自己想捎带的情况等等。在第三次作业中就思考特殊楼层应该如何换乘,3楼到2楼4楼,15楼到16楼的情况等等。 测试样例现在脑里过一遍之后再动笔可以思路更加清晰。 - 提前准备代码的复用。知道了单元小节之间的递进关系之后,在这三次的作业中就没有把代码写 死,而是有意地 分离了电梯的功能。之前不是很理解什么是 低耦合,高内聚,现在有些明白了,所谓的低耦合,可以理解为对象之间要 "相敬如宾",不能互相随意修改,凡事都要通过接口,经另一方 审核 之后才能执行的意思。这里贴出看到的一个对耦合的介绍比较好的分类[1]:
- 内容耦合。当一个模块直接修改或操作另一个模块的数据时,或一个模块不通过正常入口而转入另一个模块时,这样的耦合被称为内容耦合。内容耦合是最高程度的耦合,应该避免使用之。
- 公共耦合。两个或两个以上的模块共同引用一个全局数据项,这种耦合被称为公共耦合。在具有大量公共耦合的结构中,确定究竟是哪个模块给全局变量赋了一个特定的值是十分困难的。
- 外部耦合 。一组模块都访问同一全局简单变量而不是同一全局数据结构,而且不是通过参数表传递该全局变量的信息,则称之为外部耦合。
- 控制耦合 。一个模块通过接口向另一个模块传递一个控制信号,接受信号的模块根据信号值而进行适当的动作,这种耦合被称为控制耦合。
- 标记耦合 。若一个模块A通过接口向两个模块B和C传递一个公共参数,那么称模块B和C之间存在一个标记耦合。
- 数据耦合。模块之间通过参数来传递数据,那么被称为数据耦合。数据耦合是最低的一种耦合形式,系统中一般都存在这种类型的耦合,因为为了完成一些有意义的功能,往往需要将某些模块的输出数据作为另一些模块的输入数据。
- 非直接耦合 。两个模块之间没有直接关系,它们之间的联系完全是通过主模块的控制和调用来实现的。
1. 分析总结自己的设计策略
在本单元的作业中,我们模拟了现实中的电梯——目的选择电梯,虽然和平时见到的最常见的电梯有些不同,但是本质上无异于将在里面的按钮放到了外面而已,所以就对于作业来说,思路很好变换。按照课程组建议的模式,采用了 生产者消费者 的设计模式,通过中间的调度器起到了 托盘的作用,使请求队列和电梯之间不直接相互可见,达到了生产者和消费者低耦合的目标。
- 生产者(请求队列) 作为一个单一的可执行的类,实现了
Runnable
的接口,在主函数中单开一个线程不断从控制台获取请求并传给调度器。 - 电梯 也是作为一个单一的可执行的类,实现了
Runnable
的接口, 这里我都习惯将线程用接口而不是直接继承Thread,目的就是为了极高的拓展性,适应Java余元本身的 单继承多接口,也确实, 在第三次作业中,就可以通过继承第二次作业的电梯类而不是直接在里面修改参数更加符合面对对象的思路了—— 对扩展开放,对修改关闭。在电梯类中做的不够的地方之一是 输出也放在了电梯中,这其实是不符合电梯对象的属性或者方法,应该单独抽象出一个输出的类与电梯之间协调,如共享参量等。 - 调度器 在这里起到一个总领全局的角色,其实我还是感觉给调度器分配了太多的角色,但是暂时还没有想到如何更加剥离调度器的任务。
另:在第三次的多电梯作业中,我自己开始的时候犯了一个错误——忽略了 目的选择电梯的现实情况 ,我通过将一个需要换乘的请求在进入电梯前就分离成了两个 “相互独立的请求” ,人为地将一个活生生的人 “剥开了”,而且还将电梯 降级成为普通电梯了 ,因为比如从4-16的请求,我的策略里面会先由B电梯上到4楼并将人送到15楼, 人从15楼出来的时候才回激活A电梯上来 ,这显然不是目的选择电梯,真正的目的选择应该是人在4楼进入B电梯的时候A电梯如果空闲的话就已经在往上跑了,当人到15层的时候A电梯早就已经等好了,人可以立刻进入了。很遗憾,在互测时候,我发现很多同学都是采用了 普通电梯的调度方式 ,这说明很多同学对电梯的思考是不够的。(当然很多大佬的超神调度算法不止解决了这个问题)
在这个单元的多线程作业中,为了解决线程之间的安全问题,我将所有的共享参量都归由调度器掌管,这样一来,当生产者和消费者 调用共享的调度器实例的方法 的时候就可以由 synchronized
的方法保证共享参量操作的原子性。
看到很多人因为加锁范围较大导致出现线程死锁的情况,但是我自己也是对方法加锁却没有出现过死锁,自己分析了一下原因:对方法加锁的实质 就是对当前的对象实例加锁(this),而我没有因为this造成死锁是因为我将每个方法细分到只负责 很小的部分,比如
putInQueue
函数,只是将新的PersonRequest
放在了等待队列中,没有其他操作,所以说本质上有效的只是对arrayList
进行锁,没有对其他的参量改动。
2. 量度分析
本单元三次作业的UML及时序图如下:
- 傻瓜电梯:
- 捎带电梯:
- 多部智能电梯:
可以看出第一次的傻瓜电梯和第二次的捎带电梯,时序图没有发生改变,这说明只有电梯的内部增加了功能,而没有产生其他的相互调用;在UML图中也可以看出来,只有电梯内部自己实现的方法有所增加,没有产生新的 “纠缠”。
而第三次电梯与前两次相比,在时序图中多了一个Broker,也就是我的调度器的类,不清楚前两次的时序图中为什么没有,可能是因为没有和其他类产生交互,但是第三次因为用了 HashMap的数据结构,所以在时序图中展现了出来,此外我也是写完之后才知道—— HashMap不是线程安全的数据结构,concurrentHashMap才是,Vector也是线程安全的,但是我到现在还没有因为HashMap本身的线程不安全而出错,可能是因为锁this的时候对this的内部私有量也加了锁的原因吧。
使用JavaDesignite分析的结果如下:
回顾一下几个参量的衡量(摘自第一次博客):
- CC 圈复杂度
用来衡量一个模块判定结构的复杂程度,圈复杂度越大说明程序代码质量低,难以测试和维护- LCOM 方法的内聚缺乏度
值越大说明类内聚合越小- FANIN 类的扇入
扇入表示调用该模块的上级模块的个数,扇入越大,表示该模块的复用性好。- FANOUT 类的扇出
扇出表示该模块的直接调用的下级模块的个数,扇出大表明模块内复杂度高,但扇出过小也不好。总结就是 ,一个好的JAVA代码,设计要求是高内聚低耦合的,所以LCOM值要小,FANIN的值要大,FANOUT值合理。
按照上面锁得到的结果来看,LCOM和FANIN和FANOUT的值都是比较合理的,但是在第三次作业中CC的值显著增大,调度器中的值,增加了几个很大的数值 8 ,因此我查了一下圈复杂度具体的定义——“圈复杂度是用来衡量一个模块判定结构的复杂程度,数量上表现为独立路径的条数。而独立路径就是在控制流程图中从起点到终点的一条回路。”,可能导致我圈复杂度急剧增大的原因是对于换成的请求的拆分,我是采用了 静态拆分 的方式,即每种请求在其到来的时候,无关当时的电梯运行状况进行了分解,保证了理论上的最优解但不一定是实时的最优解,拆分方法中对于 fromFloor
和 toFloor
的枚举判断增加了独立路径的条数,导致了圈复杂度的增加,也找到了如何改善圈复杂度的常见方法,详情见[2]。
SOLID原则分析
简称 | 全称 | 中文 |
---|---|---|
SRP | The Single Responsibility Principle | 单一责任原则 |
OCP | The Open Closed Principle | 开放封闭原则 |
LSP | The Liskov Substitution Principle | 里氏替换原则 |
ISP | The Interface Segregation Principle | 接口分离原则 |
DIP | The Dependency Inversion Principle | 依赖倒置原则 |
SRP 单一责任原则
开始的时候我还是觉得我自己的电梯类 Lift
做到了比较简单的只负责人的进出和电梯的运行,结果讨论发现有的同学更加简化,将电梯类只负责开关门和上下,人都是被调度器给塞进电梯的,这样的作法当然很符合单一责任原则,按照他们的做法,应该是每个电梯的所有参数都由调度器所掌管,这样一来调度器对电梯实时的负载及运动状态是可知的,有利于后续的优化,如动态分配人员等等,而我的做法从架构上就很难进行优化,因为调度器对电梯的状态不可知,电梯在每层楼停下的时候自己判断是否有人要进出从而去掌管人员的流动,这一点上我确实有些欠缺优化。
OCP 开放封闭原则
所谓的对修改封闭,对扩展开放,我觉得自己的电梯类基本上做到了这一点,内部实现的方法很简单,除了运行和开门之外就是在每一层楼检测是否有需要进出的人,所以在第三次作业的时候我可以几乎对电梯不做修改,除了修改构造函数,使得原来的私有属性 runTime
和 doorTime
可变就可以了。
LSP 里氏替换原则
这里我并没有用到继承,所以这里略去。
ISP 接口分离原则
这里我只用了 Runnable
的接口实现线程的运行,所以这里略去分析。
DIP 依赖倒置原则
好像与本次作业关系并不大,但是贴上具体的几点方便自己理解[3]:
- 高层模块不要依赖低层模块;
- 高层和低层模块都要依赖于抽象;
- 抽象不要依赖于具体实现;
- 具体实现要依赖于抽象;
- 抽象和接口使模块之间的依赖分离。
3. 分析自己的bug
在本次作业中,我自己采取的是 定点分析和穷举测试与自动生成。
- 首先:定点分析,主要分析的是边界条件和特殊条件,比如第一二次电梯的最高和最低层已经一层和负一层之间的运行,由于官方接口的规范性,所以不会出现奇怪的从1到1这种数据,简化了很多事情。再入第三次作业中特殊楼层3和15,是换乘需求比较大的所以容易出错。我在测试自己的电梯的时候就发现了 从3层到4层不能正常工作 ,出现3层楼开关死循环的结果,看代码发现是默认
toFloor-1
的换乘方式,导致去4层的时候就需要先用C电梯从3层到3层再到4层的情况,所以门诡异地开关开关... - 其次是穷举测试,这一部分起始我做的并不是非常严谨,只是生成了所有合法的请求并输入,确实没有存在电梯卡死的情况,而且能正常输出并停止,所以就人为地 无罪认定 了。
- 最后是自己生成,主要是接触
python
的脚本生成随机数并用命令行定时输出,在这一部分上也是简单的能跑就行,因为写正确的检查脚本比较耗时。
结果:出bug了
- 在第三次作业中,加入了 限额 的条件,由于我在电梯取人的方法进行了重载,但是在扩展的时候只修改了其中一个方法,忘记了另一个方法,导致在强测中因为超载被错了一个点,落到C组。在此之后我便默认打开
structure
的显示,提醒自己是否遗漏了重载的情况。(快捷键 Alt+7)
- 第三次作业中沿用了第二次作业的代码,但是自己的换乘请求的激活是通过存储在调度器中的一个
waitingMap
来实现的,即每次有乘客出来的时候判断他的id
,如果在waitingMap
中有相同id
的人就说明这个人需要换乘,换乘后的新建请求传入调度器本身进行解析分配。所以在电梯停止的条件中,自己的思考没有到位,本来第二次作业是 没有剩下的请求且输入流终止就不再工作 ,但是没有考虑到第三次电梯中的 等候队列中还有人的情况 ,导致有一个电梯在运最后一个需要换乘的人,结果人还没到换乘的楼呢,另一个负责他后半程的电梯已经停运了,所以人到换乘楼层的时候狂按电梯也没有来接她,导致rtle
了。对此,我在修补时的解决方案是:在判定停止工作的条件中加入waitingMap.isempty()
的条件。 另外,自己的电梯是否太过独立,如果使三台电梯同时结束运行的话应该就能回避这种问题的发生。
4. 分析别人的bug
互测主要就是自己遇到的坑的定点测试,看看别人有没有3到4层无法运行的情况,看看别人有没有和我一样超载的情况,看看别人有没有只剩一个需要换乘的人在电梯里面时其他电梯就下班的情况。用自己出的错去检查别人犯的错是我比较常用的方法,其余的便是随机生成测试。
在第二单元的总结课上,老师也指出我们在第二单元提交的样例数和刀人数目和第一单元相比显著减少,这主要是以下几个原因:
- 官方提供的接口杜绝了
WF
错误的发生。虽然第一单元的样例很多,但我相信至少一半是WF
所导致的,而WF
确实如有些同学反映的,因为格式错误被揪着打有点不舒服,官方所定义的格式客观来说确实有改进的地方,比如最后一次的多项式求导中嵌套的表达式外层一定要加括号的行为,使得在设计的时候有点反常规。所以第二单元没有格式错误之后我觉得减少一半数目的测试样例也是正常现象。 - 课程作业难度有所降低,同学们的代码水平有所上升。经过第一单元的摧残之后,我相信同学们普遍Java代码水平是上了一个层次的,从
CJava
到真正的Java,这个从面向过程转换到面向对象的过程本身就可以减少部分的代码错误,再加上课程组为了体谅第一次接触多线程的我们,在课堂上以及指导书中给了足够的提示,包括 生产者消费者模型的建立 等等,所以大家在第一二次作业中几乎没有什么bug。 - 多线程的bug不好复现,身边有同学在本地测出了别人的bug但是在测评机上是空刀的情况,也有同学在互测被查出了线程不安全的bug但是在本地无法复现直接提交原来的代码又过了的情况,所以可能得到的统计数据中空刀较多。
5. 心得体会
- 多线程真是个神奇的东西! 虽然生活中到处都是异步的多线程,但是只接触过c语言编程的我真正实现了自己的多线程还是一件很奇妙的东西。记得第二单元的第一次作业虽然是很傻瓜的调度,但是我到周二上午还有点一头雾水,听了一次多线程的课之后对多线程究竟是怎么运行的还是很懵,从JVM运行的机制到实际的代码之间的鸿沟还是很难跨越的,所以我对着第三次上机时给的代码看了很久,试图弄懂线程之间的交互和时间片的分配,终于在自己实现了多线程之后才有了多线程是如何跑起来的认识。
- 多线程的线程安全十分重要! 虽然自己没有出过线程安全导致的死锁,但是身边很多人遇到,感觉还是十分可怕的。对于第三次作业中我使用了线程不安全的
HashMap
但没有出现bug还是心有余悸。之后在多线程的作业中我会对自己使用的数据结构进行提前的了解,确定其实现方法的线程安全性再使用。另外,第四次实验中给的通过HashCode
的方法来实现银行转账的线程安全控制实在是令我打开眼界,仿佛发现了新大陆,java多线程并发看起来还是非常有趣的! - 体会到了学科交叉! 在OO讲多线程的时候,恰好OS在讲多进程,两者之间不看线程和进程关系的话,粗略来讲学的都是一样的!在OS中我们使用PV操作的原语编写保证进程安全的伪代码,在OO中我们使用Java语言写出线程安全的代码,核心思想以及实现方法都特别相同。比如在OS中,我们通过一个值为0或者1的 信号量 来保证操作的原子性,来保证对共享资源或者临界资源的操作的互斥,同样地,我们在Java中也用
ReentrantLock
或者自定义的object
的Lock
来保证互斥访问,其思路都是互通的,只是实现方式不同而已。再比如,从OS课堂上学习到了产生死锁的四个必要条件:互斥、占有等待、不可抢占、环路等待,而预防死锁或者说避免死锁只需要从打破其中任何一个必要条件入手即可。在第四次实验课上让我惊叹的HashCode
的方式来解除死锁,实际上就是打破了环路等待,人为规定先后顺序之后就不会产生两方拿着资源占有等待的情形了,两门课中的代码编写都是互利互惠的,这样的感觉——有点意思。 - 第一次接触设计模式! 什么是生产者消费者模式,什么是观察者模式,什么是工厂模式,什么是单例模式,什么是代理模式?只有自己实践了才知道,这些东西已经被总结的很规范条理,但是光是知道是什么还不够,有些人可能一次性能背出23种设计模式但是自己编程的时候说不出用的是什么模式。至少通过一个单元三次作业的形式,我对单例模式和生产者消费者模式有了初步的认识,知道了托盘,也就是电梯的调度器,具体是如何管理共享的参量的,知道了单例模式实现方式等。
- 第一次接触设计原则! 总结出设计原则的人真是天才!感觉多线程要是写的时候按照七大原则一条一条对着来看的话,代码逻辑应该是不差的。见解不深没啥可以展开的,就此略过。
感谢看到最后,贴上自己多线程中记的一点点笔记
Reference: