【面向对象】第二单元总结

面向对象第二单元总结


三次作业设计策略分析


第一次作业

  • 总体设计:使用生产者-消费者模式,将输入作为生产者,将电梯作为消费者,将调度器作为共享对象。
  • 调度器:
    • 由于共享对象只会被实例化一次,因此使用单例模式,成员变量为一个指令队列。
    • 使用synchronized关键字对两个访问共享对象的方法进行同步,这两个方法使用wait-notify的策略。
  • 电梯:
    • 提供最基本的上行下行以及开关门的方法,不存在请求队列,也没有任何调度能力。
    • 采用傻瓜式调度,电梯每次只能取一条指令,执行完之后再取下一条指令,没有优化。

第二次作业

  • 总体设计:
    • 首先在总体上还是生产者-消费者模式,这一点与第一次相同。
    • 其次,在当时课上老师讲到了一种观察者模式,最初尝试这种方式的初衷,在于能够提高可拓展性,但是最后事与愿违,一方面是因为我对这种方法使用的理解上的误解,设计出了非常诡异的结构,另一方面更由于缺乏对多线程设计的认识,导致出现了非常严重的同步问题。由于这一点,直接导致我的第二次作业彻底失败,在后面我针对这几点进行具体的分析。
  • 调度器
    • 如前所述,这次调度器我分成了主调度器和子调度器。
    • 主调度器继承了java.util.Observable类,作为被观察者。
    • 子调度器继承了java.util.Observer类,作为观察者,观察主调度器的指令队列变化情况。(我当时直接是按照多个电梯的情况设想的,即为每一个电梯分派一个子调度器)
    • 关于具体的使用、我个人的误解、错误原因,我会在下一个部分详细说明。
  • 电梯
    • 这次由于对时间限制有要求,我使用了指导书中的ALS调度策略,通过判断以及设定主请求,来判断是否可以进行捎带,然而在使用中又出现了结构设计的问题,导致出现了严重的线程安全问题。
    • 电梯接收到的并不是课程组向我们提供的封装好的PersonRequest类,而是一个一个的单元请求,例如UPDOWNOPEN等。最初是为了考虑中途插入请求。
  • 显然,这次设计真的很差,包括调度器、捎带算法。

第三次作业

这次,痛定思痛,我在动笔之前自己想了好久,同时也请教了几位非常厉害的同学,设计的结构我认为相比较于前两次而言算是比较清楚,但是这次我是因为在优化时少考虑了一个条件判断,导致一个问题被强测反复测到,前期测试不充分,这一点在后面进行反思。

  • 总体设计:
    • 使用Work Thread模式
  • 调度器
    • 这次与上次相仿,也是有主调度器与子调度器之分,这次为了整体上的有序,我没有为子调度器单独构造一个类,而是将其囊括在了电梯里。
    • 显式设置的调度器,用于向不同的电梯里分发任务,这次由于考虑到强测数据的随机生成,并没有针对特定的情况进行优化,而是采用了随机争夺任务的策略,简而言之,就是如果一个判断当前任务能被那些电梯执行,并将任务放入到对应电梯的等待队列中,一旦有一个任务被一台电梯抢到,那么就删除其他电梯等待队列中相同的任务,来实现随机争夺任务。
    • 关于任务的分配与拆解,稍后解释。
  • 电梯
    • 如上所述,让电梯承担一部分调度任务,这个任务比较简单,就是从调度器中电梯对应的指令序列中,取出任务,然后进行一些捎带的处理与判断。
  • Splitrequest
    • 这是一个我封装的请求类,目的就是用于之前所说的在调度器中的任务分配与拆解。
    • 其实我们可以看到,在本次作业所指定的三个电梯中,三个电梯拥有彼此共同的楼层,即一个任务,最少只要被拆解为两个任务,就一定可以被完成。但是为了考虑到可能的后续优化以及可拓展性,我再这一个类中设置一个mainRequest,用于保存当前的立刻要被执行的指令。然后再设置一个指令等待队列,存储接下来要被执行的任务。
    • 使用原则是,每当mainRequest执行完成后,使用指令等待队列的第一个指令替换mainRequest,并将其从指令等待队列移除,一旦指令等待队列为空,则将mainRequest设置为null,表示指令执行结束。
  • SynOutput
    • 由于指导书指出提供的接口并不保证线程安全,因此封装一个线程安全的输出。

