We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← The Expert Edge: Idiom, Review and Capstones step 1 of 14
Enum state machines with data-carrying transitions
Drive a five-state vending machine through a script of six event kinds and return the log it emits.
pub fn simulate(script: Vec<String>) -> Vec<String>
You met the typestate pattern earlier: encode the state in the type, and illegal transitions stop compiling. It is beautiful and it applies to almost nothing you will ever be paid to write, because typestate needs the transitions to be known statically. A vending machine reads coins from a slot. A TCP stack reads packets from a wire. A parser reads bytes from a file. When the next transition is decided at runtime, typestate cannot help, and an enum state machine is what you actually write. This is that shape, in full.
The machine
Both enums are given to you in the starter — the design work here is the transition function, not the data model.
pub enum State {
Idle,
Collecting { credit: u32 },
Dispensing { item: String, change: u32 },
Refunding { amount: u32 },
Fault { reason: String },
}
pub enum Event { Coin(u32), Select(String), Refund, Take, Collect, Service }
Note that the states carry different payloads. That is the whole reason
to reach for an enum rather than a struct with a state: u8 field and five
optional fields hanging off it: a Dispensing machine has an item and change
and cannot have a credit, and the type says so. Every “impossible” field
combination you would have had to defend against by hand is now unspellable.
Prices are cola 75, chips 50, gum 25. Anything else is unknown.
The transition table
Implement fn step(self, ev: Event) -> (Self, String) — consuming the state
and the event, returning the next state and the single line this transition
emits.
| from | event | to | emits |
|---|---|---|---|
Idle |
Coin(n) |
Collecting { credit: n } |
credit {n} |
Collecting { credit } |
Coin(n) |
Collecting { credit + n } |
credit {credit+n} |
Collecting { credit } |
Select(item), price ≤ credit |
Dispensing { item, change } |
vend {item} change {change} |
Collecting { credit } |
Select(item), price > credit |
unchanged |
short {price-credit} more |
Collecting { credit } |
Select(item), unknown item |
Fault { reason: item } |
fault unknown {item} |
Collecting { credit } |
Refund |
Refunding { amount: credit } |
refund {credit} |
Dispensing { item, change } |
Take |
Idle |
took {item} with {change} |
Refunding { amount } |
Collect |
Idle |
collected {amount} |
Fault { reason } |
Service |
Idle |
serviced after {reason} |
| anything else | unchanged |
ignored {event} in {state} |
The lower-case names for the last row come from the name() helpers already
written for you on both enums.
Script lines are coin <n>, select <item>, refund, take, collect,
service. A line that does not parse emits bad {line} and does not
touch the state. After the last line, emit end {state} — so the final state
is always observable.
Why step takes self by value
This is the sentence to take away from the whole item:
A
self-consuming transition makes the state machine correct by construction, because there is no instant at which the old state and the new state both exist.
If step took &mut self you would have to mutate fields in place, and
halfway through Collecting { credit } → Dispensing { item, change } you
would be holding a value that is neither. With self by value, the old state
is consumed — the language will not let you read it afterwards — and the
only thing you can produce is a whole, valid new state. Ownership is doing
the work an invariant comment would otherwise have to do.
::: question The starter’s simulate loop does not compile. Before you look
at the error, predict which one it is.
It is E0382, “use of moved value”, and the note is the interesting part:
value moved here, in previous iteration of loop.
state.step(ev) moves state, because step takes self. The loop body
never puts a state back, so the second time around the loop there is nothing
to move. Rust’s move checker is flow-sensitive: moving out of a local is
perfectly legal as long as you reinitialise it before the next use. The fix
is one line — bind the returned state back into state — and it is the
clearest possible demonstration that the compiler is tracking initialisation,
not scopes.
:::
The ugly part, which nobody tells you
self-consuming transitions are lovely as long as the state lives in a local
variable, exactly as it does here. The moment the state lives inside a
struct field, they get awkward, and it is worth knowing why now rather than
discovering it in anger later.
struct Machine { state: State, log: Vec<String> }
impl Machine {
fn feed(&mut self, ev: Event) {
let (next, msg) = self.state.step(ev); // E0507: cannot move out of
self.state = next; // `self.state` behind a
self.log.push(msg); // mutable reference
}
}
You cannot move out of &mut self. If you could, an early return or a panic
would leave the struct holding a hole. The standard escape is to swap
something valid in first:
let taken = std::mem::replace(&mut self.state, State::Poisoned);
let (next, msg) = taken.step(ev);
self.state = next;
That is the real idiom, and it is why so many production state machines have
a Poisoned or Invalid variant that no transition ever produces. If your
state type implements Default, clippy’s mem_replace_with_default will
nudge you to std::mem::take instead, which is the same move spelled
shorter.
Two honest trade-offs
The _ => (state, "ignored") fallback. It makes the table above short and
it means the machine can never crash on an unexpected event. It is also a
place bugs go to hide: add a seventh event next year, forget to handle it,
and the machine silently ignores it instead of failing to compile. Sometimes
a fallback is the right call — a network protocol that must survive garbage
input — and sometimes writing out every pair and letting E0004 catch your
omissions is worth the verbosity. Decide deliberately; do not default.
Enum vs. Box<dyn State>. The Rust Book’s OOP chapter builds a state
machine as a trait object, with each state a separate type and fn request_review(self: Box<Self>) -> Box<dyn State>. That version is open:
a downstream crate can add a state without touching yours. The enum version
is closed: only you can add states — but in exchange, every match over
the enum is checked for exhaustiveness, so adding a state gives you a compile
error at every place that needs updating. That is a genuinely valuable
property and the trait-object version does not have it. Neither design is
better. Choose closed-and-exhaustive when you own all the states, which for
protocols and parsers is essentially always.
The build will police your design
With -D warnings, dead_code is an error. A State variant you never
construct, or a field you never read, will fail the build — which is why the
cases below are chosen to force every one of the five states and all six
events through the machine at least once. If something is unreachable, either
the table is wrong or the variant should not exist.
Loading visualization…
Stuck?
Rust reference solution
Sign in to attempt this problem and reveal the reference solution.