Sling Academy
Home/Rust/Rust - Exploring patterns for encoding state machines with generic parameters

Rust - Exploring patterns for encoding state machines with generic parameters

Last updated: January 07, 2025

State machines are a fundamental concept in computer science and software engineering. They are used to model systems that can be in one of a finite number of states at any given time, and that can change from one state to another in response to some input. Rust, with its strong emphasis on safety and performance, provides powerful tools for implementing state machines, especially when it comes to encoding state machines with generic parameters.

In this article, we will explore how to implement state machines in Rust using generic parameters. This approach not only leads to more reusable code but also empowers the developer with the flexibility that Rust generics naturally facilitate. We'll look at some practical examples and delve into various patterns to handle this elegantly.

Understanding the Basics

Before diving into generics, let’s briefly revisit the concept of state machines. A state machine primarily consists of:

  • States: Distinct modes or conditions the system can be in.
  • Transitions: Rules or functions that decide if and how the system should switch from one state to another.
  • Events/Inputs: External or internal signals that trigger state transitions.

Using Rust, we can model these state machines with enums and traits. Enums represent states, while traits can be employed to dictate transitions and possible actions.

Implementing Simple State Machines

Let's start with a simple example of a state machine without generics:


enum TrafficLightState {
    Red,
    Green,
    Yellow,
}

impl TrafficLightState {
    fn next(&self) -> TrafficLightState {
        match self {
            TrafficLightState::Red => TrafficLightState::Green,
            TrafficLightState::Green => TrafficLightState::Yellow,
            TrafficLightState::Yellow => TrafficLightState::Red,
        }
    }
}

This code defines a simple traffic light state machine, where the transitions are hardcoded. However, its transition logic is fixed and cannot easily accommodate different behaviors for the same transition, which is often required. This is where generic parameters become useful.

Enhancing with Generics

Generics allow us to parameterize our code with respect to the data types involved. In state machines, we can use generics to dictate the types of states and events, thus decoupling the state machine logic from specific implementation details. Consider this enhanced approach:


trait State {
    fn on_event(&self, event: &E) -> Option;
}

enum ModifiedTrafficLightState {
    Red,
    Green,
    Yellow,
}

struct ModifiedTrafficLight;

impl State for ModifiedTrafficLightState {
    fn on_event(&self, event: &&str) -> Option {
        match self {
            ModifiedTrafficLightState::Red => {
                if event == &"TIMER" {
                    Some(ModifiedTrafficLightState::Green)
                } else {
                    None
                }
            },
            ModifiedTrafficLightState::Green => {
                if event == &"TIMER" {
                    Some(ModifiedTrafficLightState::Yellow)
                } else {
                    None
                }
            },
            ModifiedTrafficLightState::Yellow => {
                if event == &"TIMER" {
                    Some(ModifiedTrafficLightState::Red)
                } else {
                    None
                }
            },
        }
    }
}

In this version, our state machine uses a generic trait State to handle transitions. The method on_event checks the current state and returns a new state if applicable, based on the event. Here, generics give us flexibility, allowing different states and events.

Advanced Patterns

In more complex scenarios, a state machine might involve storing additional data or tracking aspects beyond mere state transitions. Using structs with associated data alongside enums, it’s possible to capture this information robustly:


struct MachineContext {
    count: u32,
}

enum MachineState {
    Start,
    Processing(MachineContext),
    Finished,
}

impl State for MachineState {
    fn on_event(&self, event: &&str) -> Option {
        match self {
            MachineState::Start => {
                if event == &"BEGIN" {
                    Some(MachineState::Processing(MachineContext { count: 0 }))
                } else {
                    None
                }
            },
            MachineState::Processing(context) => {
                if event == &"PROCESS" {
                    Some(MachineState::Processing(MachineContext { count: context.count + 1 }))
                } else if context.count > 5 {
                    Some(MachineState::Finished)
                } else {
                    None
                }
            },
            MachineState::Finished => {
                None
            },
        }
    }
}

In this example, the state MachineState::Processing has associated data, which isn't possible with plain enums without generics constraints.

Conclusion

Rust's powerful type system and support for generics allow for flexible and safe implementation of state machines. By leveraging traits and generics, developers can craft intricate and responsive systems with precisely defined state transitions. Such approaches are immensely useful, enabling design patterns that are scalable, maintainable, and stylistically aligned with Rust's goals of performance and reliability.

Next Article: Building a test harness for generic functions and types in Rust

Previous Article: Rust - Simplifying code with trait aliases for combined bounds

Series: Generic types in Rust

Rust

You May Also Like

  • E0557 in Rust: Feature Has Been Removed or Is Unavailable in the Stable Channel
  • Network Protocol Handling Concurrency in Rust with async/await
  • Using the anyhow and thiserror Crates for Better Rust Error Tests
  • Rust - Investigating partial moves when pattern matching on vector or HashMap elements
  • Rust - Handling nested or hierarchical HashMaps for complex data relationships
  • Rust - Combining multiple HashMaps by merging keys and values
  • Composing Functionality in Rust Through Multiple Trait Bounds
  • E0437 in Rust: Unexpected `#` in macro invocation or attribute
  • Integrating I/O and Networking in Rust’s Async Concurrency
  • E0178 in Rust: Conflicting implementations of the same trait for a type
  • Utilizing a Reactor Pattern in Rust for Event-Driven Architectures
  • Parallelizing CPU-Intensive Work with Rust’s rayon Crate
  • Managing WebSocket Connections in Rust for Real-Time Apps
  • Downloading Files in Rust via HTTP for CLI Tools
  • Mocking Network Calls in Rust Tests with the surf or reqwest Crates
  • Rust - Designing advanced concurrency abstractions using generic channels or locks
  • Managing code expansion in debug builds with heavy usage of generics in Rust
  • Implementing parse-from-string logic for generic numeric types in Rust
  • Rust.- Refining trait bounds at implementation time for more specialized behavior