Day 19:状态模式(State Pattern)

GoF 行为型模式 C++17 Qt6 / QStateMachine

①今日主题

状态模式(State Pattern)是 GoF 二十三种设计模式中的行为型模式。它把"对象在不同状态下表现出的不同行为"封装成一组独立的状态类,让对象在内部状态发生改变时自动切换自己的行为——对客户端而言,对象仿佛"换了一个自己":同一个 play() 调用,在"播放中"和"已暂停"两个状态下产生完全不同的效果,而调用方根本不需要关心对象当前处于哪个状态。

一句话定义:把状态变成对象,让行为跟着状态走;状态切换由状态对象自己决定,新增状态只加类、不改旧代码。

②为什么需要这个模式

先看一段"没有模式"的代码。假设我们要写一个媒体播放器,它有停止、播放、暂停三种状态,每种状态下 play / pause / stop 三个操作的行为都不一样。新手最常见的写法是"枚举 + if-else":

// ---- 坏味道:枚举 + if-else 硬编码状态机 ----
#include <iostream>

enum class PlayState { Stopped, Playing, Paused };

class BadPlayer {
public:
    void play() {
        if (state_ == PlayState::Stopped) {
            std::cout << "开始播放\n";
            state_ = PlayState::Playing;
        } else if (state_ == PlayState::Playing) {
            std::cout << "已在播放\n";
        } else {                          // Paused
            std::cout << "继续播放\n";
            state_ = PlayState::Playing;
        }
    }
    void pause() {
        if (state_ == PlayState::Playing) {
            std::cout << "已暂停\n";
            state_ = PlayState::Paused;
        } else {
            std::cout << "无法暂停\n";
        }
    }
    void stop() {
        if (state_ != PlayState::Stopped) {
            std::cout << "已停止\n";
            state_ = PlayState::Stopped;
        }
    }

private:
    PlayState state_ = PlayState::Stopped;   // 当前状态
};

这段代码表面能跑,但有四个越滚越大的问题:

  • 条件判断膨胀:每增加一个操作(比如"快进"),就要再写一整条 if-else 链去枚举所有状态;每增加一个状态(比如"缓冲中"),所有操作方法都要改一遍。需要维护的分支数 = 状态数 × 操作数,呈乘法增长。
  • 违背开闭原则(OCP):新增状态必须回头修改已有的 play / pause / stop 三个方法——对扩展开放的同时,对修改也开放了。
  • 违背单一职责原则(SRP):一个类同时承担了"业务动作"(播放、暂停)和"状态流转"(什么时候切到哪个状态)两类职责,任何一个状态规则变了,都要动这个类。
  • 转移逻辑散落、易错:整个状态机的转移表被打散在各个 if-else 分支里,很难一眼看出"从哪个状态能到哪个状态"。漏写一个分支不会编译报错,只会在某个特定状态下触发诡异的运行时行为——这是状态机 bug 最可怕的地方。

核心诉求:把"状态判断"从业务方法中剥离出去,让每个状态对自己的行为负责,让"换状态"成为一件低成本、无副作用的操作。这正是状态模式要解决的问题。

③核心思想

状态模式的核心转变是:把"状态"从"一个枚举值"升级为"一个对象"。每个状态对象都是一名"专家"——它只知道自己这种状态下每个操作该做什么、做完之后应该转移到哪个状态。上下文对象(Context)不再自己做任何状态判断,它只保存一个"当前状态对象"的指针,把客户端的每个请求原封不动地委托给这个状态对象;状态对象执行完自己的行为后,如果认为需要换状态,就调用 Context 的切换接口把自己换掉。

整个过程可以用一句话概括:请求 → 委托 → 状态对象处理 → (可能)自我替换。客户端从头到尾只看到"同一个对象在调用同一个方法",但这个对象的行为却在不断变化。

生活类比一:水的三态。同样是 H₂O 分子,0°C 以下是固态(坚硬),0~100°C 是液态(流动),100°C 以上是气态(扩散)。"水"本身没有变,变的只是它处于哪个状态,而每个状态下的表现天差地别。状态模式里的 Context 就是"水分子",三个具体状态就是固、液、气。

生活类比二:交通信号灯。红灯知道自己在 30 秒后变成绿灯,绿灯知道自己在 25 秒后变成黄灯,黄灯知道自己在 5 秒后变成红灯。每个灯都只关心"自己这个状态下该亮多久、下一个是谁",整个交通系统不需要一个全局大脑来调度——状态之间的转移规则被打散到每个状态自己身上,谁也不会越权。

