码工许师傅头像
关注
【设计模式精讲】25.状态模式(State)封面图

【设计模式精讲】25.状态模式(State)

【设计模式精讲】25.状态模式(State)

【摘要】:订单能支付、能发货、能关闭——每种动作在每种状态下都有不同答案,直觉的实现是给每个方法配一份全量 switch,于是「Created/Paid/Shipped」的分支在五个方法里各复制一遍,加一个状态全体加 case,合法的迁移图散落得没人能一眼看清。本文从订单状态机的 switch 蔓延讲起,给出状态模式的 GoF 意图:状态一变、行为整个换一套,对象「看起来像换了一个类」;状态对象无状态、全程共享一份(与享元、单例同宗),迁移由当前状态自己决定;现代版给出迁移表与 C++17 的 variant 值语义状态——状态第一次能自带数据。本文重点辨析状态与策略:结构完全相同,切换驱动方相反。文末对照 AOSP 系统状态机与 Boost.Statechart。
【关键词】:状态模式、状态机、迁移、switch 消除、variant、状态共享
【代码基准】:C++17

1. 每个方法里都长着一模一样的 switch

电商订单有四个状态:已创建、已支付、已发货、已关闭。每个动作在每种状态下答案不同——直觉的实现是这样的:

// 说明性片段
// ❌ switch 在每个方法里复制粘贴
void Order::pay() {
  switch (state_) {
    case Created:
      state_ = Paid;
      break;
    case Paid:
      throw "已支付,请勿重复";
    case Shipped:
      throw "已发货,不能改支付";
    case Closed:
      throw "已关闭";
  }
}

void Order::ship() {
  switch (state_) {      // 又是一模一样的一棵
    case Created:
      throw "未支付,不能发货";
    case Paid:
      state_ = Shipped;
      break;
    ...
  }
}

三笔账很快就找上门:其一,同一批 case 在每个方法里复制——pay/ship/close/refund 四个方法四棵 switch,改一处忘三处;其二,合法迁移图无人能答——「从 Paid 能到哪」这个问题,要读完四棵 switch 才敢回答,第 22 篇中介者埋的「交互规则状态机化」伏笔在这里兑现;其三,加状态 = 全体方法加 case——想把「关闭」拆成「用户取消」与「超时关闭」,四个方法全部返工。

细看病灶会发现它和第 26 篇预告的「策略 switch」形似而神异:这里的分支不是「同一问题的平行解法」,而是对象自身的生涯阶段——行为随状态整体切换,状态之间还有合法的迁移关系。把「每种状态下的全部行为」收进一个类,switch 就没了住处。

2. 模式意图与定义

  • 一句话定义:允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。
    解决的问题:行为随状态大变、状态间有迁移规则的领域里,switch 的复制蔓延与迁移图的失散。
  • GoF 原文意图Allow an object to alter its behavior when its internal state changes. The object will appear to change its class.(允许对象在内部状态改变时改变它的行为。)后半句是全部魔法所在——调用方拿着的还是同一个 Order&,但 pay() 的答案已经完全换了人,「好像换了个类」
  • Refactoring Guru 的表述:状态模式让你能在一个对象的内部状态变化时改变其行为,使其看上去就像改变了自身的类;RG 用媒体播放器与订单做例——同一个「播放/暂停」按钮,在不同状态下做完全相反的事。

三条定性:

  1. 「状态 × 行为」矩阵按列拆开。switch 的写法是按行为切(每个方法横扫所有状态),状态模式按状态切——每种状态一个类,该状态下的全部行为住在一起;
  2. 状态对象无状态、可共享。订单号、金额、物流单号这些数据全归上下文,状态类只有行为没有字段——于是四种状态全程各共享一份实例,与第 8 篇单例的 instance()、第 15 篇享元「种类数代替实例数」一脉相承;
  3. 迁移由当前状态决定。「谁能到哪」写在各状态的操作里——CreatedState::pay 自己知道下一步是 Paid。这个「自己迁自己」的性质是与策略模式辨析的分水岭,第 6 节正面展开。

3. UML 图 + 结构说明

当前状态(共享实例)

pay() 迁移

ship() 迁移

«interface»

OrderState

+pay(o) : void

