Skip to content

← The Expert Edge: Idiom, Review and Capstones step 1 of 14

Hard End-to-End

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…