DEV Community

mohamed Tayel
mohamed Tayel

Posted on

Understanding the State Design Pattern P4

Introduction

In a previous article, we explored creating a state machine using a switch statement. While effective, it can become cumbersome with complex logic and multiple transitions. C# 8 introduced switch expressions, offering a cleaner and more readable syntax for managing state transitions. In this article, we'll delve into using switch expressions to create a state machine, with a detailed explanation of each code component.

We'll model a Treasure Chest that can be locked, unlocked, opened, or closed. By using switch expressions, we'll define the transitions in a concise, readable way, ideal for simple state machines.

Why Use Switch Expressions for State Machines?

Switch expressions in C# 8 provide a more concise syntax for pattern matching and can simplify the code required to manage state transitions. They enhance readability and make it easier to maintain and extend the code, especially when dealing with multiple conditions.


Example Scenario: Treasure Chest State Machine

States of the Chest:

  1. Locked: The chest is locked and cannot be opened unless you have a key.
  2. Closed: The chest is closed but not locked; it can be opened without a key.
  3. Open: The chest is open, allowing access to its contents.

Possible Actions:

  • Open: Attempt to open the chest.
  • Close: Attempt to close the chest.

Condition:

  • Has Key: A boolean indicating whether the user possesses the key to the chest.

Step 1: Define States, Actions, and Conditions

First, we'll define enums for the chest states and the possible actions. We'll also introduce a boolean variable to represent whether the user has the key.

namespace StateMachineExample
{
    // Define the possible states of the chest
    public enum ChestState
    {
        Locked,
        Closed,
        Open
    }

    // Define the possible actions that can be performed on the chest
    public enum Action
    {
        Open,
        Close
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • ChestState Enum: Enumerates the possible states the chest can be in:

    • Locked: The chest is locked.
    • Closed: The chest is closed but not locked.
    • Open: The chest is open.
  • Action Enum: Enumerates the possible actions that can be performed on the chest:

    • Open: Attempt to open the chest.
    • Close: Attempt to close the chest.

Step 2: Implement State Transitions with a Switch Expression

We will create a static method ManipulateChest that takes the current chest state, the action to perform, and whether the user has the key. Using a switch expression, we'll define the new state based on these inputs.

using System;

namespace StateMachineExample
{
    public static class ChestStateMachine
    {
        public static ChestState ManipulateChest(ChestState chestState, Action action, bool hasKey) =>
            (chestState, action, hasKey) switch
            {
                // Case 1: Chest is locked, action is to open, and user has the key
                (ChestState.Locked, Action.Open, true) => ChestState.Open,

                // Case 2: Chest is closed, action is to open (key doesn't matter)
                (ChestState.Closed, Action.Open, _) => ChestState.Open,

                // Case 3: Chest is open, action is to close, and user has the key (lock it)
                (ChestState.Open, Action.Close, true) => ChestState.Locked,

                // Case 4: Chest is open, action is to close, and user doesn't have the key (just close it)
                (ChestState.Open, Action.Close, false) => ChestState.Closed,

                // Default case: No state change for any other combinations
                _ => chestState
            };
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • Method Signature:

    • ManipulateChest: A static method that determines the new state of the chest based on the current state, the action, and whether the user has the key.
    • Parameters:
    • ChestState chestState: The current state of the chest.
    • Action action: The action to perform.
    • bool hasKey: Indicates if the user has the key.
    • Return Value: The new ChestState after the action is performed.
  • Switch Expression Syntax:

    • The switch expression takes a tuple (chestState, action, hasKey) and matches it against different patterns.
    • Each pattern consists of specific values for chestState, action, and hasKey.
    • The underscore _ is a discard pattern, matching any value (used when we don't care about a particular variable).
  • Cases:

    1. Case 1: When the chest is Locked, the action is Open, and hasKey is true:
      • Transition: The chest state changes to Open.
      • Explanation: The user unlocks and opens the chest using the key.
  1. Case 2: When the chest is Closed, the action is Open, regardless of hasKey:

    • Transition: The chest state changes to Open.
    • Explanation: The chest is not locked, so it can be opened without a key.
  2. Case 3: When the chest is Open, the action is Close, and hasKey is true:

    • Transition: The chest state changes to Locked.
    • Explanation: The user closes and locks the chest because they have the key.
  3. Case 4: When the chest is Open, the action is Close, and hasKey is false:

    • Transition: The chest state changes to Closed.
    • Explanation: The user closes the chest but cannot lock it without the key.
  4. Default Case (_): For any other combination of state, action, and hasKey:

    • Transition: The chest state remains unchanged.
    • Explanation: The action is invalid for the current state, so no change occurs.
  • Advantages of Switch Expressions:
    • Conciseness: The entire state transition logic is defined in a single expression.
    • Readability: Each case is clear and self-contained.
    • Pattern Matching: Allows matching on multiple variables simultaneously.

Step 3: Test the State Machine

We'll create a console application to simulate user interactions and test the state machine.

class Program
{
    static void Main()
    {
        ChestState chest = ChestState.Locked;
        Console.WriteLine($"Initial State: {chest}\n");

        // Open the chest with a key
        chest = ChestStateMachine.ManipulateChest(chest, Action.Open, hasKey: true);
        Console.WriteLine($"Action: Open with key");
        Console.WriteLine($"New State: {chest}\n");

        // Close the chest without a key
        chest = ChestStateMachine.ManipulateChest(chest, Action.Close, hasKey: false);
        Console.WriteLine($"Action: Close without key");
        Console.WriteLine($"New State: {chest}\n");

        // Attempt to open the already closed chest without key
        chest = ChestStateMachine.ManipulateChest(chest, Action.Open, hasKey: false);
        Console.WriteLine($"Action: Open without key");
        Console.WriteLine($"New State: {chest}\n");

        // Close the chest with a key (should remain closed since it's already closed)
        chest = ChestStateMachine.ManipulateChest(chest, Action.Close, hasKey: true);
        Console.WriteLine($"Action: Close with key");
        Console.WriteLine($"New State: {chest}\n");

        // Attempt to lock the chest (from closed to locked) by closing it with the key
        chest = ChestStateMachine.ManipulateChest(chest, Action.Close, hasKey: true);
        Console.WriteLine($"Action: Close with key");
        Console.WriteLine($"New State: {chest}\n");
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • Initialization:

    • We start with the chest in the Locked state.
    • Output the initial state.
  • First Action: Open the chest with the key.

    • Method Call: ManipulateChest(chest, Action.Open, hasKey: true).
    • Expected Transition: From Locked to Open.
    • Output: Displays the action taken and the new state.
  • Second Action: Close the chest without the key.

    • Method Call: ManipulateChest(chest, Action.Close, hasKey: false).
    • Expected Transition: From Open to Closed.
    • Output: Displays the action taken and the new state.
  • Third Action: Attempt to open the closed chest without the key.

    • Method Call: ManipulateChest(chest, Action.Open, hasKey: false).
    • Expected Transition: From Closed to Open.
    • Output: Displays the action taken and the new state.
  • Fourth Action: Close the chest with the key.

    • Method Call: ManipulateChest(chest, Action.Close, hasKey: true).
    • Expected Transition: From Open to Locked.
    • Output: Displays the action taken and the new state.
  • Additional Action: Attempt to close the chest again with the key (no state change expected).

    • Method Call: ManipulateChest(chest, Action.Close, hasKey: true).
    • Expected Transition: Remain in Locked state.
    • Output: Displays the action taken and the new state.

Note: The code includes detailed console outputs after each action to track the state changes.


Step 4: Run and Observe the Output

When you run the program, you should see output similar to:

Initial State: Locked

Action: Open with key
New State: Open

Action: Close without key
New State: Closed

Action: Open without key
New State: Open

Action: Close with key
New State: Locked

Action: Close with key
New State: Locked
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • Initial State: The chest is Locked.
  • After Opening with Key: The chest transitions to Open.
  • After Closing without Key: The chest transitions to Closed (not locked).
  • After Opening without Key: The chest transitions back to Open.
  • After Closing with Key: The chest transitions to Locked because the key is used to lock it.
  • Attempting to Close Locked Chest: No state change; remains Locked.

In-Depth Code Explanation

Switch Expression Details:

  • Tuple Pattern Matching: (chestState, action, hasKey) creates a tuple of the input parameters, which is then matched against specific patterns.
  • Discard Pattern _: Used to ignore the hasKey parameter when it doesn't affect the outcome (e.g., when opening a Closed chest).
  • Guard Conditions: The switch expression effectively serves as a series of guard conditions, ensuring only valid transitions occur.

Advantages of Using Switch Expressions:

  • Conciseness: The entire state transition logic is encapsulated in a single expression, reducing boilerplate code.
  • Readability: The pattern matching syntax is expressive, making it clear what conditions lead to each state transition.
  • Maintainability: Adding new states or actions is straightforward; you can simply add new patterns to the switch expression.

Handling Invalid Actions:

  • Any action that doesn't match the specified patterns falls into the default case (_), where the chest state remains unchanged. This prevents invalid state transitions and ensures the chest behaves predictably.

Extending the State Machine:

  • Adding New States: To add a new state (e.g., Broken), you would update the ChestState enum and add new cases in the switch expression to handle transitions to and from the new state.
  • Adding New Actions: Similarly, to add a new action (e.g., Lock), you'd update the Action enum and define how this action affects the chest's state in the switch expression.

Benefits of Using Switch Expressions for State Machines

  1. Improved Readability: Switch expressions provide a clear, declarative way to represent state transitions.
  2. Less Boilerplate: Reduces the amount of code needed compared to traditional switch statements.
  3. Functional Approach: Encourages immutability and pure functions, which can lead to fewer side effects.
  4. Ease of Modification: Adding or modifying states and transitions is straightforward.

Limitations and Considerations

  • No Side Effects: Switch expressions are expressions, not statements. They return a value but don't allow for executing multiple statements directly. If you need to perform actions (like logging) during transitions, you'd need to use a traditional switch statement or incorporate side-effect-causing methods within the expression.
  • Complex Logic: For highly complex state machines with extensive logic during transitions, a switch expression might become unwieldy.

Conclusion

Using switch expressions in C# 8 and later provides a concise and readable way to implement simple state machines. By leveraging pattern matching and the expressive power of switch expressions, we can define state transitions clearly and maintainably.

This approach is particularly well-suited for scenarios where state transitions are straightforward and side effects are minimal. For more complex state machines or where actions need to be performed during transitions, a different approach may be more appropriate.

Top comments (0)