设计模式 07 管理状态

作者 柚爸

管理状态一直是程序中一个很重要的因素, 毕竟从本质上说计算机就是一个状态机, 程序不过是改变计算机中电子的分布状态而已.

  1. Observer 观察者模式
  2. 练习
  3. Memento 备忘录模式
  4. 练习
  5. State 状态模式
  6. 练习

Observer 模式

这个模式相当经典, 一定要好好看, 其本质是在自身状态发生变化的时候, 通知已经在自己这里注册的一系列观察者.

很多类库都使用了这个模式, 包括后边打算看的Java 8 和Java 9新增的流式API等等.

观察者模式的核心, 其实就是要在被观察的类里边用一个容器装着观察者, 只要一发生变化, 就挨个通知容器中的观察者. 这个模式适合根据对象状态进行相应处理的情况.

惯例, 先上Observer接口. 然后在需要改变状态的类里注册一批Observer类型的观察者.

public interface Observer {

    void update(NumberGenerator generator);

}

然后是被观察者的抽象类:

import java.util.ArrayList;

public abstract class NumberGenerator {

    private ArrayList<Observer> observers = new ArrayList<>();

    //注册新的观察者
    public void addObserver(Observer observer) {
        this.observers.add(observer);
    }
    //去除观察者
    public void deleteObserver(Observer observer) {
        this.observers.remove(observer);
    }

    //通知观察者
    public void notifyObserver() {
        for (Observer observer : observers) {
            observer.update(this);
        }
    }

    public abstract int getNumber();

    public abstract void execute();
}

这里的被观察者的抽象类核心实现的就是注册观察者和通知观察者的方法, 来看实现类:

import java.util.Random;

public class RandomNumberGenerator extends NumberGenerator {

    private Random random = new Random();

    private int number;

    @Override
    public int getNumber() {
        return number;
    }

    @Override
    public void execute() {
        number = random.nextInt(1000);
        notifyObserver();
    }
}

两个观察者则是在update方法中获取被观察对象的数据:

public class DigitObserver implements Observer {

    @Override
    public void update(NumberGenerator generator) {
        System.out.println("接收到被观察者的的通知, 数值是: " + generator.getNumber());
    }
}
public class GraphObserver implements Observer {

    @Override
    public void update(NumberGenerator generator) {
        System.out.println("****" + generator.getNumber() + "****");
    }
}

然后就可以来使用了:

public class Main {
    public static void main(String[] args) {
        //创建一个被观察者和两个观察者
        RandomNumberGenerator randomNumberGenerator = new RandomNumberGenerator();
        DigitObserver digitObserver = new DigitObserver();
        GraphObserver graphObserver = new GraphObserver();

        //向被观察者中注册两个观察者
        randomNumberGenerator.addObserver(digitObserver);
        randomNumberGenerator.addObserver(graphObserver);

        //反复改变被观察者的状态
        randomNumberGenerator.execute();
        randomNumberGenerator.execute();
        randomNumberGenerator.execute();

    }
}

观察者的一大特点也是解耦, 即无需知道观察者的具体类, 只要符合观察者接口, 就可以工作, 每个观察者对于拿到的状态变化如何进行处理, 互相之间也不相干.

同样, 观察者由于是被动得到通知, 也无需知道自己观察了谁, 只要符合被观察者的接口即可.

所以设计模式的基础就是抽象类和接口这一类可以复用的API和对象. 一般只要是出现以类作为参数并且依靠参数的类提供服务的时候, 都要考虑接口或者抽象类来替代具体类.

由于这里其实观察者并不是主动的看, 而是等待被观察者的通知, 所以观察者模式又叫做Publish-Subscribe 发布-订阅模式.

额外的知识, Java也提供了对于观察者模式的支持, 就像对于迭代器模式的支持一样. 有一个接口叫做java.util.Observer, 还有一个类叫做java.util.Observable, 很显然, 前者是观察者接口, 后者是被观察者的类.

java.util.Observer接口中定义的方法是: update(Observable obj, Object arg), 第一个参数就是被观察对象, 第二个是额外附带的参数. 其结构与我们的观察者模式是一样的.

所以要使用的话, 我们只要实现java.util.Observer, 然后让被观察者继承java.util.Observable就可以了. 不过java.util.Observable是一个类而不是接口, 如果我们自己的类已经有了继承体系, 就不太好用了.

