BUAA_OO_第二单元总结

OO第二单元总结

摘要

第二单元相对于第一单元,在思路构建过程模拟的难度上有了较大的提升,因为多线程本身的不确定性和bug的随机复现性,所以需要我们构建出良好的架构以及我们要对自己的代码运行过程有着极为清晰的认识。然而,相对于第一单元,在面向对象的思路上难度稍微好了一些,主要原因是随着课程的深入,对面向对象的理解也更加深刻,除此之外,电梯相对于表达式,是一个更加贴近生活的对象(我甚至在公寓电梯里一直不出去企图分析电梯的运行过程),化简表达式时要对各种结构进行拆分建模,而分析电梯的运行过程时,其在我们脑海中构建的时候本来就是以对象的形式存在的,比如说电梯、乘客(请求)、楼座楼层、请求队列等等,我们在读题的过程中尽管不一定有非常清晰的敲代码思路,但是该建什么类,类中该有什么属性是比较容易想出来的。

第五次作业(无中生有)

1.架构与多线程分析

由0迈到1的这一步是异常艰难的(也正因如此对第五次作业的分析会多一些),由于之前对多线程根本不了解,看完相关基础知识后浏览指导书发现真的很难:既要考虑架构,也要考虑可迭代性,又要考虑线程安全问题。当然,分析完第五次作业指导书后,会发现本次作业不太需要考虑线程安全问题,因为5台电梯本身是互不干扰的,每个请求应该去哪个电梯哪个位置等待也都是固定的。换句话说,当输入线程读到一个指令的时候,我们可以直接把指令放到确定的位置,不用顾及任何事情,类比到现实的话,那就是你想从新主楼F的2楼到3楼,那你就直接去F的2楼的电梯门口等着就好了,没有什么需要考虑的。

至于调度算法,再看完指导书中的ALS基准策略后,感觉ALS好像实现起来有点复杂,过程也没太弄清楚,而且对于很多情况运行时间都不会太好,再请教了同学和浏览了学长的博客后,发现look算法比较好,而且更好理解、更好实现、性能也很不错。我在网上并没有查到特别详细的look算法,根据各种各样的信息,我实现的look算法如下:电梯有一个初始方向(一般定为上),电梯每到一个楼层对电梯内部和外部的乘客进行分析,如果到达了内部乘客的目的楼层,该乘客出去,如果外部乘客的方向(上楼或下楼)跟电梯一样,那么电梯就让他进来(这里省略的开关门的分析过程以及电梯容量问题)。当电梯内部没有人,并且电梯同方向上的其他楼层也都没有乘客叫电梯,那么电梯反向。这个算法的一个特点就是,电梯不存在主请求,只是不断的上下楼,符合条件就让进出,实现起来比较容易,捎带策略和转向策略也使得这个算法在性能上比ALS也好了很多,当然在讨论课上也发现了其他同学的一些优化方法,例如目的地和起始地距离近的乘客优先进入,这样可以减少电梯来回一次的时间。

综上,本次作业我主要采用了输入线程类InputThread,电梯类Elevator,请求表类RequestTable,策略类Strategy。至于为什么没有乘客类Person,这其实是我比较矛盾的一个地方,官方给的PersonRequest所包含的全部信息基本上就是我乘客的属性了,我就没有再建造一个Person类来去复制PersonRequest的属性,直接把指令当成人看,这里确实不太符合现实。除此之外,我并没有构建一个调度器负责安排指令位置,因为正如上面所说,指令具体去哪个电梯直接就能知道,因此我直接在输入线程内将指令传给了请求表RequestTable,而请求表类本质上就是一个三维乘客数组(这是个抽象说法),根据楼座和楼层,我就可以直接找到该位置的请求队列,因此,对于第五次作业来说,调度器确实是没什么用的,不过也得留一个心眼儿,对于以后的迭代肯定是需要调度器的。

策略类Strategy里面实现了我的look算法,至于策略类的存在意义是什么,我认为是为了以后的迭代考虑,策略类可以为我的电梯提供这种各样的策略接口,这样电梯就可以拥有不同的调度。