游戏场景类比:游戏角色有"站立 / 跑动 / 跳跃 / 受击 / 死亡"等状态。站立时可以响应移动键,跳跃中不能二段跳,死亡后任何输入都无效。如果用 if-else 写,每个输入处理函数都要判断"我现在是什么状态";用状态模式,每个状态类自己实现"这个状态下按跳跃键会怎样",代码结构与游戏设计文档一一对应。

④UML / 角色关系

状态模式包含三个角色:

角色本例类名职责
Context(上下文)MediaPlayer持有当前状态对象的指针;把客户端的请求委托给当前状态;对外提供 setState() 供状态对象切换自己
State(抽象状态)State定义每种状态下所有操作的接口(play / pause / stop),不关心具体实现
ConcreteState(具体状态)StoppedState / PlayingState / PausedState实现本状态下的具体行为;在合适的时机调用 Context::setState() 触发状态转移

对象结构:Context 以组合方式持有一个 State*(现代 C++ 用 std::unique_ptr<State> 管理所有权);State 是抽象基类;三个 ConcreteState 继承并实现它。状态转移由 ConcreteState 发起——它在自己的行为方法中调用 Context::setState(),把内部指针指向另一个 ConcreteState。

以媒体播放器为例,状态转移图如下(箭头上的标注是触发转移的操作):

Stopped --play()--> Playing
Playing --pause()--> Paused
Paused  --play()--> Playing
Playing --stop()--> Stopped
Paused  --stop()--> Stopped

注意这张图就是后面 ⑤ 中代码的结构图:每个箭头对应一个具体状态类方法体里的一行 setState(...) 调用。设计图与代码一一对应,是状态模式在工程上最大的红利。

⑤最小 C++ 示例

下面用 C++17 完整实现上面的媒体播放器。注意三个具体状态之间存在循环引用(停止要转到播放、暂停要转到停止……),所以采用"类内只声明、类外定义方法体"的标准写法,保证每个状态类在方法体中使用 std::make_unique 时,目标状态类已经是完整类型:

// ============ state_minimal.cpp —— C++17 完整可编译 ============
#include <iostream>
#include <memory>
#include <string>

class MediaPlayer;   // 前置声明:状态类的接口方法以 Context 为参数

// ---------- 抽象状态 State ----------
class State {
public:
    virtual ~State() = default;
    virtual void play(MediaPlayer &player) = 0;   // 处理"播放"请求
    virtual void pause(MediaPlayer &player) = 0;  // 处理"暂停"请求
    virtual void stop(MediaPlayer &player) = 0;   // 处理"停止"请求
    virtual std::string name() const = 0;         // 状态名(便于观察)
};

// ---------- 具体状态前置声明(存在循环依赖,方法体放到类外) ----------
class StoppedState;
class PlayingState;
class PausedState;

// ---------- 上下文 Context:持有当前状态对象,请求一律委托 ----------
class MediaPlayer {
public:
    explicit MediaPlayer(std::unique_ptr<State> initial)
        : state_(std::move(initial)) {}

    // 状态切换入口:由具体状态对象在"该转移时"调用
    void setState(std::unique_ptr<State> next) {
        state_ = std::move(next);                    // RAII:旧状态对象自动析构,无泄漏
        std::cout << "    [状态切换 -> " << state_->name() << "]\n";
    }

    // 对外接口:不判断任何状态,直接委托给当前状态对象
    void play()  { state_->play(*this); }
    void pause() { state_->pause(*this); }
    void stop()  { state_->stop(*this); }

private:
    std::unique_ptr<State> state_;   // 当前状态(所有权归 Context)
};

// ---------- 具体状态 1:停止 ----------
class StoppedState final : public State {
public:
    void play(MediaPlayer &player) override;
    void pause(MediaPlayer &player) override;
    void stop(MediaPlayer &player) override;
    std::string name() const override { return "停止"; }
};

// ---------- 具体状态 2:播放中 ----------
class PlayingState final : public State {
public:
    void play(MediaPlayer &player) override;
    void pause(MediaPlayer &player) override;
    void stop(MediaPlayer &player) override;
    std::string name() const override { return "播放中"; }
};

