I'm experimenting with using discriminated unions in C# (specifically, using the excellent OneOf
library) as means of representing and performing state transitions, taking advantage of compiler-enforced type-safety and OneOf
's Match
method.
This works fine for directed acyclic state transition graphs, like so:
State transition graph:
A -> B -> C1 -> D1 -> E
-> D2
-> C2 -> D3
State types
// state-specific constructors, fields and methods removed for brevity:
class A {
public B Next();
}
class B {
public OneOf<C1,C2> Next();
}
class C1 {
public OneOf<D1,D2> Next();
}
class C2 {
public D3 Next();
}
class D1 {
public E Next();
}
class D2 {
public E Next();
}
class D3 {
public E Next();
}
class E {
// Terminal state
}
Example state machine function:
public E Run( A initialState )
{
A a = initialState;
B b = a.Next();
return b.Next().Match(
( C1 c1 ) =>
{
return c1.Match(
d1 => d1.Next(),
d2 => d2.Next()
)
},
( C2 c2 ) =>
{
D3 d3 = c2.Next();
return d3.Next();
}
);
}
// or, more succinctly:
public E Run( A initialState )
{
return initialState
.Next() // A -> B
.Next() // B -> C1 | C2
.Match(
c1 => c1.Match( // C1 -> D1 | D2
d1 => d1.Next(), // D1 -> E
d2 => d2.Next() // D2 -> E
),
c2 => c2
.Next() // C2 -> D3
.Next() // D3 -> E
);
}
The use of .Match()
means the compiler requires the program to explicitly and exhaustively handle all possible types of values without also needing to rely on inheritance/polymorphism (as with the original State pattern).
But there's some problems:
- This is effectively a strict forward-only directed tree structure, even if the state machine graph converges to a linear state transition at the end, so if a single state is enterable from multiple other previous states (e.g. from
D1
,D2
andD3
toE
) then the code to enter into stateE
is repeated 3 times (as seen with thed1.Next()
,d2.Next()
, andd3.Next()
call-sites. - This approach does not work for cyclic state transition graphs, and most state-machines tend to be cyclic.
State transition graph:
Consider this state transition graph that shows cycles (represented by duplicate node names - I'm not good at ASCII art), like so:
A -> B -> C1 -> D -> E
-> A
-> C2 -> B
And these state types:
class A {
public B Next();
}
class B {
public OneOf<C1,C2> Next();
}
class C1 {
public OneOf<D,A> Next();
}
class C2 {
public B Next();
}
class D {
public E Next();
}
class E {
// Terminal state
}
...and if I used same-scope if
statements with OneOf.TryPick
instead of OneOf.Match
(which means we lose compiler-enforced exhaustive checks) and have to use goto
(the horror):
public E Run( A initialState )
{
A a;
stateA:
a = initialState;
stateB:
B b;
b = a.Next();
OneOf<C1,C2> bNext = b.Next();
if( bNext.TryPickT0( out C1 c1, out _ ) )
{
OneOf<D,A> c1Next = c1.Next();
if( c1Next.TryPickT0( out D d, out _ ) )
{
return d.Next();
}
else if( c1Next.TryPickT1( out a, out _ ) )
{
goto stateA;
}
else
{
throw new InvalidOperationException();
}
}
else if( b.Next.TryPickT1( out C2 c2, out _ ) )
{
b = c2.Next();
goto stateB;
}
else
{
throw new InvalidOperationException();
}
}
This is just ugly - from the use of goto
to the necessary else { throw
parts to prevent the compiler complaining about possible returns - but it has the (only) advantage of keeping program flow entirely within the Run
function to avoid mutating object instance state (as opposed to only mutating in-scoped locals, making it inherently thread-safe) - this also has advantages in async
code as the object that represents the async
state-machine is kept simpler.
There exists an alternative by using switch
with an enum type (which is bad, because I don't want to have to maintain an enum
to represent the state classes I already defined) - or C# 7.0 pattern-matching switch
(at the cost of requiring to downcast to Object
and use runtime type information for the switch
to work and the fact the compiler won't verify the switch is exhaustive, so new states could be added by another programmer and the code below would still compile (because the Match
calls were replaced with Value
because the Match's per-member lambdas would just return the state value):
public E Run( A initialState )
{
Object state = initialState;
while( true )
{
switch( state )
{
case A a:
state = a.Next();
break;
case B b:
state = b.Next().Value;
break;
case C1 c1:
state = c1.Next().Value;
break;
case C2 c2:
state = c2.Next().Value;
break;
case D d:
state = d.Next().Value;
break;
case E e:
return e;
default:
throw new InvalidOperationException( "Unknown state: " + state?.ToString() ?? "null" );
}
}
}
So - is there a way to logically jump between states without needing to satisfy the compiler with exceptions, default
and else
cases?
Aucun commentaire:
Enregistrer un commentaire