+ship(o) : void

+name() : string

CreatedState

+pay(o) : void

+ship(o) : void

PaidState

+pay(o) : void

+ship(o) : void

ShippedState

+pay(o) : void

+ship(o) : void

Order

-state_ OrderState

+pay() : void

+ship() : void

+changeTo(s) : void

三个参与者:

  • 状态(State)接口:声明该领域全部动作——每个具体状态都要对每个动作给出自己的答案;
  • 具体状态(Concrete State)CreatedState 等——只写「我这个状态下每个动作干什么、迁去哪」;
  • 上下文(Context)Order——持当前状态指针、保管全部数据字段,把每个动作原样委托给当前状态。

值得盯着图认的两处:CreatedState ..> PaidState 那两条虚线是状态互相认识的证据(迁移目标是自己的同伴)——记住它,第 6 节与策略的辨析要用;而 Order o-- OrderState 的组合边指向共享实例,上下文从不创建/销毁状态对象。拓扑与第 26 篇策略「Context o-- Strategy」完全同形——差异全在语义箭头上,这正是行为型模式比结构型更依赖意图判别的原因

4. 传统 C++ 写法(C++11 之前)

C++98 完整形态:状态接口声明全部动作,具体状态用 instance() 静态实例共享,上下文只管数据与委托:

// C++98/03 写法
#include <stdio.h>

class Order;

// ---- 状态接口 ----
class OrderState {
public:
  virtual ~OrderState() {}

  virtual void pay(Order& o) = 0;
  virtual void ship(Order& o) = 0;
  virtual const char* name()
      const = 0;

protected:
  OrderState() {}

private:
  OrderState(const OrderState&);
  OrderState& operator=(
      const OrderState&);
};

// ---- 上下文:数据归它,行为全委托 ----
class Order {
public:
  Order();

  void pay() {
    state_->pay(*this);
  }
  void ship() {
    state_->ship(*this);
  }

  void changeTo(const OrderState* s) {
    printf("  %s -> %s\n",
           state_->name(), s->name());
    state_ = s;
  }

  void dispatch() {   // 业务动作归上下文
    printf("  商品已发出\n");
  }

private:
  const OrderState* state_;
};

// ---- 具体状态:声明与定义分离,
//      因为迁移目标互相引用 ----
class CreatedState : public OrderState {
public:
  static const OrderState* instance();
  const char* name() const;
  void pay(Order& o);
  void ship(Order& o);
};

class PaidState : public OrderState {
public:
  static const OrderState* instance();
  const char* name() const;
  void pay(Order& o);
  void ship(Order& o);
};

class ShippedState : public OrderState {
public:
  static const OrderState* instance();
  const char* name() const;
  void pay(Order& o);
  void ship(Order& o);
};

const OrderState*
CreatedState::instance() {
  static CreatedState s;   // 无状态,共享
  return &s;
}
const char*
CreatedState::name() const {
  return "Created";
}
void CreatedState::pay(Order& o) {
  printf("支付成功\n");
  o.changeTo(PaidState::instance());
}
void CreatedState::ship(Order&) {
  printf("未支付,拒绝发货\n");
}

const OrderState* PaidState::instance() {
  static PaidState s;
  return &s;
}
const char* PaidState::name() const {
  return "Paid";
}
void PaidState::pay(Order&) {
  printf("重复支付,忽略\n");
}
void PaidState::ship(Order& o) {
  o.dispatch();
  o.changeTo(
      ShippedState::instance());
}

const OrderState*
ShippedState::instance() {
  static ShippedState s;
  return &s;
}
const char*
ShippedState::name() const {
  return "Shipped";
}
void ShippedState::pay(Order&) {
  printf("已发货,不能改支付\n");
}
void ShippedState::ship(Order&) {
  printf("已发货,忽略\n");
}

Order::Order()
    : state_(CreatedState::instance()) {}

int main() {
  Order order;
  order.pay();    // 支付成功 Created->Paid
  order.ship();   // 发出 Paid->Shipped
  order.pay();    // 已发货,不能改支付
  return 0;
}

对照第 1 节:四棵 switch 整体消失;加「ClosedState」= 新写一个类,已有状态里只有出边指向它的地方各加一行;「从 Paid 能到哪」——读 PaidState 两个方法即可,迁移的出边总是写在状态自己家里

