状态模式在C++里算是一个被讲烂了的话题,但真正动手写的时候,你会发现大部分教程只告诉你“把状态封装成类”,却不说清楚状态切换的时机该放在谁手里、对象销毁时状态指针怎么管、以及为什么你的状态类里塞了一堆本不该它管的数据。我前后在三个不同规模的项目里用过状态模式,踩过的坑从内存泄漏到状态爆炸都有,这篇就把这些经验摊开讲,从最朴素的需求出发,一步步推到能落地的实现。
1. 先搞清楚状态模式到底在解决什么
1.1 一个真实的需求场景
假设你在做一个嵌入式设备上的控制逻辑,设备有开机、待机、运行、故障、关机几个状态,每个状态下按键的响应、指示灯的行为、对外通信的协议都不一样。最直觉的写法是在一个大的switch或者if-else链里判断当前状态,然后执行对应逻辑。这种写法在状态少、逻辑简单的时候没问题,但一旦状态增加到五六个,每个状态下的行为又有十几处分支,代码会迅速膨胀成一坨谁都不敢碰的东西。
状态模式的核心思路很朴素:把“某个状态下的所有行为”打包成一个独立的类,主对象只持有一个指向当前状态对象的指针,所有行为都委托给这个指针去执行。状态切换的时候,换一个指针指向的对象就行。这样每个状态类的代码是内聚的,新增状态只需要新增一个类,不用去动已有的分支逻辑。
但这里有个容易被忽略的点:状态模式解决的是行为随状态变化的问题,不是状态数据存储的问题。很多人一上来就把状态相关的所有字段都塞进状态类里,结果发现状态类之间要共享大量数据,传参传得怀疑人生。正确的做法是让状态类只负责行为,数据统一放在上下文对象(Context)里,状态类通过持有Context的引用来访问数据。
1.2 和策略模式的区别,别用混了
状态模式和策略模式的类图几乎一模一样,都是Context持有一个抽象接口的指针,具体实现类实现这个接口。区别在于切换的主动权和切换的时机。
策略模式里,客户端在构造Context的时候就把策略定好了,运行过程中一般不会主动换,换也是客户端从外部换。状态模式里,状态切换往往是由Context内部的行为触发的——比如“运行”状态下检测到故障,自动切到“故障”状态,这个切换逻辑写在状态类里,客户端根本感知不到。
这个区别决定了实现上的关键差异:状态类通常需要持有Context的指针(或者引用),因为它在执行完某个行为后可能要触发状态切换;而策略类通常不需要反向持有Context。如果你写出来的“状态模式”里状态类完全不碰Context,那大概率你写的其实是策略模式。
1.3 什么时候不该用状态模式
不是所有多状态场景都值得上状态模式。我总结了几条判断标准:
- 状态数量少于三个,且每个状态下的行为不超过两三个分支,直接用
switch更省事,引入状态模式反而增加类数量。 - 状态之间的切换关系是线性的、固定的(比如简单的流程步骤),用状态机表或者一个
enum加switch就够了。 - 状态类之间需要频繁共享大量临时数据,且这些数据生命周期很短,这时候状态类的构造和销毁开销可能成为负担。
反过来,如果状态数量多、切换关系复杂(有环、有跳跃)、每个状态的行为差异大,那状态模式带来的可维护性收益就很明显了。
2. 从零搭一个能跑的最小实现
2.1 抽象状态接口的设计取舍
先定义抽象状态接口。这里第一个要做的决策是:接口里放哪些方法?我的经验是只放Context需要委托的行为,不要把Context的所有公有方法都往状态接口里塞。比如一个设备控制类,对外暴露的方法可能有pressButton()、getStatusText()、reset(),但状态类真正需要介入的只有pressButton()和reset(),getStatusText()完全可以让Context自己根据当前状态对象的类型返回,不需要虚函数。
class DeviceContext; class IDeviceState { public: virtual ~IDeviceState() = default; virtual void onButtonPress(DeviceContext& ctx) = 0; virtual void onReset(DeviceContext& ctx) = 0; virtual const char* name() const = 0; };注意这里用的是DeviceContext&而不是指针。引用传递在语义上更明确——状态类不拥有Context,也不应该检查Context是否为空。如果确实需要表达“可能没有Context”的语义,那说明设计有问题,应该重新审视。
name()方法返回状态名,主要用于日志和调试。这个方法是纯虚的,强制每个状态类实现,避免调试时打出一堆无意义的类型信息。
2.2 Context的职责边界
Context的职责就三件事:持有当前状态、提供状态切换的入口、把外部调用委托给当前状态。它不应该知道任何具体状态的细节。
class DeviceContext { public: explicit DeviceContext(std::unique_ptr<IDeviceState> initialState) : state_(std::move(initialState)) {} void pressButton() { if (state_) state_->onButtonPress(*this); } void reset() { if (state_) state_->onReset(*this); } void changeState(std::unique_ptr<IDeviceState> newState) { state_ = std::move(newState); } const char* currentStateName() const { return state_ ? state_->name() : "null"; } private: std::unique_ptr<IDeviceState> state_; };这里用std::unique_ptr而不是裸指针,是因为Context对状态对象有独占所有权,状态对象的生命周期完全由Context管理。用unique_ptr的好处是状态切换时旧状态自动销毁,不需要手动delete,也不会出现忘记释放的问题。
changeState接收的是unique_ptr,意味着调用方(通常是状态类内部)需要构造好新状态对象再传进来。这个设计有个小问题:状态类在构造新状态时可能需要访问Context的数据,但此时新状态还没挂到Context上。解决办法是让状态类的构造函数接收必要的数据,或者让新状态在第一次被调用时再从Context拉取数据。我倾向于前者,因为构造时就把依赖明确下来,比隐式的延迟初始化更清晰。
2.3 具体状态类的写法
以“运行”状态为例,按下按钮时如果检测到故障标志,就切换到故障状态;否则打印一条运行日志。
class RunningState : public IDeviceState { public: void onButtonPress(DeviceContext& ctx) override { if (hasFault_) { ctx.changeState(std::make_unique<FaultState>()); } else { // 正常运行逻辑 } } void onReset(DeviceContext& ctx) override { ctx.changeState(std::make_unique<StandbyState>()); } const char* name() const override { return "Running"; } void setFault(bool f) { hasFault_ = f; } private: bool hasFault_ = false; };这里hasFault_是状态类自己的数据,因为它只在这个状态下有意义。如果某个数据在多个状态下都要用,那就应该放到Context里。这个边界要划清楚,否则状态类会越来越臃肿。
还有一个细节:onButtonPress里调用了ctx.changeState(...),这会销毁当前正在执行的状态对象(因为state_被重新赋值,旧的unique_ptr释放)。在成员函数执行过程中销毁自身对象是未定义行为,虽然很多编译器上“碰巧能跑”,但这是实打实的坑。正确的做法是让changeState延迟执行,或者状态类不直接销毁自己。
我常用的方案是让Context维护一个“待切换状态”的成员,changeState只是把新状态存起来,真正的替换在委托调用返回之后进行:
void DeviceContext::pressButton() { if (state_) { state_->onButtonPress(*this); applyPendingState(); } } void DeviceContext::changeState(std::unique_ptr<IDeviceState> newState) { pendingState_ = std::move(newState); } void DeviceContext::applyPendingState() { if (pendingState_) { state_ = std::move(pendingState_); } }这样状态类在onButtonPress里调用changeState时,当前对象还活着,等函数返回后Context再统一替换。这个模式我在多个项目里用过,稳定可靠,而且切换逻辑集中在一处,方便加日志。
3. 状态切换的几种组织方式及其代价
3.1 状态类内部直接切换
上面演示的就是这种方式:状态类知道自己要切到哪个状态,直接构造目标状态对象。优点是直观,切换逻辑和触发条件写在一起,读代码时一目了然。缺点是状态类之间产生了编译期依赖——RunningState的源文件里要#includeFaultState的头文件,状态多了以后头文件依赖会变成一张网。
如果状态数量在十个以内,这种耦合可以接受。超过十个,建议把切换逻辑抽出来。
3.2 集中式状态工厂
把“从状态A在事件E下切到状态B”这个映射关系集中管理,状态类只负责行为,不负责构造目标状态。常见做法是Context里放一个工厂函数或者映射表:
std::unique_ptr<IDeviceState> DeviceContext::createState(StateId id) { switch (id) { case StateId::Standby: return std::make_unique<StandbyState>(); case StateId::Running: return std::make_unique<RunningState>(); case StateId::Fault: return std::make_unique<FaultState>(); default: return nullptr; } }状态类里调用ctx.requestState(StateId::Fault),Context根据id构造新状态。这样状态类之间不再互相依赖,只依赖StateId这个枚举。代价是新增状态时要同时改枚举和工厂函数,两处都要动,容易漏。
3.3 状态转移表的适用边界
状态多、转移关系复杂的时候,可以把转移关系写成一张表,运行时查表决定下一个状态。表可以用静态数组、std::map或者配置文件加载。这种方式的好处是转移逻辑和状态行为彻底分离,改转移关系不用重新编译状态类。
但状态转移表有个硬伤:转移条件往往不是单纯的事件类型,还依赖Context的数据。比如“运行状态下按按钮,如果有故障切故障,否则切待机”,这个条件判断没法直接塞进表里。所以表里通常只能存“事件+当前状态→目标状态”的静态映射,条件判断还得在状态类或者Context里做。这就导致逻辑被拆成两半,反而不如集中式工厂清晰。
我的经验是:转移条件简单(纯事件驱动)时用表,条件复杂时用状态类内部切换或集中式工厂。别为了“看起来优雅”硬上表。
3.4 三种方式的对比
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 状态类内部切换 | 直观、切换逻辑集中 | 状态类间编译期耦合 | 状态数少于10个 |
| 集中式工厂 | 解耦状态类、切换入口统一 | 枚举和工厂需同步维护 | 状态数中等、需要统一日志 |
| 状态转移表 | 转移关系可配置、易审计 | 条件判断难表达、逻辑分散 | 纯事件驱动、转移关系固定 |
4. 内存与生命周期:状态模式最容易翻车的地方
4.1 状态对象该不该复用
上面的实现里,每次切换都new一个新状态对象,旧对象销毁。如果状态切换频繁(比如每秒几十次),频繁的堆分配和释放会成为性能瓶颈。解决办法是状态对象复用:Context预先创建所有状态对象并持有,切换时只换指针,不销毁对象。
class DeviceContext { public: DeviceContext() { states_[StateId::Standby] = std::make_unique<StandbyState>(); states_[StateId::Running] = std::make_unique<RunningState>(); states_[StateId::Fault] = std::make_unique<FaultState>(); current_ = states_[StateId::Standby].get(); } void changeState(StateId id) { auto it = states_.find(id); if (it != states_.end()) current_ = it->second.get(); } private: std::map<StateId, std::unique_ptr<IDeviceState>> states_; IDeviceState* current_ = nullptr; };这里current_用裸指针,因为它不拥有对象,所有权在states_里。这种写法下状态对象只构造一次,切换是纯指针赋值,开销极小。代价是所有状态对象常驻内存,如果某个状态对象很大且很少用到,会浪费内存。不过状态对象通常只存少量标志位,内存占用可以忽略。
复用方案还有个隐患:状态对象里如果有可变成员,切换回来时状态是“脏”的。比如RunningState里的hasFault_标志,上次退出时是true,下次进入时如果没重置,会带着旧值。解决办法是在状态类的onEnter钩子里重置,或者干脆不在状态类里存可变数据,全部放Context。
4.2 循环引用与析构顺序
如果状态类持有Context的shared_ptr,而Context又持有状态类的shared_ptr,就形成循环引用,两者都释放不掉。这是状态模式里最经典的内存泄漏来源。
避免方法很简单:Context持有状态用unique_ptr,状态持有Context用裸指针或引用。所有权是单向的,Context拥有状态,状态只是借用Context。上面的实现里状态类通过参数接收DeviceContext&,不存储引用,天然避免了这个问题。如果状态类确实需要长期持有Context引用(比如异步回调场景),存裸指针,并在Context析构前确保状态对象先销毁。
析构顺序也要注意:Context析构时,成员state_(unique_ptr)会自动销毁状态对象。如果状态对象的析构函数里访问了Context的其他成员,而此时那些成员可能已经先析构了,就会出问题。C++的成员析构顺序是逆声明顺序,所以要把state_声明在Context其他成员之后,保证它先析构。这个细节很多人不注意,在状态对象析构函数里打日志或者做清理时就会踩坑。
4.3 多线程下的状态切换
如果Context会被多个线程访问,状态切换就不是简单的指针赋值了。unique_ptr的赋值不是原子操作,一个线程正在读state_,另一个线程替换它,读的线程可能拿到半销毁的对象。
最简单的方案是加锁,但锁的粒度要控制好——不要在持有锁的时候调用状态方法,否则状态方法里再触发切换会死锁。我通常的做法是:读当前状态时加锁拿到裸指针(状态对象生命周期由Context保证,只要不销毁Context就安全),调用状态方法时不持锁,切换时再加锁替换。
void DeviceContext::pressButton() { IDeviceState* s = nullptr; { std::lock_guard<std::mutex> lock(mtx_); s = current_; } if (s) s->onButtonPress(*this); }这个方案的前提是状态对象复用(不销毁),如果每次切换都销毁旧对象,那裸指针可能在调用过程中失效。所以多线程场景下,状态对象复用几乎是必须的。
5. 状态爆炸与分层:状态多了怎么管
5.1 状态数量增长的现实问题
一个设备如果有5个功能模块,每个模块有4个状态,组合起来理论上有4^5种状态。当然实际不会全部组合,但状态数量随功能增长是必然的。状态类一多,管理就成了问题:哪些状态可以互相切换、哪些是互斥的、新增一个状态要改哪些地方,全靠人脑记。
这时候需要引入分层状态或者正交状态的概念。分层状态是指状态之间有父子关系,子状态继承父状态的行为,只在需要时覆盖。比如“运行”状态下面分“正常运行”和“降级运行”,两者共享大部分行为,只在特定方法上不同。C++里可以用继承实现:DegradedRunningState继承RunningState,只覆盖差异方法。
正交状态是指多个独立的状态维度同时存在,比如“电源状态”(开/关)和“通信状态”(连接/断开)是两个正交维度,组合起来有四种情况。这种情况不适合用单一的状态类层次表达,更适合拆成多个独立的状态机,各自管理自己的状态,Context在委托时分别调用。
5.2 用组合替代继承来控制复杂度
状态类之间如果有大量共享行为,用继承会导致继承层次过深,改一个基类方法影响所有子类。这时候可以把共享行为抽成独立的组件,状态类通过组合持有组件,按需调用。
比如多个状态都需要“记录操作日志”和“检查故障标志”,可以把这两个功能做成Logger和FaultChecker组件,状态类持有它们的引用。这样共享逻辑只有一份,状态类只写自己特有的行为。组合的代价是状态类构造时要注入依赖,初始化代码会多一些,但换来的是更清晰的职责划分。
5.3 状态类的测试策略
状态类因为依赖Context,单独测试时需要构造一个Mock Context。如果状态类通过参数接收DeviceContext&,那Mock起来很麻烦,因为DeviceContext是个具体类。解决办法是把Context需要暴露给状态类的操作抽成一个接口:
class IStateHost { public: virtual ~IStateHost() = default; virtual void changeState(StateId id) = 0; virtual bool hasFault() const = 0; }; class DeviceContext : public IStateHost { /* ... */ };状态类依赖IStateHost&而不是DeviceContext&,测试时构造一个实现了IStateHost的Mock对象即可。这个改动很小,但对可测试性的提升很大。我在项目里推行这个做法后,状态类的单元测试覆盖率从几乎为零提升到了八成以上。
6. 几个实际项目里的经验教训
6.1 状态切换的日志一定要打
状态切换是排查问题时最关键的信息。我习惯在applyPendingState里打一条日志,记录从哪个状态切到哪个状态、触发的事件是什么。日志格式大概是[state] Running -> Fault, trigger=buttonPress。这条日志在复现线上问题时价值极高,能快速定位是哪个事件触发了异常切换。
日志级别用Info还是Debug要看场景。状态切换频率低的用Info,频率高的(比如每秒多次)用Debug,避免日志刷屏。另外日志里不要打状态对象的地址,那个信息对排查没帮助还占篇幅。
6.2 别在状态类里做耗时操作
状态类的onXxx方法应该快速返回,把耗时操作(网络请求、文件IO、大量计算)抛给Context或者独立的服务类去做。原因有两个:一是状态方法执行期间状态可能被其他线程切换,耗时操作会让状态不一致的窗口变大;二是状态方法里如果抛异常,状态切换的原子性会被破坏,可能停在中间状态。
如果确实需要在状态方法里触发异步操作,把任务丢给线程池,状态方法立即返回。异步任务完成后通过回调通知Context,再由Context决定是否切换状态。这样状态切换始终在Context的控制下,逻辑清晰。
6.3 状态枚举和状态类要同步维护
用集中式工厂时,StateId枚举和状态类是一一对应的。新增状态时如果只加了类没加枚举,或者只加了枚举没加工厂分支,编译能过但运行时切不过去。我的做法是在工厂函数里加一个default分支抛异常或者打错误日志,配合单元测试覆盖所有枚举值,确保漏加时能被发现。
更严格的做法是用static_assert或者编译期检查确保枚举值和工厂分支数量一致,但这需要一些模板技巧,在状态数量不多时有点过度设计。实际项目里,一个覆盖所有枚举值的单元测试就够用了。
6.4 状态对象的构造参数要克制
状态类构造函数接收的参数越多,状态切换时的构造代码就越长,而且这些参数往往是从Context里取的,等于把Context的内部数据暴露给了状态类。我的原则是:状态类构造函数最多接收两个参数,超过两个就说明这个状态类承担了太多职责,应该拆分或者把数据放到Context里让状态类自己去取。
比如RunningState如果需要在构造时知道“当前是否降级模式”和“重试次数”,这两个参数其实都可以从Context取,没必要通过构造函数传。构造函数只传那些Context没有、但状态类初始化必须的数据,比如一个指向外部服务的引用。
6.5 状态机的可视化
状态多了以后,光看代码很难在脑子里构建完整的状态转移图。我习惯用文本工具画一个简单的转移表放在代码注释里,格式是“当前状态 | 事件 | 目标状态 | 条件”。这个表不用很正式,但要有,新增状态时顺手更新。团队里其他人接手时,看这个表比读代码快得多。
如果项目允许引入外部工具,可以用状态机描述文件生成代码,但要注意生成代码的可读性往往不如手写。我的经验是状态少于十五个时手写更可控,超过十五个再考虑工具生成。
7. 一个完整的可编译示例
把上面的要点整合成一个能直接编译运行的例子,方便你对照着改。
#include <iostream> #include <memory> #include <map> #include <mutex> #include <string> enum class StateId { Standby, Running, Fault }; class DeviceContext; class IDeviceState { public: virtual ~IDeviceState() = default; virtual void onButtonPress(DeviceContext& ctx) = 0; virtual void onReset(DeviceContext& ctx) = 0; virtual const char* name() const = 0; }; class DeviceContext { public: DeviceContext() { states_[StateId::Standby] = std::make_unique<StandbyState>(); states_[StateId::Running] = std::make_unique<RunningState>(); states_[StateId::Fault] = std::make_unique<FaultState>(); current_ = states_[StateId::Standby].get(); } void pressButton() { IDeviceState* s = nullptr; { std::lock_guard<std::mutex> lock(mtx_); s = current_; } if (s) { s->onButtonPress(*this); applyPending(); } } void reset() { IDeviceState* s = nullptr; { std::lock_guard<std::mutex> lock(mtx_); s = current_; } if (s) { s->onReset(*this); applyPending(); } } void requestState(StateId id) { std::lock_guard<std::mutex> lock(mtx_); pending_ = id; hasPending_ = true; } bool hasFault() const { return faultFlag_; } void setFault(bool f) { faultFlag_ = f; } const char* currentStateName() const { std::lock_guard<std::mutex> lock(mtx_); return current_ ? current_->name() : "null"; } private: void applyPending() { std::lock_guard<std::mutex> lock(mtx_); if (hasPending_) { auto it = states_.find(pending_); if (it != states_.end()) { std::cout << "[state] " << (current_ ? current_->name() : "null") << " -> " << it->second->name() << "\n"; current_ = it->second.get(); } hasPending_ = false; } } std::map<StateId, std::unique_ptr<IDeviceState>> states_; IDeviceState* current_ = nullptr; StateId pending_ = StateId::Standby; bool hasPending_ = false; bool faultFlag_ = false; mutable std::mutex mtx_; }; class StandbyState : public IDeviceState { public: void onButtonPress(DeviceContext& ctx) override { ctx.requestState(StateId::Running); } void onReset(DeviceContext&) override {} const char* name() const override { return "Standby"; } }; class RunningState : public IDeviceState { public: void onButtonPress(DeviceContext& ctx) override { if (ctx.hasFault()) { ctx.requestState(StateId::Fault); } else { std::cout << "running normally\n"; } } void onReset(DeviceContext& ctx) override { ctx.requestState(StateId::Standby); } const char* name() const override { return "Running"; } }; class FaultState : public IDeviceState { public: void onButtonPress(DeviceContext&) override { std::cout << "fault, press ignored\n"; } void onReset(DeviceContext& ctx) override { ctx.setFault(false); ctx.requestState(StateId::Standby); } const char* name() const override { return "Fault"; } }; int main() { DeviceContext ctx; std::cout << "init: " << ctx.currentStateName() << "\n"; ctx.pressButton(); std::cout << "after press: " << ctx.currentStateName() << "\n"; ctx.setFault(true); ctx.pressButton(); std::cout << "after fault press: " << ctx.currentStateName() << "\n"; ctx.reset(); std::cout << "after reset: " << ctx.currentStateName() << "\n"; return 0; }这个例子里有几个值得注意的点。requestState只记录待切换状态,不立即替换,避免了在状态方法执行中销毁自身。applyPending在委托调用返回后统一执行切换,并打日志。状态对象在Context构造时一次性创建,切换是纯指针赋值。currentStateName加了锁,因为可能被其他线程调用。
编译命令用g++ -std=c++17 -pthread main.cpp -o demo,跑出来的输出能清楚看到状态流转和日志。你可以在这个基础上加自己的状态和事件,验证切换逻辑。
8. 什么时候该考虑替代方案
状态模式不是银弹。如果你的状态机逻辑已经复杂到状态类之间需要频繁互相调用、共享大量数据、或者状态切换条件依赖多个外部输入的组合,那可能该考虑更重的方案了,比如引入一个专门的状态机库,或者把状态逻辑外置成脚本/配置。
另一个信号是:当你发现状态类的数量增长速度超过功能增长速度,每个新功能都要新增好几个状态类,说明状态划分的粒度可能太细了。这时候应该回头审视状态的定义,把一些生命周期短、行为简单的状态合并成Context里的一个标志位,而不是独立的状态类。
我在一个项目里曾经把“初始化中”“加载配置中”“连接中”拆成三个状态类,后来发现它们的行为几乎一样,只是日志文案不同,合并成一个InitializingState加一个阶段枚举后,代码量少了三分之一,可读性反而更好。状态划分的原则是:行为有实质差异才拆,只是数据不同就合并。