Moving fields between states in a runtime state machine

Twice now, I've had to implement a state machine in Rust where transitions between states need to move data from the old state to the new, and I can't settle on a decent way to accomplish that. These state machines transition at runtime based on input received from outside the program, so all the search hits about implementing compile-time state machines in Rust are useless to me and just make it harder to find prior art.

As a simple and contrived example, let's consider implementing a sans IO state machine for the Request-O-Tron 12345. A typical session goes like this:

Welcome to the Request-O-Tron 12345!  What is your name?
> Steve
What are you requesting, Steve?
> pen
What kind of pen, Steve?
> ballpoint
What color of ballpoint pen, Steve?
> blue
How many blue ballpoint pens, Steve?
> 1
Request successful!  You will receive your 1 blue ballpoint pen(s) in 4 to 6 weeks.  Thank you, Steve!

Naïvely, you might try to code this like so:

requestotron.rs
use std::io::{self, Write};

#[derive(Clone, Debug, Eq, PartialEq)]
struct RequestOTron {
    message: Option<String>,
    state: State,
}

impl RequestOTron {
    fn new() -> Self {
        Self {
            message: Some(String::from(
                "Welcome to the Request-O-Tron 12345!  What is your name?",
            )),
            state: State::default(),
        }
    }

    fn get_message(&mut self) -> Option<String> {
        self.message.take()
    }

    fn handle_input(&mut self, input: String) {
        let input = input.trim().to_owned();
        match self.state {
            State::NeedName => {
                let name = input;
                self.message = Some(format!("What are you requesting, {name}?"));
                self.state = State::NeedRequest { name };
            }
            State::NeedRequest { name } => {
                let request = input;
                self.message = Some(format!("What kind of {request}, {name}?"));
                self.state = State::NeedKind { name, request };
            }
            State::NeedKind { name, request } => {
                let kind = input;
                self.message = Some(format!("What color of {kind} {request}, {name}?"));
                self.state = State::NeedColor {
                    name,
                    request,
                    kind,
                };
            }
            State::NeedColor {
                name,
                request,
                kind,
            } => {
                let color = input;
                self.message = Some(format!("How many {color} {kind} {request}s, {name}?"));
                self.state = State::NeedQty {
                    name,
                    request,
                    kind,
                    color,
                };
            }
            State::NeedQty {
                name,
                request,
                kind,
                color,
            } => {
                // The contract holder's not paying us enough to check that
                // this is actually a valid number.
                let qty = input;
                self.message = Some(format!("Request successful!  You will receive your {qty} {color} {kind} {request}(s) in 4 to 6 weeks.  Thank you, {name}!"));
                self.state = State::Done;
            }
            State::Done => panic!("handle_input() should not be called when Done"),
        }
    }

    fn is_done(&self) -> bool {
        self.state == State::Done
    }
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
enum State {
    #[default]
    NeedName,
    NeedRequest {
        name: String,
    },
    NeedKind {
        name: String,
        request: String,
    },
    NeedColor {
        name: String,
        request: String,
        kind: String,
    },
    NeedQty {
        name: String,
        request: String,
        kind: String,
        color: String,
    },
    Done,
}

fn main() -> io::Result<()> {
    let stdin = io::stdin();
    let mut stdout = io::stdout();
    let mut machine = RequestOTron::new();
    if let Some(msg) = machine.get_message() {
        writeln!(&mut stdout, "{msg}")?;
    }
    while !machine.is_done() {
        write!(&mut stdout, "> ")?;
        stdout.flush()?;
        let mut input = String::new();
        stdin.read_line(&mut input)?;
        machine.handle_input(input);
        if let Some(msg) = machine.get_message() {
            println!("{msg}");
        }
    }
    Ok(())
}

(Playground)

Unfortunately, this fails to compile because handle_input() tries to move String fields out from behind a reference, and String isn't Copy.

So far, I've come up with the following possible workarounds:

  1. Use std::mem::take() on the fields. This only words when the field types all implement Default.

