cv.cm

编码 · 设计模式

状态模式详解与示例

阅读约 6 分钟

简短回答

状态是一种行为型设计模式,它让对象在内部状态改变时改变其行为,看起来就像换了一个类。每个状态都成为实现同一接口的独立类,主对象(称为上下文)把工作委托给当前的状态对象。状态转换通过替换该状态对象完成,从而省去了在每个方法里检查状态字段的冗长 switch 或 if 链。

意图与它要解决的问题

意图:允许对象在其内部状态改变时改变它的行为,对象看起来像是修改了它的类。状态是《设计模式》(Gang of Four,GoF)一书中的行为型模式之一;其经典例子是 TCP 连接,它在监听、已建立和已关闭时表现各不相同。

以网上订单为例。它可以是待付款、已付款、已发货或已取消。只有待付款时才能付款,只有已付款时才能发货,待付款或已付款时可以取消,发货之后则不行。第一个版本保存一个 status 字符串,每个方法都以对它的 switch 开头。四个动作、四种状态,就是分散在四个方法里的十六个分支。新增“已退款”这样的状态,意味着要修改每一个方法,而且很容易漏掉一个。

使用状态模式后,每种状态成为一个类,它知道每个动作在该状态下意味着什么。订单持有对当前状态对象的引用,并把每个动作转发给它。

结构与参与者

角色职责
上下文客户端使用的对象;持有当前状态对象,并把与状态相关的行为委托给它
状态声明其行为依赖于状态的各个操作的接口
具体状态实现某一种状态的行为,并决定下一个状态是什么

状态转换可以由各个状态自己决定(每个状态在上下文上设置下一个状态,如下面所示),也可以由上下文借助转换表决定。让状态自己决定能把相关逻辑放在一起;集中的转换表则能让所有转换在一处一目了然。

TypeScript 实现

基类提供默认行为:拒绝该动作。每个具体状态只重写对它有效的动作。

abstract class OrderState {
  abstract readonly name: string;
  pay(order: Order): void { this.reject("pay"); }
  ship(order: Order): void { this.reject("ship"); }
  cancel(order: Order): void { this.reject("cancel"); }
  protected reject(action: string): never {
    throw new Error("Cannot " + action + " an order that is " + this.name);
  }
}

class Pending extends OrderState {
  readonly name = "pending";
  pay(order: Order) { order.setState(new Paid()); }
  cancel(order: Order) { order.setState(new Cancelled()); }
}

class Paid extends OrderState {
  readonly name = "paid";
  ship(order: Order) { order.setState(new Shipped()); }
  cancel(order: Order) { order.setState(new Cancelled()); } // refund here
}

class Shipped extends OrderState { readonly name = "shipped"; }
class Cancelled extends OrderState { readonly name = "cancelled"; }

class Order {
  private state: OrderState = new Pending();
  setState(next: OrderState) { this.state = next; }
  get status() { return this.state.name; }
  pay() { this.state.pay(this); }
  ship() { this.state.ship(this); }
  cancel() { this.state.cancel(this); }
}

const order = new Order();
order.pay();
order.ship();
console.log(order.status); // shipped
try { order.cancel(); } catch (e) { console.log((e as Error).message); }
// Cannot cancel an order that is shipped

要添加“已退款”状态,只需写一个新类,并只修改可以通向它的那些状态。Order 本身不用改。

改造前后对比

用状态字段和 switch 语句写出的同样规则。状态不多时这样可行,但每个方法都要重复完整的列表:

type Status = "pending" | "paid" | "shipped" | "cancelled";

class OrderBefore {
  status: Status = "pending";
  ship() {
    switch (this.status) {
      case "paid": this.status = "shipped"; break;
      default: throw new Error("Cannot ship an order that is " + this.status);
    }
  }
  // pay() and cancel() repeat the same switch
}

const o = new OrderBefore();
try { o.ship(); } catch (e) { console.log((e as Error).message); }
// Cannot ship an order that is pending

对于小规模的情况,这个版本完全没问题,可以说还更易读。在 TypeScript 中,为 Status 使用联合类型,可以让编译器检查每个分支是否都处理了。当每个状态的行为增长到超过一两行时,再改用状态类。

何时使用,何时不用

  • 当对象在不同状态下行为差异很大,并且许多方法里反复出现同样的状态检查时使用。
  • 当状态和转换会随时间变化,而你想把每个状态的规则放在一处时使用。
  • 用于工作流(订单、文档、工单)、协议连接、游戏角色,以及媒体播放器之类的界面组件。
  • 只有两三个状态且行为简单时不要使用;枚举或联合类型加 switch 更简单。
  • 对于带有嵌套或并行状态的复杂状态机,请考虑状态机库或状态图(statecharts)记法,而不是手写类。

实际应用

  • 网络协议的实现会为连接状态建模,如监听、已建立和已关闭,这也是 GoF 书中使用的例子。
  • XState 是用于状态机和状态图的 JavaScript 与 TypeScript 库,用于界面逻辑和工作流。
  • W3C 的 SCXML 规范定义了一种描述状态图的 XML 格式。
  • 游戏开发用状态对象表示角色(待机、奔跑、跳跃),每种状态对输入的处理方式不同。

状态与策略及其他模式的比较

模式与状态的区别
策略类结构几乎相同。策略通常由客户端选定,自己不会改变;状态则会因对象自身的动作而相互替换。
享元没有字段的状态对象可以作为享元或常量,在所有上下文之间共享。
单例无状态的状态对象通常是单个共享实例。
备忘录备忘录保存对象的数据以便之后恢复;状态改变对象所使用的行为。

常见问题

状态和策略有什么区别?

两者都把行为委托给一个可互换的对象。在策略中,通常由客户端选择算法,之后保持不变。在状态中,状态对象彼此了解,并在正常运行过程中把上下文切换到新的状态。

状态模式就是有限状态机吗?

状态模式是在面向对象代码中实现有限状态机的一种方式:每个状态是一个类,每次转换就是替换当前的状态对象。状态机也可以用转换表或库来实现。

下一个状态应该由上下文还是状态来决定?

两种都可以。由状态决定时,每个状态的逻辑集中在一起。由上下文或转换表决定时,所有转换都在一处可见,状态之间也不需要互相了解。

状态对象可以共享吗?

可以,只要它们自己不保存数据。这样所有上下文对象可以共用每种状态的一个实例,减少内存分配。

全部编码教程