三条传统写法的铁律:状态类只有行为没有数据——字段全在 Order,状态对象才能安全共享一份(谁往状态类里塞字段,谁就为并发埋雷);迁移写在状态操作里——changeTo 只由状态调用,上下文永不「查表式」地指挥迁移(要查表就整体换表驱动,见第 5 节);声明与定义分离——状态之间互为迁移目标,C++98 里先声明三个类再依次定义,绕开「先有鸡还是先有蛋」。

5. 现代 C++ 进阶写法

升级零:override 补齐 + 无锁 instance()virtual 实现处补 override 拼写即查;instance() 里的局部静态初始化自 C++11 起由语言保证线程安全(第 8 篇讲过的守卫字节),多线程下订单各持状态指针、共享同一批实例,读路径无锁。

改进一:迁移表——当规则规整成数据。若状态机的动作很规整(「状态 × 事件 → 迁移 + 动作」),类层次可以让位给一张表与一个通用引擎:

// 节选:表驱动的规整状态机
#include <cstdio>
#include <functional>
#include <string>
#include <unordered_map>

struct Rule {
  std::string to;                 // 迁移目标
  std::function<void()> action;   // 出发动作
};

// key = "当前状态:事件"
std::unordered_map<std::string, Rule>
    rules = {
  {"Created:pay", {"Paid", [] {
     std::printf("支付成功\n"); }}},
  {"Paid:ship", {"Shipped", [] {
     std::printf("商品已发出\n"); }}},
};

void fire(std::string& state,
          const std::string& event) {
  auto it = rules.find(
      state + ":" + event);
  if (it == rules.end()) {
    std::printf("非法迁移:%s + %s\n",
                state.c_str(),
                event.c_str());
    return;               // 兜底必须明确
  }
  it->second.action();
  state = it->second.to;
}

表与类两条路线的取舍:行为复杂、状态间逻辑差异大用类层次(多态的表达力);规则规整、要配置化或可视化用表(一张表就是一张可审计的迁移图)。第 22 篇中介者「规则表化」的护栏正是这条路线。

改进二:variant 值语义状态——状态第一次能带数据。「已支付时间」「物流单号」这类天然属于某个状态的数据,类层次里只能委屈地住在上下文;C++17 的 variant 让状态本身携带数据:

// 节选:值语义状态(C++17)
#include <ctime>
#include <exception>
#include <string>
#include <variant>

struct Created {};
struct Paid {
  std::time_t at;         // 状态自带数据
};
struct Shipped {
  std::string tracking;   // 物流单号
};
struct Closed {};

using State = std::variant<Created, Paid,
                           Shipped, Closed>;

class Order {
public:
  void pay() {
    std::visit(
        [this](auto& s) {
          using T = std::decay_t<
              decltype(s)>;
          if constexpr (std::is_same_v<
                            T, Created>) {
            state_ =
                Paid{std::time(nullptr)};
          } else {
            throw std::runtime_error(
                "当前状态不能支付");
          }
        },
        state_);
  }

  State state_;   // 状态是值,不是指针
};

状态变成值对象后:非法迁移天然无法表达(Paid 里根本没有「支付时间」之外的字段),状态数据随赋值整体更换,内存连续、无堆分配;代价是「状态集」编译期定死——运行期动态发现新状态(插件式状态机)还得回到类层次。

展望:协程让「异步状态机」(await 一个迁移条件)有了新写法;轻量级状态机库(如 boost-ext/SML)用表达式把迁移图写成代码,一行一条边。

6. 优缺点与适用场景

  • ✅ 优点(GoF 后果清单):行为按状态局部化——每个状态的答案集中在一个类,switch 蔓延终结;迁移规则有了住址——每个状态的出边写在自家方法里,加状态不碰旧状态的行为;状态对象无状态可共享,多上下文并发复用同一批实例;「看起来换了类」——上下文接口稳定,调用方零感知。
  • ❌ 缺点:类数量随状态膨胀——四状态四类,简单两态状态机用 enum + switch 反而更直白;迁移分散后全景图要拼装——「整张迁移图长什么样」没有一个地方能直接回答(表驱动补此短板);状态与上下文双向认识(状态要改上下文、读上下文),耦合比多数模式紧。
  • 🎯 适用场景:对象行为随状态显著变化且状态较多——订单/工单/审批流、游戏 AI 与角色、网络协议会话、播放器与设备控制;迁移规则复杂到值得单独建模;状态可作为术语与业务方对表(状态机图即沟通工具)。