练习

习题17-1

这个也很容易了, 每次计算即可:

public class IncrementNumberGenerator extends NumberGenerator {

    private int start;
    private int step;
    private int end;
    private int count = 0;
    private int currentNumber = 0;

    public IncrementNumberGenerator(int start, int step, int end) {
        this.start = start;
        this.step = step;
        this.end = end;
    }

    @Override
    public int getNumber() {
        return currentNumber;
    }

    @Override
    public void execute() {
        if (count == 0) {
            currentNumber = start;
        } else {
            if (currentNumber + step < end) {
                currentNumber += step;
            }
        }
        count++;
        notifyObserver();
    }
}

Memento 模式

这个模式的名称是一个非常经典的电影 – 记忆碎片. 在很多棋类软件中, 有悔棋的设定, 只要按一下, 就可以将棋局恢复到若干步之前的状态.

如果我们仅仅用一个对象来保存棋局的状态, 那就会丢失历史状态, 因此有了这么一个模式, 可以让一个对象来保存不同的时候的该对象的状态, 用来恢复.

其实在之前我遇到的一些例子里已经有这种用法了, 比如一个游戏的Game对象, 可以利用一系列数据生成, 于是每走动一步, 就会新创建一个Game对象保存起来. 这里的例子也是如此, 来看看吧.

这里的例子也是一个小游戏, 有一个Gamer对象, Gamer对象会创建Memento对象用于保存状态, 必要的时候可以通过Memento对象来读取状态.

import java.util.ArrayList;
import java.util.List;

public class Memento {

    int money;

    ArrayList<String> fruits;

    public int getMoney() {
        return money;
    }

    Memento(int money) {
        this.money = money;
        this.fruits = new ArrayList<>();
    }

    void addFruit(String fruit) {
        fruits.add(fruit);
    }

    List getFruits() {
        return (List) fruits.clone();
    }
}

Memento类用来保存游戏中的某个状态锁需要的数据, 也就是金钱和所持有的水果. 相当于一个游戏存档一样, 让游戏可以从存档中恢复状态.

然后是Gamer类, 可以认为是一个游戏的主体:

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class Gamer {

    private int money;
    private List<String> fruits = new ArrayList<>();
    private Random random = new Random();
    private static String[] fruitsname = {"Apple", "Grapes", "Banana", "Orange"};

    public Gamer(int money) {
        this.money = money;
    }

    public int getMoney() {
        return money;
    }

    public void bet() {
        int dice = random.nextInt(6) + 1;

        if (dice == 1) {
            money += 100;
            System.out.println("所持金钱增加了.");
        } else if (dice == 2) {
            money /= 2;
            System.out.println("所持金钱减少了");
        } else if (dice == 6) {
            String f = getFruit();
            System.out.println("获得了水果: " + f);
            fruits.add(f);
        } else {
            System.out.println("什么都没有发生");
        }

    }

    private String getFruit() {
        String prefix = "";
        if (random.nextBoolean()) {
            prefix = "好吃的";
        }
        return prefix + fruitsname[random.nextInt(fruitsname.length)];
    }

    @Override
    public String toString() {
        return "Gamer{" +
                "money=" + money +
                ", fruits=" + fruits +
                '}';
    }

    //与Memento相关的代码

    //依照现在的游戏状态创建Memento对象, 相当于保存存档
    public Memento createMemento() {
        Memento m = new Memento(money);
        for (String fruit : fruits) {
            m.addFruit(fruit);
        }
        return m;
    }

    //加载状态
    public void restoreMemento(Memento memento) {
        this.money = memento.getMoney();
        this.fruits = memento.getFruits();
    }

}

这个游戏逻辑很简单, 重点在于Memento相关的部分, 相当于一个保存存档, 一个加载存档. 然后来使用一下看看:

public class Main {

    public static void main(String[] args) {
        Gamer newGamer = new Gamer(100);

        int i = 0;

        for (; i < 5; i++) {
            System.out.println("---------------------回合" + i + "-----------------------");
            newGamer.bet();
            System.out.println(newGamer);
        }

        System.out.println("保存存档");
        Memento memento = newGamer.createMemento();

        for (; i < 10; i++) {
            System.out.println("---------------------回合" + i + "-----------------------");
            newGamer.bet();
            System.out.println(newGamer);
        }

        newGamer.restoreMemento(memento);

        System.out.println(newGamer);
    }
}

