Complex state machines
A more complex state machine, that allows you to move from one to another in your code while still using the type system to check for proper usage, can be done. Let's start by defining the state machine. We want a machine that represents the way a robot works in a car-building facility. Let's say its job is to install two doors in a car. It will first wait for the next car to come, take the door, put it in place, put the bolts in place, do the same for the second door, and then wait for the next car.
We will first define some functions that will use sensors and that we will simulate:
fn is_the_car_in_place() -> bool {
unimplemented!()
}
fn is_the_bolt_in_place() -> bool {
unimplemented!()
}
fn move_arm_to_new_door() {
unimplemented!();
}
fn move_arm_to_car() {
unimplemented!()
}
fn turn_bolt() {
unimplemented!()
}
fn grip_door() {
unimplemented!()
}
Of course, the real software would need to take many things into account. It should check that the environment is safe, the way it moves the door to the car should be optimal, and so on, but this simplification will do for now. We now define some states:
struct WaitingCar;
struct TakingDoor;
struct PlacingDoor;
And we then define the machine itself:
struct DoorMachine<S> {
state: S,
}
This machine will hold an internal state that can have some information attached to it (it can be any kind of structure) or it can have a zero-sized structure, and thus have a size of zero bytes. We will then implement our first transition:
use std::time::Duration;
use std::thread;
impl From<DoorMachine<WaitingCar>> for DoorMachine<TakingDoor> {
fn from(st: DoorMachine<WaitingCar>) -> DoorMachine<TakingDoor> {
while !is_the_car_in_place() {
thread::sleep(Duration::from_secs(1));
}
DoorMachine { state: TakingDoor }
}
}
This will simply check every 1 second whether the car is in the proper place. Once it is, it will return the next state, the TakingDoor state. The function signature makes sure that you cannot return the incorrect state, even if you do a really complex logic inside the from() function. Moreover, at compile time, this DoorMachine will have zero byte size, as we saw, so it will not consume RAM regardless of how complex our state transitions are. Of course, the code for the from() functions will be in RAM, but the necessary checks for proper transitioning will all be done at compile time.
We will then implement the next transition:
use std::time::Duration;
use std::thread;
impl From<DoorMachine<TakingDoor>> for DoorMachine<PlacingDoor> {
fn from(st: DoorMachine<TakingDoor>) -> DoorMachine<PlacingDoor> {
move_arm_to_new_door();
grip_door();
DoorMachine { state: PlacingDoor }
}
}
And finally, a similar thing can be done for the last state:
use std::time::Duration;
use std::thread;
impl From<DoorMachine<PlacingDoor>> for DoorMachine<WaitingCar> {
fn from(st: DoorMachine<PlacingDoor>) -> DoorMachine<WaitingCar> {
move_arm_to_car();
while !is_the_bolt_in_place() {
turn_bolt();
}
DoorMachine { state: WaitingCar }
}
}
The machine can start in any given state, and moving it from one to another will be as simple as writing the following:
let beginning_state = DoorMachine { state: WaitingCar };
let next_state: DoorMachine<TakingDoor> = beginning_state.into();
You might be thinking, why don't I simply write two functions and execute them sequentially? The answer is not straightforward, but it's easy to explain. This makes you avoid many issues at compile time. For example, if each state has only one possible next state, you can use a generic into() function without needing to know the current state, and it will simply work.
In a more complex environment, you might find yourself doing the following pattern:
let beginning_state = DoorMachine { state: WaitingCar };
let next_state: DoorMachine<TakingDoor> = beginning_state.into();
// Lots of code
let last_state: DoorMachine<PlacingDoor> = next_state.into();
Of course, if you look at it properly, we are no longer in the first state! What will happen if the machine tries to change the state again, thinking it's still in the first state? Well, here is where Rust comes handy. The into() function takes ownership of the binding, so this will simply not compile. Rust will complain that the beginning_state no longer exists since it has been already converted to next_state.