// ---------- 具体状态 3:已暂停 ----------
class PausedState final : public State {
public:
    void play(MediaPlayer &player) override;
    void pause(MediaPlayer &player) override;
    void stop(MediaPlayer &player) override;
    std::string name() const override { return "已暂停"; }
};

// ---------- 方法体:此时三个具体状态均已完整定义,可以互相 new ----------
void StoppedState::play(MediaPlayer &player) {
    std::cout << "开始播放\n";
    player.setState(std::make_unique<PlayingState>());   // 停止 --play()--> 播放
}
void StoppedState::pause(MediaPlayer &player) {
    std::cout << "当前已停止,无法暂停\n";                  // 非法操作:忽略并提示
}
void StoppedState::stop(MediaPlayer &player) {
    std::cout << "已经处于停止状态\n";                     // 非法操作:忽略并提示
}

void PlayingState::play(MediaPlayer &player) {
    std::cout << "正在播放中(忽略)\n";                   // 非法操作:忽略并提示
}
void PlayingState::pause(MediaPlayer &player) {
    std::cout << "已暂停\n";
    player.setState(std::make_unique<PausedState>());    // 播放 --pause()--> 暂停
}
void PlayingState::stop(MediaPlayer &player) {
    std::cout << "已停止\n";
    player.setState(std::make_unique<StoppedState>());   // 播放 --stop()--> 停止
}

void PausedState::play(MediaPlayer &player) {
    std::cout << "继续播放\n";
    player.setState(std::make_unique<PlayingState>());   // 暂停 --play()--> 播放
}
void PausedState::pause(MediaPlayer &player) {
    std::cout << "已经处于暂停状态\n";                     // 非法操作:忽略并提示
}
void PausedState::stop(MediaPlayer &player) {
    std::cout << "已停止\n";
    player.setState(std::make_unique<StoppedState>());   // 暂停 --stop()--> 停止
}

// ---------- 客户端 ----------
int main() {
    MediaPlayer player(std::make_unique<StoppedState>());  // 初始状态:停止

    player.play();    // 停止 -> 播放
    player.play();    // 已在播放,忽略
    player.pause();   // 播放 -> 暂停
    player.play();    // 暂停 -> 播放(继续)
    player.stop();    // 播放 -> 停止
    player.pause();   // 停止状态下的非法操作
    return 0;
}

程序输出:

开始播放
    [状态切换 -> 播放中]
正在播放中(忽略)
已暂停
    [状态切换 -> 已暂停]
继续播放
    [状态切换 -> 播放中]
已停止
    [状态切换 -> 停止]
当前已停止,无法暂停

编译方式:g++ -std=c++17 state_minimal.cpp -o state_minimal && ./state_minimal。可以看到:客户端 main() 里没有任何一个 if 判断,所有"该不该做、做完了去哪"的规则都封装在三个状态类内部;非法操作(停止时暂停、播放中播放)被对应的状态对象静默忽略,从结构上杜绝了漏判分支。

⑥Qt 实战示例

好消息:这个模式 Qt 已经替你完整实现了一遍,而且做成了基础设施——就是 Qt 状态机框架(State Machine Framework):QStateMachine(状态机容器,即 Context)、QState(状态)、QFinalState(终态)、QHistoryState(历史状态)、QAbstractTransition(转移)。你不需要手写状态类,只需要声明状态、声明转移、把转移和信号绑定起来。

下面的例子是一个三态订单演示:待支付 → 已支付 → 已完成,每次点击按钮推进一个状态,标签文本由状态机自动更新。assignProperty 的作用是"进入某状态时,自动把对象的某个属性设置为指定值":

// ============ main.cpp —— Qt6 + C++17 状态机演示 ============
#include <QApplication>
#include <QStateMachine>
#include <QState>
#include <QLabel>
#include <QPushButton>
#include <QVBoxLayout>
#include <QWidget>

