Rust 中不违反 OCP 的多状态 typestate pattern
结论
实现方式不止一种。Rust 没有通用方案可以在增加 typestate 维度后,让所有类型定义和实现保持不变。
新增状态只要参与编译期检查,就需要在类型系统的某个位置描述这个维度。实际使用时,可以按下面的方式组织:
Car只保留一个状态参数S;- 用具体类型表示完整状态;
- 将状态约束和状态转换拆成多个小 trait;
- 对外提供 type alias,避免调用方直接书写复杂的状态类型;
- 将新增维度所需的修改集中在状态表示层,尽量不影响
Car的业务方法。
这种结构通常比把所有关联类型都放进一个庞大的 CarState trait 更容易维护。
trait 不能当作具体状态类型
下面的返回类型无法成立:
fn build_new_car() -> Car<CarState<DoorsClosed, RoofClosed>>
主要有两个原因:
CarState是 trait,不能像泛型结构体State<D, R>一样实例化。- typestate 使用具体的静态类型表示当前状态。改用
dyn CarState会擦除这些类型信息,而且包含关联类型的 trait object 还必须明确指定所有关联类型。
因此,函数需要返回一个具体状态类型,或者返回该具体类型的别名。
将状态能力拆成小 trait
CarState 可以只作为完整状态的标记,每种能力则由单独的 trait 描述:
use std::marker::PhantomData;
pub trait DoorState {}
pub struct DoorsOpen;
pub struct DoorsClosed;
impl DoorState for DoorsOpen {}
impl DoorState for DoorsClosed {}
pub trait RoofState {}
pub struct RoofOpen;
pub struct RoofClosed;
impl RoofState for RoofOpen {}
impl RoofState for RoofClosed {}
pub trait CarState {}
pub struct State<D, R>(PhantomData<(D, R)>);
impl<D, R> CarState for State<D, R>
where
D: DoorState,
R: RoofState,
{
}
接着分别定义“门已关闭”和“门已打开”等能力。每个关联类型只描述对应转换的结果:
pub trait DoorsAreClosed: CarState {
type WithDoorsOpen: CarState;
}
pub trait DoorsAreOpen: CarState {
type WithDoorsClosed: CarState;
}
impl<R> DoorsAreClosed for State<DoorsClosed, R>
where
R: RoofState,
{
type WithDoorsOpen = State<DoorsOpen, R>;
}
impl<R> DoorsAreOpen for State<DoorsOpen, R>
where
R: RoofState,
{
type WithDoorsClosed = State<DoorsClosed, R>;
}
Roof 维度也按同样的方式定义:
pub trait RoofIsClosed: CarState {
type WithRoofOpen: CarState;
}
pub trait RoofIsOpen: CarState {
type WithRoofClosed: CarState;
}
impl<D> RoofIsClosed for State<D, RoofClosed>
where
D: DoorState,
{
type WithRoofOpen = State<D, RoofOpen>;
}
impl<D> RoofIsOpen for State<D, RoofOpen>
where
D: DoorState,
{
type WithRoofClosed = State<D, RoofClosed>;
}
Car 的方法只依赖所需能力
Car 仍然只需要一个泛型参数:
pub struct Car<S: CarState> {
model: String,
_state: PhantomData<S>,
}
impl<S: CarState> Car<S> {
fn change_state<T: CarState>(self) -> Car<T> {
Car {
model: self.model,
_state: PhantomData,
}
}
}
门相关的方法只关心门的状态,无须了解 Roof 维度:
impl<S> Car<S>
where
S: DoorsAreClosed,
{
pub fn open_doors(self) -> Car<S::WithDoorsOpen> {
self.change_state()
}
pub fn drive(&self) {
println!(
"The {} is driving safely with its doors closed.",
self.model
);
}
}
impl<S> Car<S>
where
S: DoorsAreOpen,
{
pub fn close_doors(self) -> Car<S::WithDoorsClosed> {
self.change_state()
}
}
Roof 方法同样只依赖对应的能力:
impl<S> Car<S>
where
S: RoofIsClosed,
{
pub fn open_roof(self) -> Car<S::WithRoofOpen> {
self.change_state()
}
}
impl<S> Car<S>
where
S: RoofIsOpen,
{
pub fn close_roof(self) -> Car<S::WithRoofClosed> {
self.change_state()
}
}
这样,每个业务方法只声明自己需要的约束。
用类型别名简化对外 API
调用方无须反复书写完整的 State<...>:
pub type NewCar = Car<State<DoorsClosed, RoofClosed>>;
impl Car<State<DoorsClosed, RoofClosed>> {
pub fn new(model: impl Into<String>) -> Self {
Self {
model: model.into(),
_state: PhantomData,
}
}
}
pub fn build_new_car() -> NewCar {
Car::new("Mymodel")
}
类型别名不会创建新的 nominal type,但可以隐藏大部分泛型细节。还可以根据 API 的含义提供其他别名:
pub type ClosedCar = Car<State<DoorsClosed, RoofClosed>>;
pub type OpenTopCar = Car<State<DoorsClosed, RoofOpen>>;
如果不希望外部代码构造任意状态,可以保持 State 的字段私有,只公开允许使用的别名和构造函数。
增加新维度时需要修改什么
假设以后增加 Engine 状态,完整状态可能变为:
pub trait EngineState {}
pub struct EngineRunning;
pub struct EngineStopped;
impl EngineState for EngineRunning {}
impl EngineState for EngineStopped {}
pub struct State<D, R, E>(PhantomData<(D, R, E)>);
这时需要修改 State 的实现,以及各转换 trait 的底层实现,让转换过程保留 E:
impl<D, R, E> CarState for State<D, R, E>
where
D: DoorState,
R: RoofState,
E: EngineState,
{
}
impl<R, E> DoorsAreClosed for State<DoorsClosed, R, E>
where
R: RoofState,
E: EngineState,
{
type WithDoorsOpen = State<DoorsOpen, R, E>;
}
原有业务方法则不需要改动:
impl<S> Car<S>
where
S: DoorsAreClosed,
{
pub fn open_doors(self) -> Car<S::WithDoorsOpen> {
self.change_state()
}
}
所需修改被限制在状态表示和转换适配层,不会扩散到每个 Car 方法。对实际项目来说,这通常是比较合理的 OCP 边界。
为什么无法做到完全开放扩展
Doors × Roof 与 Doors × Roof × Engine 属于不同的类型空间。增加维度后,编译器需要知道:
- 如何在类型中保存新状态;
- 旧转换是否需要保留新状态;
- 哪些操作依赖新状态;
- 构造函数应当选择哪个初始状态。
因此,增加编译期状态维度时,通常无法让所有旧代码都保持不变。Rust 也没有内建的可扩展 type-level record 或 row polymorphism,不能自动向任意状态集合追加字段并推导全部转换。
在这里,OCP 的实际作用是控制修改范围。加入一个会影响类型不变量的维度,本身就会改变状态模型。
什么时候不适合继续使用 typestate
当状态维度频繁增加、组合数量很大,或者对象需要放进同一个集合时,运行时状态通常更合适:
pub enum DoorState {
Open,
Closed,
}
pub enum RoofState {
Open,
Closed,
}
pub struct Car {
model: String,
doors: DoorState,
roof: RoofState,
}
这种设计会把检查从编译期移到运行时,但能让 API 更稳定,也更便于序列化、存储和动态分发。
typestate 适合状态数量较少、转换规则稳定,并且需要在编译期拒绝非法操作的场景。如果主要目的是减少样板代码,可以使用 declarative macro 或 procedural macro 生成状态类型与转换实现。宏能减少重复代码,却无法消除状态模型本身的变更。
备注:内容仅供参考。