在进行了5次bet()之后, 保存存档, 再进行5次bet(), 然后加载存档, 再打印出此时的游戏状态, 可以看到已经恢复到了原来的状态.

这里还可以使用一个某个长度的容器, 每次状态改变之后, 都保存一个状态, 这样就可以实现类似悔棋的功能了.

本质上说, 这里就像一个系统的快照一样, 只保存必要的数据, 然后通过其中恢复.

练习

练习18-1

一般Caretaker都是在包外操作的, 如果让Caretaker角色可以任意的操作Memento对象, 则可以直接修改历史状态, 打破了这个模式对外的封装.


练习18-2

减少保存空间的方法不外乎减少基础数据量和压缩数据两种办法.


练习18-3

首先不管加上什么修饰符, Memento类对于自己的字段都是可以访问的. Gamer类可以获取但不能改变, 则设置成private比较好, 然后通过getter来暴露.

既然是private, 自然Main类也就无法获取了.


练习18-4

这个就不实际做了, 其实就是套了一个输出对象序列化的外壳。

State 模式

用类来表示状态,这是state模式的核心思想。

在传统的判断状态的方法里,需要用到if语句等等,现在我们将状态封装到一个类中,对于不同的状态,呼叫不同的类来提供服务。

当然,在具体的语句中肯定还是会有if, 但是将其封到了类中之后,结构更加清晰了。

书里举得例子是一个金库,这个金库有白天和黑夜两种模式。与其使用if判断,state模式将金库分成两个状态的对象, 即夜晚的金库和白天的金库, 两个对象的行为不同。来看接口先:

public interface State {

    void doClock(Context context, int hour);

    void doUse(Context context);

    void doAlarm(Context context);

    void doPhone(Context context);
}

通过这个状态对象的接口,就可以发现,不同状态下的这些行为其实是不同的。Context也是一个接口,是为金库提供服务的对象。

然后就是Context接口:

public interface Context {

    void setClock(int hour);

    void changeState(State state);

    void callSecurityCenter(String msg);

    void recordLog(String msg);
}

这个接口其实是实现状态切换的类,其中使用State对象来提供具体服务。

然后来看两个金库的具体实现类:

public class DayState implements State {

    private DayState() {

    }

    private static DayState singleton = new DayState();

    public static State getInstance() {
        return singleton;
    }

    @Override
    public void doClock(Context context, int hour) {
        //在非上班时间, 将context对象中的金库对象切换成黑夜的金库
        if (hour <= 9 || hour >= 17) {
            context.changeState(NightState.getInstance());
        }
    }

    @Override
    public void doUse(Context context) {
        context.recordLog("白天的金库运行中");
    }

    @Override
    public void doAlarm(Context context) {
        context.callSecurityCenter("按下警铃:白天");
    }

    @Override
    public void doPhone(Context context) {
        context.callSecurityCenter("正常通话:白天");
    }

    @Override
    public String toString() {
        return "[白天的金库]";
    }
}

程序运行起来只有一个金库的两个状态, 因此先使用了单例模式。doClock是核心方法,即将context对象使用的金库对象切换成夜晚,如果不是,就不进行切换。

NightState类很相似:

public class NightState implements State {

    private NightState() {

    }

    private static NightState singleton = new NightState();

    public static State getInstance() {
        return singleton;
    }

    @Override
    public void doClock(Context context, int hour) {
        //在上班时间, 切换成白天的金库
        if (hour >= 9 && hour < 17) {
            context.changeState(DayState.getInstance());
        }
    }

    @Override
    public void doUse(Context context) {
        context.recordLog("夜晚的金库运行中");
    }

    @Override
    public void doAlarm(Context context) {
        context.callSecurityCenter("按下警铃:夜晚");
    }

    @Override
    public void doPhone(Context context) {
        context.callSecurityCenter("正常通话:夜晚");
    }

    @Override
    public String toString() {
        return "[夜晚的金库]";
    }
}

然后就是SetFrame类, 实现了Context接口, 可以将其理解为一个使用两个金库对象的管理对象, 也就是管理金库的两个状态. 作者这里使用了一个GUI界面, 对于我来说, 关键是要看如何管理两种状态:

import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

public class SafeFrame extends Frame implements Context, ActionListener {