〔辨析〕状态 vs 策略(第 26 篇)——结构全同、驱动方相反。两张 UML 几乎重叠:上下文持一个接口指针、逐操作委托。三处分野:谁驱动切换——策略由客户端或外部条件注入一次(换不换是使用者的事),状态由对象自己在操作里迁移(自己换自己);参与者互相认识吗——策略们是平行解法、彼此陌生,状态们互为迁移目标、彼此认识(图上那两条 ..> 虚线);建模对象——策略建模「同一问题的多种算法」,状态建模「一个对象的生涯阶段」。一句话:策略是算法插头,状态是对象的生涯。另两条:状态 vs 观察者(第 24 篇)——观察者是对象向外的广播,状态是对象向内的重构;状态 vs 中介者(第 22 篇)——中介者用状态机管理一组对象的交互阶段,本篇管单个对象自身的阶段,两者可以嵌套。

7. 开源项目中的身影

AOSP:StateMachine,系统服务的中枢神经。Android 框架内置 com.android.internal.util.StateMachine:状态组织成层次树,消息(Message)到达时交给当前状态processMessage 处理并按需迁移,Wi-Fi、蓝牙等系统服务用它管理连接生涯(Disconnected → Connecting → Connected),未处理的消息自动上浮给父状态——第 18 篇责任链的「上浮找认领」与第 12 篇的组合树在这里与本篇合流。

点评:系统服务的状态机选择类层次而非表,正是因为各状态的行为差异巨大且要挂定时器、日志等富逻辑——第 5 节的取舍判据在工业代码里的直接印证。

Boost.Statechart:重量级状态机的全功能形态。Boost 的官方状态机库支持状态层次、历史伪状态、延迟事件、异步运行——UML 状态图能表达的它基本都能:

// 说明性片段(需包含 Boost/statechart,
// 节选其教程骨架)
class Active;
class StopWatch
    : public sc::state_machine<
          StopWatch, Active> {};

class Stopped;
class Active
    : public sc::simple_state<
          Active, StopWatch, Stopped> {
 public:
  typedef sc::transition<
      EvReset, Active> reactions;
};

// 迁移即类型:reaction 列表声明出边

点评:功能与复杂度等重——学习曲线陡、编译慢,新项目多转向 SML 这类表达式式轻量库(transition 写成一行代码)。它像状态机世界的「全功能相机」:专业场合无可替代,日常记录用手机(表驱动/variant)就好。

轻量路线:variant + 表——现代 C++ 的无名状态机。第 5 节的两条路线本身就是「无名却无处不在」的基础设施:协议解析的 enum 分支、variant 里的会话状态、unordered_map 的迁移规则——大量生产状态机从未引入任何模式名,却在做完全相同的事:把「状态 × 行为」按列拆开、给迁移一个明确住址。模式的完成态,是被吸收进日常语法。

本篇小结

行为随状态整体切换的领域,别让每个方法各扛一棵 switch:把「每种状态下的全部行为」收进一个类,上下文持共享实例逐操作委托——switch 消失、迁移出边写回状态自己家、对象「看起来换了类」。状态类零数据、全共享,是安全并发的底线;规则规整时退到迁移表,需要状态携带数据时换 variant 值语义——工具三档,按复杂度取。与策略的辨析刻进肌肉:算法插头 vs 对象生涯,外部注入 vs 自己迁移。下一篇策略模式,把主导权从对象手里交还给外部——并且要回第 1 篇的老朋友 calcPrice,兑现那一篇埋下的承诺。

本文模式定义与角色划分参考了 Refactoring Guru《设计模式》中文版「状态」一章,意图译文与「谁拥有迁移」的讨论参考了 GoF《Design Patterns》第 5 章 State 一节。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/xusiwei1236/article/details/164630150

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--