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:
- Locked: The chest is locked and cannot be opened unless you have a key.
- Closed: The chest is closed but not locked; it can be opened without a key.
- 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
}
}
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
};
}
}
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
, andhasKey
. - The underscore
_
is a discard pattern, matching any value (used when we don't care about a particular variable).
- The switch expression takes a tuple
-
Cases:
-
Case 1: When the chest is
Locked
, the action isOpen
, andhasKey
istrue
:-
Transition: The chest state changes to
Open
. - Explanation: The user unlocks and opens the chest using the key.
-
Transition: The chest state changes to
-
Case 1: When the chest is
-
Case 2: When the chest is
Closed
, the action isOpen
, regardless ofhasKey
:-
Transition: The chest state changes to
Open
. - Explanation: The chest is not locked, so it can be opened without a key.
-
Transition: The chest state changes to
-
Case 3: When the chest is
Open
, the action isClose
, andhasKey
istrue
:-
Transition: The chest state changes to
Locked
. - Explanation: The user closes and locks the chest because they have the key.
-
Transition: The chest state changes to
-
Case 4: When the chest is
Open
, the action isClose
, andhasKey
isfalse
:-
Transition: The chest state changes to
Closed
. - Explanation: The user closes the chest but cannot lock it without the key.
-
Transition: The chest state changes to
-
Default Case (
_
): For any other combination of state, action, andhasKey
:- 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");
}
}
Explanation:
-
Initialization:
- We start with the chest in the
Locked
state. - Output the initial state.
- We start with the chest in the
-
First Action: Open the chest with the key.
-
Method Call:
ManipulateChest(chest, Action.Open, hasKey: true)
. -
Expected Transition: From
Locked
toOpen
. - Output: Displays the action taken and the new state.
-
Method Call:
-
Second Action: Close the chest without the key.
-
Method Call:
ManipulateChest(chest, Action.Close, hasKey: false)
. -
Expected Transition: From
Open
toClosed
. - Output: Displays the action taken and the new state.
-
Method Call:
-
Third Action: Attempt to open the closed chest without the key.
-
Method Call:
ManipulateChest(chest, Action.Open, hasKey: false)
. -
Expected Transition: From
Closed
toOpen
. - Output: Displays the action taken and the new state.
-
Method Call:
-
Fourth Action: Close the chest with the key.
-
Method Call:
ManipulateChest(chest, Action.Close, hasKey: true)
. -
Expected Transition: From
Open
toLocked
. - Output: Displays the action taken and the new state.
-
Method Call:
-
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.
-
Method Call:
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
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 thehasKey
parameter when it doesn't affect the outcome (e.g., when opening aClosed
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 theChestState
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 theAction
enum and define how this action affects the chest's state in the switch expression.
Benefits of Using Switch Expressions for State Machines
- Improved Readability: Switch expressions provide a clear, declarative way to represent state transitions.
-
Less Boilerplate: Reduces the amount of code needed compared to traditional
switch
statements. - Functional Approach: Encourages immutability and pure functions, which can lead to fewer side effects.
- 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)