至于线程通信,wait-notify机制固然不错,但是我认为有一个相对不符合现实的地方就是,如果来了乘客,理论上我们只需要唤醒该乘客所乘坐的电梯即可,而不是用notifyAll去唤醒所有电梯再让剩余的电梯发现没乘客然后接着睡。于是我在网上查到了一个LockSupport工具类,其中调用LockSupport.park()直接就可以在该位置进行暂停(wait),而LockSupport.unpark(Thread)(括号内是你要唤醒的线程,也就是某个电梯)可以唤醒该线程。除此之外,这两个方法不用像wait和notifyAll封装在sychronized里,也不用考虑先后顺序问题,使用起来非常直观且可靠。wait-notify机制对于我们对多线程的理解更加深入,而LockSupport工具类对于对多线程不熟悉的人来说用起来更加方便,当然只是个人想法。

2.代码分析

UML类图如下:

UML协作图(三个线程视角)如下:

Statistic分析如下:

Metrics分析如下:

根据上述分析结果,我们可以发现:首先,在架构设计方面,利用输入线程来获取指令,将指令送往指定队列然后唤醒指定电梯,电梯唤醒后调用自身的策略类,策略类来进行调整电梯和乘客的状态。因此,我将look算法全部在Strategy类中实现的,因此Strategy的复杂度也确实高了一些,其实根据指导书,策略类理论上只是负责提供主请求,真正的上下楼和开关门应该是电梯自身干的事情,但是由于我在实现look算法的过程中没有用到主请求这一对象,因此这里的设计确实复杂了一些,也确实有悖面向对象的思想。

在线程交互方面,其实只有输入线程唤醒电梯线程这一个稍微复杂的地方,再一个就是电梯线程的停止条件需要包含输入线程是否结束。实际上第五次作业基本上不存在线程竞争,需要加锁保证线程同步的地方有两处,一个是官方提供的输出类,一个是请求队列的add和remove,因为我的请求队列是一个ArrayList,因此需要加锁保证其add和remove能够线程同步,其他地方确实不需要考虑。

直观分析代码本身,发现Strategy的look算法实现的非常复杂,我为了能够保证方法行数不超过60,强行在一些地方进行了封装,因此这里的实现相当的不优雅。

对于该架构的优点:由于电梯之间比较独立,大体上整个电梯运行的过程还算是比较清晰,而且策略类的构建有利于后面作业的扩展,因为不同的电梯可能需要考虑不同的策略

该架构的缺点:灵活性还是不够,无法适应其他种类的电梯,由于采用的是静态调度,对后期作业可能存在的动态分配请求并没有良好的适应性,而且多个方法的实现都非常的复杂冗长,debug不方便且不优雅。

第六次作业(多多益善)

1.架构、调度器与多线程分析

第六次作业在第五次作业的基础上,架构方面有了一定的改动。根据指导书,我们增加了横向循环电梯,原先的楼层变成了横向电梯的楼座,原先的楼座变成了横向电梯的楼层,而且没有最高处和最低处之分,除此之外,对于横向请求,只有相对方向,没有绝对方向,因为电梯只要保持一个方向走,总会到达该请求的目的地。经过分析,由于横向电梯和纵向电梯拥有很多相同的属性,我打算把这两种电梯解离成纵向电梯(VerElevator)和横向电梯(HorElevator),同时继承一个主电梯类(Elevator),除了基本的属性,两种电梯的区别主要在于策略的不同,因此,Strategy里除了包含第一次实现的纵向look算法,又添加了横向look算法(所以复杂度又增加了......)。除此之外,对于电梯的属性(楼座,ID,楼层,内部乘客等),我又添加了一个二维请求数组<楼层,请求队列>。其实根据实际,乘客/请求可以说是和电梯是分开的,他们更像是楼的属性,然而之所以我将10层楼的请求队列放入电梯内部,是因为我忽略了楼的这个概念,电梯则是一个架空的对象,每个电梯有10个队列,进入电梯等同于外部队列remove,内部队列add,乘客出电梯等同于内部队列remove,而与这五栋楼没什么关系了。