  2. Clone the fields. This is usually a feasible option, but it wastes memory (unless the compiler is smart enough to just do a move instead? I have no idea how much that can be relied upon.).

  3. Wrap the fields in Option and use Option::take() on them. This then forces you to use unwrap()/expect() at the end of processing or at any other time that you need access to the actual values, and impossible states are no longer unrepresentable.

  4. Declare the fields on the machine struct itself, and change the state enum to purely C-style, like so:

    struct RequestOTron {
        message: Option<String>,
        state: State,
        name: String, // or Option<String>
        request: String,
        kind: String,
        color: String,
    }
    
    enum State {
        NeedName,
        NeedRequest,
        NeedKind,
        NeedColor,
        NeedQty,
        Done,
    }
    

    This requires the fields to all have default/uninitialized values available which you have to be careful to avoid using before the actual initialization proper, and if any default is unsuitable for outside use, you've got the same problems as with Option.

  5. At the start of each method, use std::mem::replace() to switch the state field with some "void" variant so that the state is no longer behind a reference, thereby allowing you to move fields out of the state and into the next variant, which you must ensure you put back by the end of the method. For the example above, this mostly consist of adding a State::Void variant and changing match self.state to match std::mem::replace(&mut self.state, State::Void). The main problem with this approach is that you'll need to be very careful around early returns in order to avoid leaving the "void" variant in place. The code can also get verbose if many inputs don't cause a state transition, forcing you to rebuild & assign back the state you just matched on.

    This solution can be taken further by making each variant of the state enum into a unary tuple struct whose inner type holds the relevant fields and implements a handle_input(self, input: String) -> State method (Note the consuming receiver!); combined with enum dispatch (whether through the crate of that name or just the general technique), this lets you simplify the handle_input() method on the machine to just:

    fn handle_input(&mut self, input: String) {
        // Take out the state:
        let state = std::mem::replace(&mut self.state, State::Void);
        // Transition it:
        let state = state.handle_input(input);
        // And then put it back:
        self.state = state;
    }
    

    … while also having to write out a separate handle_input() method on each of the new state types.

Now that I've written all this out, it seems that cloning and replace() are the only decent options, so it comes down to excess memory usage (maybe) versus boilerplate code. (A lot probably comes down to that.)

Are there any other approaches to moving fields around that I've overlooked? What would you recommend or go with?

Sometimes you can use an owned builder pattern even though it's all the same enum type. That is, make your state transitions work on an owned type.

If you want to stay with &mut self, you can use replace_with and still make your state transitions work on a owned type.

use replace_with::replace_with_or_abort as replace_with;
// ...
    pub fn handle_input(&mut self, input: String) {
        replace_with(self, |this| this.handle_input_inner(input));
    }

    fn handle_input_inner(self, input: String) -> Self {
        let input = input.trim().to_owned();
        match self.state {
            State::NeedName => {
                let name = input;
                let message = Some(format!("What are you requesting, {name}?"));
                let state = State::NeedRequest { name };
                Self { message, state }
            }
    // ...

It's similar to your (5) in some ways, but no Void variant; instead the signature makes you return Self. And you don't have to dispatch if you don't want to.

5 Likes

I'm aware of replace_with (though I can never remember its name). I rejected it for an earlier state machine because I was only considering using it on the state field (Using it on self didn't occur to me), but, in that case, I also needed to pass a reference to another ("shared") field of self to the inner handle_input(), which wouldn't have worked with passing &mut self.state to replace_with() at the same time. There's also the added wrinkle that sometimes you want to return other data directly from a state machine method, and replace_with() isn't really compatible with that.

However, there seems to be a problem with your sample code: replace_with() takes three arguments, requiring a "default-generating" closure between your first & second arguments. self doesn't always have a reasonable default, and using it on self.state instead just brings back the need for Void.

I actually used replace_with_or_abort, that's just annoyingly long to type:

use replace_with::replace_with_or_abort as replace_with;

The replace_with module in the playground is just code pasted from the replace_with crate, since the crate itself is not available in the playground.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.