    private TextField textClock = new TextField(60);
    private TextArea textScreen = new TextArea(10, 60);
    private Button buttonUse = new Button("使用金库");
    private Button buttonAlarm = new Button("按下警铃");
    private Button buttonPhone = new Button("正常通话");
    private Button buttonExit = new Button("退出");


    //设置程序启动时候的状态, 这里状态直接就是一个对象
    private State state = DayState.getInstance();

    //这是构造器, 就是启动一个窗口和四个按钮
    public SafeFrame(String title) {
        super(title);
        setBackground(Color.lightGray);
        setLayout(new BorderLayout());
        add(textClock, BorderLayout.NORTH);
        textClock.setEditable(false);
        add(textScreen, BorderLayout.CENTER);
        textScreen.setEditable(false);
        Panel panel = new Panel();
        panel.add(buttonUse);
        panel.add(buttonAlarm);
        panel.add(buttonPhone);
        panel.add(buttonExit);

        add(panel, BorderLayout.SOUTH);

        pack();

        show();

        buttonUse.addActionListener(this);
        buttonAlarm.addActionListener(this);
        buttonExit.addActionListener(this);
        buttonPhone.addActionListener(this);
    }

    //这是核心方法, ActionListener接口的方法

    //在按下按钮的时候, 调用state对象的方法, 具体是哪个状态, 是由时间决定的.
    @Override
    public void actionPerformed(ActionEvent e) {
        System.out.println(e.toString());
        if (e.getSource() == buttonUse) {
            state.doUse(this);
        } else if (e.getSource() == buttonPhone) {
            state.doPhone(this);
        } else if (e.getSource() == buttonAlarm) {
            state.doAlarm(this);
        } else if (e.getSource() == buttonExit) {
            System.exit(0);
        } else {
            System.out.println("-->");
        }
    }

    //这个方法也很关键, 由此可见, context和state对象是采取双向分发的方式, context中调用state对象的动作, state对象根据时间设置context中要使用的状态对象
    @Override
    public void setClock(int hour) {
        String current = "现在时间是: ";
        if (hour < 10) {
            current = "0" + hour + ":00";
        } else {
            current = hour + ":00";
        }
        System.out.println(current);
        textClock.setText(current);
        state.doClock(this, hour);
    }

    //state对象根据当前状态设置不同的状态对象
    @Override
    public void changeState(State state) {
        System.out.println("从" + this.state + "变成" + state);
        this.state = state;
    }

    @Override
    public void callSecurityCenter(String msg) {
        textScreen.append("call " + msg + "\n");
    }

    @Override
    public void recordLog(String msg) {
        textScreen.append("record " + msg + "\n");

    }
}

使用起来很简单:

public class Main {

    public static void main(String[] args) throws InterruptedException {
        SafeFrame safeFrame = new SafeFrame("金库模式");
        while (true) {

            for (int hour = 0; hour < 24; hour++) {
                safeFrame.setClock(hour);
                Thread.sleep(500);
            }
        }
    }
}

这个模式状态对象和使用状态的对象之间联系还是挺紧密的. 看上去其实是state对象在操作context对象, 确实很有意思.

看了一下作者的总结,State对象是不由外部使用的, 外部使用Context对象及其API. Context是一个状态管理器, 负责持有状态并根据状态做出响应.

想了一下, 如果要添加更多的状态, 比如把时间分的更细一些, 只需要新增一个状态类, 然后修改自己的doClock方法来根据条件改变成其他状态对象即可. Context的代码完全无需修改, 依然是将改变的条件传给state对象.

由此可见双向分发和单例模式在state模式中都是必须的. 书中最后也提到了, 状态处理和切换实际上分散在各个状态对象中, Context对象永远就做两件事情, 设置时间, 调用方法, 就结束了. 这是很好的解耦办法.

不过书中234也提到了, 谁负责状态切换, 谁就要知道其他所有的状态对象, 我们这里是State对象, 当然也可以让Context对象来切换. 这时候就有点像仲裁者模式了. 如果状态实在太复杂, 就可以考虑状态机算法.

练习

练习19-1

这是因为awt体系里已经有继承了, 所以无法采用抽象类. 如果是自行编写的其他程序, 可以考虑抽象类, 这样可以少写几个方法和域的定义.


练习19-2

这里引入了分钟,可以单独判断分钟, 也可以通过获取系统时间. 不一一写代码了.