此次作业还增加了添加电梯功能,因此,我这次构建了一个调度类(Schedule),该类的作用是两个,一个是获取增加电梯的请求向电梯容器中添加新电梯,另一个是获取乘客的请求将其分配到指定电梯。因此调度类里拥有我目前所拥有的所有电梯。而由于我每一部电梯都拥有属于自己的请求集合,我采用基准策略来将指令送往电梯。由于电梯个数的增多,其实理论上采用自由竞争的分配策略会更好一些,然而由于我不想对已有的架构进行大的调整,并且我想保证线程的安全性,因此,采用基准策略来平均分摊电梯,不过,依照我的理解,我并没有按照指导书中说的那样进行分配,而是选择目前内外乘客数量最少的电梯优先分配。我用了一个比较朴素的思想就是,越忙的电梯尽量就别给他增添请求了,把请求给一些比较闲的电梯能节省一点时间,尽管没有理论上验证是否合理,不过感觉和基准策略差不太多。与此同时,几台电梯同时去抢夺一个请求队列的情况就不存在,换句话说我直接就是多了10台电梯,然后无论怎么添加,各个电梯之间依然是相互独立的。

2.代码分析

UML类图如下:

UML协作图如下:

Statistic分析如下:

Metrics分析如下:

线程角度:由于我本着求稳的心态,在线程通信这方面基本上和第一次作业一样,换句话说尽管电梯增加了,每个请求去哪个电梯依然是固定的,该用sychronized封装的地方和上次也没什么区别。

架构角度:多了调度类,电梯也存在了继承关系,但是由于我所实现的调度本身就是一个静态的过程,而且两种电梯除了look算法有差别外在第六次作业中基本也没什么区别,因此主要的工作量依然是Strategy类中对于两种look算法的实现。这里我也确实是继承了我第一次作业的缺点,Strategy类不仅没有减轻负担反而还来了个加倍的工作量。其实这里应该对策略类和电梯类的交互进行一下重构,但是由于本人的懒惰就没有进行。

该架构的优点:对横纵向电梯的分离有利于后期的迭代,每个电梯对应各自的请求队列从而拥有良好的线程安全性。

该架构的缺点:完全采用基准策略静态分配,导致性能与使用自由竞争/其他优化方案的架构相比确实差了一些,其他缺点跟第一次一样。

第七次作业(动静结合)

1.架构、调度器与多线程分析

到了第二单元的最后一次作业,我的目的也变得很简单了,因为无需考虑后期的迭代了(可以为所欲为了哈),因此唯一的目的就是正确性(捎带着性能)。第七次作业可能都会猜到会有不同楼座不同楼层的请求了,这就涉及到了中转的过程了,我前两次作业牺牲了一部分性能就是为了尽量保证线程的安全性,这次无论如何都不能偷懒了,因为电梯线程之间是一定会有交互的。

相对于第六次作业,第七次作业主要加入了如下新功能:电梯的属性增加了,容量和爬楼时间由常数变成了可以改变的属性,除此之外,横向电梯多了一个属性就是开关门信息,利用一个1~31的数字,将其二进制的每一位和A~E楼座对应,代表着在某些楼座横向电梯无法开门。不过这些其实都是可以用正常的方法就可以解决的,最关键的就是这次作业来了较为随意的请求,这里就需要对该类请求做出特别的分析。其实,阅读指导书可以发现,只要是不同楼座的请求,都可能会发生中转过程。因此我构建了一个SpecialRequest类,继承官方提供的PersonRequest类,对于读到的fromBuilding和toBuilding不同的请求,都转化成SpecialRequest类型供调度器安排,而且SpecialRequest类中还多了一个属性就是中转楼层

