Rust State Machines
Why Are State Machines in Rust Special?
The short answer is that the move semantics and static checking of moved values lets an API designer attach a resource to a state machine which isn't arbitrarily cloneable and treat the state machine with the same semantics. So if you have a physical communication interface talking to hardware then you wouldn't want an API client to make arbitrary copies of it or references to it. If you did allow that then the two parts of the client code could drive their own virtual copy to different states and interfere.
Rust also has other features that are not, currently at least, common in other programming languages that make it straightforward to encode properties of the state machine graph directly into the API for a state machine and keep the API practical.
Introduction
Nothing in this section should be particularly surprising to anyone that has ever written a state machine in Rust before. But it will be helpful if you haven't. This section will build up a more and more effective state machine API until we reach the best of the well-known solutions.
State Machines Implemented with Integers
At the most basic level a state machine is an integer which changes value representing the state.
const STATE_A: u8 = 0;
const STATE_B: u8 = 1;
const STATE_C: u8 = 2;
fn main() {
let mut _uno = STATE_A;
_uno = STATE_B;
_uno = STATE_C;
}
While this is simple it doesn't really do anything but the barest bookkeeping. And notably if we defined an additional state machine there's nothing preventing us from accidentally mixing the two.
const STATE_A: u8 = 0;
const STATE_B: u8 = 1;
const STATE_C: u8 = 2;
const STATE_ALPHA: u8 = 0;
const STATE_BETA: u8 = 1;
const STATE_GAMMA: u8 = 2;
fn main() {
let mut _uno = STATE_A;
_uno = STATE_B;
_uno = STATE_C;
let mut _dos = STATE_ALPHA;
_dos = STATE_BETA;
_dos = STATE_GAMMA;
}
This state of affairs is what you'll get if you use integer types or enum
in C or C++.
With Typed Enumerations
C++ has improved on that with enum class
which makes enumerations more strictly typed so values from one enum class
can't be trivially assigned to objects typed with a different enum class
.
And enum class
es share that same restriction with Rust's enum
s.
enum UnoContainer {
StateA,
StateB,
StateC,
}
enum DosContainer {
StateAlpha,
StateBeta,
StateGamma,
}
fn main() {
let mut _uno = UnoContainer::StateA;
_uno = UnoContainer::StateB;
_uno = UnoContainer::StateC;
let mut _dos = DosContainer::StateAlpha;
_dos = DosContainer::StateBeta;
_dos = DosContainer::StateGamma;
}
With Payload Data
If we wanted to associate additional data (or handles to hardware) then at this point things start to get complicated for most programming languages since we'd have to implement a tagged union over a number of small container classes for each state. And that's something you definitely could do, and something we will do later, but pattern matching and destructuring makes it easier to use. We can also nest one state machine into a subset of the states of another state machine.
pub struct Data {
// Pretend this contains something that can't/shouldn't be Copy/Clone.
}
enum UnoContainer {
StateA(Data),
StateB(Data),
StateC(Data),
}
enum DosContainer {
StateAlpha(),
StateBeta(UnoContainer),
StateGamma(UnoContainer),
}
fn main() {
let mut _dos = DosContainer::StateAlpha();
_dos = DosContainer::StateBeta(UnoContainer::StateA(Data {}));
_dos = match _dos {
DosContainer::StateBeta(uno) => DosContainer::StateBeta(match uno {
UnoContainer::StateA(data) => UnoContainer::StateB(data),
_ => unreachable!(),
}),
_ => unreachable!(),
};
_dos = match _dos {
DosContainer::StateBeta(uno) => DosContainer::StateGamma(uno),
_ => unreachable!(),
};
_dos = match _dos {
DosContainer::StateGamma(uno) => DosContainer::StateGamma(match uno {
UnoContainer::StateB(data) => UnoContainer::StateC(data),
_ => unreachable!(),
}),
_ => unreachable!(),
};
}
At this point you'd be right to think that while it might be easier to use than a C++ equivalent it is way too complicated to use for such a simple use case. Plus there's no indication of which state transitions are illegal anywhere so you wouldn't be able to to know if the implementation should forbid any of the transitions not taken in the example.
With Typestates
The typestate pattern provides a different way to encode our state machine states as a program
that will let us restrict illegal transitions and simplify the client code when the path through the state machine is statically known.
The very quick and rough overview is that each state is its own type with its own methods
and any method which performs a transition consumes the original self
state type and returns the new state type.
struct Data {
// Pretend this contains something that can't/shouldn't be Copy/Clone.
}
struct StateA {
d: Data,
}
struct StateB {
d: Data,
}
struct StateC {
_d: Data,
}
impl StateA {
fn new() -> Self {
Self { d: Data {} }
}
fn go2b(self) -> StateB {
StateB { d: self.d }
}
}
impl StateB {
fn go2c(self) -> StateC {
StateC { _d: self.d }
}
}
fn main() {
let a = StateA::new();
let b = a.go2b(); // Using `a` after this line is a compiler error.
let _c = b.go2c(); // Using `b` after this line is a compiler error.
}
Note that from the main
function you can't:
- directly create a
StateB
orStateC
- transition directly from
StateA
toStateC
- transition backwards from
StateB
toStateA
orStateC
toStateB
- or inject a new
Data
into the state machine after the state machine was initialized inStateA
.
But you do get a method for each legal transition. And the API uses the type system itself to do once, statically, what we would have to dynamically pattern match every time otherwise. Most importantly for clients it removes the repeated payload destructuring/constructing.
Unfortunately these statically type-checked benefits also mean that one state machine can't always just be embedded within a second directly. If the states were an independent Cartesian product then they could be held side-by-side. If the inner machine state were completely determined by the outer state (i.e. the inner state is known statically) then that particular state could just be added as a member. But those special cases may not always be the case.
With Enumerated Typestates
So we unfortunately need to interpose something between the inner and outer state machines in order to handle multiple inner machine states.
To do that I'll bring back the UnoContainer
but instead of it containing the payload for the state it holds the state type itself.
This unfortunately means that, in the general case, inner state machines will have to have their states dynamically determined to operate on them.
But it does mean that if we statically know the state of the outer state machine we still get the simple client code of the typestate pattern
for at least the outer state machine.
And because you can't access the unioned items in the enum
without pattern match
ing (or some equally type strict equivalent)
the current dynamic state the transitions you have access to are type safe.
struct Data {
// Pretend this contains something that can't/shouldn't be Copy/Clone.
}
struct StateA {
d: Data,
}
struct StateB {
d: Data,
}
struct StateC {
_d: Data,
}
impl StateA {
fn new() -> Self {
Self { d: Data {} }
}
fn go2b(self) -> StateB {
StateB { d: self.d }
}
}
impl StateB {
fn go2c(self) -> StateC {
StateC { _d: self.d }
}
}
enum UnoContainer {
StateA(StateA),
StateB(StateB),
StateC(StateC),
}
struct StateAlpha {}
struct StateBeta {
pub(crate) uno: UnoContainer,
}
struct StateGamma {
pub(crate) uno: UnoContainer,
}
impl StateAlpha {
fn new() -> Self {
Self {}
}
fn go2beta(self) -> StateBeta {
StateBeta {
uno: UnoContainer::StateA(StateA::new()),
}
}
}
impl StateBeta {
fn go2gamma(self) -> StateGamma {
StateGamma { uno: self.uno }
}
}
impl StateGamma {}
fn main() {
let alpha = StateAlpha::new();
let beta = alpha.go2beta();
let beta = StateBeta {
uno: match beta.uno {
UnoContainer::StateA(a) => UnoContainer::StateB(a.go2b()),
_ => unreachable!(),
},
};
let gamma = beta.go2gamma();
let _gamma = StateGamma {
uno: match gamma.uno {
UnoContainer::StateB(b) => UnoContainer::StateC(b.go2c()),
_ => unreachable!(),
},
};
}
If we imagine that the previous programs were in fact libraries with the main
split off from them
then any state which can be represented is a potential starting point and any transition is possible.
But with this program only StateAlpha
(or StateA
if not using the outer state machine) is directly constructable.
So a plain reading of the state machine would look like this.
And you might think this is pretty slick. But that implementation is actually less strict than that graph and we've actually painted ourselves into a corner on making them match.
- When transitioning a state machine we seemingly need to expose the members of the state machines which contain it.
- And this seemingly can't be done by reference because the transition needs to de-initialize the parent.
- So at some level we seemingly need to expose a generic constructor.
- And that means our machine could be in
StateBeta|StateB
and go toStateGamma|StateA
no matter what constructor we make available.
- State machines can't trivially be made members of other objects and transitioned in borrowing methods.
- If you're using the object directly then you can de-initialize and re-initialize as you see fit though.
- However, a method which is
&mut self
doesn't let you just move a field ofself
for a little bit and then re-assign it even if it's statically obvious thatself
is reconstituted at every exit of the method. At least, in the current version ofrustc
(rustc 1.88.0 (6b00bc388 2025-06-23)
) it doesn't.
struct A {}
struct B {
a: A,
}
impl B {
fn self_move(&mut self) {
let a = self.a;
// error[E0507]: cannot move out of `self.a` which is behind a mutable reference
// --> src/main.rs:7:17
// |
// 7 | let a = self.a;
// | ^^^^^^ move occurs because `self.a` has type `A`, which does not implement the `Copy` trait
// |
self.a = a;
}
}
fn main() {
let b = B { a: A {} };
}
At this point you might want to throw in the towel and just handroll your own encapsulation and make everything Clone
or reference counted.
But we can still do better.
Transforming Moves Into Mutation
So the question seemingly now is if there is something (preferably without unsafe code) that can "cheat" the usual rules of Rust
and permit us to use a reference to get the full power of methods which move self
.
With Cell
If you're sufficiently familiar with Rust your first instinct is probably to use Cell
since it exists to permit interior mutability
even when the Cell
itself is not marked as mutable.
And that would probably lead you to implement something like UpdateCell
from update_cell
.
For clarity I've simplified the state machine code so we can focus on this update mechanism.
use std::cell::Cell;
struct StateA {}
struct StateB {}
struct StateC {}
impl StateA {
fn new() -> Self {
Self {}
}
fn go2b(self) -> StateB {
StateB {}
}
}
impl StateB {
fn go2c(self) -> StateC {
StateC {}
}
}
enum UnoContainer {
StateA(StateA),
StateB(StateB),
StateC(StateC),
}
// Excerpt from `update_cell`
// https://docs.rs/update_cell/0.1.0/src/update_cell/lib.rs.html#50-70
struct UpdateCell<T> {
value: Cell<Option<T>>,
}
impl<T> UpdateCell<T> {
fn new(val: T) -> Self {
Self {
value: Cell::new(Some(val)),
}
}
fn update<F: FnOnce(T) -> T>(&mut self, func: F) {
let val = self.value.take().unwrap();
self.value.set(Some(func(val)))
}
}
struct API {
uno: UpdateCell<UnoContainer>,
}
impl API {
fn new() -> Self {
Self {
uno: UpdateCell::new(UnoContainer::StateA(StateA::new())),
}
}
fn update_uno<F: FnOnce(UnoContainer) -> UnoContainer>(&mut self, f: F) {
self.uno.update(f);
}
}
fn main() {
let mut api = API::new();
api.update_uno(|uno| match uno {
UnoContainer::StateA(a) => UnoContainer::StateB(a.go2b()),
_ => unreachable!(),
});
api.update_uno(|uno| match uno {
UnoContainer::StateB(b) => UnoContainer::StateC(b.go2c()),
_ => unreachable!(),
});
}
UpdateCell
allows us to both expose the inner state machine to all the transitions permitted by the state types as well as
use methods which move self
from within a &mut self
method while guaranteeing the object is well-formed at the end.
Notably the API
object is not entirely valid during the update_uno
call, however, because it's &mut self
and that reference
is not passed into the lambda it's inaccessible for the duration of lambda, apart from the state machine, which is well-formed.
Without Cell
The implementation of UpdateCell
is, surprisingly, actually a little bit more complicated than is necessary.
If you read the implementation critically you'll notice that the Cell
isn't actually being used to "open the interior mutability escape hatch".
So we can simplify (and rename) UpdateCell
and keep the exact same behavior.
struct Update<T> {
data: Option<T>,
}
impl<T> Update<T> {
fn new(data: T) -> Self {
Self { data: Some(data) }
}
fn update<F: FnOnce(T) -> T>(&mut self, f: F) {
self.data = Some(f(self.data.take().unwrap()));
}
}
The explanation for what it's doing is also a bit more obvious now that Cell
is removed in that Option::take
swaps a None
value
with the contents of data
which are guaranteed to be Some
when it's called.
Then, after the lambda is invoked, it must return a meaningful value which gets restored to the Option
.
And those two operations only require mutating the Option
, not that it be moved.
Restricting the Lambda
Earlier on I noted that the typestate pattern meant that we could prevent a client from arbitrarily swapping out the internals of a state.
However, exposing the inner state for arbitrary transformation opens up that state to be replaced largely arbitrarily.
In this example Data
has been changed to take an id
and API
creates it with an id
of 1
.
But then we get trick API
into replacing the inner state machine with one having a Data
object with id
of 2
instead of 1
.
struct Data {
// Pretend this contains something that can't/shouldn't be Copy/Clone.
id: u8,
}
impl Data {
fn new(id: u8) -> Self {
Self { id }
}
}
struct StateA {
d: Data,
}
struct StateB {
d: Data,
}
struct StateC {
_d: Data,
}
impl StateA {
fn new(d: Data) -> Self {
Self { d }
}
fn go2b(self) -> StateB {
StateB { d: self.d }
}
}
impl StateB {
fn go2c(self) -> StateC {
StateC { _d: self.d }
}
}
enum UnoContainer {
StateA(StateA),
StateB(StateB),
StateC(StateC),
}
struct Update<T> {
data: Option<T>,
}
impl<T> Update<T> {
fn new(data: T) -> Self {
Self { data: Some(data) }
}
fn update<F: FnOnce(T) -> T>(&mut self, f: F) {
self.data = Some(f(self.data.take().unwrap()));
}
}
struct API {
uno: Update<UnoContainer>,
}
impl API {
fn new() -> Self {
Self {
uno: Update::new(UnoContainer::StateA(StateA::new(Data::new(1)))),
}
}
fn update_uno<F: FnOnce(UnoContainer) -> UnoContainer>(&mut self, f: F) {
self.uno.update(f);
}
}
fn main() {
let mut api = API::new();
let a = StateA::new(Data::new(2));
api.update_uno(|uno| match uno {
UnoContainer::StateA(_) => UnoContainer::StateB(a.go2b()),
_ => unreachable!(),
});
api.update_uno(|uno| match uno {
UnoContainer::StateB(b) => UnoContainer::StateC(b.go2c()),
_ => unreachable!(),
});
}
In that example the a
in main
is moved into the capture of the first lambda and then is moved when calling go2b
.
This is only possible because the lambda only has to be FnOnce
which is allowed to move objects from its capture.
If we restrict it to be Fn
then swapping out the internals can still be done by taking a reference from outside the lambda
and then cloning it inside the lambda.
But that only works if we've made or can make the contained items Clone
.
#[derive(Clone)]
struct Data {
_id: u8,
}
impl Data {
fn new(id: u8) -> Self {
Self { _id: id }
}
}
#[derive(Clone)]
struct StateA {
d: Data,
}
struct StateB {
d: Data,
}
struct StateC {
_d: Data,
}
impl StateA {
fn new(d: Data) -> Self {
Self { d }
}
fn go2b(self) -> StateB {
StateB { d: self.d }
}
}
impl StateB {
fn go2c(self) -> StateC {
StateC { _d: self.d }
}
}
enum UnoContainer {
StateA(StateA),
StateB(StateB),
StateC(StateC),
}
struct Update<T> {
data: Option<T>,
}
impl<T> Update<T> {
fn new(data: T) -> Self {
Self { data: Some(data) }
}
fn update<F: Fn(T) -> T>(&mut self, f: F) {
self.data = Some(f(self.data.take().unwrap()));
}
}
struct API {
uno: Update<UnoContainer>,
}
impl API {
fn new() -> Self {
Self {
uno: Update::new(UnoContainer::StateA(StateA::new(Data::new(1)))),
}
}
fn update_uno<F: Fn(UnoContainer) -> UnoContainer>(&mut self, f: F) {
self.uno.update(f);
}
}
fn main() {
let mut api = API::new();
let a = StateA::new(Data::new(2));
api.update_uno(|uno| match uno {
UnoContainer::StateA(_) => UnoContainer::StateB((&a).clone().go2b()),
_ => unreachable!(),
});
api.update_uno(|uno| match uno {
UnoContainer::StateB(b) => UnoContainer::StateC(b.go2c()),
_ => unreachable!(),
});
}
But if you think of a lambda as an object with an ad-hoc data type (the closure) plus a function pointer then constraining away the capture would make it even harder to inject an arbitrary value but still possible.
struct Data {
// Pretend this contains something that can't/shouldn't be Copy/Clone.
_id: u8,
}
impl Data {
fn new(id: u8) -> Self {
Self { _id: id }
}
}
struct StateA {
d: Data,
}
struct StateB {
d: Data,
}
struct StateC {
_d: Data,
}
impl StateA {
fn new(d: Data) -> Self {
Self { d }
}
fn go2b(self) -> StateB {
StateB { d: self.d }
}
}
impl StateB {
fn go2c(self) -> StateC {
StateC { _d: self.d }
}
}
enum UnoContainer {
StateA(StateA),
StateB(StateB),
StateC(StateC),
}
struct Update<T> {
data: Option<T>,
}
impl<T> Update<T> {
fn new(data: T) -> Self {
Self { data: Some(data) }
}
fn update(&mut self, f: fn(T) -> T) {
self.data = Some(f(self.data.take().unwrap()));
}
}
struct API {
uno: Update<UnoContainer>,
}
impl API {
fn new() -> Self {
Self {
uno: Update::new(UnoContainer::StateA(StateA::new(Data::new(1)))),
}
}
fn update_uno(&mut self, f: fn(UnoContainer) -> UnoContainer) {
self.uno.update(f);
}
}
fn main() {
let mut api = API::new();
api.update_uno(|uno| match uno {
UnoContainer::StateA(_) => {
let a = StateA::new(Data::new(2));
UnoContainer::StateB(a.go2b())
}
_ => unreachable!(),
});
api.update_uno(|uno| match uno {
UnoContainer::StateB(b) => UnoContainer::StateC(b.go2c()),
_ => unreachable!(),
});
}
So try as I might I can't find a way to make exposing the state machine this way not make the whole thing swappable.
You could make passthrough methods which call the methods defined on the state types but you'd also have to dynamically pattern match the actual state it's in and decide what to do for states that don't make that method available. Since that means the client wouldn't see the not-obviously-defined transitions it's not ideal.
Protecting the Internal State
So in order to protect that internal state we need to come up with a more clever layer to put on top of what we have so far. The problem with what we've covered so far is that we either expose the internals to be moved in its entirety or we allow the client code to interact with the state machine without "agreeing" that the state machine is in the state it in fact is.
We can get around those problems by taking each of the state machine state methods and mapping them to some enumeration. Then the client is "informed" of the machine state by calling a lambda with a type signature which constrains the machine state and the client then tells the machine which transition to take.
struct Data {
// Pretend this contains something that can't/shouldn't be Copy/Clone.
_id: u8,
}
impl Data {
fn new(id: u8) -> Self {
Self { _id: id }
}
}
struct StateA {
d: Data,
}
struct StateB {
d: Data,
}
struct StateC {
_d: Data,
}
impl StateA {
fn new(d: Data) -> Self {
Self { d }
}
fn go2b(self) -> StateB {
StateB { d: self.d }
}
}
impl StateB {
fn go2c(self) -> StateC {
StateC { _d: self.d }
}
}
enum UnoContainer {
StateA(StateA),
StateB(StateB),
StateC(StateC),
}
enum UnoStateAApply {
Go2B(),
}
enum UnoStateBApply {
Go2C(),
}
enum UnoStateCApply {}
struct Update<T> {
data: Option<T>,
}
impl<T> Update<T> {
fn new(data: T) -> Self {
Self { data: Some(data) }
}
fn update<F: FnOnce(T) -> T>(&mut self, f: F) {
self.data = Some(f(self.data.take().unwrap()));
}
}
struct API {
uno: Update<UnoContainer>,
}
impl API {
fn new() -> Self {
Self {
uno: Update::new(UnoContainer::StateA(StateA::new(Data::new(1)))),
}
}
fn uno_apply(
&mut self,
fa: fn() -> UnoStateAApply,
fb: fn() -> UnoStateBApply,
fc: fn() -> UnoStateCApply,
) {
self.uno.update(|uno| match uno {
UnoContainer::StateA(a) => match fa() {
UnoStateAApply::Go2B() => UnoContainer::StateB(a.go2b()),
},
UnoContainer::StateB(b) => match fb() {
UnoStateBApply::Go2C() => UnoContainer::StateC(b.go2c()),
},
UnoContainer::StateC(c) => match fc() {},
});
}
}
fn main() {
let mut api = API::new();
api.uno_apply(
|| UnoStateAApply::Go2B(),
|| unreachable!(),
|| unreachable!(),
);
api.uno_apply(
|| unreachable!(),
|| UnoStateBApply::Go2C(),
|| unreachable!(),
);
}
The prior examples in this section have only been demonstrating a single, non-nested state machine for brevity.
However, I think it's necessary to show we can go back and re-create the two-level state machine we had before.
We'll be moving the application of the transitions into a new type representing the overall state machine.
And we'll also add "no operation" enumeration values so the application methods can play double-duty for mutating only the inner state machine.
Plus, for more practical applications it might be appropriate to pass in parameters to a transition which will require FnOnce
.
struct Update<T> {
data: Option<T>,
}
impl<T> Update<T> {
fn new(data: T) -> Self {
Self { data: Some(data) }
}
fn update<F: FnOnce(T) -> T>(&mut self, f: F) {
self.data = Some(f(self.data.take().unwrap()));
}
}
struct Data {
// Pretend this contains something that can't/shouldn't be Copy/Clone.
_id: u8,
}
impl Data {
fn new(id: u8) -> Self {
Self { _id: id }
}
}
struct StateA {
d: Data,
}
struct StateB {
d: Data,
}
struct StateC {
_d: Data,
}
impl StateA {
fn new(d: Data) -> Self {
Self { d }
}
fn go2b(self) -> StateB {
StateB { d: self.d }
}
}
impl StateB {
fn go2c(self) -> StateC {
StateC { _d: self.d }
}
}
enum UnoContainer {
StateA(StateA),
StateB(StateB),
StateC(StateC),
}
struct Uno {
uno: Update<UnoContainer>,
}
impl Uno {
fn new(d: Data) -> Self {
Self {
uno: Update::new(UnoContainer::StateA(StateA::new(d))),
}
}
fn apply<
FA: FnOnce(&mut StateA) -> UnoStateAApply,
FB: FnOnce(&mut StateB) -> UnoStateBApply,
FC: FnOnce(&mut StateC) -> UnoStateCApply,
>(
&mut self,
fa: FA,
fb: FB,
fc: FC,
) {
self.uno.update(|uno| match uno {
UnoContainer::StateA(mut a) => match fa(&mut a) {
UnoStateAApply::NoOp() => UnoContainer::StateA(a),
UnoStateAApply::Go2B() => UnoContainer::StateB(a.go2b()),
},
UnoContainer::StateB(mut b) => match fb(&mut b) {
UnoStateBApply::NoOp() => UnoContainer::StateB(b),
UnoStateBApply::Go2C() => UnoContainer::StateC(b.go2c()),
},
UnoContainer::StateC(mut c) => match fc(&mut c) {
UnoStateCApply::NoOp() => UnoContainer::StateC(c),
},
});
}
}
enum UnoStateAApply {
NoOp(),
Go2B(),
}
enum UnoStateBApply {
NoOp(),
Go2C(),
}
enum UnoStateCApply {
NoOp(),
}
struct StateAlpha {}
struct StateBeta {
uno: Uno,
}
struct StateGamma {
uno: Uno,
}
impl StateAlpha {
fn new() -> Self {
Self {}
}
fn go2beta(self, d: Data) -> StateBeta {
StateBeta { uno: Uno::new(d) }
}
}
impl StateBeta {
fn uno(&mut self) -> &mut Uno {
&mut self.uno
}
fn go2gamma(self) -> StateGamma {
StateGamma { uno: self.uno }
}
}
impl StateGamma {
fn uno(&mut self) -> &mut Uno {
&mut self.uno
}
}
enum TresContainer {
StateAlpha(StateAlpha),
StateBeta(StateBeta),
StateGamma(StateGamma),
}
struct Tres {
tres: Update<TresContainer>,
}
impl Tres {
fn new() -> Self {
Self {
tres: Update::new(TresContainer::StateAlpha(StateAlpha::new())),
}
}
fn apply<
FA: FnOnce(&mut StateAlpha) -> TresStateAlphaApply,
FB: FnOnce(&mut StateBeta) -> TresStateBetaApply,
FC: FnOnce(&mut StateGamma) -> TresStateGammaApply,
>(
&mut self,
fa: FA,
fb: FB,
fc: FC,
) {
self.tres.update(|tres| match tres {
TresContainer::StateAlpha(mut a) => match fa(&mut a) {
TresStateAlphaApply::NoOp() => TresContainer::StateAlpha(a),
TresStateAlphaApply::Go2Beta(d) => TresContainer::StateBeta(a.go2beta(d)),
},
TresContainer::StateBeta(mut b) => match fb(&mut b) {
TresStateBetaApply::NoOp() => TresContainer::StateBeta(b),
TresStateBetaApply::Go2Gamma() => TresContainer::StateGamma(b.go2gamma()),
},
TresContainer::StateGamma(mut c) => match fc(&mut c) {
TresStateGammaApply::NoOp() => TresContainer::StateGamma(c),
},
});
}
}
enum TresStateAlphaApply {
NoOp(),
Go2Beta(Data),
}
enum TresStateBetaApply {
NoOp(),
Go2Gamma(),
}
enum TresStateGammaApply {
NoOp(),
}
struct API {
tres: Tres,
}
impl API {
fn new() -> Self {
Self { tres: Tres::new() }
}
fn run_once(&mut self) {
let d = Data::new(1);
self.tres.apply(
|_| TresStateAlphaApply::Go2Beta(d),
|_| unreachable!(),
|_| unreachable!(),
);
self.tres.apply(
|_| unreachable!(),
|beta| {
beta.uno().apply(
|_| UnoStateAApply::Go2B(),
|_| unreachable!(),
|_| unreachable!(),
);
TresStateBetaApply::Go2Gamma()
},
|_| unreachable!(),
);
self.tres.apply(
|_| unreachable!(),
|_| unreachable!(),
|gamma| {
gamma.uno().apply(
|_| unreachable!(),
|_| UnoStateBApply::Go2C(),
|_| unreachable!(),
);
TresStateGammaApply::NoOp()
},
);
}
}
fn main() {
let mut api = API::new();
api.run_once();
}
This implementation has some nice properties:
- If you move the state machine definition (not
API
from the code) into its own library then the library has no panics.- This is because a transition can only be attempted to be applied when it matches a type signature matching the machine state.
- This doesn't mean a transition or even a state might be illegal for the application. In the example these are marked as
unreachable!()
. - Transition methods which return an
Error
can still be handled because the target state isn't directly specified.
- Even when nesting state machines, the user of any given state machine is unable to see any unspecified intermediate state at any point.
- Any outer state machine is mutably borrowed for the duration of the
apply
method and is mostly hidden for the duration of the lambdas. - The exception to that is that the current state of the machine is made visible only via mutable reference so it can't be arbitrarily subtituted.
- Any outer state machine is mutably borrowed for the duration of the
- At no point does anything have to be either
Copy
orClone
.- This means that the state machine can hold on to special resources which also can't be cloned.
- And we don't need
Arc
,Rc
orCow
to de-duplicate copies.
- If the path the state machine takes is known statically you could opt to deal with the outermost state machine's state types directly.
- This avoids needing to either specify a large number of
unreachable!()
lambdas or boilerplate functions to specify exactly one lambda for the outermost state machine.- Unfortunately that doesn't work for any inner state machines.
- You could also extend the implementation by implementing
From
traits forTres
to construct it from the typestate container.- You might want to do that if you have a complex, yet statically known, startup sequence then hand the state machine to another part of code for dynamic transitions.
- This avoids needing to either specify a large number of
If there's no existing name for this kind of pattern let's call it a "tagged transition" pattern.
Wrap Up
What Should Library Writers Implement?
If you write a library and want to make this way of using state machines available to its clients then at a minimum you should implement your code with the typestate pattern. The reasoning is that the typestate pattern, when specifying a single-level state machine, has a failry well-encapsulated way to use it.
However, if you're specifying a state machine with more than one level then I would recommend something like the tagged transition approach.
Also, if there's any chance your client would be determining which transitions to take dynamically and you don't provide some dynamic
method for traversing the state machine then your users might get confused about what to do.
After having read this you might think it's obvious what to do with the typestate pattern to use it dynamically
but would you have been able to come up with UpdateCell
or Update
?
I suspect not, so providing something as powerful and well encapsulated as the apply
function is recommended.
Use in a GUI
The above programs are just complex enough to demonstrate the relevant principles.
But it might be helpful, whether for learning purposes or persuasive purposes, to have it in an application.
So I've created an iced
GUI which has a state machine
which isn't any more complicated but is enough to drive the GUI.
Prior Work
If you read other currently-existing writing
on Rust's peculiar style of state machines you probably won't find anything that maps to the idea of an Update
wrapper type.
I didn't find any anyway.
I did however find the update_cell
crate when I thought that Update
needed the
escape hatch from Rust's typical ownership rules using std::cell::Cell
.
I was motivated to write this post due to the fact that no one seemed to have combined these two ideas together.
But I can't claim that anything within this article is per se novel as I don't know what all the users of update_cell
are using it for.
Given how simple the implementation of Update
is it's hard to be sure there isn't some other implementation floating around
in someone else's state machine framework.