int main(int argc, char *argv[]) {
    QApplication app(argc, argv);

    // ---- 界面:一个标签 + 一个按钮 ----
    QWidget window;
    auto *layout = new QVBoxLayout(&window);
    auto *label  = new QLabel("待支付");          // 文本由状态机自动更新
    auto *button = new QPushButton("推进状态");
    layout->addWidget(label);
    layout->addWidget(button);

    // ---- 状态机:声明三个状态 ----
    QStateMachine machine;                        // Context 角色
    auto *pending = new QState();                 // 待支付
    auto *paid    = new QState();                 // 已支付
    auto *done    = new QState();                 // 已完成

    // 进入某状态时,自动设置 label 的 text 属性
    pending->assignProperty(label, "text", "待支付");
    paid->assignProperty(label, "text", "已支付");
    done->assignProperty(label, "text", "已完成");

    // ---- 转移:按钮 clicked 信号驱动 ----
    pending->addTransition(button, &QPushButton::clicked, paid);
    paid->addTransition(button, &QPushButton::clicked, done);
    done->addTransition(button, &QPushButton::clicked, pending);  // 循环演示

    machine.addState(pending);
    machine.addState(paid);
    machine.addState(done);
    machine.setInitialState(pending);
    machine.start();                              // 启动状态机

    window.show();
    return app.exec();
}
# ============ CMakeLists.txt ============
cmake_minimum_required(VERSION 3.16)
project(qt-state-demo VERSION 1.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(Qt6 REQUIRED COMPONENTS Widgets)

qt_standard_project_setup()

qt_add_executable(qt-state-demo main.cpp)

target_link_libraries(qt-state-demo PRIVATE Qt6::Widgets)

运行效果:编译运行后,窗口显示一个标签和一个按钮;每次点击按钮,状态机沿"待支付 → 已支付 → 已完成 → 待支付……"循环转移,标签文本随之自动更新。这个例子展示了 Qt 状态机的三个核心能力:信号驱动转移(addTransition(sender, signal, target),按钮点击信号直接触发转移)、属性绑定(assignProperty,进入状态自动改属性)、自动管理(状态机负责进入/退出状态、触发 onEntry / onExit 回调)。

工程启示:当框架已经提供成熟的状态机基础设施时,不要自己重复造轮子。手写 GoF 状态模式适合库内部、无框架依赖的场景;在 Qt 项目里,优先考虑 QStateMachine——它把"状态转移"做成了声明式配置,比手写状态类更直观、更不易错。

⑦代码执行流程

以 ⑤ 的最小示例为例,程序从启动到结束的完整执行流程:

  1. 初始化:main() 创建 MediaPlayer,传入 std::make_unique<StoppedState>(),内部指针 state_ 指向"停止"状态对象。
  2. 第一次 play():Context 不做任何判断,直接把请求委托给当前状态对象 StoppedState::play()。
  3. 状态对象处理:StoppedState::play() 打印"开始播放",然后调用 player.setState(std::make_unique<PlayingState>())——由状态对象自己决定转移。
  4. 状态切换:setState() 用 std::move 把新状态对象装入 unique_ptr,旧状态对象随 unique_ptr 析构自动释放(RAII,无内存泄漏),随后打印"状态切换 -> 播放中"。
  5. 第二次 play():请求委托给 PlayingState::play(),它打印"正在播放中(忽略)",不发生转移——同一方法、不同状态、不同行为。
  6. pause():委托给 PlayingState::pause(),打印"已暂停"并转移到 PausedState。
  7. 后续调用:每次请求都重复"委托 → 状态对象处理 → 可能转移"这一循环,直到 main() 返回、MediaPlayer 析构,状态对象随成员 unique_ptr 自动释放。全程客户端代码零分支、零手动内存管理。

这个流程的精髓在于第 2、5 步的对比:客户端代码一模一样,行为却完全不同——因为行为的所有权已经从"调用方"转移到了"状态对象"手里。

⑧为什么这样设计

状态模式把问题拆解成几个清晰的解耦点,每一条都对应一条设计原则:

⑨不使用会怎样

回到 ② 的坏代码,把场景放大来看。假设一个订单系统有 6 个状态:待支付、已支付、已发货、已签收、已取消、售后中;每个状态有 6 个操作:查看、支付、发货、签收、取消、申请售后。用 if-else 实现,需要维护 6 × 6 = 36 个分支,且分散在 6 个方法里。当第 7 个状态"退款中"加入时,6 个方法全部要改;改漏一个分支,就会出现"已取消的订单还能发货"这类逻辑漏洞。

这种 bug 的可怕之处在于:它不会编译报错,也不会在测试环境的常规路径上触发——只有走到某个特定状态组合时才会爆炸,而且问题定位极难,因为你根本说不清"是哪一行 if 写漏了"。真实项目中,状态机缺陷是出了名的疑难杂症,很多团队最后被迫引入状态机框架来"镇压"这类 bug。

除了分支膨胀,还有三个隐性代价:

⑩何时使用

适合的场景(3-5 个):

不适合的场景(2-4 个):

过度设计提醒:状态模式会带来"状态类数量 ≈ 状态数"的类膨胀。如果项目只有两个状态、且一两年内看不到新增状态的可能,老老实实用枚举 + if 更划算——模式是解决问题的工具,不是 KPI。判断标准很简单:如果画不出清晰的状态转移图,就不要用状态模式。

⑪与其他模式的区别

⑫Qt 源码中的体现

诚实说明:Qt 自身并不会为每个控件手写经典 GoF 状态模式,而是用 QStateMachine 框架或属性/位标志解决问题——这提醒我们:模式要活学活用,当框架基础设施已经覆盖需求时,直接使用基础设施永远优于自己造轮子。

⑬面试常见问题

Q1:状态模式和策略模式的结构几乎一样,区别到底在哪?

答:结构相同(组合 + 委托),意图不同。策略模式由客户端选择算法、可任意替换,策略之间互不感知、彼此平行;状态模式的转移内置于状态对象之间,由状态对象自己决定下一个状态,客户端不能指定。策略是"平行的算法族",状态是"时序的状态机";前者解决"怎么做",后者解决"现在该做什么"。

Q2:状态转移由谁负责?两种风格各有什么优缺点?

答:两种风格。① 分散式:具体状态对象在行为方法里调用 Context::setState() 切换,符合"状态自治",但转移逻辑分散在各个状态类里,全局视图不直观;② 集中式:Context 维护一张转移表(当前状态 × 事件 → 下一状态),转移集中、一目了然,但 Context 又要依赖所有具体状态类。Qt 的 QStateMachine 是"集中声明"的典型;小型手写实现多用分散式。

Q3:状态对象应该是每个 Context 一份,还是全局共享?

答:取决于状态对象有无内部可变数据。若状态对象是纯行为、无内部状态(只读),可以做成共享实例,甚至配合 Flyweight 模式复用,Context 只保存指针;若状态对象需要记录该 Context 的私有数据,则每个 Context 一份。C++ 中通常用 std::unique_ptr 持有、随 Context 生命周期管理,RAII 保证无泄漏。

Q4:状态模式有哪些缺点?

答:① 类数量膨胀——每个状态一个类,状态多时代码量明显增加;② 状态类之间互相依赖、转移逻辑分散,单看某个类读不出完整状态图,需要借助文档;③ 状态对象常常需要访问 Context 的内部数据,可能破坏封装。缓解手段:转移表集中管理、状态与数据分离、配合工厂模式创建状态对象。

Q5:如何从结构上防止非法转移(如"已取消"的订单还能"发货")?

答:状态模式天然把"非法转移"变成"该状态下没有这个行为"——CanceledState 里根本没有 ship() 的有效实现(直接忽略或抛出业务异常),非法调用在语义上就不存在,从结构上杜绝了 if-else 方案中"漏判分支"这一整类 bug。这也是状态模式在健壮性上最实在的收益。

⑭今日练习

需求:实现一个电梯控制系统(建议 15-30 分钟)。

电梯有三个状态:静止(Idle)、运行(Moving)、开门(DoorOpen)。对外提供四个操作:开门 openDoor()、关门 closeDoor()、上行 moveUp()、停止 stop()。规则如下:

提示 1:先画状态转移图(三个圆圈 + 带箭头的转移线),再写代码——转移图就是你的类结构图,画不出来说明规则没想清楚。

提示 2:参照 ⑤ 最小示例的结构:抽象 State 声明 4 个操作,每个具体状态只实现"自己状态下该干什么、要不要转移";注意三个状态类之间存在循环依赖,用"类内声明 + 类外定义方法体"的写法。

⑮今日总结

一句话记忆:把"状态"变成"对象",行为跟着状态走,新增状态只加类、不改旧代码。

代码特征信号(看到这些就该考虑状态模式):

明日预告:Day 20 策略模式(Strategy Pattern)——把"算法族"封装成可互换的对象,让算法的选择与使用它的客户端解耦。它与今天的状态模式结构神似而意图迥异,明天我们将重点对比这对"孪生模式",并演示如何在 Qt 中用它替换繁杂的 if-else 算法分发。