这样整体架构就出来了,比第六次多了一个SpecialRequest类。至于调度器的变化,这里确实是第七次作业最关键的地方:调度器在读到PersonRequest的时候,如果发现这个请求的起始和目的楼座不同,那么就会重新new一个SpecialRequest对象,复制过来PersonRequest的所有属性,同时计算需要中转的楼层,根据目前所有楼层的电梯,在既存在横向电梯且开关门信息符合该请求的楼层中选择距离起始楼层和目的楼层之和最小的,找到中转楼层后电梯在分析请求的目的楼层时,对于SpecialRequest的目的楼层就不是toFloor,而是transferFloor了。

对于多种电梯的选择,我依然采用基准策略,但是在这基础上根据自己的理解进行了优化:在选择电梯的时候,速度快容量大的电梯优先选择。采用这样的策略在请求比较少的时候和自由竞争的性能差不多,但是在请求比较多的时候性能如何确实不太好说了。

如何实现整个中转过程呢?这里我采用了动静结合的方式。调度器本来只是输入线程的属性,但是由于从电梯中出来的请求可能是中转的请求,因此该请求还需要重新分配电梯,因此需要给策略类也配备一个调度器,所以当中转的乘客出电梯时候,策略类里会分析出该乘客目前的起始地和目的地,重新将一个新的PersonRequest扔进调度器内,这样就做到了静态分配电梯动态调度中转请求

在多线程角度看,调度器的功能之一就是根据读进来的请求分配电梯,由于调度器封装进了策略类内部,因此策略类的工作并没有变得特别繁重,而且电梯与电梯之间采用调度器进行沟通连接,线程安全方面也还算可以。但是由于中转请求的特点,电梯线程的结束条件需要多考虑一下,前两次作业电梯线程的结束条件是输入线程结束且本电梯的所有请求处理完毕,可中转请求的特点就是目前用不上的电梯过一段时间后可能会使用,因此电梯线程结束条件就改成了输入线程结束且全部电梯的所有请求处理完毕。这样就又导致了一个问题,不能光在输入线程结束后唤醒所有空闲的线程,在电梯线程结束后也要唤醒全部线程,否则输入线程提前结束,请求还没有处理完,会出现先唤醒再沉睡的错误(当然这里的问题因代码而异)。

2.代码分析

UML类图如下:

UML协作图如下:

Statistics分析如下:

Metrics分析如下:

根据上图可以看出,可拓展性几乎没有了,因此多个类的复杂度很高,而Strategy和Schedule两个类正是整个电梯调度过程的核心类,复杂度过高对debug和bug的修复确实会有很大影响。

该架构的好处:线程安全性高,动静结合的中转过程使得其既有基准策略的线程安全,又在性能上有了一定的优化。

该架构的缺点:真的不能再迭代了,再来其他电梯或策略是真的写不下去了;在性能上不太稳定,相比于自由竞争会差一些。

同步块和锁的分析

由于本人三次作业都在尽量避免线程冲突,优化方案也都尽量在静态过程中进行,因此在同步块和锁的这方面确实思考的不够多。

首先就是输出类,官方包提供的输出类是线程不安全的,而且TimableOutput相当于一个全局变量,如果有多个位置同时调用它的println方法的时候会发生一些奇怪的事情导致时间戳不递增。
因此,根据其他同学的想法,利用synchronized将TimableOutput.println封装成同步方法。

其次还需要考虑的地方就是ArrayList本身是线程不安全的,所以如果两个地方同时对ArrayList进行增删操作的时候可能会发生异常,因此,前两次作业仅仅是用synchronized锁住了特定的数组,这样可以避免该数组同时remove和add。等到了第七次作业,我采用了利用Collections.synchronizedList方法使数组变成线程安全的(代码如下),因为由于一些优化方法,需要对数组内部的元素进行排序,然而同时数组也可能会增删元素,因此我直接利用该方法让ArrayList变成线程安全的可以免除后顾之忧,但是性能可能会略有下降。

点击查看代码
        for (int i = 0; i < 10; i++) {
            Elevators[i] = new ArrayList<>();
            Collections.synchronizedList(Elevators[i]);
        }

互测/自测的策略+bug分析

