行为型模式:状态模式与命令模式
从创建到行为
前四节我们一直围绕「对象的创建」做文章——策略模式虽是行为型模式,但我们学习它的主要目的是引出三大设计原则。从本节开始,我们正式进入行为型模式(Behavioral Patterns)的领域。
创建型模式关注的是「对象怎么来」,行为型模式关注的则是「对象怎么做」——更准确地说,是对象之间如何分配职责、如何通信。本节介绍两种常见的行为型模式:状态模式和命令模式。它们看似解决不同的问题,但有一个共同主题:用对象替代条件分支——状态模式用状态对象替代 if-else 状态判断,命令模式用命令对象替代方法的直接调用。
状态模式
一个满是 if-else 的噩梦
考虑一个酒店房间管理系统。每个房间有三种状态:空闲、已预订、已入住。不同状态下能执行的操作不同——空闲的房间可以预订或直接入住,已预订的房间可以入住或取消预订,已入住的房间可以退房。
最直觉的做法是在每个操作方法里用状态字符串做条件判断:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | // 伪代码——不使用状态模式 if (state.equals("空闲")) { if (预订房间) { 预订操作; state = "已预订"; } else if (住进房间) { 入住操作; state = "已入住"; } } else if (state.equals("已预订")) { if (住进房间) { 入住操作; state = "已入住"; } else if (取消预订) { 取消操作; state = "空闲"; } } else if (state.equals("已入住")) { // ...更多分支 } |
这段代码有几个明显问题:
- 状态越多,分支越深。如果再加上「维修中」「VIP 专属」等状态,
if-else会呈笛卡尔积式膨胀 - 违反开闭原则。新增状态需要修改已有的条件语句块
- 违反单一职责原则。一个方法同时处理所有状态下的逻辑,职责不清
有没有办法把不同状态下的行为「拆」到各自的类里?这就是状态模式要做的事情。
模式定义
状态模式
状态模式(State Pattern)允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。
Allow an object to alter its behavior when its internal state changes. The object will appear to change its class.
别名:状态对象(Objects for States)。属于对象行为型模式。
「看起来似乎修改了它的类」——这个说法值得品味。客户端与环境对象交互时,同一个方法在不同时刻表现出完全不同的行为,仿佛对象变成了另一个类的实例。实际上,对象本身没变,变的是它内部持有的状态对象。
模式结构
classDiagram
direction LR
class Context {
-state : State
+setState(State state) void
+request() void
}
class State {
<<abstract>>
+handle(Context context)* void
}
class ConcreteStateA {
+handle(Context context) void
}
class ConcreteStateB {
+handle(Context context) void
}
Context o--> State : state
State <|-- ConcreteStateA
State <|-- ConcreteStateB
三个核心角色:
- Context(环境类):拥有状态的对象,维护一个
State实例。客户端通过 Context 发起请求,Context 把请求委托给当前状态对象处理 - State(抽象状态类):定义一个接口,封装与 Context 某个特定状态相关的行为
- ConcreteState(具体状态类):每个子类实现一种状态下的行为,并在适当时候触发状态转换
状态模式如何工作
状态模式的核心思路是将每种状态下的行为封装到独立的类中。以酒店房间为例,重构后的「空闲状态」类只需要关心空闲状态下的操作:
1 2 3 4 5 6 7 8 9 10 11 12 | public class FreeState extends RoomState { @Override public void handle(Room context, String action) { if (action.equals("预订")) { System.out.println("预订成功"); context.setState(new BookedState()); // 转换到已预订状态 } else if (action.equals("入住")) { System.out.println("直接入住"); context.setState(new CheckedInState()); // 转换到已入住状态 } } } |
对比之前的 if-else 瀑布:每个状态类只负责自己的行为逻辑,状态转换通过调用 context.setState() 完成。当系统需要增加新状态时,只需新增一个状态类,不必修改已有状态类的核心逻辑。
需要注意的是,状态类需要通过 Context 的引用来回调 setState() 方法触发状态切换。因此,状态类与环境类之间通常存在关联关系或依赖关系。
下面这张状态图展示了酒店房间的状态转换逻辑:
stateDiagram-v2
direction LR
[*] --> 空闲
空闲 --> 已预订 : 预订房间
空闲 --> 已入住 : 直接入住
已预订 --> 已入住 : 办理入住
已预订 --> 空闲 : 取消预订
已入住 --> 空闲 : 退房
实例:论坛用户等级
来看一个更完整的例子。某论坛系统的用户分为三个等级:新手、高手、专家,等级由积分决定。不同等级下的行为差异如下:
| 行为 | 新手( 分) | 高手( 且 分) | 专家( 分) |
|---|---|---|---|
| 发表留言 | +1 分 | +2 分(双倍) | +2 分(双倍) |
| 回复留言 | +1 分 | +1 分 | +1 分 |
| 下载文件 | 不可用 | 扣除积分(余额不足则拒绝) | 扣除一半积分 |
每次操作后都要检查积分变化是否触发等级转换。如果用 if-else 实现,每个操作方法里都要写一长串状态检查和转换逻辑。而用状态模式,我们只需为三个等级各写一个状态类:
classDiagram
direction LR
class ForumAccount {
-user : String
-point : int
-state : AbstractState
+setState(AbstractState state) void
+setPoint(int point) void
+getPoint() int
+writeNote() void
+replyNote() void
+downloadFile() void
}
class AbstractState {
<<abstract>>
#account : ForumAccount
#point : int
+checkState()* void
+writeNote()* void
+replyNote()* void
+downloadFile()* void
}
class PrimaryState {
+checkState() void
+writeNote() void
+replyNote() void
+downloadFile() void
}
class MiddleState {
+checkState() void
+writeNote() void
+replyNote() void
+downloadFile() void
}
class ExpertState {
+checkState() void
+writeNote() void
+replyNote() void
+downloadFile() void
}
ForumAccount o--> AbstractState : state
AbstractState <|-- PrimaryState
AbstractState <|-- MiddleState
AbstractState <|-- ExpertState
每个状态类的 checkState() 方法在操作完成后检查积分,决定是否需要切换状态。例如 PrimaryState(新手状态)在积分达到 1000 分时切换到 ExpertState,达到 100 分时切换到 MiddleState。这样每个状态类只管自己等级内的逻辑和「出口条件」,干净整洁。
优缺点
优点:
| 优点 | 说明 |
|---|---|
| 封装转换规则 | 状态转换逻辑内聚到状态类中,而非散落在条件语句里 |
| 枚举化状态 | 将隐式的字符串/整数状态显式化为类,在编译期就能发现状态遗漏 |
| 消除条件语句 | 将庞大的 if-else / switch 替换为多态分派 |
| 易于扩展 | 新增状态只需添加新的状态类 |
| 共享状态对象 | 多个环境对象可以共享同一个状态对象,减少对象个数 |
缺点:
- 增加类和对象的数量——每种状态都是一个类
- 结构较为复杂,使用不当会导致状态类之间的依赖关系混乱
- 对开闭原则的支持不完美:增加新状态可能需要修改已有状态类中的转换逻辑
第三个缺点值得注意。虽然状态模式消除了环境类中的条件判断,但状态转换逻辑转移到了各个具体状态类中。增加新状态时,需要修改与之相关的已有状态类(因为可能有新的转换路径),这违反了开闭原则。
适用场景
- 对象的行为依赖于其状态,且状态在运行时频繁改变
- 代码中存在大量与状态相关的条件语句,导致可维护性差
典型的应用领域包括工作流系统(如 OA 审批:未办理 → 正在办理 → 正在审核 → 已完成)和游戏开发(角色升级带来行为变化,游戏本身也有开始 / 运行 / 暂停 / 结束等状态)。
模式扩展
共享状态
当多个环境对象需要共享同一个状态时(例如所有论坛账户共享同一套等级规则),可以将状态对象定义为环境类的静态成员。这样多个环境实例使用同一个状态对象,减少内存开销。
简单状态模式 vs 可切换状态模式
根据状态之间是否需要转换,状态模式可以分为两种变体:
简单状态模式:各状态相互独立,不需要在状态之间切换。客户端直接实例化某个状态类并设置到环境对象中。由于不涉及状态间的相互引用,这种模式完全遵守开闭原则——新增状态类对已有系统毫无影响,甚至可以通过配置文件动态切换。
可切换状态模式:这是更常见的情况——状态之间存在转换关系。具体状态类内部需要调用 context.setState() 进行状态切换,因此状态类与环境类之间存在双向依赖。这种模式的灵活性更高,但增加新状态可能需要修改相关的已有状态类。
命令模式
请求也可以是对象
想象一个万能遥控器:你按下一个按钮,灯亮了;按下另一个按钮,空调开了。遥控器(请求发送者)并不知道灯泡和空调的内部工作原理,它只知道「按下按钮就会有事发生」。如果你想把某个按钮从「开灯」改成「关窗帘」,只需重新绑定按钮的功能,遥控器本身不需要任何改动。
这就是命令模式的直觉:把「请求」本身封装成一个对象,发送者只需要持有这个对象并调用它,不需要知道请求最终由谁执行、如何执行。
模式定义
命令模式
命令模式(Command Pattern)将一个请求封装为一个对象,从而使我们可用不同的请求对客户进行参数化;对请求排队或者记录请求日志,以及支持可撤销的操作。
Encapsulate a request as an object, thereby letting you parameterize clients with different requests, queue or log requests, and support undoable operations.
别名:动作模式(Action)或事务模式(Transaction)。属于对象行为型模式。
定义中有三个关键能力:
- 参数化:同一个调用者可以绑定不同的命令对象,实现不同的功能
- 排队 / 日志:命令对象可以被存储、传递、序列化,支持延迟执行和操作记录
- 撤销 / 恢复:命令对象可以记录执行前的状态,支持 Undo/Redo
模式结构
classDiagram
direction LR
class Client
class Invoker {
-command : Command
+setCommand(Command cmd) void
+call() void
}
class Command {
<<abstract>>
+execute()* void
}
class ConcreteCommand {
-receiver : Receiver
+execute() void
}
class Receiver {
+action() void
}
Client ..> Invoker
Client ..> ConcreteCommand
Invoker o--> Command : command
Command <|-- ConcreteCommand
ConcreteCommand --> Receiver : receiver
五个角色各司其职:
| 角色 | 职责 |
|---|---|
| Command | 抽象命令接口,声明 execute() 方法 |
| ConcreteCommand | 实现 execute(),将请求委托给 Receiver |
| Invoker | 调用者 / 请求发送者,持有命令对象,调用其 execute() |
| Receiver | 接收者,实际执行业务操作 |
| Client | 创建命令对象并组装 Invoker 和 Receiver 的关系 |
典型代码
命令模式的代码结构非常规整。抽象命令类定义执行接口:
1 2 3 | public abstract class Command { public abstract void execute(); } |
具体命令类持有接收者的引用,在 execute() 中调用接收者的方法:
1 2 3 4 5 6 7 8 9 10 11 12 | public class ConcreteCommand extends Command { private Receiver receiver; public ConcreteCommand(Receiver receiver) { this.receiver = receiver; } @Override public void execute() { receiver.action(); // 将请求委托给接收者 } } |
调用者只认识抽象命令,不关心具体命令是谁、接收者是谁:
1 2 3 4 5 6 7 8 9 10 11 | public class Invoker { private Command command; public void setCommand(Command command) { this.command = command; } public void call() { command.execute(); // 调用者只管「发号施令」 } } |
接收者包含实际的业务逻辑:
1 2 3 4 5 | public class Receiver { public void action() { // 具体的业务操作 } } |
整个调用链路是:Client → Invoker → Command → Receiver。Invoker 和 Receiver 之间没有直接引用,完全通过 Command 对象解耦。
sequenceDiagram
participant Client
participant Invoker
participant Command as ConcreteCommand
participant Receiver
Client->>Command: new ConcreteCommand(receiver)
Client->>Invoker: setCommand(command)
Client->>Invoker: call()
Invoker->>Command: execute()
Command->>Receiver: action()
实例一:电视机遥控器
电视机遥控器是命令模式的经典比喻。电视机是接收者(Receiver),遥控器是调用者(Invoker),每个按钮对应一个具体命令:
classDiagram
direction LR
class Controller {
-openCommand : Command
-closeCommand : Command
-changeCommand : Command
+call(String type) void
}
class Command {
<<abstract>>
+execute()* void
}
class TVOpenCommand {
-tv : Television
+execute() void
}
class TVCloseCommand {
-tv : Television
+execute() void
}
class TVChangeCommand {
-tv : Television
+execute() void
}
class Television {
+open() void
+close() void
+changeChannel() void
}
Controller o--> Command
Command <|-- TVOpenCommand
Command <|-- TVCloseCommand
Command <|-- TVChangeCommand
TVOpenCommand --> Television
TVCloseCommand --> Television
TVChangeCommand --> Television
遥控器(Controller)持有三个命令对象,分别对应开机、关机、换台操作。遥控器不需要了解电视机的内部工作原理——它甚至不知道自己控制的是电视机还是音响。只要命令对象实现了 Command 接口,遥控器就能正常工作。
实例二:功能键设置
来看一个更贴近实际开发的例子。某系统提供了一组功能键(FunctionButton),用户可以自定义每个功能键的用途——按下时可能退出系统,也可能打开帮助界面。用户通过修改配置文件来改变功能键的绑定:
classDiagram
direction LR
class FunctionButton {
-command : Command
+setCommand(Command cmd) void
+onClick() void
}
class Command {
<<abstract>>
+execute()* void
}
class ExitCommand {
-handler : SystemExitClass
+execute() void
}
class HelpCommand {
-handler : DisplayHelpClass
+execute() void
}
class SystemExitClass {
+exit() void
}
class DisplayHelpClass {
+display() void
}
FunctionButton o--> Command
Command <|-- ExitCommand
Command <|-- HelpCommand
ExitCommand --> SystemExitClass
HelpCommand --> DisplayHelpClass
这个设计的精妙之处在于:功能键类(FunctionButton)与具体功能类(SystemExitClass、DisplayHelpClass)之间完全解耦。通过配置文件(如 XML)指定每个功能键绑定哪个命令类,运行时利用反射动态加载——增加新功能不需要修改任何已有代码,完美满足开闭原则。
优缺点
优点:
| 优点 | 说明 |
|---|---|
| 降低耦合 | 发送者与接收者完全解耦,互不知晓 |
| 易于扩展 | 新增命令类无需修改已有代码,满足开闭原则 |
| 命令队列 | 命令对象可以排队、延迟执行、异步调度 |
| 宏命令 | 可以将多个命令组合成一个宏命令批量执行 |
| Undo/Redo | 命令对象可以记录状态,支持撤销和恢复 |
缺点:
- 可能导致系统中存在过多的具体命令类——每一个操作都对应一个命令类,命令种类多时类的数量会膨胀
适用场景
- 需要将请求的发送者与接收者解耦,使得调用者和接收者不直接交互
- 需要在不同时间指定请求、排队请求和执行请求
- 需要支持命令的撤销(Undo)和恢复(Redo)操作
- 需要将一组操作组合在一起执行(宏命令)
实际应用
Java AWT/Swing 事件模型:Java 的委派事件模型(Delegation Event Model, DEM)就是命令模式的典型应用。界面组件(如 Button、Frame)是请求发送者,事件监听器接口(如 ActionListener)是抽象命令,用户自定义的监听器实现类是具体命令。界面组件只认识监听器接口,不关心具体实现。
Shell 编程:UNIX Shell 脚本可以将多条命令封装在一个脚本文件中,一条命令就能执行整个命令序列——这本质上就是宏命令。
模式扩展
撤销操作
命令模式天然适合实现撤销功能。思路很简单:在命令对象中增加一个 undo() 方法,在执行 execute() 之前保存接收者的状态。调用者维护一个命令历史栈,撤销时弹出栈顶命令并调用其 undo() 方法即可:
1 2 3 4 | public abstract class Command { public abstract void execute(); public abstract void undo(); // 撤销操作 } |
文本编辑器的 Ctrl + Z 就是这种机制的直接体现:每次编辑操作(插入、删除、替换)都封装为一个命令对象入栈,撤销时从栈中取出最近的命令并恢复到执行前的状态。
宏命令
宏命令(Macro Command)是命令模式和组合模式联用的产物。宏命令本身也是一个 Command,但它内部维护一个命令列表。调用宏命令的 execute() 时,会依次调用列表中每个命令的 execute():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | public class MacroCommand extends Command { private List<Command> commands = new ArrayList<>(); public void add(Command cmd) { commands.add(cmd); } @Override public void execute() { for (Command cmd : commands) { cmd.execute(); } } } |
宏命令的成员可以是简单命令,也可以是另一个宏命令——形成树状的命令结构。这使得一键执行一系列复杂操作成为可能。
状态模式 vs 命令模式
两种模式都用独立的对象封装了行为,但出发点不同:
| 维度 | 状态模式 | 命令模式 |
|---|---|---|
| 核心目的 | 根据内部状态改变行为 | 解耦请求的发送者和接收者 |
| 对象封装的是 | 一种状态下的完整行为 | 一个具体请求 |
| 状态/命令由谁切换 | 状态对象自行触发转换 | 客户端显式绑定命令 |
| 典型场景 | 状态机、工作流 | 遥控器、事件处理、Undo/Redo |
设计工具箱更新:
| 层次 | 内容 |
|---|---|
| OO 基础 | 抽象、封装、多态、继承 |
| OO 原则 | 封装变化、面向接口编程、组合优于继承 |
| OO 模式 | 策略模式、简单工厂模式、工厂方法模式、抽象工厂模式、建造者模式、原型模式、状态模式、命令模式 |