基于度量的程序结构分析


上面一节主要解释了一下我这几次作业的设计,这一节将根据一些分析数据,反思一下这几次的结构设计。

第一次作业

  • 结构分析

通过上面的关系图可以看出,这次就是一个非常单纯的生产者-消费者模式,主要内容我已经在第一部分对应进行介绍了,这里针对上面的类图进行一些补充的说明。我在主类中启动了电梯线程,之所以没有实例化调度器,是因为调度器已经使用了单例模式,在这个类中已经实例化好,并且使用了final关键字防止修改。

public class Dispatcher {
    private static Dispatcher dispatcher = new Dispatcher();
    private ArrayList<PersonRequest> list = new ArrayList<>();
  	public static Dispatcher getInstance() {
        return dispatcher;
    }
}
  • 依赖度分析

总体而言这次几个模块之间的依赖是比较才正常的,除去一些必要的依赖,比如生产者消费者对于共享变量的依赖等,其余并没有过大的耦合。

  • 复杂度分析


这次由于没有进行任何优化,使用的也是简单的傻瓜式调度的方法,所以基本上每一个类类中的每一个方法复杂度都比较低。

第二次作业

  • 结构分析

上面这张结构图看上去比较复杂,其中有很大一部分在于我设计的不合理,这一点我稍后会解释。我先来解释一下上面的结构图。

首先我们可以看到,这里面占比最大的就是一个SubDispatcher,这就是我在第一部分提到的子调度器,子调度器是一个观察者,在结构图上方的Dispatcher,就是之前提到的被观察者,一个主调度器。被观察者发生变化后,子调度器及时获取新的指令。

链接着子调度器的,在结构图的最下方,是Elevator以及TaskLoader,其中Elevator在第一部分介绍过,他就是一个只能根据指令队列进行执行的电梯,并不具有任何调度能力。至于TaskLoader,我想是这次设计一个很大的败笔,由于Java所提供的观察者类和被观察者类并不具有线程这样不断获取与分发的功能,所以我希望通过TaskLoader这样一个模块去不断地为电梯加载任务。下面附上一小段java.util.Obsevable以及与之配套的java.util.Obsever类的源代码。

public class Observable {
    private boolean changed = false;
    private Vector<Observer> obs;
  	
  	// 下面的方法用于添加观察者,对应的还有删除观察者的操作
  	public synchronized void addObserver(Observer o) {
        if (o == null)
            throw new NullPointerException();
        if (!obs.contains(o)) {
            obs.addElement(o);
        }
    }
  	
  	// 下面是一个通知观察者的函数,它的功能,主要就是调用观察者的update方法
  	public void notifyObservers(Object arg) {
        Object[] arrLocal;

        synchronized (this) {
            if (!changed)
                return;
            arrLocal = obs.toArray();
            clearChanged();
        }

        for (int i = arrLocal.length-1; i>=0; i--)
            ((Observer)arrLocal[i]).update(this, arg);
    }
}
public interface Observer {
    void update(Observable o, Object arg);
}

有了以上这些,我想在这里反省一下我出现的线程安全问题。我在当时以为,只要将调度器中所有需要同步的方法使用synchronized关键词修饰,就能避免线程安全问题,错误地把synchronized与线程安全划了等号。这就导致,这次的TaskLoader在使用调度器中的一个变量进行条件判断,并且已经进入了if-else语句块中,这时Elevator通过调用调度器的方法,修改了这个变量,但是这时TaskLoader中并不知道这个变量发生了改变,因此就出错了。

