While c# does not have discriminated unions, you can do something similar by introducing a type hierarchy for your various states, then using the pattern and pattern matching features introduced in C# versions 8, 9 and 10 to implement your state transitions.
For instance, consider the following hypothetical state machine. The machine has to get three inputs from the user, then continue getting inputs from the user until the input matches some terminal string. But in any event, the machine should terminate with an error after getting some maximum number of strings.
One way this could be be implemented as a type hierarchy of records as follows. First define the following state types:
public abstract record State(int Count); // An abstract base state that tracks shared information, here the total number of iterations.
public sealed record InitialState(int Count) : State(Count) { public InitialState() : this(0) {}}
public record StateA(int Count, string Token, int InnerCount) : State(Count) { }
public record StateB(int Count, string Token) : State(Count);
public sealed record FinalState(int Count) : State(Count);
public sealed record ErrorState(int Count) : State(Count);
Using these types, the required state machine could be implemented as follows:
string terminalString = "stop";
int maxIterations = 100;
State state = new InitialState();
// Negation pattern: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/patterns#logical-patterns
while (state is not FinalState && state is not ErrorState)
{
// Get the next token
string token = GetNextToken();
// Do some work with the current state + next token
Console.WriteLine("State = {0}", state);
// Transition to the new state
state = state switch // Switch Expression: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/switch-expression
{
var s when s.Count > maxIterations =>
new ErrorState(s.Count + 1),
InitialState s =>
new StateA(s.Count + 1, token, 0),
StateA s when s is { InnerCount : > 3 } => //https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/patterns#property-pattern
new StateB (s.Count + 1, token),
StateA s =>
s with { Count = s.Count + 1, Token = token, InnerCount = s.InnerCount + 1 }, // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#nondestructive-mutation
StateB s when s.Token == terminalString =>
new FinalState(s.Count + 1),
StateB s =>
s with { Count = s.Count + 1, Token = token },
_ => throw new Exception($"Unknown state {state}"),
};
// Do some additional work with the new state
}
Console.WriteLine("State = {0}", state);
Key points:
Use of the is
operator and the negation pattern to determine whether iteration has terminated:
while (state is not FinalState && state is not ErrorState)
Use of the switch expression to match on the type of the current state along with case guards to specify additional conditions:
var s when s.Count > maxIterations =>
Use of property patterns to match selected properties of a concrete state against known values in a case guard:
StateA s when s is { InnerCount : > 3 } =>
Use of nondestructive mutation to return modified states of the same type from an existing state:
s with { Count = s.Count + 1, Token = token, InnerCount = s.InnerCount + 1 },
Records implement value equality by default so which could be used in a case guard when
clause.
Demo fiddle #1 here.
A second, similar approach you could use if most of your states do not have internal data would be to create some common interface for all states, use records for states with internal data, and use static singletons for states with no internal data.
E.g., you could define the following states:
public interface IState { }
public sealed class State : IState
{
private string state;
private State(string state) => this.state = state;
// Enum-like states with no internal data
public static State Initial { get; } = new State(nameof(Initial));
public static State Final { get; } = new State(nameof(Final));
public static State Error { get; } = new State(nameof(Error));
public override string ToString() => state;
}
// Record states with internal data
public record class StateA(string Token, int Count) : IState;
public record class StateB(string Token) : IState;
And then implement the state machine defined above as follows:
string terminalString = "stop";
int maxIterations = 100;
(int count, IState state) = (0, State.Initial);
while (state != State.Final && state != State.Error)
{
// Get the next token
string token = GetNextToken();
// Do some work with the current state + next token
Console.WriteLine("State = {0}", state);
// Transition to the new state
state = state switch // Switch Expression: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/switch-expression
{
_ when count > maxIterations =>
state = State.Error,
State s when s == State.Initial =>
new StateA(token, 0),
StateA s when s is { Count : > 3 } => //https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/patterns#property-pattern
new StateB (token),
StateA s =>
s with { Token = token, Count = s.Count + 1 }, // https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#nondestructive-mutation
StateB s when s.Token == terminalString =>
State.Final,
StateB s =>
s with { Token = token },
_ => throw new Exception($"Unknown state {state}"),
};
// Do some additional work with the new state
}
Console.WriteLine("State = {0}", state);
Key points:
The use of static singletons for states with no internal data resembles the enumeration class idea by Jimmy Bogard for creating enums with methods.
Case guards can use members outside the object used in the switch expression. Here the iteration count is not included in the states themselves so a separate counter count
is used instead.
The interface IState
has no members, but you could imagine adding something to it, e.g. a method to return some handler:
public interface IState
{
public virtual Action<string> GetTokenHandler() => (s) => Console.WriteLine(s);
}
public record class StateA(string Token, int Count) : IState
{
public Action<string> GetTokenHandler() => (s) => Console.WriteLine("The current count is {0} and the current token is {1}", Count, Token);
}
The method could have a virtual default implementation that is is overridden in some, but not all, states.
Demo fiddle #2 here.