跟第一单元相比,第二单元的输入如果手动构造会很麻烦,但是格式非常固定,由于我还不会什么高级语言,所以拿C随便生成了一些数据。比如说第五次作业,只要按照指导书限定好每个数据(时间,楼层,楼座,ID)的范围,就能够生成很多请求;第六、七次作业只需要添加一些可以生成增加电梯的随机输出,再丰富一些细节就可以了。不过我不会写评测机来验证我的代码的正确性,所以在生成数据的时候,一般只生成五六行,然后通过肉眼来观察过程的正确性。最后生成50-100行的数据来观察代码是否会出现TLE或者RA等错误。如果产生了TLE这样的错误,我一般会删减一些代码,尽量将错误锁定在几个特定输入上,然后就开始捋自己的代码执行过程,这个过程确实是低效的,但对我来说是不得不做的。至于判断是CTLE是RTLE,由于本人不会写评测机,因此确实不容易确定,只能通过IDEA的debug功能和在各个位置进行print来判断线程是否结束,哪里陷入死循环等等。至于bug时而出现时而不出现的问题,我也只能多做尝试,尽量保存每次出现bug的输入输出,没有什么巧妙的办法。

互测的时候,我是利用自动生成数据+构造刁钻数据的方法,自动生成数据一般比较随机,刁钻数据一般是如下几种:某种相同请求在同一时刻大量出现、同一座(层)楼有各种方向的请求、每层楼都有大量请求等等。由于不像第一单元利用肉眼就能看出对错,况且本地输出和评测机输出很有可能不一致,所以这几次互测我把每个数据都直接交到评测机上了。

对于产生的bug,主要是输出类线程不安全的问题,也是因为写完第五次作业后,清明节假期期间没太思考,既没看讨论区也没看水群,所以这里的问题给忽略了。

心得体会和反思

两个单元的学习确实感觉收获了很多。对于面向对象的理解更加深入,面向对象的英文是Object-Oriented,根据我的理解,Object其实说白了就是东西,对于程序员来说,在用代码描绘某件抽象的过程时,我们需要从中构建出实体出来,换句话说要对代码的功能和变量进行分类,对于一个需求,在学C语言程序设计的时候我们可能更加关注的是数据结构和运行过程,而对于面向对象,我们还要关注的有在实现需求的过程中,整个流程是由哪几个东西来干,每个东西有什么特点、能干什么事情,东西与东西之间有什么联系。当然,我的理解可能还很肤浅甚至是错误的,但是,我在学习OO课的过程中,确确实实能够感受到自己的编程思想在发生一些转变。

不过,需要提升的地方还有很多,比如说架构设计和代码实现上,尽管现在拿来题先开始设计架构而不是直接敲键盘了,但是设计到实现的过程还很艰难,感觉设计的时候考虑的东西还不够周到,导致实现的时候很多地方都得重新构思。其次,关于每个功能的代码实现,代码总是超过限制的行数,觉得自己可能在框架上是面向对象的,但是在具体敲键盘的时候依然是面向过程的,希望在以后的学习过程中能够提升自己面向对象的能力,能够更好的利用面向对象的思想理解世界。

除此之外,对于实验课我也有一些反思。每次实验课助教们都在对我们的作业架构进行一定的提示,所以实验课代码确实是我们应该借鉴和学习的,但是这两个单元我都忽视了实验代码的重要性,导致一直在堆自己的*山。但是,不得不说,我经常看不太懂实验代码,尽管实验代码的架构很清晰巧妙,但是我还需要很多时间去理解。所以很多时候自己的烂代码就像是自己的孩子,而实验代码就像是别人家的孩子,不管别人家的孩子多么优秀,我只愿意呵护关心自己的孩子。我这个比喻并不恰当,但是确实希望以后能够多理解理解实验代码,毕竟实验代码的架构和实现方式确实是教科书式的,如果能熟练应用在自己的作业中会更好。

posted @ 2022-04-29 12:45  霍墨墨  阅读(25)  评论(0编辑  收藏  举报