行为型模式:状态模式与命令模式

从创建到行为

前四节我们一直围绕「对象的创建」做文章——策略模式虽是行为型模式,但我们学习它的主要目的是引出三大设计原则。从本节开始,我们正式进入行为型模式(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
    [*] --> 空闲
    空闲 --> 已预订 : 预订房间
    空闲 --> 已入住 : 直接入住
    已预订 --> 已入住 : 办理入住
    已预订 --> 空闲 : 取消预订
    已入住 --> 空闲 : 退房

实例:论坛用户等级

来看一个更完整的例子。某论坛系统的用户分为三个等级:新手、高手、专家,等级由积分决定。不同等级下的行为差异如下:

行为 新手(<100< 100 分) 高手(100\ge 100<1000< 1000 分) 专家(1000\ge 1000 分)
发表留言 +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 模式 策略模式、简单工厂模式、工厂方法模式、抽象工厂模式、建造者模式、原型模式、状态模式命令模式