在这里我们可以看到,问题的关键不仅仅是将要将调度器中的什么方法使用synchronized关键词修饰,而是什么方法应该放到调度器中并进行同步,说到底,就是有该同步的方法没有做到同步。

那么,到底什么样的方法需要进行同步呢,什么才算是真正同步了呢?下面我想结合这次让我蒙受了巨大损失的代码,谈谈我的理解。

关于什么样的方法需要进行同步,我想,应当是直接或间接访问了共享变量的方法,都应当进行同步。在当时设计的时候,我只注意到了直接访问共享变量的方法,即在调度器里的方法。

public class TaskLoader extends Thread {
  	@Override
    public void run() {
        while (true) {
            PersonRequest request = sd.getWaitingRequest();
            if (request == null) {
                sd.addTask(null);
                break;
            } else {
              	......
            }
          ......
        }
      ......
}
public class Elevator extends Thread {
    private void debusPerson(int id) {
            sd.delPassengerById(id);
            sd.finishRequestById(id);
            TimableOutput.println(
                    String.format("OUT-%d-%d", id, sd.getCurrentFloor()));
        }
}

这里Elevator中的sd.finishRequestById(id);这一语句会修改SubDispatcher中的mainRequest,而TaskLoader需要使用mainRequest,这里就没有做同步。当时我错误的以为只要在调度器中将访问的方法使用synchronized修饰即可,而没有注意到这种修饰并不能使的两个方法同步,我们要同步的不仅仅是取的这个过程,而是整个两个函数的执行。

  • 依赖度分析

这次通过上面的统计表可以看到,TaskLoaderElevator以及SubDispatcher这三个模块彼此的依赖性非常高,这一点在最开始的结构图中也可以看到。这一部分也是一个很大的败笔,在最初设计的时候我本是希望能够降低多部电梯的耦合,提高可拓展性,感觉在一个电梯里出现这种相互依赖也并没有过于在意。但是,实际上,正是这种设计,一定程度上导致我当时没有注意到上面线程安全的问题,同时,一旦电梯的请求发生变化,或是要进行优化修改调度算法,都会造成很大的不便。

