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 commands and return the log it produces.
pub fn simulate(script: Vec<String>) -> Vec<String>
A state machine is a value that is always in exactly one of a fixed set of states and moves between them in response to events. Rust offers two common ways to model one. The typestate pattern gives each state its own type, so calling a method that is not allowed in the current state fails to compile. That only works when the compiler can tell, at each point in the program, which state the value is in. A vending machine cannot know at compile time which coin or button press comes next, so its state has to be stored as an ordinary runtime value. An enum with one variant per state is the usual way to do that, and this exercise builds one.
The machine
The starter defines both enums, a name() method on each, a price lookup and
a script parser. Your work is the transition function step, plus one fix in
simulate.
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 }
Each state carries its own data. A Dispensing value has an item and an
amount of change, and it has no credit field at all. Compare a struct with a
numeric state field and five Option fields: there, nothing stops code from
setting a credit while dispensing, and every reader must check which fields
are meaningful. With the enum, those combinations cannot be written.
Prices are cola 75, chips 50 and gum 25. Item names are case sensitive,
and any other name is an unknown item.
The transition table
Implement fn step(self, ev: Event) -> (Self, String). It consumes the
current state and the event and returns the next state together with the
one log line that transition produces.
| current state | event | next state | log line |
|---|---|---|---|
Idle |
Coin(n) |
Collecting with credit n |
credit {n} |
Collecting { credit } |
Coin(n) |
Collecting with credit credit + n |
credit {credit + n} |
Collecting { credit } |
Select(item), unknown item |
Fault { reason: item } |
fault unknown {item} |
Collecting { credit } |
Select(item), price at most credit |
Dispensing { item, change: credit - price } |
vend {item} change {change} |
Collecting { credit } |
Select(item), price above credit |
unchanged | short {price - credit} more |
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} |
| any other pair | unchanged | ignored {event} in {state} |
In the last row, {event} and {state} are the lower-case names returned by
the name() methods, such as ignored take in idle. Some consequences of the
table:
- Only
Coindoes anything inIdle. Selecting an item there, even an unknown one, is ignored rather than a fault. - An unknown item is a fault whatever the credit, and the credit is not kept:
Faultstores only the item name. - A coin of 0 is accepted, so
coin 0inIdlemoves toCollectingwith credit 0. - Coins inserted while dispensing, refunding or in a fault are ignored, not added to any credit.
Reading the script
Each script line becomes one event through the starter's parse function,
which you do not need to change. It splits the line on whitespace, so extra
spaces are allowed, and recognises these forms:
coin <n>, where<n>must parse as au32(0 to 4294967295; Rust's integer parser also accepts a leading+).select <item>.refund,take,collectandservice, with nothing after them.
Words after the argument of coin or select are ignored, so select gum now selects gum. Any other line, including an empty one, is a bad line: it
logs bad {line} with the original line text and leaves the state unchanged.
After the last line, log end {state} using the state's name, so the result
always ends with the final state. An empty script therefore returns
["end idle"]. The tests never let the credit in one transaction exceed
u32::MAX, so plain + is enough. (If it did, credit + n would panic in a
debug build and wrap around in a release build, with Rust's default
overflow settings.)
Why step takes self by value
When step takes self rather than &mut self, the old state is moved into
the function, so step owns it. Each arm can take the old variant apart
and do anything an owner can with its fields, including moving a String
into the new state or returning the whole old state unchanged, as the
catch-all row does. The caller can no longer use the old state after the call; the only
state it has is the one step returned, so it cannot keep acting on a state
the machine has already left.
A &mut self version is also possible: each arm assigns the new state with
*self = State::Dispensing { .. } and returns only the log line. For this
table that works, because the vended item comes from the Select event and
the old states' Strings are only formatted into messages. What &mut self
cannot do is move a field out of the old state, since a field cannot be moved
out from behind a reference; a transition that needed to would require a
helper such as std::mem::take or a clone.
Check your understanding
The starter's simulate loop does not compile. Before you look
Show answer
Hide answer
at the error, predict what the compiler objects to.
It reports E0382, "borrow of moved value: state". The error points at
state.name() after the loop, and the label on state.step(ev) says the
value was moved "due to this method call, in previous iteration of loop".
state.step(ev) moves state, because step takes self. The loop body
never puts a state back, so after the first iteration the variable holds no
value. The compiler tracks whether a variable holds a value at each point in
the program, so moving out of a local is allowed as long as it is assigned
again before the next use. The fix is one line: assign the returned state
back to state (state = next;). The same compiler output also warns that
next is unused and that state does not need mut; both warnings go away
with that fix.
When the state lives in a struct field
A consuming step is easy to call when the state is a local variable, as it
is here. When the state is a field and you only have &mut access to the
struct, the direct call does not compile:
struct Machine { state: State, log: Vec<String> }
impl Machine {
fn feed(&mut self, ev: Event) {
let (next, msg) = self.state.step(ev); // error[E0507]
self.state = next;
self.log.push(msg);
}
}
The error is E0507, "cannot move out of self.state which is behind a
mutable reference". A mutable reference must leave a valid value behind. If
the move were allowed and step then panicked, the struct would be left with
no state while other code could still reach it, for example during unwinding
when it is dropped.
One common fix is to put a placeholder in the field first with
std::mem::replace, which stores a new value and returns the old one. This
needs an extra variant, here called Poisoned, that no transition ever
produces (plus an arm for it in name() and any other match that lists
every variant):
let taken = std::mem::replace(&mut self.state, State::Poisoned);
let (next, msg) = taken.step(ev);
self.state = next;
self.log.push(msg);
If step panics, the field is left as Poisoned, which later code can
detect. Two alternatives are storing Option<State> and calling
Option::take, or giving State a Default implementation and calling
std::mem::take(&mut self.state), which is mem::replace with the default
value. Clippy's mem_replace_with_default lint suggests mem::take when the
replacement is the type's default value, written as T::default() or as a
standard constructor such as String::new(); it does not fire for an explicit
variant such as State::Poisoned.
Two trade-offs
The catch-all arm. The last row of the table is one (state, ev) => ...
arm. It keeps the match short and gives every unexpected pair a defined
result. The cost is that if someone later adds a seventh event, the compiler
will not point out the transitions that should handle it, because the
catch-all already matches it. Listing every state and event pair explicitly
is longer, but then a missing pair is a compile error (E0004,
"non-exhaustive patterns"). Which is better depends on how often the set of
events changes and how costly a silently ignored event would be.
Enum or trait object. The object-oriented chapter of The Rust
Programming Language builds a state machine from a State trait, with each
state a separate type and transitions such as fn request_review(self: Box<Self>) -> Box<dyn State>. That design is open: if the trait is public,
code in another module or crate can add a state by implementing it. The enum
design is closed: only the enum's definition can add a state, but every
match without a catch-all arm is checked for exhaustiveness, so adding a
variant produces a compile error at each match that does not handle it. An
enum is usually the simpler choice when one module owns the complete set of
states, as it does here.
The lint gate and unused code
Rust submissions must pass Clippy with warnings treated as errors. That
includes the dead_code lint, which reports private items that nothing in
the program uses. It is a check on the source code, not on which paths the
tests run. Once the loop compiles, the starter still gets two such warnings:
price and Event::name are private and unused while step is
todo!(). A step that follows the table calls both, so the warnings
disappear.
dead_code does not catch everything. With Rust 1.98 in this single-file
program, unused variants and fields of the pub enums are not reported, so
adding a Poisoned variant that nothing constructs would still pass the
gate. The test cases are what check the behaviour: together they drive
every state and every event kind through the machine at least once.
Your code, saved here
Sign in to access your accepted solutions and saved draft history. You can keep reading the lesson without an account.
Sign in to your accountStuck?
Rust reference solution
Sign in to track your attempts toward unlocking the reference solution.