1 stable release

1.0.0 Sep 8, 2021

#32 in #fsm

Download history 14/week @ 2024-07-22 45/week @ 2024-07-29 98/week @ 2024-08-05 52/week @ 2024-08-26 17/week @ 2024-09-02 117/week @ 2024-09-09 59/week @ 2024-09-16 43/week @ 2024-09-23 27/week @ 2024-09-30 37/week @ 2024-10-07 39/week @ 2024-10-14 25/week @ 2024-10-21 25/week @ 2024-10-28 34/week @ 2024-11-04

124 downloads per month
Used in 3 crates (via xvc-pipeline)

MIT/Apache

38KB
870 lines

Sad Machine

sad_machine provides a macro to declaratively define a state machine and the transitions between states. It's focused on providing a nice API for applications that deal with event loops and use a state machine to keep track of their state.

sad_machine is a fork of the sm library which removes the traits and keeps only the macro, and redesigns the generated code to be more enum-friendly.

Usage

sad_machine exposes only one macro, state_machine!. A quick example:

use sad_machine::state_machine;

state_machine! {
    Lock {
        InitialStates { Locked, Unlocked }

        TurnKey {
            Locked => Unlocked
            Unlocked => Locked
        }

        BreakKeyhole {
            Locked, Unlocked => Broken
        }

        Repair {
            Broken => Locked
        }
    }
}

fn main() {
    let mut lock = Lock::locked();

    loop {
        match lock {
            Lock::Locked(m @ LockedState::FromInit) => lock = m.turn_key(),
            Lock::Unlocked(m) => lock = m.turn_key(),
            Lock::Locked(m) => lock = m.break_keyhole(),
            Lock::Broken(_) => break,
        }
    }

    assert_eq!(lock, Lock::Broken(BrokenState::FromBreakKeyhole));
}

In this example, the macro generated:

  • An enum called Lock containing all states of the enum.
  • An enum for each state containing the name of the event that triggered the transition. For the Unlocked state, the enum is called UnlockedState and contains the two cases FromInit, FromTurnKey.
  • Two initialization functions: Lock::locked() and Lock::unlocked(), mirroring the states defined in InitialStates.
  • Transition methods for the state enums. For the Broken state, a .repair() method is generated which mirrors the Repair event.

A few differences from sm's API:

  • The generated code is not wrapped in a module, and all enums and functions are pub.
  • Initial states are encoded as functions on the state enum.
  • Transitions are encoded as methods on the object contained inside the cases of the state enum.
  • The initial state and transition functions all return the state enum.
  • The cases of the state enum contain the name of the event that triggered the transition. Each state has its own enum for this purpose.
  • The transitions do not consume the original state machine.
  • You can only define one state machine per macro instantiation.

Descriptive Example

The below example explains step-by-step how to create a new state machine using the provided macro, and then how to use the created machine in your code.

Declaring a new State Machine

First, we import the macro from the crate:

use sad_machine::state_machine;

Next, we initiate the macro declaration:

state_machine! {

Then, provide a name for the machine, and declare a list of allowed initial states:

    Lock {
        InitialStates { Locked, Unlocked }

Finally, we declare one or more events and the associated transitions:

        TurnKey {
            Locked => Unlocked
            Unlocked => Locked
        }

        BreakKeyhole {
            Locked, Unlocked => Broken
        }
    }
}

And we're done. We've defined our state machine structure, and the valid transitions, and can now use this state machine in our code.

Using your State Machine

You can initialise the machine as follows:

let sm = Lock::locked();

We've initialised our machine in the Locked state. The sm is as an enum covering all possible states of the state machine, and each state contains the name of the event that triggered it. A full pattern match on the state enum looks like this:

match lock {
    Lock::Locked(LockedState::FromInit) => ..,
    Lock::Locked(LockedState::FromTurnKey) => ..,
    Lock::Locked(LockedState::FromRepair) => ..,
    Lock::Unlocked(UnlockedState::FromInit) => ..,
    Lock::Unlocked(UnlockedState::FromTurnKey) => ..,
    Lock::Broken(BrokenState::FromBreakKeyhole) => ..,
}

To transition this machine to the Unlocked state, we send the turn_key method on the LockedState object:

let lock = match lock {
    Lock::Locked(locked) => locked.turn_key(),
    _ => panic!("wrong state"),
}

Caveat emptor

The state machine does not consume the previous state when performing a transition, as opposed to sm's behavior, so be careful when operating in a concurrent context.

It also doesn't prevent you from constructing a state that is not one of the initial states, due to Rust's lack of private constructors for enums.

Why fork

Some of the design choices that sm makes conflict with my use case.

I was using the library in an event loop where:

  1. The state is stored as its enum Variant representation, and can only advance by one step in a single loop
  2. Multiple events can trigger a state change to a certain state, but I don't particularly care about the event that triggered the stage change

sm seems to have different design goals:

  1. The transition method returns a Machine type and not an enum, which forces me to call .as_enum() on its result every time to store it as Variant, but makes it easy to trigger multiple state transitions in a single piece of code
  2. The cases of the Variant enum also include the name of the event that triggered the state change, which led me to duplicate code in multiple branches for each state that had multiple entry points

sad_machine's API focuses on the state enum rather than on concrete states. The transition methods it generates return the enum, which makes it harder to trigger multiple transitions in the same piece of code, but on the other hand it removes the cruft of calling .as_enum() on the result, and its state enum does not encode the event name in the name of its cases, but rather carries it inside itself.

This forks keeps sm's parser for the DSL to define the state machine and changes the generated code.

License

Licensed under either of

This was the license of the original crate and I'd rather not change it.

Contribution

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

Dependencies

~1.5MB
~38K SLoC