Maszyny stanów i łańcuchy Markova

12 Maja 2020

W zeszłym tygodniu wpadłem na pytanie: jak się w praktyce implementuje maszyny stanu? Szczególnie interesowało mnie podejście w tworzeniu gier. Ku mojemu zdziwieniu i udręce, kod który zobaczyłem w tutorialach na YouTubie był słaby, bo był bardzo mocno powiązany z innymi częściami systemu piszącego. Dlatego postanowiłem chwilę poeksperymentować i teraz zaprezentować moje podejście.

Tydzień temu opisałem typy algebraiczne w C#. Można mieć maszyny stanu, z prostymi stanami - reprezentowane przez enum, albo nasze stany mogą być parametryzowane, do czego typy algebraiczne nadają się świetnie.

Maszyna stanów

Zacznę bardzo prosto i abstrakcyjnie. Maszyna stanów, to koncept bardzo bliski do automatów rozpoznających wyrażenia regularne. Mamy kilka stanów oraz podpisane strzałki między nimi. Strzałka reprezentuje przejście ze stanu A do stanu B jeśli zaszło wydarzenie E, którym podpisana jest ta strzałka (więcej znajdziesz na Wikipedii).

Wobec tego możemy stworzyć prostą abstrakcyjną klasę dla naszych maszyn:

public abstract class StateMachine<TState, TEvent>
{
    public TState State { get; protected set; }
    
    public abstract void ProcessEvent(TEvent @event);
    
    public StateMachine(TState initialState)
        => State = initialState;
}

Nasza maszyna ma jakiś stan i zdolność do zmiany tego stanu pod wpływem jakiegoś zdarzenia.

Przykład implementacji

Powiedzmy, że chcemy modelować naszą maszyną stanu pogodę. Utworzyłem więc algebraiczny typ danych, zgodnie z poprzednim blogpostem, który odpowiada tej pseudo definicji:

type WeatherState =
    | Sun(int ConsecutiveDays)
    | Rain(int ConsecutiveDays)
    | Storm(int ConsecutiveRainDays)

Do tego potrzebujemy jeszcze zdarzenia

type WeatherEvent =
    | NoChange
    | StartRain
    | StopRain
    | BringStorm

Teraz mając już określone dane będziemy chcieli zaimplementować maszynę stanu, która opisze przejścia między nimi. Można to zrobić na kilka różnych sposobów, ale ja poszedłem najprostszą ścieżką.

public override void ProcessEvent(WeatherEvent @event)
{
    var state = State;
    switch(state.Tag)
    {
        case WeatherState.Enum.Sun:
            State = HandleEventAtSun(state.As<WeatherState.Sun>(), @event);
            break;
        case WeatherState.Enum.Rain:
            State = HandleEventAtRain(state.As<WeatherState.Rain>(), @event);
            break;
        case WeatherState.Enum.Storm:
            State = HandleEventAtStorm(state.As<WeatherState.Storm>(), @event);
            break;
        default: throw new PatternMatchException<WeatherState.Enum>(state.Tag);
    }
}

Co jest dla mnie istotne, to żebym był w stanie napisać testy do tej maszyny. Powyższa metoda jest bardzo prosta, a testowalna logika przejść między stanami została wyprowadzona do poszczególnych funkcji.

internal WeatherState HandleEventAtRain(WeatherState.Rain state, WeatherEvent @event)
{
    switch(@event)
    {
        case WeatherEvent.NoChange:
            return new WeatherState.Rain(state.ConsecutiveDays + 1);
        case WeatherEvent.StopRain:
            return new WeatherState.Sun();
        case WeatherEvent.BringStorm:
            return new WeatherState.Storm(state.ConsecutiveDays + 1);
        case WeatherEvent.StartRain:
            throw new ApplicationException($"Invalid event '{@event.Tag}' in state '{state.Tag}'.");
        default: throw new PatternMatchException<WeatherEvent.Enum>(@event.Tag);
    }
}

Szybka maszyna dla enumów

Kiedy nasze stany i zdarzenia nie mają dodatkowych parametrów to można je reprezentować jako zwykły enum i przejścia zaimplementować w postaci macierzy:

public unsafe sealed class EnumStateMachine<TState, TEvent>
    : StateMachine<TState,TEvent>
    where TState : System.Enum
    where TEvent : System.Enum
{
    public EnumStateMachine(TState initialState, 
                            delegate*<ref TState[,], void> fillMatrix) 
        : base(initialState)
    {
        var states = Enum.GetValues(typeof(TState)).Length;
        var events = Enum.GetValues(typeof(TEvent)).Length;
        TransitionMatrix = new TState[states, events];
        fillMatrix(ref TransitionMatrix);
    }
    public TState[,] TransitionMatrix;

    public override void ProcessEvent(TEvent @event)
    {
        var state = State;
        State = TransitionMatrix[
                    Unsafe.As<TState,int>(ref state),
                    Unsafe.As<TEvent,int>(ref @event)
                ];
    }
}

Tutaj istotne jest aby enum był zadeklarowany z domyślnymi wartościami 0-N. Ponadto trzeba do stanów dołożyć stan błędu (najlepiej o wartości 0), bo nie ma jak rzucić wyjątku w przypadku gdy pewien stan nie ma przejścia po pewnym zdarzeniu.

Funkcja fillMatrix jest przekazana jako delegat funkcyjny, bo Action nie wspierał parametru ref.

Łańcuchy Markova

Łańcuchy Markova opisują maszyny stanów, gdzie zajście zdarzenia E w stanie A ma prawdopodobieństwo P. Możemy napisać funkcję, która dostając losową liczbę rzeczywistą z zakresu 0-1 oraz stan, zwraca nam zdarzenie zgodne z pewnym prawdopodobieństwem.

public static WeatherEvent WeatherMarkov(WeatherState state, double chance)
{
    switch(state.Tag)
    {
        case WeatherState.Enum.Sun:
            if (chance < 0.8)
                return WeatherEvent.NoChange;
            else
                return WeatherEvent.StartRain;
        case WeatherState.Enum.Rain:
            if (chance < 0.75)
                return WeatherEvent.NoChange;
            else if (chance < 0.95)
                return WeatherEvent.StopRain;
            else return WeatherEvent.BringStorm;
        case WeatherState.Enum.Storm:
            if (chance < 0.6)
                return WeatherEvent.NoChange;
                // continue to rain
            else
                return WeatherEvent.StopRain;
        default:
            throw new PatternMatchException<WeatherState.Enum>(state.Tag);
    }
}

Potem możemy jej użyć z naszą maszyną stanów:

var @event = MarkovWeather(stateMachine.State, random.NextDouble());
stateMachine.ProcessEvent(@event);

Podsumowanie

Praca z maszynami stanów opartymi na algebraicznych typach danych, które są niezależne od reszty systemu pozwala nam na pisanie łatwo testowalnego, przenośnego kodu. Kiedy potrzebujemy maksimum wydajności to użycie prostych typów enumeratorów da nam najlepsze wyniki, ale ograniczy nam nieco pole manewru. Wszystko zależy od tego jaki problem się próbuje rozwiązać.

Bardzo możliwe, że w przyszłości zastosuję powyższe rozwiązania w praktyce i postaram się wtedy podlinkować tutaj taki praktyczny przykład.