I am trying implement a Typestate Pattern for a game. I would like a game state to transition from Initialized to Started, but only if enough players have joined.
Ideally I would be able to consume self conditionally, only when the game can actually be started, otherwise return an Result::Err and do not consume self.
impl Game<Initialized> {
//...
pub fn start(&mut self) -> Result<Game<Started>, GameError> { // what I want to achieve here would be consuming self only when result is Ok
if self.players.len() < 2 {
return Err(NotEnoughPlayers);
}
//.. some more logic that mutates the players (like initializing them with cards)
Ok(Game{
players: self.players,
state: Started
})
}
}
Couple of workarounds I could find are:
Do not consume self and manually drop the previous state when Result is Ok
Always return Game, and you give back the ownership of the same object if conditions are not met (not enough players), but here I would loose the information on the reason why (the GameError::NotEnoughPlayers)
but none of them feels optimal.
Instantiate it. Here, you'd set the state to the initial state (that often enough is 'initialize'). You might return an error from the instantiation. If you want, it is 'not enough players'.
After that succeeds, you'd call the SM in a loop. But an SM doesn't need to return it's state nor your game structure. But if you want, return an error. In an over-simplified case just a bool.
You are missing the point. The point is to ensure at compile time that only certain (valid) transitions are possible, which requires changing the type of the state. Note the generic type parameter on State.
You can return a struct which contains the original Gameand an error kind enum.
That said, I suspect you will find that typestate is not very useful here, overall. The main value of typestate is that it statically prevents the calling code from doing a wrong sequence of operations — like how a builder prevents use of a partially-initialized value by being a different type than the fully-initialized value. But, if the operations are being driven by a single thing, like a game application's main loop (whether it be GUI or network server) then you end up having to convert the type-state back into dynamic state.
A statemachine transitions by itself after having received an event.
FSMs work perfectly by themselfes. As soon as you change the state from outside of the FSM directly, you are just moving (parts) of the FSM out of the FSM. Until the FSM is empty and became useless.
But @kpreid raised a good point: because now if I want to actually get the result, I still need to have some dynamic type, as otherwise there is a mismatch on the type
eg: this does not compile
struct GameLoop<T: GameState> {
game: Game<T>
}
#[test]
fn game_start() {
let mut game_loop = GameLoop{ game: new_game() };
let game = &mut game_loop.game;
// do stuff like add players...
let res = game.start();
match res {
Err(NotEnoughPlayers(game)) => game_loop.game = game,
Ok(game) => game_loop.game = game <--- compiler error, mismatch
Err(_) => {}//ignore other errors
}
}
Do you have any suggestion on how to avoid it, or am I forced to use dyn and hence loosing the benefit of having static type checking?
The latest code snippet smells like a design error. If the game failed to start, why would you want to put it back to the loop? Wasn't
it the whole point to detect errors, so that the game is not started, after all?
Given the requirement quoted above the returning of an ErrResult when there is not enough players seems wrong or at least misleading.
To my mind "Initialised" and "Started" are both valid states, normal phases that your program goes through. Neither of them is an error. Having too few or too many players to continue with a game is a normal situation as your program runs, neither of them is an error.
My conclusion about state machines in Rust is that the "type state pattern" and "state machines" are different things and should not be conflated. That if you want to transition between states depending on run time conditions in a loop (a state machine) then it's better not to try and get those state transitions validated by the type system at compile time.
There seems to be techniques for improving checking of state transitions at run time but they get very complicated adding a lot of conceptual overhead that is not justified for such a simple thing as a state machine.
You may be interested in the recent discussion of state machines in Rust here: On State Machines which links to some examples.
Especially since you have ended up mutating an existing resource that contains the state anyway.
You might as well have game_loop.started = false.
I think a function might be a better construct to use here. game_start() should return a game that has started with the correct number of players, or an error if the game start was cancelled during the process of adding players.
That way, if let Ok(started_game) = game_start() is all the static type checking you need - the Result::Err variant forces you, the programmer, to consider that the game may have failed to start.
Remember that static type checking is for confirming the code you've written is correct at compile time.
You can't use static type checking to prove you have added the right number of players at runtime. It's inherently a dynamic thing, I don't know if there are 3 or 4 players until the program has started executing.
Given some state machine with states A, B, C and D with transitions allowed like:
A -> B
B -> C
B -> A
C -> D
D -> A
i.e. A -> C is not allowed. Which is repeatedly run in a loop. Then it is tempting to try and use the Rust type system to at least ensure that invalid transitions cannot be written in the code. Whilst the actual decision on transitions is still made at run time dependent on data that is not known at compile time.
In the above example I should be able to compile code that transitions from B to C and from B to A but I should not be able to write compile code that would try to transition from B to D. Still leaving decisions on actual transitions to conditions at run time.
So far though, all the solutions I have found that try to disallow writing invalid transitions are all way over complicated and difficult to understand to my mind.
Maybe someone out there has a simpler way to do what I describe.
// States.
struct A;
struct B;
struct C;
struct D;
// Transitions.
macro_rules! impl_into {
($T:ty, $f:ident, $O:ident) => {
impl $T {
fn $f(self) -> $O {
$O
}
}
}
}
impl_into!(A, into_b, B);
impl_into!(B, into_c, C);
impl_into!(B, into_a, A);
impl_into!(C, into_d, D);
impl_into!(D, into_a, A);
// Run-time representation of state where the state is unknown.
enum State {
A(A),
B(B),
C(C),
D(D),
}
impl State {
fn into_a(self) -> Result<Self, Self> {
match self {
State::B(state) => {
// Impossible to call invalid transitions because they are simply not implemented.
Ok(State::A(state.into_a()))
},
// It is possible to make a mistake here by forgetting to add a valid transition.
other => Err(other),
}
}
// Implement (or ideally generate) into_b, into_c, ....
}
Eventually, picking up on @kpreid point and still using the suggestion from @paramagnetic, I have discarded the type state patter, and just used simple structs for the state and Box<dyn Any> to hold them.
use std::any::Any;
enum GameError {
SomeState1Error(State1),
}
struct State1 {}
impl State1 {
pub fn go_to_state2(self) -> Result<State2, GameError> {
if(..all is good..) {
return Ok(State2{})
}
Err(GameError::SomeState1Error(self))
}
}
struct State2 {}
impl State2 {
pub fn go_to_state3(self) -> ... {
...
}
}
...
let mut game_state: Box<dyn Any> = Box::new(State1 {});
match game_state.downcast::<State1>() {
Ok(state) => {
match state.go_to_state2() {
Ok(new_state) => {
game_state = Box::new(new_state);
println!("Success!");
}
Err(GameError::SomeState1Error(old_state)) => {
game_state = Box::new(old_state);
println!("Right state but the peration failed");
}
}
},
Err(old_state) => {
game_state = Box::new(old_state);
println!("You cannot perform this operation at this stage of the game");
}
}
I can still have strict type checking when working with specific states, and only need to verify the right state when the api endpoint is called.
Hopefully this solution makes sense, but if you have further suggestions let me know!
Your match statement is almost the same as the State implementation block in my example, with the difference being that your implementation requires a new Box heap allocation on every transition. I would encourage you to write or generate an enum that can contain the possible states at run-time if the set of states is known at compile-time.