22、状态模式:模式动机与定义、角色与结构、C++代码实现、状态机应用

模式动机:为什么我们需要状态模式?

先聊个我自己的经历。几年前我接手过一个通信协议解析模块,里面有个对象会根据收到的报文类型,在「空闲」、「同步」、「数据接收」、「校验」这几个状态之间跳转。最初的实现方式很简单——一个枚举变量加一个巨大的 switch-case。

嗯,刚开始确实爽。写起来快,逻辑也直观。但三个月后需求变了,要新增一个「重传」状态。我打开那个文件,看到 switch 里密密麻麻的分支,头皮都麻了。改一个状态,得去翻所有 case 有没有遗漏。更可怕的是,某个 case 里忘了 break,直接穿透到下一个状态,线上出了个隐蔽的 bug。

你想想看,这种场景其实很常见。一个对象的行为,完全取决于它内部的状态。状态一多,条件分支就爆炸。每次新增状态,都要动既有代码,改一处可能影响全局。这就是状态模式要解决的问题——把状态相关的行为抽出来,封装成独立的状态类,让状态切换变成对象替换

核心动机:允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。

模式定义与角色结构

状态模式的定义其实很简洁:将每个状态封装成一个类,状态之间的转换由状态类自身或上下文类来管理。说白了,就是把原来散落在 switch-case 里的逻辑,搬到各自的状态类里去。

来看一下它的角色结构:

  • Context(上下文):持有当前状态对象的引用。对外暴露的接口,实际上委托给当前状态对象去执行。
  • State(抽象状态):定义所有具体状态的公共接口。通常是一个抽象类或接口。
  • ConcreteState(具体状态):实现 State 接口,封装了该状态下的具体行为。同时负责决定何时切换到下一个状态。

我习惯把 Context 想象成一个「遥控器」,State 就是「当前按下的模式键」。按了空调模式,遥控器就表现出空调的行为;按了电视模式,就表现出电视的行为。对象还是那个对象,但行为变了。

Context - state: State* + Request() State(抽象) + Handle(Context*) 持有 ConcreteStateA + Handle(Context*) ConcreteStateB + Handle(Context*) ConcreteStateC + Handle(Context*) 状态切换:Handle() 内部修改 Context 的 state

C++ 代码实现

直接上代码。我们实现一个简单的 TCP 连接状态机:有 Established、Listening、Closed 三个状态。每个状态对「打开连接」、「关闭连接」、「监听」这三个操作有不同的响应。

// 前向声明
class TCPConnection;

// 抽象状态类
class TCPState {
public:
    virtual ~TCPState() = default;
    virtual void Open(TCPConnection* conn) = 0;
    virtual void Close(TCPConnection* conn) = 0;
    virtual void Listen(TCPConnection* conn) = 0;
};

// 上下文类
class TCPConnection {
public:
    TCPConnection() : state_(new ClosedState()) {}

    void SetState(TCPState* s) { 
        delete state_; 
        state_ = s; 
    }

    void Open()   { state_->Open(this); }
    void Close()  { state_->Close(this); }
    void Listen() { state_->Listen(this); }

private:
    TCPState* state_;
};

// 具体状态:Closed
class ClosedState : public TCPState {
public:
    void Open(TCPConnection* conn) override {
        std::cout << "从 Closed 切换到 Established\n";
        conn->SetState(new EstablishedState());
    }
    void Close(TCPConnection* conn) override {
        std::cout << "已经是 Closed 状态,无需操作\n";
    }
    void Listen(TCPConnection* conn) override {
        std::cout << "从 Closed 切换到 Listening\n";
        conn->SetState(new ListeningState());
    }
};

// 具体状态:Established
class EstablishedState : public TCPState {
public:
    void Open(TCPConnection* conn) override {
        std::cout << "连接已建立,不能重复 Open\n";
    }
    void Close(TCPConnection* conn) override {
        std::cout << "从 Established 切换到 Closed\n";
        conn->SetState(new ClosedState());
    }
    void Listen(TCPConnection* conn) override {
        std::cout << "从 Established 切换到 Listening\n";
        conn->SetState(new ListeningState());
    }
};

// 具体状态:Listening
class ListeningState : public TCPState {
public:
    void Open(TCPConnection* conn) override {
        std::cout << "从 Listening 切换到 Established\n";
        conn->SetState(new EstablishedState());
    }
    void Close(TCPConnection* conn) override {
        std::cout << "从 Listening 切换到 Closed\n";
        conn->SetState(new ClosedState());
    }
    void Listen(TCPConnection* conn) override {
        std::cout << "已经在 Listening 状态\n";
    }
};

个人经验:我习惯把状态切换的逻辑放在具体状态类的 Handle 方法里,而不是放在 Context 中。这样新增状态时,只需要新增一个类,改一下相关状态的切换逻辑,Context 完全不用动。符合开闭原则。

状态机应用:从理论到实战

状态模式最常见的应用场景就是有限状态机(FSM)。我在项目中用状态模式写过协议栈、游戏角色 AI、工作流引擎。这里分享一个我踩过的坑。

避坑指南:我曾经在一个状态机里,让每个状态类在 Handle 方法中直接 delete 自己,然后 new 出下一个状态。结果在状态切换时,当前对象已经被销毁了,后续代码还在访问它的成员变量——直接崩溃。后来我改成在 Context 里统一管理状态对象的生命周期,状态类只负责返回「下一个状态类型」的标识,由 Context 来执行切换和销毁。

来看一个更实用的状态机框架。假设我们要实现一个电梯控制系统,有「静止」、「运行中」、「开门」、「故障」四个状态。每个状态对「按楼层」、「关门」、「开门」、「急停」等事件有不同的响应。

当前状态 按楼层 关门 开门 急停
静止 → 运行中 无变化 → 开门 → 故障
运行中 排队等待 无变化 不允许 → 故障
开门 排队等待 → 静止 无变化 → 故障
故障 不允许 不允许 不允许 无变化

你看,用状态表来设计,逻辑一目了然。每个格子对应一个状态类里的一个方法实现。新增一个「检修」状态,只需要新增一个类,填好这张表里的对应行为就行。原有的代码一行都不用改。

核心要点:状态模式不是银弹。如果状态只有两三个,switch-case 反而更简洁。我个人的判断标准是:当状态数量超过 4 个,或者状态转换规则经常变化时,果断上状态模式

最后说一个细节。状态模式里的状态对象,很多时候是无状态的——它本身不保存数据,只封装行为。这种情况下,多个 Context 可以共享同一个状态实例,用单例模式实现具体状态类,能省不少内存。嗯,这个技巧我在一个嵌入式项目里用过,效果很好。

状态模式的核心思想,说白了就是把变化封装起来。状态会变,行为会变,那就让每个状态自己管好自己的事。你想想看,这其实跟现实世界很像——人在不同状态下,行为就是不一样。代码也应该是这样。


公众号:蓝海资料掘金营,微信 deep3321