设计模式之状态模式

参考资料

  • 曾探《JavaScript设计模式与开发实践》;
  • JavaScript 设计模式之状态模式
  • javascript 设计模式之状态模式

定义

状态(State)模式属于行为型设计模式,对有状态的对象,把复杂的“判断逻辑”提取到不同的状态对象中,允许状态对象在其内部状态发生改变时改变其行为。

简单的说,就是定义好各个状态(一般是定义好的),通过一个中间对象来设置和获取当前状态,并执行对应状态的方法。

状态模式的关键是区分事物内部的状态,事物内部状态的改变往往会带来事物的行为改变。

在状态模式中,我们创建表示各种状态的对象和一个行为随着状态对象改变而改变的 context 对象。

意图: 允许对象在内部状态发生改变时改变它的行为,对象看起来好像修改了它的类。

主要解决: 对象的行为依赖于它的状态(属性),并且可以根据它的状态改变而改变它的相关行为。

何时使用: 代码中包含大量与对象状态有关的条件语句。

如何解决: 将各种具体的状态类抽象出来。

注意事项: 在行为受状态约束的时候使用状态模式,而且状态不超过 5 个。

使用场景:

  • 点灯开关(开、关);
  • 文件下载(开始、暂停、完成、失败等);
  • 游戏(走动、攻击、防御、跌倒、跳跃);
  • 红绿灯(红、绿、黄切换);

初识状态模式

我们来想象这样一个场景:有一个电灯,电灯上面只有一个开关。当电灯开着的时候,此时按下开关,电灯会切换到关闭状态;再按一次开关,电灯又将被打开。同一个开关按钮,在不同的状态下,表现出来的行为是不一样的。

第一个例子:电灯程序

首先给出不用状态模式的电灯程序实现:

var Light = function() {
  this.state = 'off' // 电灯初始状态 off
  this.button = null // 电灯开关按钮
}

接下来定义 Light.prototype.init 方法,该方法负责在页面中创建一个真实的 button 节点。假设这个 button 就是电灯的开关按钮,当 buttononclick 事件被触发时,就是电灯开关被按下的时候。

Light.prototype.init = function () {
  var button = document.createElement('button'),
    self = this;
  button.innerHTML = '开关';
  this.button = document.body.appendChild(button);
  this.button.onclick = function () {
    self.buttonWasPressed();
  }
};

当开关被按下时,程序会调用 self.buttonWasPressed 方法,开关按下之后的所有行为,都将被封装在这个方法里。

Light.prototype.buttonWasPressed = function () {
  if (this.state === 'off') {
    console.log('开灯');
    this.state = 'on';
  } else if (this.state === 'on') {
    console.log('关灯');
    this.state = 'off';
  }
};
var light = new Light();
light.init();

但是,这个世界上的电灯并非只有一种。许多酒店里有另外一种电灯,这种电灯也只有一个开关,但它的表现是:第一次按下打开弱光,第二次按下打开强光,第三次才是关闭电灯。现在必须改造上面的代码来完成这种新型电灯的制造:

Light.prototype.buttonWasPressed = function () {
  if (this.state === 'off') {
    console.log('弱光');
    this.state = 'weakLight';
  } else if (this.state === 'weakLight') {
    console.log('强光');
    this.state = 'strongLight';
  } else if (this.state === 'strongLight') {
    console.log('关灯');
    this.state = 'off';
  }
};

现在来看下上述程序的缺点:

  • buttonWasPresses 是违反开放-封闭原则的,每次新增或者修改时都需要改动此方法中的代码。
  • 所有跟状态有关的行为,都封装在 buttonWasPressed 里面,如果状态过多,这个方法将会很复杂。
  • 状态的切换非常不明显,而且不能一目了然地明白电灯一共有几种状态。
  • 状态之间的切换关系,不过是往 buttonWasPressed 方法里堆砌 if、else 语句,增加或者修改一个状态可能需要改变若干个操作,这使 buttonWasPressed 更加难以阅读和维护。

状态模式改进电灯程序

通常我们谈到封装,一般都会优先封装对象的行为,而不是对象的状态。但在状态模式中刚好相反,状态模式的关键是把事物的每种状态都封装成单独的类,跟此种状态有关的行为都被封装在这个类的内部,所以 button 被按下的的时候,只需要在上下文中,把这个请求委托给当前的状态对象即可,该状态对象会负责渲染它自身的行为。

首先将定义 3 个状态类,分别是 offLightStateWeakLightStatestrongLightState。这 3 个类都有一个原型方法 buttonWasPressed,代表在各自状态下,按钮被按下时将发生的行为:

// OffLightState:
var OffLightState = function (light) {
  this.light = light;
};
OffLightState.prototype.buttonWasPressed = function () {
  console.log('弱光'); // offLightState 对应的行为 
  this.light.setState(this.light.weakLightState);
};

// WeakLightState:
var WeakLightState = function (light) {
  this.light = light;
};
WeakLightState.prototype.buttonWasPressed = function () {
  console.log('强光'); // weakLightState 对应的行为 
  this.light.setState(this.light.strongLightState);
};

// StrongLightState:
var StrongLightState = function (light) {
  this.light = light;
};
StrongLightState.prototype.buttonWasPressed = function () {
  console.log('关灯'); // strongLightState 对应的行为
  this.light.setState(this.light.offLightState); // 切换状态到 offLightState
};

接下来改写 Light 类,在 Light 类的构造函数里为每个状态类都创建一个状态对象,这样一来我们可以很明显地看到电灯一共有多少种状态:

var Light = function () {
  this.offLightState = new OffLightState(this);
  this.weakLightState = new WeakLightState(this);
  this.strongLightState = new StrongLightState(this);
  this.button = null;
};

button 按钮被按下的事件里,Context 也不再直接进行任何实质性的操作,而是通过 self.currState.buttonWasPressed() 将请求委托给当前持有的状态对象去执行。

Light.prototype.init = function () {
  var button = document.createElement('button'),
    self = this;
  this.button = document.body.appendChild(button);
  this.button.innerHTML = '开关';
  this.currState = this.offLightState;
  this.button.onclick = function () {
    self.currState.buttonWasPressed();
  }
};

最后还要提供一个 Light.prototype.setState 方法,状态对象可以通过这个方法来切换 light 对象的状态。

Light.prototype.setState = function (newState) {
  this.currState = newState
}

这种情况下,当我们需要为 light 对象增加一种新的状态时,只需要增加一个新的状态类即可。假设现在 light 对象多了一种超强光的状态,那就先增加 SuperStrongLightState 类:

var SuperStrongLightState = function (light) {
  this.light = light
}
SuperStrongLightState.prototype.buttonWasPressed = function () {
  console.log('关灯');
  this.light.setState(this.light.offLightState);
};

然后在 Light 构造函数里新增一个 superStrongLightState 对象:

var Light = function () {
  this.offLightState = new OffLightState(this);
  this.weakLightState = new WeakLightState(this);
  this.strongLightState = new StrongLightState(this);
  this.superStrongLightState = new SuperStrongLightState(this); // 新增 superStrongLightState 对象 
  this.button = null;
};

最后改变状态类之间的切换规则,从 StrongLightState→OffLightState 变为 StrongLightState→SuperStrongLightState→OffLightState:

StrongLightState.prototype.buttonWasPressed = function () {
  console.log('超强光'); // strongLightState 对应的行为 
  this.light.setState(this.light.superStrongLightState);// 切换状态到 offLightState
};

缺少抽象类的变通方式

我们看到,在状态类中将定义一些共同的行为方法,Context 最终会将请求委托给状态对象的这些方法,在这个例子里,这个方法就是 buttonWasPressed。无论增加了多少种状态类,它们都必须实现 buttonWasPressed 方法。

Java 中,所有的状态类必须继承自一个 State 抽象父类,当然如果没有共同的功能值得放入抽象父类中,也可以选择实现 State 接口。这样做的原因一方面是我们曾多次提过的向上转型,另一方面是保证所有的状态子类都实现了 buttonWasPressed 方法。遗憾的是,JavaScript 既不支持抽象类,也没有接口的概念。所以在使用状态模式的时候要格外小心,如果我们编写一个状态子类时,忘记了给这个状态子类实现 buttonWasPressed 方法,则会在状态切换的时候抛出异常。因为 Context 总是把请求委托给状态对象的 buttonWasPressed 方法。

我们可以让抽象父类的抽象方法直接抛出一个异常,这个异常至少会在程序运行期间就被发现:

var State = function () { };
State.prototype.buttonWasPressed = function () {
  throw new Error('父类的 buttonWasPressed 方法必须被重写');
};
var SuperStrongLightState = function (light) {
  this.light = light;
};
SuperStrongLightState.prototype = new State(); // 继承抽象父类
SuperStrongLightState.prototype.buttonWasPressed = function () {
  console.log('关灯');
  this.light.setState(this.light.offLightState);
};

JavaScript版本的状态机