  • 复杂度分析



首先,在TaskLoader中,由于这次对指令的处理不合理(正如之前所说,我是单纯的把指令拆解成了"指令元",认为这样可能会方便插入指令,但是事后想想,即使不这样做,也有很方便的方法实现插入,反而因为每一个"指令元”包含的信息量太小(比如UP以及DOWN缺乏楼层信息等等),导致插入过程比较麻烦且略显抽象,同时,这是因为这个插入部分发生了线程安全问题),导致这部分的复杂度过高。

其次,由于当时认识的误区,在SubDispatcher中提供了大量的同步访问共享变量的方法,这些方法不仅没有保证了真正的线程同步,只是保证了访问过程的同步,而且更是让这个类显得极其臃肿,因此导致其复杂度非常高。

第三次作业

  • 结构分析


首先不说每一个类内部的具体复杂度,单纯就这个结构来看,我个人认为是比我第二次的结构要合理很多,这一次我才用Work-Thread模式,我想这一点,只要将我程序的结构图的样式稍作变形,即可等到我们讲义上给出的Work-Thread模式的模式图。在这里,输入线程(主线程)充当client,调度器充当Channel,电梯充当Worker,至于请求,正如第一部分所述,我使用的是自己封装的一个SplitRequest。而使用这一设计模式,其目标就在于能够更好地为多个工人分派任务。

  • 依赖度分析

从上面的表格或者从结构图中都可以看出,这次没有过于多余的耦合与依赖,因为没有像第二次作业一样一些诡异的设计,整体依赖度在合理范围之内。

  • 复杂度分析



这就是本次作业设计的一大问题所在——部分的类中方法过于集中,导致类的复杂度过高。比如上面表格中最主要的两个类SchedulerElevator。下面我来分别解释一下。

Scheduler类的复杂度过高,原因是因为我的一个认识误区。正如第二次作业中所说,我对线程同步的方法理解不够,以为所有要同步的方法都要放在共享对象,即这里的调度器里。其实这样只是为了方便管理。但是像这次一样需要进行各种的调度和调控,其实可以将一些方法重新分装为一个类,或者合并到其他的类中。要想这样做的同时保持同步,只要使用synchronized语句块即可,注意语句块的括号中所写的不是类而是一个对象,因为要获取的是一个对象的锁(这里也不必担心同步时语句块内有return语句,这样仍然没有问题)。(不过这次确实注意到了要正确的同步所有访问共享变量的方法)

Elevator类复杂度过高,是因为在电梯中增加了对捎带的判断,而这也是一个败笔,电梯参与了调度的处理,而这不是电梯该做的事,合理的做法是让电梯做好自己该做的事,这样想来,完全可以另外创建一个类,专门负责进行对电梯指令的调度,让它作为一个线程,同时让电梯也作为一个线程。

三次作业的UML协作图分析

我已经在上面一部分对三次作业的协同控制的设计进行了介绍,这里直接给出我使用PlantUML插件绘制的UML图。

这个插件使用的基本语法以及示例可以参考博客:https://blog.csdn.net/weixin_34417814/article/details/86804988

关于UML中各种标记,可以参考《图解Java多线程设计模式》

  • 第一次作业

  • 第二次作业

  • 第三次作业

基于SOLID原则对三次作业进行评价

SOLID原则具体而言是指:

S.O.L.I.D.是指SRP(单一责任原则)、OCP(开放封闭原则)、LSP(里氏替换原则)、ISP(接口分离原则)和DIP(依赖倒置原则)。

SRP强调的是模块之间的分离程度,通俗来讲,就是“每个模块做好自己该做的事”。通过上面的分析可以看到,第一次作业彼此之间分工明确,满足了这一原则的要求。第二次作业虽然结构设计总体而言很差,但是这一原则也是满足的,调度器就是负责调度,而电梯也只是接受指令,并执行,而没有插手任何有关调度的事情。但是第三次作业,我的电梯没有满足这一要求,电梯插手了调度,用来进行捎带的判断。这里可以更改一下设计,将电梯的队列调度从电梯中分离出来,让电梯听从其的安排。

OCP强调的是可扩展性,通俗来讲,就是接到新的任务,能够在原有程序上扩展,而不必大量修改内部结构以及整体结构的程度。这次的三次作业里,第二次确实是由第一次扩展过来的,虽然因为功能的要求增加了很多,但整体的架构以及基本的函数没有太大变化。正如之前所言,我在第二次添加了很多不合理的结构,为了保证第三次作业的结构设计,我进行了重构。

LSP要求子类包含父类的全部属性。本次作业的电梯之间并没有使用继承,其他方面也没有涉及到继承的问题,因此不涉及到这个问题。

ISP即“接口隔离原则”,一个类不应该依赖于它不需要的接口,本单元作业同样同样没有涉及到接口,所以也不涉及这个问题。

DIP原则,即依赖倒置原则,主要指的是实现类之间不产生依赖关系,依赖关系通过接口或者抽象类产生。这次作业在操作过程中单纯想着借助不同的多线程设计模式,没有关注这方面。我很想知道,怎样通过接口产生依赖关系,下面的内容参考自网上这篇博客:https://blog.csdn.net/qq_24451605/article/details/51355701

这篇博客中的例子如下:

interface Tool{
    public void doWork( Job job );
}

interface people{
    public void use ( Tool tool , Job job );
}

interface Job{
    public void finish();
}
class Student1 implements Person{

    @Override
    public void use(Tool tool, Job job) {
        System.out.print("Student use ");
        tool.doWork(job);
    }
}

class Pencil implements Tool{

    @Override
    public void doWork(Job job) {
        System.out.print("Pencile to do ");
        job.finish();
    }
}

class HomeWork implements Job{

    @Override
    public void finish() {
        System.out.println("Homework!!");
    }
}

上面的例子可以看出,三个具体实现的实现类之间没有依赖,他们所依赖的,是想要依赖的实现类对应的接口。那么这样如何降低了耦合呢?比如,就在上面这个例子,当我们的JobTool的实体发生了变化时,比如Pencil变成了Pen,如果我们是具体实现类的相互依赖,那么Person类中也要对函数的接口作出对应的修改。

这样的设计,又如何应用到我们这几次电梯的作业中呢?我们这几次电梯作业的依赖,最主要的,是生产者-调度器的依赖以及调度器-消费者(电梯)的依赖。如果要实现这种方式,更改的思路也很清楚,就是从生产者、调度器、消费者三者中将相互依赖的部分抽取出来构造接口,然后用对应的实现类实现这个接口。但是我不太清楚这样是否真的必要。因为这样设计的初衷在于防止因为具体实现类对于的变换对于其他的影响,但是在我们的作业中,这三部分在整体上是不会发生之前举例所说的那种改变的。不过确实,如果能遵循这种设计想法,我们所做的便可以不是简单的电梯调度,同时也抽象出来其背后的多线程设计思想。

自己程序的Bug分析

第一次作业

这次由于任务比较基础,同时我也没有使用比较特别的调度方法,只是进行傻瓜式调度,所以在中测和互测中均没有找到Bug。

第二次作业

这次的程序,与其说是Bug,不如说是从根本上的结构设计就不合理,导致出现了线程安全问题,即我没有做到正确地同步该同步的方法,具体的分析请见前文中对于第二次作业的分析部分。

第三次作业

这次吸取之前的教训,在强测和互测中没有出现线程安全的问题,但是出现了Wrong Answer,真的是Bug了。这次Bug出在了电梯调度过程中的捎带处理上,我在判断捎带时分为了两个阶段,分别是到达指令的起始楼层前以及到达后。在到达前,判断是否捎带,我只考虑了电梯向上运行时的情况,忽视了电梯向下运行的情况,导致错误捎带了不该捎带的请求,进而产生了Bug。(顺带也深刻认识到了全面测试的重要性,由于进入强测前自己测试地不够充分,导致这个问题的发生,进而使得强测和互测被hack很多,其实这些都是同质Bug,但是会因此在强测中丢掉大量的分数)。具体变更如下。

发现别人Bug采用的策略

这次发现别人Bug的主要方式有两种。

一种是去读别的同学写的代码。正如之前课上所讲,越是复杂的代码越有可能出现Bug,而要找到Bug,应当先从这方面找。我一般会先会直接浏览一下,大致了解结构之后,找比较长,或者看起来逻辑并不是那么明显的地方,这里还可以借助IDEA的代码复杂度分析工具辅助,然后从这部分优先看起。

另一种就是生成随机数据测试。我承认,这里存在一定的盲目性,但是有时大量的随机数据有助于发现一些因为线程安全问题而导致的Bug,以及因为优化问题而导致的超时的Bug,以及自己在第一阶段没有注意的Bug。

心得体会

首先总体上看,第二单元里我见识到了很多,从自己之前从未了解过的多线程,到各种设计模式,以及多线程设计的注意事项(比如如何正确进行线程同步,印象极其深刻,惨痛换来的),同时也barefacedly去找了几位非常优秀的同学要代码来读,学到了东西,有收获。不过从实际表现上看,近几次作业做的真的是很差。这也充分体现了课下自己测试的重要性。我想在接下来一单元,我在真正动工之前,会花更多的时间在设计上,而且不止一个人设计,在自己拿出设计方案后,和同学一起讨论,对这个方案进行评估与修改,然后再进行实现,磨刀不误砍柴功(例如,第二次作业,自己大致想了想然后就动工,一开始写起来挺快,到最后越写问题越多,最终花费几天时间,结果却极其的差)。另一方面,我会更加注重课下自己测试,我也改进了自己的测试系统,在下一单元付之实践。

原文地址:https://www.cnblogs.com/yjy2019/p/10735213.html