一、什么是State模式
在面向对象编程中,是用类表示对象的。也就是说,程序的设计者需要考虑用类来表示什么东西。类对应的东西可能存在于真实世界中,也可能不存在于真实世界中。
在State模式中,我们用类来表示状态。在现实世界中,我们会考虑各种东西的“状态”,但是几乎不会将状态当作“东西”看待。因此,可能大家很难理解“用类来表示状态”的意思。
在本文中,我们将要学习用类来表示状态的方法。以类来表示状态后,我们就能通过切换类来方便地改变对象的状态。当需要增加新的状态时,如何修改代码这个问题也会很明确。
用一句话来概括:State模式就是用类来表示状态。
二、State模式示例程序
这里我们来看一个警戒状态每小时会改变一次的警报系统。
功能表:
结构图:
下面我们来用程序实现这个金库警报系统。
2.1 伪代码
2.1.1 不使用State模式的伪代码
刚接触到这样的需求,你会怎样设计代码呢?如果是我,我可能会这样设计:
使用金库时被调用的方法() { if(白天) { 向警报中心报告使用记录 } else if(晚上) { 向警报中心报告紧急事态 } } 警铃响起时被调用的方法() { 像警报中心报告紧急事态 } 正常通话时被调用的方法() { if(白天) { 呼叫警报中心 } else if(晚上) { 呼叫警报中心的留言电话 } }
2.1.2 使用State模式的伪代码
并不能说上面的代码有什么不对,只是我们今天要讲的State模式是完全不同的角度,咱们一起来看看他们的区别在哪,state模式的好处在哪。
表示白天的状态的类{ 使用金库时被调用的方法() { 向警报中心报告使用记录 } 警铃响起时被调用的方法() { 向警报中心报告紧急事态 } 正常通话时被调用的方法(){ 呼叫警报中心 } } 表示晚上的状态的类{ 使用金库时被调用的方法() { 向警报中心报告紧急事态 } 警铃响起时被调用的方法() { 向警报中心报告紧急事态 } 正常通话时被调用的方法(){ 呼叫警报中心的留言电话 } )
大家看明白以上两种伪代码之间的区别了吗?也许此时你会说,这**也能用类来表示?
在没有使用State模式的2.1.1中,我们会先在各个方法里面使用if语句判断现在是白天还是晚上,然后再进行相应的处理。而在使用了State模式的2.1.2中,我们用类来表示白天和晚上。这样,在类的各个方法中就不需要用if语句判断现在是白天还是晚上了。
总结起来就是,2.1.1是用方法来判断状态,2.1.2是用类来表示状态。那么,大家能够想象出我们是如何从方法的深处挖出被埋的“状态”,将它传递给调用者的吗?
接下来我们就来看看示例程序:
2.2 各个类之间的关系
先来看一下所有的类和接口。
再看看类图:
2.3 State接口
state接口是表示金库状态的接口。在state接口中定义了以下事件对应的接口:设置时间、使用金库、按下警铃、正常通话。
以上这些接口分别对应我们之前在伪代码中编写的“使用金库时被调用的方法”等方法。这些方法的处理都会根据状态不同而不同。可以说,state接口是一个依赖于状态的方法的集合。
public interface State { //设置时间 public abstract void doClock(Context context, int hour); //使用金库 public abstract void doUse(Context context); //按下警铃 public abstract void doAlarm(Context context); //正常通话 public abstract void doPhone(Context context); }
2.4 DayState类
Daystate类表示白天的状态。
对于每个表示状态的类,我们都只会生成一个实例。因为如果每次发生状态改变时都生成一个实例的话,太浪费内存和时间了。为此,此处我们使用了Singleton模式。
doUse、doAlarm、doPhone分别是使用金库、按下警铃、正常通话等事件对应的方法。它们的内部实现都是调用Context中的对应方法。请注意,在这些方法中,并没有任何“判断当前状态”的if语句。在编写这些方法时,开发人员都知道“现在是白天的状态”。在State模式中,每个状态都用相应的类来表示,因此无需使用if语句或是switch语句来判断状态。
public class DayState implements State{ //单例模式 private static DayState singleton = new DayState(); private DayState() {} public static State getInstance() { return singleton; } //切换白天或黑夜 @Override public void doClock(Context context, int hour) { if (hour<9 || 17<=hour) { 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("正常通话(白天)"); } public String toString() { return "[ 白天 ]"; } }
2.5 NightState类
NightState类表示晚上的状态。它与DayState类一样,也使用了Singleton模式。Nightstate类的结构与 Daystate完全相同。
public class NightState implements State{ private static NightState singleton = new NightState(); private NightState() {} public static State getInstance() { return singleton; } @Override public void doClock(Context context, int hour) { if (9<=hour && hour<17) { context.changeState(DayState.getInstance()); } } @Override public void doUse(Context context) { context.callSecurityCenter("紧急:晚上使用金库!"); } @Override public void doAlarm(Context context) { context.callSecurityCenter("按下警铃(晚上)"); } @Override public void doPhone(Context context) { context.recordLog("晚上的通话录音"); } public String toString() { return "[ 晚上 ]"; } }
2.6 Context接口
Context接口是负责管理状态和联系警报中心的接口。在介绍SafeFrame类时结合代码再说它实际进行了哪些处理。
public interface Context { //设置时间 public abstract void setClock(int hour); //改变状态 public abstract void changeState(State state); //联系警报中心 public abstract void callSecurityCenter(String msg); //在警报中心留下记录 public abstract void recordLog(String msg); }
2.7 SafeFrame类
SafeFrame类是使用GUI实现警报系统界面的类( safe有“金库”的意思)。它实现了context接口。
这里有必要说一下我们对按钮监听器的设置。我们通过调用各个按钮的addActionListener方法来设置监听器。addActionListener方法接收的参数是“当按钮被按下时会被调用的实例”,该实例必须是实现了ActionListener接口的实例。本例中,我们传递的参数是this,即SafeFrame类的实例自身(从代码中可以看到,SafeFrame类的确实现了ActionListener接口)。“当按钮被按下后,监听器会被调用”这种程序结构类似于我们在第17章中学习过的Observer模式。
还有必要说的是,在actionPerformed方法中虽然出现了if语句,但是它是用来判断“按钮的种类”的,而并非用于判断“当前状态”。请不要将我们之前说过“使用State模式可以消除if语句”误认为是“程序中不会出现任何if语句”。
public class SafeFrame extends Frame implements ActionListener, Context { //GUI控件 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()); //配置textClock add(textClock, BorderLayout.NORTH); textClock.setEditable(false); //配置textScreen 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); buttonPhone.addActionListener(this); buttonPhone.addActionListener(this); } //按下按钮后该方法会被调用,在该方法中,我们会先判断当前哪个按钮被按下了,然后进行相应的处理 @Override public void actionPerformed(ActionEvent e) { System.out.println(e.toString()); if (e.getSource() == buttonUse) { state.doUse(this); } else if (e.getSource() == buttonAlarm) { state.doAlarm(this); } else if (e.getSource() == buttonPhone) { state.doPhone(this); } else if (e.getSource() == buttonExit) { System.exit(0); } else { System.out.println("?"); } } //设置时间 @Override public void setClock(int hour) { String clockstring = "现在时间是"; if (hour < 10) { clockstring += "0" + hour + ":00"; } else { clockstring += hour + ":00"; } System.out.println(clockstring); textClock.setText(clockstring); state.doClock(this, hour); } //改变状态 @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"); } }
2.8 用于测试的Main类
Main类生成了一个safeFrame类的实例并每秒调用一次setClock方法,对该实例设置一次时间。这相当于在真实世界中经过了一小时。
public class Main { public static void main(String[] args) { SafeFrame frame = new SafeFrame("State Sample"); while (true) { for (int hour = 0; hour < 24; hour++) { //设置时间 frame.setClock(hour); try { Thread.sleep(1000); } catch (InterruptedException e) { } } } } }
程序的时序图:
三、拓展思路的要点
3.1 分而治之
在编程时,我们经常会使用分而治之的方针。它非常适用于大规模的复杂处理。当遇到庞大且复杂的问题,不能用一般的方法解决时,我们会先将该问题分解为多个小问题。如果还是不能解决这些小问题,我们会将它们继续划分为更小的问题,直至可以解决它们为止。分而治之,简单而言就是将一个复杂的大问题分解为多个小问题然后逐个解决。
在State模式中,我们用类来表示状态,并为每一种具体的状态都定义一个相应的类。这样,问题就被分解了。在本章的金库警报系统的示例程序中,只有“白天”和“晚上”两个状态,可能大家对此感受不深,但是当状态非常多的时候,State模式的优势就会非常明显了。
请大家再回忆一下前面的伪代码。在不使用State模式时,我们需要使用条件分支语句判断当前的状态,然后进行相应的处理。状态越多,条件分支就会越多。而且,我们必须在所有的事件处理方法中都编写这些条件分支语句。
State模式用类表示系统的“状态”,并以此将复杂的程序分解开来。
3.2 依赖于状态的处理
Main类会调用SafeFrame类的setClock方法,告诉setClock方法“请设置时间”。在setClock方法中,会像下面这样将处理委托给State类:state.doClock (this, hour) 。
也就是说,我们将设置时间的处理看作是“依赖于状态的处理”。
当然,不只是 doClock方法。在State接口中声明的所有方法都是“依赖于状态的处理”,都是“状态不同处理也不同”。这虽然看似理所当然,不过却需要我们特别注意。
在State模式中,我们应该如何编程,以实现“依赖于状态的处理”呢?总结起来有如下两点。
定义接口,声明抽象方法
定义多个类,实现具体方法
这就是State模式中的“依赖于状态的处理”的实现方法。
这里故意将上面两点说得很笼统,但是,如果大家在读完这两点之后会点头表示赞同,那就意味着大家完全理解了State模式以及接口与类之间的关系。
3.3 应当是谁来管理状态迁移
用类来表示状态,将依赖于状态的处理分散在每个ConcreteState角色中,这是一种非常好的解决办法。
不过,在使用State模式时需要注意应当是谁来管理状态迁移。
在示例程序中,扮演Context 角色的SafeFrame类实现了实际进行状态迁移的changeState方法。但是,实际调用该方法的却是扮演ConcreteState角色的 DayState类和NightState类。也就是说,在示例程序中,我们将“状态迁移”看作是“依赖于状态的处理”。这种处理方式既有优点也有缺点。
优点是这种处理方式将“什么时候从一个状态迁移到其他状态”的信息集中在了一个类中。也就是说,当我们想知道“什么时候会从 DayState类变化为其他状态”时,只需要阅读DayState类的代码就可以了。
缺点是“每个ConcreteState角色都需要知道其他ConcreteState角色”。例如,DayState类的doClock方法就使用了Nightstate类。这样,如果以后发生需求变更,需要删除NightState类时,就必须要相应地修改Daystate类的代码。将状态迁移交给ConcreteState角色后,每个ConcreteState角色都需要或多或少地知道其他ConcreteState角色。也就是说,将状态迁移交给ConcreteState角色后,各个类之间的依赖关系就会加强。
我们也可以不使用示例程序中的做法,而是将所有的状态迁移交给扮演Context角色的SafeFrame类来负责。有时,使用这种解决方法可以提高ConcreteState角色的独立性,程序的整体结构也会更加清晰。不过这样做的话,Context角色就必须要知道“所有的ConcreteState 角色”。在这种情况下,我们可以使用Mediator模式。
当然,还可以不用State模式,而是用状态迁移表来设计程序。所谓状态迁移表是可以根据“输入和内部状态”得到“输出和下一个状态”的一览表。当状态迁移遵循一定的规则时,使用状态迁移表非常有效。
此外,当状态数过多时,可以用程序来生成代码而不是手写代码。
3.4 不会自相矛盾
如果不使用State模式,我们需要使用多个变量的值的集合来表示系统的状态。这时,必须十分小心,注意不要让变量的值之间互相矛盾。而在State模式中,是用类来表示状态的。这样,我们就只需要一个表示系统状态的变量即可。
在示例程序中,SafeFrame 类的state字段就是这个变量,它决定了系统的状态。因此,不会存在自相矛盾的状态。
3.5 易于增加新的状态
在State模式中增加新的状态是非常简单的。以示例程序来说,编写一个XXXState类,让它实现State接口,然后实现一些所需的方法就可以了。当然,在修改状态迁移部分的代码时,还是需要仔细一点的。因为状态迁移的部分正是与其他ConcreteState角色相关联的部分。
但是,在State模式中增加其他“依赖于状态的处理”是很困难的。这是因为我们需要在State接口中增加新的方法,并在所有的ConcreteState 角色中都实现这个方法。
虽说很困难,但是好在我们绝对不会忘记实现这个方法。假设我们现在在State接口中增加了一个doYYY方法,而忘记了在Daystate类和Nightstate类中实现这个方法,那么编译器在编译代码时就会报错,告诉我们存在还没有实现的方法。
如果不使用State模式,就必须用if语句判断状态。这样就很难在编译代码时检测出“忘记实现方法”这种错误了(在运行时检测出问题并不难。我们只要事先在每个方法内部都加上一段“当检测到没有考虑到的状态时就报错”的代码即可)。
3.6 实例的多面性
请注意SafeFrame类中的以下两条语句。
safeFrame类的构造函数中的 buttonUse .addActionListener (this) ;
actionPerformed方法中的 state.doUse (this) ;
这两条语句中都有this。那么这个this到底是什么呢?当然,它们都是safeFrame类的实例。由于在示例程序中只生成了一个safeFrame 的实例,因此这两个this其实是同一个对象。
不过,在addActionListener方法中和doUse方法中,对this的使用方式是不一样的。
向addActionListener方法传递this时,该实例会被当作“实现了ActionListener接口的类的实例”来使用。这是因为addActionListener方法的参数类型是ActionListener类型。在addActionListener方法中会用到的方法也都是在ActionListener接口中定义了的方法。至于这个参数是否是safeFrame类的实例并不重要。
向doUse方法传递this时,该实例会被当作“实现了Context接口的类的实例”来使用。这是因为douse方法的参数类型是context类型。在doUse方法中会用到的方法也都是在Context接口中定义了的方法(大家只要再回顾一下 Daystate类和Nightstate类的doUse方法就会明白了)。
大家一定要透彻理解此处的实例的多面性。
四、相关的设计模式
4.1 Singleton模式
Singleton模式常常会出现在ConcreteState角色中。在示例程序中,我们就使用了Singleton模式。这是因为在表示状态的类中并没有定义任何实例字段(即表示实例的状态的字段)。
4.2 Flyweight模式
在表示状态的类中并没有定义任何实例字段。因此,有时我们可以使用Flyweight模式在多个Context角色之间共享ConcreteState角色。
五、思考题
5.1、
题目:
本来应当将Context定义为抽象类而非接口,然后让Context类持有state字段,这样更符合State模式的设计思想。但是在示例程序中我们并没有这么做,而是将Context角色定义为context 接口,让safeFrame类持有state字段,请问这是为什么呢?
答案:
因为在Java中只能单一继承,所以如果将Context角色定义为类,那么由于safeFrame类已经是Frame类的子类了,它将无法再继承context类。
不过,如果另外编写一个Context类的子类,并将它的实例保存在SafeFrame类的字段中,那么通过将处理委托给这个实例是可以实现习题中的需求的。
5.2、
题目:如果要对示例程序中的“白天”和“晚上”的时间区间做如下变更,请问应该怎样修改程序呢?
答案:
需要修改Daystate类(代码清单19-4 )以及Nightstate类(代码清单19-5)的doclock方法。
如果事先在SafeFrame类中定义一个isDay方法和一个isNight方法,让外部可以判断当前究竞是白天还是晚上,那么就可以将白天和晚上的具体时间范围限制在safeFrame类内部。这样修改后,当时间范围发生变更时,只需要修改safeFrame类即可。
5.3、
题目:请在示例程序中增加一个新的“午餐时间(12:00~12:59)”状态。在午餐时间使用金库的话,会向警报中心通知紧急情况;在午餐时间按下警铃的话,会向警报中心通知紧急情况;在午餐时间使用电话的话,会呼叫警报中心的留言电话。