上面我们使用的是传统的面向对象的方式实现状态模式,在JavaScript中,没有规定状态对象一定要从类中创建而来。另外,JavaScript可以非常方便利用委托技术,不需要事先让一个对象持有另一个对象,我们可以通过Function.prototype.call方法直接把请求委托给某个对象字面来执行。下面看下实现的代码:

var FSM = {
  off: {
    buttonWasPressed: function () {
      console.log('关灯')
      this.currentState = FSM.on
    }
  },  
  on: {
    buttonWasPressed: function () {
      console.log('开灯')
      this.currentState = FSM.off
    }
  }
}

var Light = function () {
  this.currentState = FSM.off // 设置初始状态
  this.button = null
}

Light.prototype.init = function () {
  var self = this

  var button = document.createElement('button')
  this.button = document.body.appendChild(button)
  this.button.innerHTML = '开关'

  this.button.onclick = function () {
    self.currentState.buttonWasPressed.call(self)  // 把请求委托给状态机FSM
  }
}

const light = new Light()
light.init()

我们还可以使用闭包来编写这个例子,我们需要实现一个delegate函数:

var delegate = function (client, delegation) {
  return {
    buttonWasPressed: function () {  // 将客户的请求委托给delegation对象
      return delegation.buttonWasPressed.apply(client, arguments)
    }
  }
}

var FSM = {
  off: {
    buttonWasPressed: function () {
      console.log('关灯')
      this.currentState = FSM.on
    }
  },  
  on: {
    buttonWasPressed: function () {
      console.log('开灯')
      this.currentState = FSM.off
    }
  }
}

var Light = function () {
  this.offState = delegate(this, FSM.off)
  this.onState = delegate(this, FSM.on)
  this.currentState = this.offState // 设置初始状态
  this.button = null
}

Light.prototype.init = function () {
  var self = this

  var button = document.createElement('button')
  this.button = document.body.appendChild(button)
  this.button.innerHTML = '开关'

  this.button.onclick = function () {
    self.currentState.buttonWasPressed()
  }
}

状态模式中的性能优化点

  • 有两种选择来管理 state 对象的创建和销毁。第一种是仅当 state 对象被需要时才创建并随后销毁,另一种是一开始就创建好所有的状态对象,并且始终不销毁它们。如果 state 对象比较庞大,可以用第一种方式来节省内存,这样可以避免创建一些不会用到的对象并及时地回收它们。但如果状态的改变很频繁,最好一开始就把这些 state 对象都创建出来,也没有必要销毁它们,因为可能很快将再次用到它们。
  • 我们为每个 Context 对象都创建了一组 state 对象,实际上这些 state 对象之间是可以共享的,各 Context 对象可以共享一个 state 对象,这也是享元模式的应用场景之一。

状态模式和策略模式的关系

相同点

它们都有一个上下文、一些策略或者状态类,上下文把请求委托给这些类来执行。

区别

策略模式中的各个策略类之间是平等又平行的,它们之间没有任何联系,所以客户必须熟知这些策略类的作用,以便客户可以随时主动切换算法;

而在状态模式中,状态和状态对应的行为是早已被封装好的,状态之间的切换也早被规定完成,改变行为这件事情发生在状态模式内部。对客户来说,并不需要了解这些细节。这正是状态模式的作用所在。

优缺点

优点

  • 状态模式定义了状态与行为之间的关系,并将它们封装在一个类里。通过增加新的状态类,很容易增加新的状态和转换。
  • 避免 Context 无限膨胀,状态切换的逻辑被分布在状态类中,也去掉了 Context 中原本过多的条件分支。
  • 用对象代替字符串来记录当前状态,使得状态的切换更加一目了然。
  • Context 中的请求动作和状态类中封装的行为可以非常容易地独立变化而互不影响。

缺点

  • 状态模式的缺点是会在系统中定义许多状态类,编写 20 个状态类是一项枯燥乏味的工作,而且系统中会因此而增加不少对象。
  • 由于逻辑分散在状态类中,虽然避开了不受欢迎的条件分支语句,但也造成了逻辑分散的问题,我们无法在一个地方就看出整个状态机的逻辑。

总结

状态模式既是解决程序中的雍肿的分支判断语句问题,将每个分支转换为一种状态独立出来,方便每种状态的管理又不至于每次执行时遍历所有分支。在程序中到底产出哪种行为结果,决定选择哪种状态,而选择何种状态又是持续运行时决定的。当然状态模式最终目的即是简化分支判断流程

你可能感兴趣的:(设计模式,设计模式,状态模式)